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:
@@ -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()]
|
else:
|
||||||
# Prefer largest window (the main chart, not a tooltip or child)
|
print(f"WARN: no window contains {cfg.window_title!r} — bring TradeStation to front manually", flush=True)
|
||||||
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
|
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user