feat(run): --startup-delay + canary sanity check at startup

- 5s countdown before the loop starts so user can alt-tab TradeStation to
  the foreground and minimize whatever covers it.
- First frame triggers a canary phash check. Drift → WARN printed, clears
  auto-pause so user can Ctrl+C without the loop going silent. Canary
  status ('drift=X/Y' or 'capture_failed') is included in the startup
  ping so it's visible on Discord/Telegram.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-16 07:17:28 +00:00
parent be7c4f82e8
commit e114941bb7

View File

@@ -58,6 +58,10 @@ def main(argv=None) -> None:
"--capture-stub", action="store_true",
help="Use stub capture (reads PNGs from samples/); useful for smoke-testing on Linux",
)
p_run.add_argument(
"--startup-delay", type=float, default=5.0, metavar="SEC",
help="Seconds to wait before the loop starts (bring TradeStation to front). Default 5.",
)
# journal
p_journal = sub.add_parser("journal", help="Add a trade journal entry interactively")
@@ -139,6 +143,15 @@ def _cmd_run(args) -> None:
cfg = Config.load_current(Path("configs"))
duration_s = args.duration * 3600 if args.duration is not None else None
capture_stub = args.capture_stub or bool(os.environ.get("ATM_STUB_CAPTURE"))
delay = getattr(args, "startup_delay", 0.0)
if delay > 0 and not capture_stub:
print(f"Bring TradeStation to front, minimize PowerShell/VS Code. Starting in {delay:.0f}s...", flush=True)
for i in range(int(delay), 0, -1):
print(f" {i}...", flush=True)
time.sleep(1.0)
print("RUN", flush=True)
run_live(cfg, duration_s=duration_s, capture_stub=capture_stub)
@@ -282,15 +295,29 @@ def run_live(cfg, duration_s=None, capture_stub: bool = False) -> None:
]
notifier = FanoutNotifier(backends, Path(cfg.dead_letter_path))
# Startup ping — confirms both channels work before the first bar closes.
# Sanity check: capture one frame, confirm canary matches calibration.
from datetime import datetime
dur_note = f" duration={cfg.heartbeat_min:d}m heartbeat" if duration_s is None else f" duration={duration_s/3600:.2f}h"
first_frame = capture()
if first_frame is None:
print("WARN: first capture returned None — window/region missing", flush=True)
canary_status = "capture_failed"
else:
first_check = canary.check(first_frame)
canary_status = f"drift={first_check.distance}/{cfg.canary.drift_threshold}"
if first_check.drifted:
print(f"WARN: canary drift at startup ({canary_status}). Wrong window in front?", flush=True)
canary.resume() # clear the auto-pause so user can Ctrl+C and fix
dur_note = f" dur=∞" if duration_s is None else f" dur={duration_s/3600:.2f}h"
notifier.send(Alert(
kind="heartbeat",
title="ATM started",
body=f"config={cfg.config_version}{dur_note} at {datetime.now().isoformat(timespec='seconds')}",
body=(
f"cfg={cfg.config_version}{dur_note} @ {datetime.now().isoformat(timespec='seconds')}\n"
f"canary: {canary_status}"
),
))
audit.log({"event": "started", "config": cfg.config_version})
audit.log({"event": "started", "config": cfg.config_version, "canary": canary_status})
start = time.monotonic()
heartbeat_due = start + cfg.heartbeat_min * 60