feat: focus fereastra TradeStation pe /ss, /resume și market_open

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 16:19:40 +03:00
parent 9c44eb6e31
commit 9e0202c9ee

View File

@@ -197,6 +197,31 @@ def _cmd_dryrun(args) -> None:
sys.exit(0 if result.acceptance_pass else 1) 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: def _cmd_run(args) -> None:
cfg = Config.load_current(Path("configs")) cfg = Config.load_current(Path("configs"))
cfg = _apply_operating_hours_cli_overrides(cfg, args) 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 # Auto-focus TradeStation by title substring so user doesn't need to alt-tab
if not capture_stub and cfg.window_title: if not capture_stub and cfg.window_title:
try: title = _focus_window_by_title(cfg.window_title)
import pygetwindow as gw # type: ignore[import-untyped] if title:
needle = cfg.window_title.lower() print(f"Focused window: {title!r}", flush=True)
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: else:
print(f"WARN: no window contains {cfg.window_title!r} — bring TradeStation to front manually", flush=True) print(f"WARN: no window contains {cfg.window_title!r} — bring TradeStation to front manually", flush=True)
except ImportError:
pass
delay = getattr(args, "startup_delay", 0.0) delay = getattr(args, "startup_delay", 0.0)
if delay > 0 and not capture_stub: if delay > 0 and not capture_stub:
@@ -777,9 +788,13 @@ def _maybe_log_transition(
audit: _AuditLike, audit: _AuditLike,
notifier: _NotifierLike, notifier: _NotifierLike,
status_body: str = "", status_body: str = "",
) -> None: ) -> str | None:
"""Log market_open / market_closed exactly once per transition. """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 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 alert/audit event is emitted for the initial evaluation. This prevents a
spurious market_open alert when run_live_async starts in-window. spurious market_open alert when run_live_async starts in-window.
@@ -790,14 +805,14 @@ def _maybe_log_transition(
window_reason = "closed" window_reason = "closed"
else: else:
# user_paused / drift_paused don't change market window state # user_paused / drift_paused don't change market window state
return return None
if window_reason == state.last_window_state: if window_reason == state.last_window_state:
return return None
if state.last_window_state is None: if state.last_window_state is None:
state.last_window_state = window_reason state.last_window_state = window_reason
return return None
event_name = "market_open" if window_reason == "open" else "market_closed" event_name = "market_open" if window_reason == "open" else "market_closed"
audit.log({"ts": now, "event": event_name, "reason": reason}) audit.log({"ts": now, "event": event_name, "reason": reason})
@@ -813,6 +828,7 @@ def _maybe_log_transition(
body=body, body=body,
)) ))
state.last_window_state = window_reason state.last_window_state = window_reason
return event_name
def _sync_detection_tick( def _sync_detection_tick(
@@ -923,7 +939,13 @@ async def _run_tick(ctx: RunContext) -> _TickSyncResult:
if ctx.lifecycle is not None: if ctx.lifecycle is not None:
skip = _should_skip(now, ctx.lifecycle, ctx.cfg, ctx.canary) skip = _should_skip(now, ctx.lifecycle, ctx.cfg, ctx.canary)
sb = _brief_status(ctx) 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: if skip is not None:
# No detection this tick. Empty result → _handle_fsm_result no-op. # No detection this tick. Empty result → _handle_fsm_result no-op.
return _TickSyncResult() 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))) ctx.notifier.send(Alert(kind="status", title="ATM Status", body="\n".join(lines)))
elif cmd.action == "ss": elif cmd.action == "ss":
now_ss = time.time() 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) frame_ss = await asyncio.to_thread(ctx.capture)
if frame_ss is None: if frame_ss is None:
ctx.notifier.send(Alert( ctx.notifier.send(Alert(
@@ -1063,6 +1089,10 @@ async def _dispatch_command(ctx: RunContext, cmd) -> None:
"ts": time.time(), "event": "user_resumed", "ts": time.time(), "event": "user_resumed",
"was_drift": was_drift, "was_user": was_user, "force": force, "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 # Adaptive response
if was_drift and not force: if was_drift and not force:
title = "Pauză user eliminată — dar Canary drift activ" title = "Pauză user eliminată — dar Canary drift activ"