Add two new Telegram commands so the user can manage monitoring without
restarting the process:
- /pause sets lifecycle.user_paused = True. The detection loop then
short-circuits via _should_skip without touching FSM / canary state.
- /resume clears user_paused. R2 decision: drift-pause is NOT lifted by
plain /resume (the drift may be legit and require recalibration).
"/resume force" (value=1) also calls canary.resume(). The response
message adapts to context:
- drift active + plain resume → explains force requirement
- force + drift → confirms override, warns about recurrence
- out-of-window → explains monitor will resume at next open
- otherwise → plain "Monitorizare reluată"
- /status now shows "Activ: <pause_reason | activ>" and window state.
commands.py: extend CommandAction literal and _parse_command to accept
pause, resume, and "resume force" (value=1 signal).
Tests: test_commands.py parse coverage;
test_pause_command_sets_user_paused_and_skips_detection,
test_resume_clears_user_paused_and_canary_when_forced,
test_resume_during_drift_keeps_canary_paused_without_force (R2 #21),
test_resume_out_of_window_responds_with_pending_message,
test_status_command_reports_pause_reason,
test_lifecycle_with_drift_then_resume_then_fire (E2E #16).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add OperatingHoursCfg (enabled/timezone/weekdays/start_hhmm/stop_hhmm) so
the run loop can align with NYSE session hours instead of the user's
local wall clock (fixes DST drift between NY and Europe/Bucharest).
- Config parses [options.operating_hours] and resolves ZoneInfo at load,
fail-fast on invalid tz or weekday names. The tz is cached on
_tz_cache so the detection loop pays zero per-tick cost.
- LifecycleState tracks user_paused + last_window_state across ticks.
- Module-scope _should_skip(now, state, cfg, canary) returns skip reason
or None. Weekday check uses datetime.weekday() + a fixed MON..SUN list
(locale-free; strftime('%a') is localized).
- _maybe_log_transition emits market_open / market_closed once per edge.
R2: when last_window_state is None (startup), just seed — do not send
a spurious market_open alert when run_live_async launches in-window.
- _run_tick consults the lifecycle guard before scheduling the heavy
detection thread, so drain + transition logging still happen when the
tick is skipped.
- CLI flags --tz / --weekdays / --oh-start / --oh-stop override TOML.
(Kept distinct from the existing --start-at/--stop-at sleep-until-time
semantics to avoid breaking current deployments — deviation noted.)
- configs/example.toml documents the new [options.operating_hours] table.
Tests: parametrized window matrix (tests #8), transition logging (#9),
notification side-effect (#10), R2 #20 startup suppression, R2 #22
locale-independent weekday, plus guards for user_paused / canary
precedence and config-parse error paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the FSM observes ARMED → light_{green,red} directly (the dark
prime was missed), the color classifier likely mis-labeled the dark
phase as gray/background. Missing a fire is worse than a noisy alert,
so the new [options.alerts] fire_on_phase_skip flag (default True)
emits a phase_skip_fire alert on that transition with the standard
240s lockout to dedupe detector bounces.
Adds public StateMachine.is_locked / record_fire so the handler does
not reach into private attrs. _handle_tick now accepts an optional
cfg to read the flag; falls back to True if cfg is absent (tests).
Config gains AlertBehaviorCfg (new alerts field), parsed from
[options.alerts]. Example TOML updated with an explanatory comment.
Tests: test_phase_skip_fire_when_flag_on,
test_phase_skip_no_fire_when_flag_off,
test_phase_skip_lockout_suppresses_spam,
test_state_machine_is_locked_and_record_fire_public_api.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Canary auto-pause was silent: when drift > threshold the module flipped
to paused without any user-facing notification, leaving the user to
wonder why detection went dark. Add an optional on_pause_callback
invoked exactly once per not_paused→paused transition. Wrap the call
in try/except so a notifier failure can never break the detection
cycle.
main.py wires the callback to emit canary_drift_paused audit event
plus a warn Alert guiding the user toward /resume or recalibration.
Tests: test_canary_pause_callback_fires_once (idempotent),
test_canary_resume_allows_new_pause_notification (re-arms after
resume), test_canary_pause_callback_exception_does_not_crash_check
(safety).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Refactor _detection_loop by moving _run_tick, _handle_fsm_result,
_dispatch_command, and _drain_cmd_queue to module scope, passing
dependencies via a RunContext dataclass. This unblocks direct unit
testing of the drain path.
CRITICAL bug fix: the previous loop issued `continue` when the tick
returned res=None (canary paused or similar), which skipped the
drain block. Commands piled up in cmd_queue while detection was
paused — the hang observed on 2026-04-17 after canary drift-pause.
The refactored loop now runs _drain_cmd_queue UNCONDITIONALLY on
every iteration, after _handle_fsm_result, so pause-state never
starves the command channel.
Tests: test_drain_works_when_canary_paused,
test_drain_works_when_out_of_window,
test_drain_isolates_dispatch_exceptions (exception isolation +
audit/warn wiring).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add logs/.gitkeep to track directory structure. Extend .gitignore with
logs/fires, logs/pause.flag, logs/detections/, and configs/current.txt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Any exception in _dispatch_command (status, ss, etc.) was leaking out of the
asyncio.QueueEmpty try/except, crashing _detection_loop and cancelling the
poller — making the bot permanently unresponsive for the rest of the session.
Separate the queue-empty check from the dispatch into two try blocks.
Dispatch errors now log to audit + print to terminal + send a Telegram warn.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
httpx was in dev deps only, causing ImportError for users doing `pip install -e .`
since atm.commands imports httpx at module level. Moved to main dependencies.
Also stubs TelegramPoller and ScreenshotScheduler in the sync catchup test to
prevent flaky CI failures from attempted real network connections.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AuditLog deadlock: log() held self._lock and called _open() which called
close() which tried to acquire self._lock again — RLock not needed,
refactored to _close_locked() (called while already holding lock).
pyproject.toml: pytest-asyncio + httpx in dev deps.
test_main.py:
- lifecycle integration test (MUST-HAVE): IDLE→ARMED→PRIMED→auto-poll
starts→FIRE→auto-poll stops, asserts scheduler event order
- asyncio import for async test marker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
run_live() is now a thin asyncio.run() wrapper. run_live_async():
- Blocking pipeline (capture→canary→detect→_handle_tick→snapshot) in
asyncio.to_thread() per decision 1 (_sync_detection_tick function)
- TelegramPoller + ScreenshotScheduler as background asyncio tasks
- asyncio.Queue[Command] for inter-task communication
- Auto-start scheduler on PRIMED, auto-stop on fire/cooled/phase_skip
- 7-step graceful shutdown sequence
- heartbeat_due uses time.monotonic() (prevents immediate-fire regression)
- Status command: FSM state, last detection, uptime, fire count, canary health
- "ss" command: one-shot capture+annotate+send via to_thread
- Price overlay in _save_annotated_frame (dot_pos_abs + canary_ok params)
- test_main.py: ScriptedDetector.step(ts, frame=None) for zero regression
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TelegramCfg gains allowed_chat_ids (default: [chat_id]), poll_timeout_s=30,
auto_poll_interval_s=180. _from_dict reads from TOML; absent key defaults to
primary chat_id so existing configs need no changes.
Detector.step(ts, frame=None): when frame is provided the capture() call is
skipped — async loop pre-captures once, shares frame between canary+detection.
DetectionResult.dot_pos_abs carries absolute (x,y) for price overlay.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
detection thread and async heartbeat call log() concurrently.
Without a lock, two threads can both see today != _current_date
and double-open the file, corrupting the handle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Silent screenshots for periodic auto-poll — Telegram param
disable_notification=True suppresses phone notification sound.
Discord ignores the field (no equivalent).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
heartbeat_due was initialized from time.monotonic() but compared against
time.time(), causing the first heartbeat to always trigger on the first
loop iteration (duplicate message at startup).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Toate alertele Discord/Telegram traduse: armat, pregătit, recuperare,
semnal, activ, niveluri, pornit/oprit. Comentariile de business-logic
din main.py traduse în română.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Underscores in alert text (dark_green, FIRE_BUY) broke Telegram's
legacy Markdown parser, causing ok:false → retries exhausted → failed.
HTML parse_mode is more robust and doesn't treat _ as italic.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three bundled fixes on the dispatch + FSM + notifier triangle:
1. Telegram silent-success bug: parse JSON body after 200 OK, raise on
ok:false so FanoutNotifier retries + DLQs + stats surface the failure.
Previously Discord succeeded while Telegram silently dropped.
2. Per-kind screenshot attach: new AlertsCfg dataclass with per-kind toggle
(late_start, catchup, arm, prime, trigger). _save_annotated_frame helper
extracted from inline FIRE block, threaded via Snapshot closure into
_handle_tick. Failures audit-logged, never silent.
3. Post-FIRE catchup regression (d7305fb): residual dark_green/dark_red dots
after a FIRE cycle look like startup-catchup from (color, state) alone.
New fsm.fired_in_session(direction) gate suppresses synth-arm after a
cycle already fired in that direction. Opposite direction unaffected.
Also: queue-overflow on_drop audit callback, periodic + shutdown heartbeat
stats per-backend, config back-compat (bool or dict for attach_screenshots).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Catchup branch gated on first_accepted, but an earlier accepted gray tick
consumes the flag before a dark_* arrives, so the real prime-phase color
falls through to noise classification and no alert fires. Gate on
IDLE + dark_* alone — self-sufficient and correct.
Regression: 2 unit tests for _handle_tick + 1 integration test feeding
run_live a scripted gray→gray→dark_red→dark_red→light_red sequence.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Writes one JSONL line per detector.step() with ts, rgb, match_name,
distance, confidence, dot_found, window_found, accepted, color.
Captures UNKNOWN classifications and no-dot frames that today's
audit log skips, so the user can verify post-session what colors
the program actually saw.
Reuses AuditLog for daily rotation + buffering. Separate subdir
keeps audit.jsonl uncluttered.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Notify on IDLE→ARMED and ARMED→PRIMED so the user gets staged warnings
before FIRE. If atm run starts mid-cycle on dark_green/dark_red, synth
a catchup arm so the prime alert still fires (audit tagged catchup:true).
If it starts on light_green/light_red, emit one late_start warn and skip
the FSM feed — FIRE already passed.
Extracted _handle_tick() as a pure helper (takes fsm + duck-typed
notifier/audit Protocols) so the dispatch is unit-testable without
mocking FanoutNotifier. 9 new tests cover arm, prime, refresh silence,
catchup synth-arm (+ audit), and late-start on both directions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- samples/: full frame saved every time accepted colour changes (enough
diversity for the labelled corpus, no constant-N-seconds flood).
- logs/fires/: annotated frame saved on every BUY/SELL trigger, attached
to the Discord/Telegram Alert so the push includes a visual.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pygetwindow.activate() brings the calibrated window to the foreground so
the user doesn't need to alt-tab during the startup-delay. Largest window
matching cfg.window_title (case-insensitive substring) wins. If minimized,
restore first. Failures are warnings, not errors — user can still focus
it manually during the countdown.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Usage: atm run --start-at 16:30 --stop-at 23:00
Sleeps until next occurrence of the start time, runs until stop-at. If
start-at is in the past today, rolls over to tomorrow. Duration flag is
overridden when --stop-at is given.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- 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>
Answers 'is it even running?' within seconds of 'atm run' — no waiting
30 min for heartbeat + no need for a live trigger to occur.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
With tol=25 all dots in the strip fused into one blob via 1px anti-aliased
bridges between adjacent dots → centroid landed mid-strip instead of on the
rightmost dot. Erosion (3x3 kernel, 2 iters) cleanly separates discrete
dots before connected-components labelling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dots on M2D MAPS strip are so close that anti-aliased edge pixels bridge
adjacent dots column-counts → the previous walk-left approach merged the
entire strip into one cluster and picked its midpoint.
Connected components (8-connectivity) treats each dot as a separate blob
even when antialiased edges touch. We pick the blob with the largest
right-edge, then return its centroid. Robust, O(pixels), one opencv call.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Also: calibrate._sample_rgb now snaps to the most-saturated pixel within 15px
of the click, so rough clicks still pick up the dot's pure colour. Default
dot-colour tolerance bumped 30→60 to absorb anti-aliasing.
Test fixture _SAMPLED_RGB recomputed for the new 36/49 dilution (was 24/49
when sampling at the trailing edge).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User's chart background is pure black (0,0,0) but detector hardcoded (18,18,18)
with tol=15. Gap pixels between dots (0,0,0) fell outside background tolerance,
so find_rightmost_dot locked onto a gap pixel rather than a dot. Now falls back
to the config's background spec if defined.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same fix as atm debug: desktop snapshot happens several seconds after user
confirms, giving time to alt-tab TradeStation to the foreground and get
rid of terminal/IDE windows covering it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Quick sanity check after calibration. Prints window/dot detection state,
classified colour + confidence, saves full/cropped/annotated frames to logs/.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Problem: region-select crop is in its own coord-frame, but runtime capture
used pygetwindow+mss with the window's coords → ROI coords mismatch, chart
not captured correctly.
Fix: region-select now returns (image, virtual-desktop region). Wizard saves
'chart_window_region' in config. At runtime, _build_capture() prefers region
mode: grabs the same virtual-desktop rectangle, so ROI coords line up.
Users who calibrated before this commit must re-run 'atm calibrate' OR add
chart_window_region to their TOML manually. If window moves, canary will
detect drift and auto-pause.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Window-level PrintWindow/mss returns blank on GPU-accelerated apps
(TradeStation, some trading terminals). Switch to full-desktop screenshot
+ drag-rectangle as the default method — reliable across all apps.
- Title prompt still asked (needed by 'atm run' to locate window at runtime).
- Captured region saved to logs/calibrate_capture_<ts>.png for verification.
- Old window-capture path kept as fallback if region-select is cancelled.
- Minor: silence pyright windll attr warning.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Enumerate all matching windows, let user pick the largest one if multiple.
- Try PrintWindow first (works with GPU-accelerated apps like TradeStation
that render blank under plain mss capture).
- Detect blank/uniform captures and fall back to mss automatically.
- Save captured frame to logs/calibrate_capture_<ts>.png for debugging.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace skeletal wizard (window title only) with complete click-to-sample flow:
- Screenshot target window via mss+pygetwindow (Windows) or --screenshot PNG
- Single-canvas Tk wizard with scaled display + back/skip/save controls
- Stepwise clicks collect: dot_roi, all 7 dot colours (+ optional background),
chart_roi, 2 y-axis reference points with prices, canary region
- Auto-compute canary baseline phash at save time
- Pull Discord/Telegram creds from ATM_DISCORD_URL/ATM_TG_TOKEN/ATM_TG_CHAT env
- Add --screenshot flag to 'atm calibrate' for non-Windows dev testing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Cast res.color to DotColor in run_live loop.
- Add [tool.pyright] extraPaths to pyproject.toml for IDE resolution.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>