From 9e0202c9eea18a4d14bc6a4bd7950d0f9936b33b Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Tue, 21 Apr 2026 16:19:40 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20focus=20fereastra=20TradeStation=20pe?= =?UTF-8?q?=20/ss,=20/resume=20=C8=99i=20market=5Fopen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captura e region-based (mss), deci dacă alt app acoperă TS, screenshot-urile manuale și cele scheduled după redeschiderea pieței erau inutile. Acum aducem TS în față înainte de captură. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/atm/main.py | 78 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/src/atm/main.py b/src/atm/main.py index cba4489..d43778b 100644 --- a/src/atm/main.py +++ b/src/atm/main.py @@ -197,6 +197,31 @@ def _cmd_dryrun(args) -> None: sys.exit(0 if result.acceptance_pass else 1) +def _focus_window_by_title(needle: str) -> str | None: + """Adu în față cea mai mare fereastră al cărei titlu conține `needle`. + + Returnează titlul găsit sau None (niciun match, Win32 a refuzat activate, + sau `pygetwindow` lipsește — caz normal pe Linux/WSL unde rulează doar testele). + """ + try: + import pygetwindow as gw # type: ignore[import-untyped] + except ImportError: + return None + needle_lc = needle.lower() + matches = [w for w in gw.getAllWindows() if w.title and needle_lc in w.title.lower()] + matches.sort(key=lambda w: (w.width or 0) * (w.height or 0), reverse=True) + if not matches: + return None + win = matches[0] + try: + if win.isMinimized: + win.restore() + win.activate() + return win.title + except Exception: + return None + + def _cmd_run(args) -> None: cfg = Config.load_current(Path("configs")) cfg = _apply_operating_hours_cli_overrides(cfg, args) @@ -228,25 +253,11 @@ def _cmd_run(args) -> None: # Auto-focus TradeStation by title substring so user doesn't need to alt-tab if not capture_stub and cfg.window_title: - try: - import pygetwindow as gw # type: ignore[import-untyped] - needle = cfg.window_title.lower() - matches = [w for w in gw.getAllWindows() if w.title and needle in w.title.lower()] - # Prefer largest window (the main chart, not a tooltip or child) - matches.sort(key=lambda w: (w.width or 0) * (w.height or 0), reverse=True) - if matches: - win = matches[0] - try: - if win.isMinimized: - win.restore() - win.activate() - print(f"Focused window: {win.title!r}", flush=True) - except Exception as exc: - print(f"Could not focus {win.title!r}: {exc}", flush=True) - else: - print(f"WARN: no window contains {cfg.window_title!r} — bring TradeStation to front manually", flush=True) - except ImportError: - pass + title = _focus_window_by_title(cfg.window_title) + if title: + print(f"Focused window: {title!r}", flush=True) + else: + print(f"WARN: no window contains {cfg.window_title!r} — bring TradeStation to front manually", flush=True) delay = getattr(args, "startup_delay", 0.0) if delay > 0 and not capture_stub: @@ -777,9 +788,13 @@ def _maybe_log_transition( audit: _AuditLike, notifier: _NotifierLike, status_body: str = "", -) -> None: +) -> str | None: """Log market_open / market_closed exactly once per transition. + Returns the event name emitted (``"market_open"`` / ``"market_closed"``) + or ``None`` when no transition happened. Callers that need to react to + the opening (e.g. bring TradeStation to the foreground) use this value. + Startup guard (R2): when last_window_state is None we just seed it; no alert/audit event is emitted for the initial evaluation. This prevents a spurious market_open alert when run_live_async starts in-window. @@ -790,14 +805,14 @@ def _maybe_log_transition( window_reason = "closed" else: # user_paused / drift_paused don't change market window state - return + return None if window_reason == state.last_window_state: - return + return None if state.last_window_state is None: state.last_window_state = window_reason - return + return None event_name = "market_open" if window_reason == "open" else "market_closed" audit.log({"ts": now, "event": event_name, "reason": reason}) @@ -813,6 +828,7 @@ def _maybe_log_transition( body=body, )) state.last_window_state = window_reason + return event_name def _sync_detection_tick( @@ -923,7 +939,13 @@ async def _run_tick(ctx: RunContext) -> _TickSyncResult: if ctx.lifecycle is not None: skip = _should_skip(now, ctx.lifecycle, ctx.cfg, ctx.canary) sb = _brief_status(ctx) - _maybe_log_transition(skip, ctx.lifecycle, now, ctx.audit, ctx.notifier, status_body=sb) + transition = _maybe_log_transition( + skip, ctx.lifecycle, now, ctx.audit, ctx.notifier, status_body=sb, + ) + if transition == "market_open" and ctx.cfg.window_title: + title = await asyncio.to_thread(_focus_window_by_title, ctx.cfg.window_title) + ctx.audit.log({"ts": now, "event": "window_focused", "command": "market_open", "title": title}) + await asyncio.sleep(0.15) if skip is not None: # No detection this tick. Empty result → _handle_fsm_result no-op. return _TickSyncResult() @@ -1026,6 +1048,10 @@ async def _dispatch_command(ctx: RunContext, cmd) -> None: ctx.notifier.send(Alert(kind="status", title="ATM Status", body="\n".join(lines))) elif cmd.action == "ss": now_ss = time.time() + if cfg.window_title: + title = await asyncio.to_thread(_focus_window_by_title, cfg.window_title) + ctx.audit.log({"ts": now_ss, "event": "window_focused", "command": "ss", "title": title}) + await asyncio.sleep(0.15) frame_ss = await asyncio.to_thread(ctx.capture) if frame_ss is None: ctx.notifier.send(Alert( @@ -1063,6 +1089,10 @@ async def _dispatch_command(ctx: RunContext, cmd) -> None: "ts": time.time(), "event": "user_resumed", "was_drift": was_drift, "was_user": was_user, "force": force, }) + if cfg.window_title: + title = await asyncio.to_thread(_focus_window_by_title, cfg.window_title) + ctx.audit.log({"ts": time.time(), "event": "window_focused", "command": "resume", "title": title}) + await asyncio.sleep(0.15) # Adaptive response if was_drift and not force: title = "Pauză user eliminată — dar Canary drift activ"