Compare commits

..

40 Commits

Author SHA1 Message Date
8a1be979fe chore(calibration): 22 frame-uri auto-captured din sesiunea 2026-04-22
Output din live loop (auto-capture pe schimbare de culoare) pentru
sesiunea de azi. Filename = culoare detectată de FSM (poate fi greșită).
Următorul pas manual: review și label-uire în calibration_labels.json
pentru atm validate-calibration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:59:21 +03:00
248ad6b10e feat(telegram): /rebase + /rebase confirm pentru re-anchor canary baseline
/rebase capturează + propune phash nou (screenshot adnotat cu red rect pe
canary.roi, old/new hash, distance, TTL 180s). /rebase confirm rescrie
baseline_phash în TOML-ul activ (regex line-match, păstrează comentariile),
mirror în cfg live via object.__setattr__ (CanaryRegion e frozen), clear
user_paused + drift_paused într-un singur shot — similar /resume.

Fix adiacent: _dispatch_ctx / _mock_config_class setează cfg.window_title=None
explicit; 5 teste _dispatch_command pre-existente eșuau pe MagicMock auto-
truthy care propaga în _focus_window_by_title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:56:51 +03:00
45ed502b3d feat(telegram): /ss + /resume cu verify multi-bulină și header FSM step
/ss și /resume afișează acum markerii top-3 buline sub ROI (cercuri pline,
r=7, culoarea clasificată) cu tick vertical roșu pe pick-ul FSM (rightmost).
Caption compact: `N/3 STATE` header + `emoji c1/c2/c3: name ← pick`.
FIRE_{BUY|SELL} afișat ca 3/3 când fire_ts e în ultimele 30s.

/resume face capture ÎNAINTE de clearing state → zero race cu FSM tick
simultan. Capture fail → title marchează "⚠️ captură eșuată", resume-ul
rulează oricum.

config: <version> mutat din caption în /status (acolo are sens pentru
verificare de calibrare, nu la fiecare /ss).

Adaugă find_top_dots în vision.py (top-N variantă a find_rightmost_dot,
tie-break determinist pe y). 5 teste sintetice noi + 4 teste noi pentru
dispatcher resume (screenshot inline, capture-fail, order-of-ops,
parity /ss <-> fire path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:38:29 +03:00
5ebe26e5d5 test(calibration): 8 labels + 3 scenarii noi (inclusiv regresie 2026-04-21)
calibration_labels.json: 16 → 24 entry-uri. Toate cele 8 frame-uri dumpate
automat de live loop azi (gray×5, dark_green×1, yellow×1, dark_red×1) trec
prin validate-calibration cu detector-ul curent (24/24 PASS).

scenarios.json: 8 → 11 scenarii:

1. buy_catchup_opposite_rearm_to_sell — REGRESSION pentru bug-ul de azi.
   Trace real din log: 17:00 catchup dark_green → PRIMED_BUY (synth arm+prime),
   apoi 17:45 yellow → ARMED_SELL via opposite_rearm. Verifică că dispatch-ul
   nou emite kind=opposite_rearm și că scheduler-ul se oprește.

2. buy_armed_gray_persist — gray între arm și prime ține ARMED_BUY
   (reason=persist). Acoperă o ramură FSM neacoperită.

3. buy_primed_gray_cooldown — gray după prime ucide ciclul (reason=cooled,
   IDLE, scheduler stop). Confirmă semantica M2D că chart-ul tăcut post-prime
   înseamnă setup expirat.

Total: 11/11 scenarii PASS, 238/238 teste (235 + 3 noi scenarii regresie).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:35:22 +03:00
75a17f9640 feat: auto-capture scrie direct în calibration/frames/ (elimină pasul manual)
Live loop-ul dumpa frame-uri pe schimbare de culoare în samples/, iar userul
copia manual cele utile în calibration/frames/ pentru labelling și regresie.
Pas inutil — acum scrie direct în corpus.

- samples_dir → calibration/frames/ (mkdir parents=True)
- stub capture (ATM_STUB_CAPTURE pentru smoke test Linux) citește din aceeași locație
- 8 PNG-uri orfane din samples/ (20260421_*) mutate în corpus
- CLAUDE.md clarifică: filename = culoarea detectată (poate fi greșită);
  calibration_labels.json rămâne singurul ground truth (manual)

Impact zero pe validate-calibration (iterează peste labels.json, ignoră fișiere
extra) și test_scenarios_regression.py (referă doar frame-uri curate din
scenarios.json).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:31:06 +03:00
ebc986abd3 docs(claude): scoate atm dryrun samples din quick-ref (corpus e calibration/)
samples/ nu mai e corpus-ul activ — calibration/frames/ + calibration_labels.json
au înlocuit workflow-ul vechi. `atm dryrun samples` rămâne comandă validă dar
necesită samples/labels.json care nu mai există în uzul curent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:26:51 +03:00
66ffa4bb9a fix: opposite_rearm/rearm alerts + /resume unified + canary-pause UX guards
Trei găuri observate în sesiunea 2026-04-21:

A. _handle_tick nu avea branch pentru reason=opposite_rearm (PRIMED_* ↔
   ARMED_opus) sau reason=rearm (PRIMED_* → ARMED_* aceeași direcție). La
   17:45 yellow a trecut FSM-ul PRIMED_BUY→ARMED_SELL corect, dar zero alert
   pe Telegram. Adaugă helper _emit_arm_alert (DRY cu branch-ul arm existent)
   și două branch-uri noi cu kind=opposite_rearm / kind=rearm.

B. Canary drift se curăța doar cu /resume force — user ușor confundă
   /set_interval cu „relansare" și rămâne în drift-pause (cazul 18:09 azi).
   /resume acum curăță user_paused + canary.resume() într-o singură comandă.
   /resume force rămâne alias acceptat (muscle memory legacy).

C. Heartbeat-ul afișa „activ ARMED_SELL" deși detecția era oprită de 3 ore
   (state FSM înghețat). Extract _build_heartbeat_alert care arată
   „⚠️ pauzat (drift)" + „[drift-pause]" când canary.is_paused.

Guard-uri pentru comenzi când canary e paused:
- /set_interval: refuzat cu warn „Trimite /resume"
- /ss: screenshot trimis + body-ul include „⚠️ DETECȚIE OPRITĂ"

11 teste noi (1 critical regression pentru bug-ul A observat azi), plus
actualizarea test-ului /resume existent care aserta vechiul comportament.
Total: 235 passed + 8 scenarii regresie.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:23:20 +03:00
a796e91e90 fix: forțează maximize TradeStation la focus + atm debug --canary
Canary drift imediat după /ss, /resume, market_open sau startup când
TradeStation nu era maximizat — chart_window_region e absolute, deci orice
dimensiune sub full-screen capturează alt conținut și phash-ul diverge.
SW_RESTORE pe fereastră zoomed o de-maximiza; acum IsIconic-unminimize +
SW_SHOWMAXIMIZED necondiționat.

Adaugă atm debug --canary: salvează crop-ul canary ROI + distance vs baseline
pentru diagnostic rapid când drift-ul apare în teren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:48:45 +03:00
2c1dae14fc fix: forțează SetForegroundWindow prin ALT-key pentru anti focus-stealing
pygetwindow.activate() raporta succes dar Windows refuza silent ridicarea
ferestrei TS (confirmat în audit: window_focused cu titlul corect, dar
screenshot-ul manual tot prindea app-ul de deasupra). Trucul standard:
keybd_event(ALT) resetează focus-lock-ul, apoi SetForegroundWindow via
ctypes pe hwnd direct. Fallback la win.activate() dacă lipsește _hWnd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:22:47 +03:00
9e0202c9ee 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>
2026-04-21 16:19:40 +03:00
9c44eb6e31 feat: mută secretele Discord/Telegram din TOML în .env
TOML-urile din configs/ rămân 100% calibrare — safe to commit. Secretele
(ATM_DISCORD_URL, ATM_TG_TOKEN, ATM_TG_CHAT) trăiesc în .env la rădăcină
(ignored), cu loader stdlib (shell wins peste file). Validare fail-fast
pentru env lipsă, placeholder REPLACE_ME, chat_id non-numeric.

Include .env.example + secţiune README §Secrets. Tests: 19 noi (env loader +
missing-env + placeholder + chat_id + regression post-migrate snapshot).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:37:24 +03:00
9e8cbafbd4 feat: calibration/ corpus + scenarii regresie FSM
- calibration/frames/: 16 PNG-uri ground-truth numite {ts}_{color}.png,
  copiate din logs/fires (izolate de samples/ și logs/fires/ care se pot goli)
- calibration/calibration_labels.json: mutat din samples/, curățat de entries
  cu fișiere inexistente, extins la acoperire completă 7 culori → 16/16 PASS
- calibration/scenarios.json: 8 secvențe FSM (BUY/SELL full cycle, phase_skip,
  catchup, post-fire suppression) pe frame-uri reale
- tests/test_scenarios_regression.py: parametrizat pe scenarios.json, asertează
  color+state+reason+trigger+alerts+scheduler prin pipeline-ul
  Detector → _handle_tick
- docs: README + CLAUDE reflectă noua structură, incidentul 2026-04-20/21
  (pixel saturat UNKNOWN → FSM blocat în PRIMED → polling continuu) +
  troubleshooting pentru trigger UNKNOWN

Pytest: 184 → 192 passed (+8 scenarii regresie).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 08:32:11 +03:00
bed79fcc35 calibrare 2026-04-21 07:25:38 +03:00
Claude Agent
082361e3c7 Revert "fix: trimite alert Telegram la închidere fereastră CMD pe Windows"
This reverts commit e1572fd3e3.
2026-04-18 13:00:16 +00:00
Claude Agent
e1572fd3e3 fix: trimite alert Telegram la închidere fereastră CMD pe Windows
CTRL_CLOSE_EVENT omora procesul instant, înainte ca finally să ruleze.
_run_live_win32 instalează SetConsoleCtrlHandler care cancelează task-ul
asyncio → finally trimite "ATM oprit" și flushează notifier-ul (~5s fereastra).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:51:27 +00:00
Claude Agent
414ad69369 feat: heartbeat suprimate afara orelor, format compact, status simplificat
- _in_trading_window(): helper nou — suprima heartbeat in afara ferestrei de tranzactionare
- _heartbeat_loop: format compact romanian (STATE | semnale: N | Xh), fara statistici backend
- ATM pornit: "canary:" -> "senzor: ok/deviat (dist/thresh)"
- ATM oprit: simplificat la "durata: Xh | semnale: N"
- /status: 2-3 linii compacte, etichete pauza in romana, fara "Canary"
- _maybe_log_transition: parametru status_body optional; titluri "Piata deschisa/inchisa"
- _brief_status(): helper nou pentru mesajele de tranzitie piata
- 10 teste noi (trading_window, status compact, fereastra); 194 passed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:41:52 +00:00
42a1a0e7fd feat: /help command, atm.bat launcher, tzdata fix pentru Windows
- Telegram /h /help — listă comenzi în română
- atm.bat — pornire cu venv local automat, pip install la primul run
- tzdata adăugat în deps principale cu marker sys_platform==win32
- README: secțiuni dev, instalare Windows, flow-uri calibrare

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 13:11:44 +03:00
7b91cb0cd0 feat: telegram commands, autopoll, operating hours, validate-calibration, phase-skip backstop 2026-04-18 12:42:03 +03:00
92a4b377c2 readme 2026-04-18 12:41:39 +03:00
5b61bd7b60 readme romana 2026-04-18 12:30:57 +03:00
212f77f0ee docs(readme): rescriere completă în română + workflow validate-calibration
- Tradus întreg README-ul din engleză în română, simplu și clar.
- Secțiune nouă "Reguli critice la calibrare" care codifică cele 3 lecții
  din incidentul 2026-04-17: click pe rightmost dot, canary pe pixel static,
  calibrare în sesiune activă.
- Workflow detaliat de corectare iterativă cu validate-calibration: cum
  colectezi samples în timpul sesiunii live (prin /ss), cum actualizezi
  calibration_labels.json, ce faci când FAIL, rollback.
- Exemplu real cu tabelul de ajustări aplicate în 2026-04-18-1220.toml
  (dark_red, light_red din evidență live; dark_green, light_green
  ajustate proporțional +45/+18 pe canalul dominant).
- Troubleshooting: două linii noi (alertă pe culoare greșită, hang după
  N ore rezolvat în c5024ce).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:28:31 +03:00
37f0b14468 docs: reflect Telegram /pause/resume, operating hours, phase-skip backstop, validate-calibration
README gets: operating-hours config + CLI override flags, Telegram command table
with /pause /resume [force] semantics, validate-calibration usage + exit codes,
new audit event reference, phase-skip backstop note, and test count bump.

CLAUDE.md quick reference now lists the new subcommand, CLI flags, and
Telegram commands so future sessions pick them up without re-reading main.py.

TODOS.md marks the 2026-04-17 hang fix, canary drift notification,
phase-skip backstop, operating-hours window, and validate-calibration as
done with commit pointers; adds exchange-calendar holidays as known gap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:09:44 +03:00
40cc67b4c6 fix(run): _should_skip tz check uses isinstance, tolerates mock cfg
Existing lifecycle tests mock cfg via MagicMock; the attribute auto-return
made `cfg.operating_hours._tz_cache` truthy-but-not-a-tzinfo, crashing
datetime.fromtimestamp with TypeError. Guard with isinstance(tz, tzinfo)
so mock configs are silently skipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:07:07 +03:00
8bae507bbd feat(cli): atm validate-calibration — offline color classification gate
Adds `atm validate-calibration LABEL_FILE` subcommand that runs the Detector
on a set of labeled PNG frames and reports per-sample PASS/FAIL with top-3
candidate colors and RGB-distance suggestions for failures. Exits 0 on 100%
PASS, 1 on any FAIL, 2 on missing/malformed label file.

- New module src/atm/validate.py with ValidationReport + SampleRecord
  dataclasses; reuses Detector.step(frame), does not reimplement color
  classification.
- main.py: new `validate-calibration` subparser and _cmd_validate_calibration
  handler wired into the dispatch map.
- samples/calibration_labels.json seeded with 3 entries from the 2026-04-17
  incident, plus a README describing the schema.
- tests/test_validate.py covers the 3 planned cases: PASS, FAIL w/ top-3
  + suggestion, missing file (graceful error, no traceback).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:02:48 +03:00
23865776e3 feat(commands): /pause /resume + adaptive dispatch + richer /status
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>
2026-04-18 12:01:19 +03:00
54f55752c1 feat(run,config): operating hours window + timezone-aware lifecycle state
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>
2026-04-18 11:59:22 +03:00
8b53b8d3c9 feat(alerts): fire_on_phase_skip backstop + public FSM lockout API
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>
2026-04-18 11:55:39 +03:00
9cf49caf8a feat(canary): single-shot on_pause_callback + wire Telegram drift alert
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>
2026-04-18 11:53:22 +03:00
c5024ce600 feat(run): extract detection loop helpers + unconditional cmd drain
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>
2026-04-18 11:52:28 +03:00
153196f762 chore(git): track logs dir; ignore runtime state files
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>
2026-04-18 10:10:21 +03:00
Claude Agent
3b40aed939 fix(run): isolate command dispatch exceptions from detection loop
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>
2026-04-17 11:29:42 +00:00
Claude Agent
0f7dd5dc84 fix(deps+tests): move httpx to prod deps; stub Poller+Scheduler in sync test
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>
2026-04-17 11:00:40 +00:00
Claude Agent
63642e71dd chore(todos): mark integration test done
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 10:54:24 +00:00
Claude Agent
424437ceaf fix(audit)+test: deadlock fix + lifecycle test + pytest-asyncio
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>
2026-04-17 10:54:10 +00:00
Claude Agent
ca6e578175 feat(run): async refactor — run_live_async + 7-step shutdown
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>
2026-04-17 10:37:17 +00:00
Claude Agent
4123b31a22 feat(commands,scheduler): TelegramPoller + ScreenshotScheduler
TelegramPoller: httpx async long-poll, startup drain, chat_id filter,
degrade after 3×401, Command dataclass with minute→second conversion.

ScreenshotScheduler: asyncio task, capture+annotate in to_thread (decisions 9+13),
silent=True on periodic screenshots, explicit constructor params.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 10:18:08 +00:00
Claude Agent
c1b89ad6a9 feat(config,detector): TelegramCfg polling fields + Detector.step optional frame
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>
2026-04-17 10:17:17 +00:00
Claude Agent
fd04fcd5e6 fix(audit): threading.Lock on AuditLog.log + close (P1 bug)
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>
2026-04-17 10:16:28 +00:00
Claude Agent
c6714e8d5e feat(notifier): Alert.silent + TelegramNotifier disable_notification
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>
2026-04-17 10:16:17 +00:00
Claude Agent
238243b1ce chore: add gstack skill routing rules to CLAUDE.md 2026-04-17 08:32:50 +00:00
90 changed files with 5536 additions and 887 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Copiază acest fișier în .env (la rădăcina proiectului) și pune valorile reale.
# .env e în .gitignore — secretele NU ajung pe git.
#
# Variabilele de shell au prioritate peste .env. Dacă ai făcut `export ATM_TG_TOKEN=...`
# cândva în .bashrc / profil, aceasta suprascrie .env — verifică cu `printenv | grep ATM_`.
ATM_DISCORD_URL=https://discord.com/api/webhooks/REPLACE_ME
ATM_TG_TOKEN=REPLACE_ME
ATM_TG_CHAT=REPLACE_ME

7
.gitignore vendored
View File

@@ -46,14 +46,15 @@ ENV/
# ATM runtime artefacts
logs/*.jsonl
logs/dead_letter.jsonl
logs/detections/
logs/fires
logs/pause.flag
samples/*.png
samples/*.jpg
samples/labels.json
trades.jsonl
# configs: keep template + current marker, not generated calibration
configs/*.toml
!configs/example.toml
# configs: now committable (secrets live in .env — see .env.example)
# Claude scheduler state
.claude/

75
CLAUDE.md Normal file
View File

@@ -0,0 +1,75 @@
# ATM — Automated Trading Monitor
Personal Faza-1 tool for the M2D strategy. Python 3.11+.
## Quick Reference
```bash
pip install -e ".[windows]" # Windows: live capture
pip install -e ".[dev]" # Linux/macOS: dev + tests (WSL: create venv first)
cp .env.example .env # secretele Discord/Telegram (vezi README §Secrets)
atm calibrate # Tk wizard
atm debug --delay 5 # one-shot capture + detect
atm validate-calibration calibration/calibration_labels.json # offline color gate
atm run --start-at 16:30 --stop-at 23:00 # live session
atm run --tz America/New_York --oh-start 09:30 --oh-stop 16:00 # NYSE window override
pytest -q # 230+ tests (core + 8 scenarii regresie + env loader)
pytest tests/test_scenarios_regression.py -v # FSM pe imagini reale
```
## Calibration corpus
`calibration/` — persistent, auto-suficient. Conține:
- `frames/` — PNG-uri raw `{ts}_{color}.png` scrise **automat** de live loop la fiecare schimbare de culoare (filename = culoarea detectată, poate fi greșită)
- `calibration_labels.json` — ground truth **manual** (gate offline pentru `atm validate-calibration`)
- `scenarios.json` — secvențe FSM pentru `tests/test_scenarios_regression.py`
Workflow după sesiune: review frame-urile noi din `frames/`, adaugi entry-uri în `calibration_labels.json` cu culoarea pe care ai văzut-o TU pe chart (nu neapărat cea din filename), rulezi `atm validate-calibration`.
## Telegram commands (live)
`/ss` `/status` `/pause` `/resume` `/rebase` `/3` (interval min) `/stop`
- `/rebase` — propune un `baseline_phash` nou pentru canary: capturează frame, crop pe `canary.roi`, phash → trimite screenshot adnotat (cerc roșu pe ROI) cu old/new hash + distance. `/rebase confirm` în ≤180s aplică: rescrie `baseline_phash` în TOML-ul activ (păstrează comentariile), mirror în `cfg` la runtime, clear `user_paused` + `drift_paused`. Fără confirm, nimic nu se modifică. Folosește-l când layout-ul TS s-a schimbat intenționat și vrei să re-ancorezi canary-ul fără `atm calibrate` full.
- `/ss` — verify multi-bulină: adnotează top-3 buline din `dot_roi` (cerc roșu gros pe pick-ul FSM, cercuri colorate subțiri pe vecini) + caption cu clasificarea fiecăreia (nume, RGB, distanță, confidence) + `config: {version}`. Cercul colorat folosește `cfg.colors[name].rgb` la runtime — DRY cu paleta activă.
- `/resume` clears BOTH user pause and canary drift-pause in one shot (`/resume force` still accepted as legacy alias). Trimite un singur Alert cu screenshot adnotat inline (capture rulează **înainte** de clearing state → zero race cu FSM tick-uri). Dacă capture eșuează, title conține `⚠️ captură eșuată` și resume-ul se execută oricum.
- Drift-pause emits a single Telegram alert on transition. While paused, `/set_interval` is refused and `/ss` captions warn that detection is off.
- Heartbeat shows `⚠️ pauzat (drift)` instead of `activ` while canary is paused.
## Operating-hours config
`[options.operating_hours]` in TOML: `enabled`, `timezone` (NYSE local, e.g. `America/New_York`), `weekdays`, `start_hhmm`, `stop_hhmm`. Timezone validated at load; `_tz_cache` reused per tick. Boundary crossings log `market_open` / `market_closed` and notify once. Startup in-window is silent.
## Phase-skip backstop
`[options.alerts] fire_on_phase_skip = true` (default) — ARMED→light_* direct (dark_* missed) still emits a `⚠️ PHASE SKIP` alert using FSM lockout to suppress spam.
## Palette gotcha (2026-04-21 recalibration)
TradeStation M2D indicators paint the four bright colors at near-pure saturation:
turquoise `(0,253,253)`, yellow `(253,253,0)`, light_green `(0,255,0)`, light_red `(255,0,0)`.
If Tk-wizard calibration samples a slightly desaturated pixel, classifier returns `UNKNOWN`
(distance > tolerance=60) → FSM never sees trigger → stuck in PRIMED → scheduler polls
forever. Always run `atm validate-calibration calibration/calibration_labels.json` after
recalibrating. Current active config: `configs/2026-04-21-recalib.toml`.
## Skill routing
When the user's request matches an available skill, ALWAYS invoke it using the Skill
tool as your FIRST action. Do NOT answer directly, do NOT use other tools first.
The skill has specialized workflows that produce better results than ad-hoc answers.
Key routing rules:
- Product ideas, "is this worth building", brainstorming → invoke office-hours
- Bugs, errors, "why is this broken", 500 errors → invoke investigate
- Ship, deploy, push, create PR → invoke ship
- QA, test the site, find bugs → invoke qa
- Code review, check my diff → invoke review
- Update docs after shipping → invoke document-release
- Weekly retro → invoke retro
- Design system, brand → invoke design-consultation
- Visual audit, design polish → invoke design-review
- Architecture review → invoke plan-eng-review
- Save progress, checkpoint, resume → invoke checkpoint
- Code quality, health check → invoke health

536
README.md
View File

@@ -1,98 +1,198 @@
# ATM — Automated Trading Monitor
# ATM — Monitor Automat de Trading
Personal Faza-1 tool for the **M2D strategy**. Watches the M2D MAPS colored-dot strip on a TradeStation chart, runs a phased state machine (ARMEDPRIMEDFIRE), pushes Discord + Telegram alerts with an annotated screenshot on BUY/SELL. You execute the trade manually in TradeLocker.
Tool personal pentru strategia **M2D**. Urmărește banda de puncte colorate M2D MAPS de pe un chart TradeStation, rulează o mașină de stări pe faze (ARMEDPRIMEDFIRE) și trimite alerte pe Discord + Telegram cu screenshot adnotat la fiecare semnal BUY/SELL. **Execuția trade-ului o faci tu manual în TradeLocker.**
No auto-execution. Faza 2 (auto-execute) is blocked on prop-firm TOS audit — see `docs/phase2-prop-firm-audit.md`.
Fără execuție automată. Faza 2 (auto-execute) e blocată de auditul TOS prop-firm — vezi `docs/phase2-prop-firm-audit.md`.
---
## Project layout
## Cum e organizat proiectul
```
atm/
├── configs/ # calibration outputs + current.txt marker
├── configs/ # calibrări + current.txt (marcaj care config e activ)
├── calibration/ # corpus auto-suficient pentru validare + regresie
│ ├── calibration_labels.json # etichete per-frame pentru atm validate-calibration
│ ├── scenarios.json # secvențe FSM (arm→prime→trigger etc.) pentru test_scenarios_regression.py
│ ├── frames/ # PNG-uri numite {ts}_{color}.png, izolate de logs/fires și samples
│ └── README.md
├── logs/
│ ├── YYYY-MM-DD.jsonl # per-cycle audit log, rotates at local midnight
│ ├── dead_letter.jsonl # alerts that failed after retries
│ ├── fires/ # annotated screenshots, one per BUY/SELL trigger
│ └── calibrate_capture_*.png / debug_*.png # gitignored debug artifacts
├── samples/ # full frames saved automatically on each colour change
├── src/atm/ # package
│ ├── config.py # frozen dataclass + TOML loader
│ ├── vision.py # ROI crop, phash, pixel↔price, Hough, connected-components
│ ├── state_machine.py # 5-state phased FSM, per-direction lockout
│ ├── detector.py # capture → crop → find rightmost dot → classify → debounce
│ ├── canary.py # layout phash drift watchdog with pause-file gating
│ ├── levels.py # Phase-B SL/TP line extraction
│ ├── notifier/ # FanoutNotifier + Discord webhook + Telegram bot
│ ├── audit.py # line-buffered JSONL, daily rotation
│ ├── calibrate.py # Tk wizard (region-select + click-sample)
│ ├── labeler.py # Tk UI → labels.json
│ ├── dryrun.py # replay corpus, precision/recall gate
│ ├── journal.py # trade entries
│ ├── report.py # weekly R-multiple PnL
── main.py # unified CLI
├── tests/ # 105 pytest cases
── TODOS.md # P1/P2/P3 backlog, Faza 2 items
│ ├── YYYY-MM-DD.jsonl # audit zilnic, se rotește la miezul nopții local
│ ├── dead_letter.jsonl # alerte care au eșuat după retries
│ ├── fires/ # screenshot-uri adnotate, unul per trigger BUY/SELL (tranzitoriu, se poate goli)
│ └── calibrate_capture_*.png / debug_*.png # artefacte debug (gitignored)
├── samples/ # frame complet salvat automat la fiecare schimbare de culoare (tranzitoriu)
├── src/atm/ # pachetul Python
│ ├── config.py # dataclass + loader TOML
│ ├── vision.py # crop ROI, phash, pixel↔preț, Hough, componente conectate
│ ├── state_machine.py # FSM 5 stări + lockout per direcție
│ ├── detector.py # capture → crop → găsește dot-ul rightmost → clasifică → debounce
│ ├── canary.py # watchdog layout via phash drift + flag de pauză
│ ├── levels.py # extracție SL/TP pe Faza-B
│ ├── notifier/ # FanoutNotifier + webhook Discord + bot Telegram
│ ├── audit.py # JSONL line-buffered, rotație zilnică
│ ├── calibrate.py # wizard Tk (selectează regiune + click pe culori)
│ ├── labeler.py # UI Tk → labels.json
│ ├── dryrun.py # replay pe corpus, gate precision/recall
│ ├── validate.py # gate offline de clasificare a culorilor
│ ├── journal.py # înregistrări trade-uri
── report.py # raport săptămânal PnL în R
│ └── main.py # CLI unificat
── tests/ # 192 teste pytest (184 core + 8 scenarii regresie)
└── TODOS.md # backlog P1/P2/P3
```
---
## Install
## Instalare
Python 3.11+ required. Clone, then:
Python 3.11+.
```bash
pip install -e ".[windows]" # Windows: live capture + window focus
pip install -e . # Linux / macOS: dev / dryrun only (no live)
### Windows (producție)
```powershell
python -m venv .venv
.venv\Scripts\activate
pip install -e ".[windows]"
# → creează .venv\Scripts\atm.exe
atm --help
```
`[windows]` pulls `mss`, `pygetwindow`, `pywin32`.
`[windows]` aduce `mss`, `pygetwindow`, `pywin32`. Fără venv, `pip install -e ".[windows]"` direct în Python-ul global funcționează la fel.
Pornire rapidă cu scriptul inclus — instalează automat la primul run:
```powershell
atm.bat # prima rulare: pip install + atm run
atm.bat run --stop-at 23:00
atm.bat debug
```
### WSL / Linux (dev + teste)
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
`[dev]` aduce `pytest`, `pytest-cov`, `pytest-asyncio`. Nu include dependențele Windows (`mss`, `pygetwindow`, `pywin32`) — nu rulează capture live.
---
## Calibration
## Secrets
One-time per chart layout. Run on the machine that will do live capture.
Credențialele Discord/Telegram NU se țin în TOML — trăiesc în `.env` la rădăcina proiectului:
```bash
cp .env.example .env
# apoi editezi .env cu valorile reale
```
Variabile necesare:
| Variabilă | Ce e |
|---|---|
| `ATM_DISCORD_URL` | Webhook URL-ul Discord (canalul unde vin alertele) |
| `ATM_TG_TOKEN` | Token-ul bot-ului Telegram (de la `@BotFather`) |
| `ATM_TG_CHAT` | Chat ID (group-ul sau user-ul; prefix `-` pentru group) |
`.env` e în `.gitignore` — secretele nu ajung pe git. `configs/*.toml` **pot** fi comise pe git (calibrare pură, safe to version).
La pornire, dacă `.env` e găsit, loader-ul printează pe stderr:
```
[atm.config] .env: loaded 3 vars (0 overridden by shell)
```
**⚠️ Shell env wins peste `.env`.** Dacă ai făcut `export ATM_TG_TOKEN=...` cândva în `.bashrc` / profil, aceasta override-uiește `.env` — verifică cu `printenv | grep ATM_`. Mesajul `(N overridden by shell)` te avertizează când se întâmplă.
Config-ul se refuză să pornească dacă:
- lipsește oricare din cele 3 variabile (mesaj cu numele variabilei + hint către `.env.example`);
- `ATM_DISCORD_URL` sau `ATM_TG_TOKEN` conține `REPLACE_ME` (ai copiat `.env.example` dar n-ai editat);
- `ATM_TG_CHAT` nu-i numeric (opțional cu `-` la început pentru group).
---
## Dev
```bash
pytest -q # toate testele (192+)
pytest tests/test_commands.py # un modul specific
pytest tests/test_scenarios_regression.py -v # scenarii FSM pe imagini reale
pytest -q --cov=atm --cov-report=term-missing # cu coverage
```
Smoke-test fără Windows (stub de captură din `samples/`):
```bash
atm run --capture-stub --duration 0.05
```
Structura testelor:
| Fișier | Ce acoperă |
|---|---|
| `test_commands.py` | parsing comenzi Telegram |
| `test_config.py` | loader TOML, attach_screenshots |
| `test_handle_tick.py` | loop principal, snapshot, FSM |
| `test_main.py` | lifecycle, operating hours, canary, dispatcher |
| `test_validate.py` | gate offline clasificare culori |
| `test_canary.py` | drift + callback pauză |
| `test_scenarios_regression.py` | secvențe FSM pe frame-uri reale (arm→prime→trigger, phase_skip, catchup, post-fire suppression) |
---
## Calibrare
Se face o singură dată per layout de chart. Trebuie să ruleze pe mașina pe care face capture live (Windows, fizic — nu RDP/virtual).
```powershell
atm calibrate # 3s default countdown; use --delay 10 if you want more time
atm calibrate # countdown 3s default; pune --delay 10 dacă vrei mai mult timp
```
Flow:
1. Dialog: substring of the chart window title (e.g. `TradeStation` or `DIA`). Stored in config for later auto-focus.
2. **"Ready?" message** → click OK → 3s countdown in terminal. Alt-tab TradeStation to the foreground and minimize anything covering it.
3. Full-desktop screenshot is captured and shown in a scaled Tk window.
4. **Drag a rectangle** over the chart (include the M2D MAPS strip). Enter = confirm. Esc = cancel.
5. Step-by-step clicks on the selected region:
- M2D MAPS strip: top-left + bottom-right corners
- One click on each of: turquoise, yellow, dark_green, dark_red, light_green, light_red, gray dot + chart background (8 total — "Skip" if a colour isn't currently visible)
- Chart area: top-left + bottom-right (for Phase-B line detection)
- Two known price levels on the y-axis (pixel y → enter price)
- Canary region: top-left + bottom-right on a stable UI element (axis label, title bar)
6. **Save**writes `configs/YYYY-MM-DD-HHMM.toml` + marker `configs/current.txt`. Pulls Discord/Telegram creds from env (`ATM_DISCORD_URL`, `ATM_TG_TOKEN`, `ATM_TG_CHAT`) if set; otherwise `REPLACE_ME` placeholders — edit the TOML manually.
1. Dialog: substring din titlul ferestrei chart-ului (ex. `TradeStation` sau `DIA`). Se salvează în config pentru auto-focus ulterior.
2. **Mesaj "Ready?"** → click OK → countdown 3s în terminal. Alt-tab pe TradeStation, minimizează tot ce-l acoperă.
3. Se face screenshot full-desktop, apare o fereastră Tk scalată.
4. **Trage un dreptunghi** peste chart (include și banda M2D MAPS). Enter = confirmă. Esc = anulează.
5. Click pas cu pas pe regiunea selectată:
- M2D MAPS strip: colț stânga-sus + colț dreapta-jos
- Un click pe fiecare culoare: turquoise, yellow, dark_green, dark_red, light_green, light_red, gray + background (8 total — "Skip" dacă o culoare nu-i vizibilă acum)
- Chart: colț stânga-sus + colț dreapta-jos (pentru detecția de linii în Faza-B)
- Două prețuri cunoscute pe axa Y (pixel y → introduci prețul)
- Canary: colț stânga-sus + colț dreapta-jos pe un element UI **stabil** (etichetă axă, bară titlu)
6. **Save**scrie `configs/YYYY-MM-DD-HHMM.toml` + marcaj `configs/current.txt`. TOML-ul conține doar calibrare — secretele Discord/Telegram se țin în `.env` la rădăcina proiectului (vezi secțiunea **Secrets** de mai jos).
What gets written:
- `chart_window_region = {x, y, w, h}` — virtual-desktop absolute rectangle. Runtime capture crops the same box, so the window must stay in that position.
- `dot_roi`, `chart_roi`, `canary.roi` — coords relative to the selected region.
- Per-colour RGB (sampled via saturation-snap within 15px of the click, mean of 5x5 around the snapped centre).
- `y_axis` linear-interp pair.
- `canary.baseline_phash` of the canary ROI.
### ⚠️ Reguli critice la calibrare (evită incidentul 2026-04-17)
Sampling tips:
- Click colours that are **actually present** in the current dot history. If a colour isn't visible, skip it — `atm dryrun` will tell you if the skipped value doesn't match real dots.
- Default tolerance is 60 for dot colours, 25 for background. Tighten via TOML after dryrun if misclassifications creep in.
**1. Click EXCLUSIV pe dot-ul din DREAPTA al strip-ului.**
Banda M2D MAPS e istoric: dot-ul din dreapta = activ/curent, restul sunt mai vechi. TradeStation desenează dot-ul activ mai strălucitor decât cele vechi. Detector-ul live citește MEREU dot-ul din dreapta. Dacă dai click pe unul din stânga, culoarea calibrată e mai întunecată decât realitatea → clasificare greșită live (dark_red poate ajunge citit ca light_red, de exemplu).
**2. Canary pe un pixel STATIC.**
NU pune regiunea canary peste: volume bar, preț curent, ceas/timestamp. Orice se schimbă natural în acea zonă declanșează drift-pause silent → bot-ul se oprește din detecție fără alertă vizibilă (asta s-a întâmplat la 22:25 pe 17.04, drift=129). Alege: o etichetă de axă, un titlu de panel, un colț de bordură.
**3. Calibrează în mijlocul unei sesiuni active**, nu dimineața înainte de deschidere. Dot-urile sunt clar vizibile și reflectă exact aceleași setări de rendering ca la live.
### Ce scrie în TOML
- `chart_window_region = {x, y, w, h}` — dreptunghi absolut virtual-desktop. Capture-ul la runtime crop-ează exact aceeași cutie, deci fereastra **nu trebuie mutată** după calibrare.
- `dot_roi`, `chart_roi`, `canary.roi` — coordonate relative la regiunea selectată.
- RGB per culoare (eșantionat cu saturation-snap într-o rază de 15px de click, media unui box 5x5 în jurul pixelului snapped).
- `y_axis` — pereche de interpolare liniară.
- `canary.baseline_phash` al ROI-ului canary.
Tips de sampling:
- Click pe culori **chiar vizibile acum** în istoricul dot-urilor. Dacă o culoare nu-i vizibilă, skip — `atm dryrun` îți zice dacă valoarea ratată nu se potrivește cu dot-uri reale.
- Tolerance default: 60 pentru dot-uri, 25 pentru background. Strângi în TOML după dryrun dacă apar misclasificări.
---
## Smoke-test after calibration
## Smoke-test după calibrare
```powershell
atm debug --delay 5
```
Captures one frame. Saves `logs/debug_full_<ts>.png`, `logs/debug_dot_roi_<ts>.png`, `logs/debug_annotated_<ts>.png`. Prints:
Ia un frame. Salvează `logs/debug_full_<ts>.png`, `logs/debug_dot_roi_<ts>.png`, `logs/debug_annotated_<ts>.png`. Tipărește:
```
window_found: True
@@ -102,116 +202,322 @@ classified: gray distance=24 confidence=0.79
accepted: True color=gray
```
Open the annotated PNG: yellow rectangle = `dot_roi`, red circle = detected dot. The circle should land on the ACTUAL rightmost colored dot in the M2D MAPS strip. If not:
- Circle mid-strip → wrong window under the capture region (bring TradeStation to front).
- Circle on a non-dot UI element → `dot_roi` boundaries capture too much; recalibrate narrower.
- `color=None` + `UNKNOWN` → tolerances too tight OR sampled RGBs don't match real dots; recalibrate clicking on actual dots.
Deschizi PNG-ul adnotat: dreptunghi galben = `dot_roi`, cerc roșu = dot detectat. Cercul trebuie să pice pe **dot-ul colorat cel mai din dreapta** din banda M2D MAPS. Dacă nu:
- Cerc la mijloc de strip → alt window e sub regiunea de capture (adu TradeStation în față).
- Cerc pe element UI non-dot → `dot_roi` prea larg; recalibrează mai îngust.
- `color=None` + `UNKNOWN` → tolerances prea strânse SAU RGB-urile eșantionate nu se potrivesc cu dot-urile reale; recalibrează cu click pe dot-uri reale.
---
## Live run
## Validare offline a calibrării
Verifici dacă calibrarea actuală clasifică corect un set de frame-uri etichetate manual, **fără să aștepți sesiunea live**. Esențial după orice recalibrare.
```bash
atm validate-calibration calibration/calibration_labels.json
```
Format input (`calibration/calibration_labels.json`):
```json
[
{"path": "calibration/frames/20260420_171501_yellow.png", "expected": "yellow"},
{"path": "calibration/frames/20260420_172104_dark_red.png", "expected": "dark_red"},
{"path": "calibration/frames/20260420_173004_light_red.png", "expected": "light_red"}
]
```
Frame-urile sunt copiate în `calibration/frames/` cu numele `{timestamp}_{culoare}.png`
— numele reflectă ground truth-ul vizibil pe dot, nu label-ul de eveniment din
`logs/fires/`. Directorul e auto-suficient: `samples/` și `logs/fires/` se pot
goli oricând fără să afecteze validarea.
Output: per fiecare frame PASS/FAIL + culoarea detectată + top 3 candidați după distanță RGB + sugestii de pixel pentru misclasificări.
Exit code:
- `0` — 100% PASS (poți porni live în siguranță)
- `1` — cel puțin un FAIL
- `2` — input invalid/lipsă
### Trei surse de frame-uri, roluri distincte
| Sursă | Unde se salvează | Cum se populează | Folosit de |
|---|---|---|---|
| `calibration/frames/` | PNG-uri curate `{ts}_{color}.png` | **manual** — copii din `logs/fires/` doar cele verificate | `atm validate-calibration` + `test_scenarios_regression.py` |
| `samples/` | frame complet la fiecare **schimbare de culoare** detectată | automat de `atm run` | `atm label` + `atm dryrun` |
| `logs/fires/` | screenshot adnotat la fiecare alertă BUY/SELL, `/ss` manual, **interval automat `/3`** | manual sau scheduler | sursă pentru `calibration/frames/` |
`calibration/` e singurul director **persistent**. Celelalte două se pot goli
după ce ai extras ce-ți trebuie — tranzitorii prin natură.
### Regresie FSM pe frame-uri reale
`calibration/scenarios.json` definește secvențe ordonate (arm → prime → trigger,
phase_skip, catchup, suprimare dark_* post-fire) care refolosesc aceleași frame-uri.
`tests/test_scenarios_regression.py` rulează fiecare secvență prin pipeline-ul real
`Detector → _handle_tick`, asertând per pas: culoarea detectată, tranziția FSM
(prev→next + reason + trigger), alertele emise prin notifier, și starea
scheduler-ului (running/stopped).
Extensii fără cod nou: adaugi un scenariu în JSON și pytest-ul îl consumă automat
(parametrizat pe `id`). Dacă scenariul cere o combinație de culori noi, copii
frame-ul în `calibration/frames/` cu numele `{timestamp}_{culoare}.png`.
**Flow A — calibrare fină cu screenshots automate (`/3`)**
Util când vrei să acumulezi repede frame-uri din culori reale, fără să aștepți schimbări de culoare.
1. **În sesiunea live**, trimite `/3` în Telegram → bot-ul face screenshot automat la 3 minute și îl salvează în `logs/fires/*_ss.png`. Oprești cu `/stop`.
2. **După sesiune**, adaugi intrări în `calibration/calibration_labels.json` pentru fiecare screenshot relevant, cu culoarea pe care ai văzut-o TU pe chart:
```json
{"path": "logs/fires/20260420_151234_ss.png", "expected": "dark_green", "note": "văzut live, ratat de bot"}
```
3. **Rulează validarea:**
```bash
atm validate-calibration calibration/calibration_labels.json
```
4. **Interpretează rezultatul:**
- **Toate PASS** → calibrarea ține, continui live fără modificări.
- **Măcar un FAIL** → output-ul îți arată pixelul real (ex. `RGB(128, 0, 0)`), centrul curent din TOML (ex. `dark_red RGB(83, 0, 0)`) și distanța. Două opțiuni:
- **Fix tactic rapid:** editezi TOML-ul direct, muți centrul culorii aproape de pixelul observat. Rulezi iar `validate-calibration`. Te oprești când e PASS.
- **Fix complet:** la următoarea sesiune live completă, rulezi `atm calibrate` de la zero pe Windows, cu **disciplina cele 3 reguli critice de mai sus** (rightmost dot, pixel static pentru canary, în timpul unei sesiuni active).
5. **Acumulezi mai multe samples în timp.** Obiectiv: 2-3 intrări per culoare în `calibration_labels.json`. Cu cât fișierul are mai multe etichete, cu atât calibrarea următoare e validată mai solid.
**Flow B — gate de precizie pe corpus de schimbări de culoare**
`atm run` salvează automat în `samples/` un frame complet la fiecare schimbare de culoare detectată. După sesiune:
```powershell
# Today's session 16:3023:00 Romania local
atm label samples # UI Tk — etichetezi fiecare frame cu culoarea reală văzută pe chart
atm dryrun samples # replay prin detector + FSM; exit 0 dacă precision=100%, recall≥95%
```
Dacă gate-ul pică, ajustezi `tolerance` per culoare în TOML sau corectezi eșantioanele nepotrivite, apoi rulezi iar `atm dryrun` până trece.
### Workflow de corectare iterativă (când apare o alertă greșită live)
Scenariu: ai rulat o sesiune live, ai văzut pe chart o culoare pe care bot-ul n-a detectat-o (sau a detectat greșit).
1. **În timpul sesiunii** — două opțiuni pentru a captura dovezi:
- `/ss` în Telegram → un screenshot instant în `logs/fires/`
- `/3` în Telegram → screenshots automate la 3 min în `logs/fires/` (util dacă nu ești la monitor continuu); oprești cu `/stop`
2. **După sesiune**, adaugi intrările relevante în `calibration/calibration_labels.json` cu culoarea corectă și rulezi `atm validate-calibration` (Flow A de mai sus).
3. Dacă apar FAIL-uri, aplici fix tactic în TOML sau recalibrezi complet.
### Exemplu real — incidentul 2026-04-17
La 20:53 s-a afișat un dark_red pe chart dar bot-ul l-a citit ca light_red (alertă ratată). Root cause: calibrarea anterioară (`2026-04-16-0703.toml`) a fost făcută dând click pe dot-uri istorice (mai întunecate), nu pe dot-ul activ din dreapta.
Fix aplicat în `2026-04-18-1220.toml`, pe bază de evidență live:
| Culoare | Centru vechi | Pixel live observat | Centru nou |
|---|---|---|---|
| dark_red | (83, 0, 0) | (128, 0, 0) | **(128, 0, 0)** |
| light_red | (153, 0, 0) | (171, 0, 0) | **(171, 0, 0)** |
| dark_green | (0, 77, 0) | — | **(0, 122, 0)** (ajustat proporțional: +45 pe G) |
| light_green | (0, 153, 0) | — | **(0, 171, 0)** (ajustat proporțional: +18 pe G) |
yellow, turquoise, gray, background — lăsate neschimbate (nu am dovezi live care să justifice ajustarea).
După fix: `atm validate-calibration` → 3/3 PASS, confidence 1.00 pe ambele roșuri.
### Exemplu real — incidentul 2026-04-20/21 (culori saturate)
User a observat screenshot-uri poll periodice după ce un trigger BUY/SELL părea deja declanșat. Dovadă: `logs/fires/20260420_214908_poll.png` avea pixel verde pur `(0, 255, 0)` (trigger light_green) dar detector-ul îl clasifica `UNKNOWN`. Investigație: 27/114 PNG-uri din corpus ieșeau UNKNOWN pentru că paleta din `2026-04-18-1220.toml` avea centrele celor patru culori luminoase **prea întunecate** — distanța până la pixelul real depășea toleranța de 60.
Fix aplicat în `2026-04-21-recalib.toml`:
| Culoare | Centru vechi | Pixel live observat | Centru nou | d(vechi) |
|---|---|---|---|---|
| turquoise | (0, 153, 153) | (0, 253, 253) | **(0, 253, 253)** | 141 |
| yellow | (153, 153, 0) | (253, 253, 0) | **(253, 253, 0)** | 141 |
| light_green | (0, 171, 0) | (0, 255, 0) | **(0, 255, 0)** | 84 |
| light_red | (171, 0, 0) | (255, 0, 0) | **(255, 0, 0)** | 84 |
dark_green, dark_red, gray, background — neschimbate (nu ieșeau UNKNOWN).
Consecință invizibilă pentru user: fără trigger acceptat de FSM, starea rămânea blocată în `PRIMED_*` → `ScreenshotScheduler` nu primea `reason=fire/cooled/phase_skip/opposite_rearm` → polling continuu la 3 min ore în șir.
După fix: corpus 27→0 UNKNOWN pe culorile luminoase (restul 9 sunt pixeli off-ROI crem, nu dot-uri). `atm validate-calibration calibration/calibration_labels.json` → 16/16 PASS.
**Lesson learned:** la recalibrare cu wizard-ul Tk, dacă folosești o imagine screenshot (nu captură live), pipeline-ul de saturation-snap poate rata pixelul cel mai saturat și să ia un dot ușor desaturat. Regulă: după wizard, verifică imediat cu `atm validate-calibration` pe un corpus cu toate 7 culorile. Dacă vreo culoare iese UNKNOWN, corectează manual în TOML cu pixelul real observat.
**Rollback** dacă ceva merge prost:
```bash
echo "2026-04-18-1220.toml" > configs/current.txt # sau 2026-04-16-0703.toml
```
---
## Sesiunea live
```powershell
# Sesiunea de azi 16:3023:00 România local
atm run --start-at 16:30 --stop-at 23:00
# Indefinite
# Fără limită
atm run
# Fixed duration (hours)
# Durată fixă (ore)
atm run --duration 2
# Linux / headless smoke (reads samples/*.png in a loop)
# Linux/WSL smoke (rulează pe fișiere din samples/)
atm run --capture-stub --duration 0.05
```
Startup sequence:
1. Wall-clock wait until `--start-at` (if set).
2. `pygetwindow.activate()` on the first window matching `cfg.window_title`brings TradeStation to the foreground automatically (restores if minimised).
3. 5s countdown (`--startup-delay`).
4. Capture first frame + canary check. Status (`drift=X/Y` or `capture_failed`) is included in the startup ping.
5. **"ATM started" ping** on Discord + Telegram.
6. Main loop: every `loop_interval_s` (default 5s) — capture → canary → detect → state machine → maybe notify → maybe Phase-B.
7. At `--stop-at` (or `--duration`): **"ATM stopped" ping**, then exit.
Startup:
1. Așteptare wall-clock până la `--start-at` (dacă e setat).
2. `pygetwindow.activate()` pe prima fereastră care conține `cfg.window_title` — aduce TradeStation în față (restaurează dacă-i minimizată).
3. Countdown 5s (`--startup-delay`).
4. Primul frame + check canary. Status (`drift=X/Y` sau `capture_failed`) e inclus în ping-ul de start.
5. **Ping "ATM started"** pe Discord + Telegram.
6. Loop principal: la fiecare `loop_interval_s` (default 5s) — capture → canary → detect → FSM → poate notifică → poate Faza-B.
7. La `--stop-at` (sau `--duration`): **ping "ATM stopped"**, apoi exit.
Per-cycle behaviour:
- Canary drift → auto-pause (logs `paused`, skips detection). Clear by running `atm run` again with the pause-file removed.
- Detector reports UNKNOWN → stays in current state (logged as `noise`).
- Colour change → full frame saved to `samples/YYYYMMDD_HHMMSS_<color>.png` (for corpus).
- FIRE (BUY/SELL, not locked) → annotated PNG saved to `logs/fires/`, attached to the alert, `LevelsExtractor` armed.
- Phase-B complete → "Levels SL=… TP1=… TP2=…" push.
- Heartbeat every `heartbeat_min` minutes.
Comportament per ciclu:
- Drift canary → auto-pause + **alertă Telegram single-shot** (`⚠️ Canary drift=N — monitorizare pauzată`). Anulezi cu `/resume force` în Telegram, sau repornești cu flag-ul de pauză șters.
- Detector raportează UNKNOWN → rămâne în starea curentă (loghează `noise`).
- Schimbare de culoare → frame complet salvat în `samples/YYYYMMDD_HHMMSS_<color>.png` (pentru corpus).
- FIRE (BUY/SELL, nu locked) → PNG adnotat salvat în `logs/fires/`, atașat la alertă, `LevelsExtractor` armed.
- **Phase-skip backstop** (`fire_on_phase_skip=true` default) → ARMED → light_red/light_green direct (dark_* ratat) emite totuși alertă `⚠️ PHASE SKIP` cu screenshot. Lockout-ul FSM previne spam.
- Faza-B completă → push "Levels SL=… TP1=… TP2=…".
- Heartbeat la fiecare `heartbeat_min` minute.
Keep PowerShell minimized during the session so it doesn't cover TradeStation.
Ține PowerShell minimizat în timpul sesiunii ca să nu acopere TradeStation.
### Fereastra orelor de trading
Configurezi din TOML (sursă adevăr: NYSE local, timezone-aware — DST-ul e gestionat automat):
```toml
[options.operating_hours]
enabled = true
timezone = "America/New_York" # validat fail-fast la load
weekdays = ["MON", "TUE", "WED", "THU", "FRI"]
start_hhmm = "09:30" # deschidere NYSE
stop_hhmm = "16:00" # închidere NYSE
```
Tick-urile din afara ferestrei sunt skipped (logged doar la tranziție). La traversarea boundary-ului bot-ul emite `market_open` / `market_closed` în Telegram — o singură dată per tranziție. **Pornirea în-fereastră nu emite alertă spurioasă.**
Override din CLI (bat TOML-ul):
```
atm run --tz America/New_York --weekdays MON,TUE,WED,THU,FRI --oh-start 09:30 --oh-stop 16:00
```
> `--oh-start / --oh-stop` sunt **diferite** de `--start-at / --stop-at`.
> `--start-at / --stop-at` = wall-clock session bounds (când pornește procesul și când se oprește).
> `--oh-start / --oh-stop` = fereastra NYSE în care detecția rulează efectiv în interiorul sesiunii.
> Se combină.
### Comenzi Telegram
Trimiți în chat-ul bot-ului:
| Comandă | Efect |
|---|---|
| `/ss` sau `/screenshot` | Screenshot acum |
| `/status` | Stare FSM + motiv pauză + fereastră open/closed |
| `/pause` | Suspendă detecția (heartbeat-urile continuă) |
| `/resume` | Elimină DOAR pauza user. Dacă Canary e drift-paused, **rămâne paused** — folosește `/resume force` |
| `/resume force` | Elimină și drift-pause-ul canary (după recalibrare) |
| `/3` sau `/interval 3` | Interval auto-screenshot = 3 min |
| `/stop` | Oprește scheduler-ul de screenshot |
| `/h` sau `/help` | Listă scurtă a tuturor comenzilor disponibile |
Doar `allowed_chat_ids` sunt acceptate. După 3 `401` consecutive, poller-ul intră în mod degradat.
---
## After the session
## După sesiune
```powershell
atm label samples # Tk UI — label each saved frame with true dot colour
atm dryrun samples # replay through detector + FSM; exits 0 if precision=100%, recall>=95%
atm label samples # UI Tk — etichetezi fiecare frame salvat cu culoarea reală
atm dryrun samples # replay prin detector + FSM; exit 0 dacă precision=100%, recall95%
```
If the gate fails, tune per-colour `tolerance` in `configs/<current>.toml`, or recalibrate colour samples that didn't match. Re-run `atm dryrun` until it passes. Only then do you trust live signals.
Dacă gate-ul pică, ajustezi `tolerance` per culoare în `configs/<current>.toml`, sau recalibrezi eșantioanele care n-au potrivit. Rulezi iar `atm dryrun` până trece. **Numai atunci ai încredere în semnalele live.**
Trade record-keeping:
Pentru calibrare fină a clasificării de culori (Flow A cu `/3`), vezi secțiunea **Validare offline a calibrării** de mai sus.
Evidență trade-uri:
```powershell
atm journal # interactive entry after a real trade
atm report --week 2026-16 # weekly win rate + R PnL + slippage
atm journal # înregistrare interactivă după un trade real
atm report --week 2026-16 # win rate săptămânal + PnL în R + slippage
```
---
## DPI / multi-monitor notes
## Note DPI / multi-monitor
- Calibration region is virtual-desktop-absolute; runtime capture uses the same rectangle. **Don't move the TradeStation window** after calibrating. Canary will catch drift and pause automatically.
- Changing DPI scaling or moving to a different monitor with different DPI → recalibrate.
- RDP / virtual desktops: `mss` can return black frames over RDP. Run locally on the same physical machine as TradeStation.
- Regiunea din calibrare e absolută virtual-desktop; runtime capture folosește același dreptunghi. **Nu muta fereastra TradeStation** după calibrare. Canary prinde drift-ul și pauzează automat.
- Schimbi DPI scaling sau muți pe un alt monitor cu DPI diferit → recalibrezi.
- RDP / desktop virtual: `mss` poate returna frame-uri negre peste RDP. Rulează local pe aceeași mașină fizică pe care e TradeStation.
---
## Troubleshooting
| Symptom | Likely cause | Fix |
| Simptom | Cauză probabilă | Fix |
|---|---|---|
| `capture_failed` in startup ping | `chart_window_region` references coords off-screen (different monitor layout) | Recalibrate. |
| Startup canary `drift=X/8` with X >> 8 | Wrong window is in the capture region | Make sure TradeStation is the window at `cfg.chart_window_region`. Relaunch. |
| `WARN: no window contains 'xxx'` at startup | `cfg.window_title` substring matches nothing | Edit `window_title` in TOML to a substring that's unique to TradeStation. |
| No alerts even after trigger ought to fire | Check `logs/YYYY-MM-DD.jsonl` for `event=tick` entries — are colours accepted? Is `trigger` ever set? | If always UNKNOWN → tolerances too tight. If `trigger` but `locked=true` → lockout from prior fire, normal. |
| Discord OK, Telegram silent (or vice versa) | `logs/dead_letter.jsonl` contains failed alerts with error | Fix credentials in TOML, restart. |
| Heartbeat shows `telegram: failed > 0` | Telegram returned `ok:false` (bot blocked, invalid chat_id, parse error) | Check `logs/dead_letter.jsonl` for the `error_str` / `description` field. Common: bot never started by user in Telegram, or wrong `chat_id` flavor (channel vs group vs DM). |
| Debug circle on mid-strip instead of right edge | Anti-aliasing bridges dots in the mask | Already fixed via erosion+connected-components — ensure `git pull` is current. |
| Wizard window is tiny / image not visible | Tk geometry default on Windows | Already fixed — `git pull`. Image is scaled to fit screen. |
| `capture_failed` în ping-ul de start | `chart_window_region` referă coords off-screen (alt layout monitor) | Recalibrează. |
| Canary la startup arată `drift=X/8` cu X 8 | Alt window e în regiunea de capture | TradeStation trebuie să fie ferestra la `cfg.chart_window_region`. Relansează. |
| `WARN: no window contains 'xxx'` la start | `cfg.window_title` nu prinde nimic | Editează `window_title` în TOML cu un substring unic pentru TradeStation. |
| Nu vin alerte deși ar trebui | Verifică `logs/YYYY-MM-DD.jsonl` — `event=frame` au culoare acceptată? `trigger` setat? | Dacă mereu UNKNOWN → tolerances prea strânse SAU RGB-urile calibrate nu se potrivesc. Rulează `atm validate-calibration`. Dacă `trigger` dar `locked=true` → lockout de la fire anterior, normal. |
| Alertă pe culoare greșită (ex. dark_red → light_red) | Calibrarea a luat dot istoric, nu activ | Rulează `atm validate-calibration`. Corectezi tactic în TOML sau recalibrezi cu regula rightmost dot. |
| Discord OK, Telegram tace (sau invers) | `logs/dead_letter.jsonl` are alertele eșuate + eroarea | Fixezi credențiale în TOML, restart. |
| Heartbeat arată `telegram: failed > 0` | Telegram a răspuns `ok:false` | Check `logs/dead_letter.jsonl` pentru `error_str` / `description`. Comun: bot-ul nu-a fost pornit de user în Telegram, sau `chat_id` greșit (channel vs group vs DM). |
| Bot-ul "moare" după N ore, heartbeat merge dar comenzile nu răspund | Era bug-ul de hang din 2026-04-17 — drain coadă de comenzi sărit când Canary paused | Fixat în `c5024ce`. Update git pull. |
| Poll-uri periodice continuă deși un trigger BUY/SELL s-a afișat pe chart | Trigger-ul a ieșit UNKNOWN (pixel saturat, paletă întunecată) → FSM blocat în PRIMED → scheduler nu primește `fire/cooled/phase_skip` | Rulează `atm validate-calibration calibration/calibration_labels.json`. Dacă vreo culoare luminoasă iese UNKNOWN, actualizezi centrul RGB în TOML la pixelul real observat. Vezi incidentul 2026-04-20/21. |
---
## Windows Task Scheduler (production)
## Windows Task Scheduler (producție)
For hands-off daily runs surviving reboots:
Pentru rulare automată zilnică care supraviețuiește reboot-urilor:
1. Task Scheduler → Create Task → name `ATM M2D Monitor`
1. Task Scheduler → Create Task → nume `ATM M2D Monitor`
2. **General**: "Run only when user is logged on", "Run with highest privileges"
3. **Triggers**: New → Daily, Start `16:30`
4. **Actions**: New → Program `C:\path\to\python.exe`, Arguments `-m atm run --stop-at 23:00`, Start in `D:\PROIECTE\atm`
5. **Conditions**: uncheck "Start only if AC power" (if laptop)
5. **Conditions**: debifează "Start only if AC power" (dacă e laptop)
6. **Settings**: "If task runs longer than 7 hours → stop"
Click-right → Run to test manually. Manual DST-change check twice a year (Mar / Oct first week).
Click-right → Run, să testezi manual. Check DST schimbare de două ori pe an (prima săptămână din martie / octombrie).
---
## Quick command reference
## Referință rapidă comenzi
```
atm calibrate [--screenshot PATH] [--delay SEC] # Tk wizard
atm calibrate [--screenshot PATH] [--delay SEC] # wizard Tk
atm debug [--delay SEC] # one-shot capture + detect
atm label SAMPLES_DIR # Tk labeling
atm dryrun SAMPLES_DIR # corpus gate
atm label SAMPLES_DIR # etichetare Tk
atm dryrun SAMPLES_DIR # gate pe corpus
atm validate-calibration LABEL_FILE.json # gate offline clasificare culori
atm run [--duration H] [--start-at HH:MM] [--stop-at HH:MM] [--startup-delay SEC] [--capture-stub]
atm journal [--file PATH] # interactive trade entry
atm report [--week YYYY-WW] [--file PATH] # weekly summary
[--tz TZNAME] [--weekdays MON,TUE,...] [--oh-start HH:MM] [--oh-stop HH:MM]
atm journal [--file PATH] # înregistrare interactivă
atm report [--week YYYY-WW] [--file PATH] # raport săptămânal
```
Exit code: `atm dryrun` exits 0 if gate passes, 1 otherwise. Other commands follow standard convention.
Exit codes:
- `atm dryrun` — 0 pass, 1 fail.
- `atm validate-calibration` — 0 toate PASS, 1 orice FAIL, 2 input invalid.
- Restul: standard.
---
## Evenimente audit
Scrise în `logs/YYYY-MM-DD.jsonl`. Cele adăugate recent:
| Event | Payload | Când |
|---|---|---|
| `canary_drift_paused` | `distance` | Primul tick cu drift după o stare curată; emite alertă Telegram |
| `user_paused` | — | `/pause` primit |
| `user_resumed` | `was_drift`, `was_user`, `force` | `/resume` sau `/resume force` |
| `market_open` / `market_closed` | `reason` | Boundary fereastră operating-hours (o dată per tranziție; **nu** la startup) |
| `phase_skip_fire` | `direction` | Alertă backstop când ARMED→light_* direct |
| `command_error` | `action`, `error` | Excepție la dispatch (izolată de loop-ul de detecție) |

View File

@@ -30,11 +30,20 @@ Per-kind mute toggles for notifications in case arm/prime turn out too noisy in
- `cfg.notify.arm: bool = true`
- `cfg.notify.prime: bool = true`
- `cfg.notify.late_start: bool = true`
- `cfg.notify.resume_screenshot: bool = true` — gate `_save_inspect_frame` + inline screenshot în `/resume` Alert dacă recover-urile dese din drift devin zgomotoase.
Default all true. Gate each `notifier.send()` in `_handle_tick()` on the flag. Start after 3+ live sessions confirm the signal/noise ratio.
Blocked on: Faza 1 baseline evidence.
## P3-inspect-top-n-configurable
Parser comandă `/ss N` (ex: `/ss 5`) ca override pentru `n` în `find_top_dots` (default 3). Util dacă ROI scope se extinde și vrei o privire de ansamblu pe mai multe buline.
- Extindere `_parse_command` în `commands.py` (similar cu `/set_interval N`).
- Caption scaling: pentru N>3 formatter-ul trebuie să limiteze cele mai puțin relevante detections (ex: doar top-3 labels vizibile în poză, restul doar listate în caption).
- Start când `find_top_dots` + caption multi-bulină s-au dovedit util în practică.
## P3-faza2-exec
Auto-execution on TradeLocker. Blocked on TOS audit (see `docs/phase2-prop-firm-audit.md`). Not started until GO decision + 20+ Faza 1 sessions.
@@ -49,9 +58,30 @@ Read-only web view of today's audit JSONL + recent triggers. Useful for review a
---
## P2-yaxis-recalib-detect — Y-axis recalibration detection
Price overlay (from Telegram commands feature) uses `y_axis` linear interpolation to show current price on screenshots. When the user rescales the chart y-axis (common after overnight price gaps), the calibration becomes stale and prices shown are incorrect. Canary check detects layout drift but NOT y-axis range changes.
- Possible approaches: OCR on y-axis labels (fragile), track price range consistency across sessions, or simple "calibration age" warning after N hours.
- Start after price overlay is live and the false-price frequency is known.
- Depends on: Telegram commands + price overlay feature being shipped.
## Quality debt
- [ ] **Integration test for run_live loop**: currently mocked at module level. Add a short-duration in-memory loop test that threads real detector/state_machine/audit together (no network).
- [x] **Integration test for run_live loop**: lifecycle async test added in `tests/test_main.py` (IDLE→ARMED→PRIMED auto-poll→FIRE auto-stop).
- [x] **Detection-loop hang on canary pause** (2026-04-17 incident): `_drain_cmd_queue` now runs unconditionally; helpers extracted to module scope for testability (commit `c5024ce`).
- [x] **Silent canary drift-pause**: single-shot Telegram alert on `not_paused → paused` (commit `9cf49ca`).
- [x] **Phase-skip backstop**: `fire_on_phase_skip` (default on) emits alert when ARMED→light_* direct (commit `8b53b8d`).
- [x] **Operating hours window**: NYSE-timezone-aware gate with `/pause` `/resume` `/resume force` control (commits `54f5575`, `2386577`).
- [x] **Offline calibration gate**: `atm validate-calibration` replays labeled frames through detector (commit `8bae507`).
- [ ] **Coverage report**: run `pytest --cov=atm --cov-report=term-missing`, aim for ≥ 85% per module.
- [ ] **Typing strictness**: run `pyright src/` with strict mode, fix reported issues.
- [ ] **Perf baseline**: profile one detection cycle on a representative frame; ensure < 100ms so 5s loop has ample headroom.
- [ ] **Exchange calendar holidays**: operating_hours doesn't know about NYSE closures (MLK, Thanksgiving, Good Friday). User `/pause`s manually for now.
## P3-secret-scan-hook
Pre-commit hook (`gitleaks` sau `detect-secrets`) care scanează diff-urile pentru pattern-uri de secrete înainte de commit. Centură de siguranţă acum `configs/*.toml` se comit risc crescut de leak prin copy-paste viitor (ex: cineva pune un token de test direct în TOML pentru un quick hack şi uită).
- Start după ce avem 2+ contributors sau după primul incident "aproape am comis un secret".
- Tool de ales: `gitleaks` (binary, fără Python dep) > `detect-secrets` (Python, config mai complex).

15
atm.bat Normal file
View File

@@ -0,0 +1,15 @@
@echo off
cd /d "%~dp0"
if not exist ".venv\Scripts\atm.exe" (
echo Instalez atm in venv local...
python -m venv .venv
call .venv\Scripts\activate.bat
pip install -e ".[windows]"
)
if "%~1"=="" (
.venv\Scripts\atm.exe run
) else (
.venv\Scripts\atm.exe %*
)

49
calibration/README.md Normal file
View File

@@ -0,0 +1,49 @@
# calibration/ — frame corpus for validation & regression
Two artifacts, one frame pool:
- `calibration_labels.json` — per-frame color labels. Used by
`atm validate-calibration` to check the current palette classifies known-good
dots correctly before a live session.
- `scenarios.json` — ordered frame sequences per FSM scenario (full cycle,
phase skip, catchup, post-fire suppression). Consumed by
`tests/test_scenarios_regression.py` which runs each sequence through the
full `Detector → _handle_tick` pipeline and asserts color, FSM reason/state,
emitted alerts, and scheduler on/off.
Frames live in `calibration/frames/` and are self-contained: purging
`logs/fires/` or `samples/` does not break either artifact.
## calibration_labels.json schema
## Schema
A JSON array of entries. Each entry:
| Field | Type | Required | Description |
|------------|---------|----------|----------------------------------------------------------------|
| `path` | string | yes | Path to a PNG frame (relative to CWD or absolute). |
| `expected` | string | yes | Expected color name: one of `turquoise`, `yellow`, `dark_green`, `dark_red`, `light_green`, `light_red`, `gray`. |
| `note` | string | no | Freeform annotation; shown in SUGGESTIONS output. |
## Usage
```bash
atm validate-calibration calibration/calibration_labels.json
```
Exit codes:
- `0` — every sample PASS
- `1` — one or more FAIL
- `2` — label file missing or malformed JSON
## Adding new samples
1. Find a screenshot in `logs/fires/` whose dot color you can verify by eye.
2. **Copy it into `calibration/frames/`** — this directory is self-contained so
`logs/fires/` and `samples/` can be emptied without breaking validation.
3. Append an entry with `path` (pointing to `calibration/frames/...`),
`expected`, and an optional `note`.
4. Re-run validation. If it FAILs, the SUGGESTIONS section will tell you the
RGB distance between the observed pixel and the expected color's center —
use that as input for `atm calibrate`.

View File

@@ -0,0 +1,122 @@
[
{
"path": "calibration/frames/20260420_200002_turquoise.png",
"expected": "turquoise",
"note": "BUY arm visible in poll; rgb=(0,253,253)"
},
{
"path": "calibration/frames/20260421_072757_turquoise.png",
"expected": "turquoise",
"note": "BUY arm via manual /ss; rgb=(0,253,253)"
},
{
"path": "calibration/frames/20260420_171501_yellow.png",
"expected": "yellow",
"note": "SELL arm event; rgb=(253,253,0)"
},
{
"path": "calibration/frames/20260420_194505_yellow.png",
"expected": "yellow",
"note": "SELL arm event; rgb=(253,253,0)"
},
{
"path": "calibration/frames/20260420_194721_yellow.png",
"expected": "yellow",
"note": "SELL arm visible in manual /ss; rgb=(253,253,0)"
},
{
"path": "calibration/frames/20260418_124645_dark_green.png",
"expected": "dark_green",
"note": "BUY prime catchup; rgb=(0,128,0)"
},
{
"path": "calibration/frames/20260420_185102_dark_green.png",
"expected": "dark_green",
"note": "BUY prime; rgb=(0,128,0)"
},
{
"path": "calibration/frames/20260420_213706_dark_green.png",
"expected": "dark_green",
"note": "BUY prime catchup; rgb=(0,128,0)"
},
{
"path": "calibration/frames/20260420_172104_dark_red.png",
"expected": "dark_red",
"note": "SELL prime; rgb=(128,0,0)"
},
{
"path": "calibration/frames/20260420_195701_dark_red.png",
"expected": "dark_red",
"note": "SELL prime; rgb=(128,0,0)"
},
{
"path": "calibration/frames/20260420_210905_dark_red.png",
"expected": "dark_red",
"note": "SELL prime; rgb=(128,0,0)"
},
{
"path": "calibration/frames/20260420_163303_light_green.png",
"expected": "light_green",
"note": "BUY trigger (FIRE); rgb=(0,255,0)"
},
{
"path": "calibration/frames/20260420_214908_light_green.png",
"expected": "light_green",
"note": "regression 2026-04-20: BUY trigger visible in poll (original complaint); rgb=(0,255,0) was UNKNOWN under pre-2026-04-21 calibration"
},
{
"path": "calibration/frames/20260420_173004_light_red.png",
"expected": "light_red",
"note": "SELL trigger (FIRE); rgb=(255,0,0)"
},
{
"path": "calibration/frames/20260420_175005_gray.png",
"expected": "gray",
"note": "idle gray dot via manual /ss; rgb=(128,128,128)"
},
{
"path": "calibration/frames/20260420_185702_gray.png",
"expected": "gray",
"note": "idle gray dot in poll; rgb=(128,128,128)"
},
{
"path": "calibration/frames/20260421_164210_gray.png",
"expected": "gray",
"note": "2026-04-21 16:42 poll — gray între sesiuni; rgb=(128,128,128)"
},
{
"path": "calibration/frames/20260421_164452_gray.png",
"expected": "gray",
"note": "2026-04-21 16:44 poll — gray idle; rgb=(128,128,128)"
},
{
"path": "calibration/frames/20260421_165209_gray.png",
"expected": "gray",
"note": "2026-04-21 16:52 poll — gray idle; rgb=(128,128,128)"
},
{
"path": "calibration/frames/20260421_170045_dark_green.png",
"expected": "dark_green",
"note": "2026-04-21 17:00 BUY prime catchup (synth arm+prime); rgb=(0,128,0)"
},
{
"path": "calibration/frames/20260421_174502_yellow.png",
"expected": "yellow",
"note": "2026-04-21 17:45 opposite_rearm PRIMED_BUY→ARMED_SELL — frame bug regresiv; rgb=(253,253,0)"
},
{
"path": "calibration/frames/20260421_174804_gray.png",
"expected": "gray",
"note": "2026-04-21 17:48 gray persist în ARMED_SELL; rgb=(128,128,128)"
},
{
"path": "calibration/frames/20260421_220346_dark_red.png",
"expected": "dark_red",
"note": "2026-04-21 22:03 SELL prime seara; rgb=(128,0,0)"
},
{
"path": "calibration/frames/20260421_222108_gray.png",
"expected": "gray",
"note": "2026-04-21 22:21 gray idle; rgb=(128,128,128)"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

338
calibration/scenarios.json Normal file
View File

@@ -0,0 +1,338 @@
[
{
"id": "buy_full_cycle",
"description": "IDLE → ARMED_BUY → PRIMED_BUY → IDLE(fire). Turquoise arm, dark_green prime, light_green trigger.",
"steps": [
{
"frame": "calibration/frames/20260420_200002_turquoise.png",
"expected_color": "turquoise",
"expected_reason": "arm",
"expected_state": "ARMED_BUY",
"expected_trigger": null,
"expected_new_alerts": ["arm"],
"expected_scheduler_running": false
},
{
"frame": "calibration/frames/20260420_185102_dark_green.png",
"expected_color": "dark_green",
"expected_reason": "prime",
"expected_state": "PRIMED_BUY",
"expected_trigger": null,
"expected_new_alerts": ["prime"],
"expected_scheduler_running": true
},
{
"frame": "calibration/frames/20260420_163303_light_green.png",
"expected_color": "light_green",
"expected_reason": "fire",
"expected_state": "IDLE",
"expected_trigger": "BUY",
"expected_new_alerts": [],
"expected_scheduler_running": false
}
]
},
{
"id": "sell_full_cycle",
"description": "Mirror of buy_full_cycle: yellow arm, dark_red prime, light_red trigger.",
"steps": [
{
"frame": "calibration/frames/20260420_171501_yellow.png",
"expected_color": "yellow",
"expected_reason": "arm",
"expected_state": "ARMED_SELL",
"expected_trigger": null,
"expected_new_alerts": ["arm"],
"expected_scheduler_running": false
},
{
"frame": "calibration/frames/20260420_172104_dark_red.png",
"expected_color": "dark_red",
"expected_reason": "prime",
"expected_state": "PRIMED_SELL",
"expected_trigger": null,
"expected_new_alerts": ["prime"],
"expected_scheduler_running": true
},
{
"frame": "calibration/frames/20260420_173004_light_red.png",
"expected_color": "light_red",
"expected_reason": "fire",
"expected_state": "IDLE",
"expected_trigger": "SELL",
"expected_new_alerts": [],
"expected_scheduler_running": false
}
]
},
{
"id": "buy_phase_skip",
"description": "ARMED_BUY → light_green direct (dark_green missed). Backstop `fire_on_phase_skip` emits phase_skip_fire alert.",
"steps": [
{
"frame": "calibration/frames/20260421_072757_turquoise.png",
"expected_color": "turquoise",
"expected_reason": "arm",
"expected_state": "ARMED_BUY",
"expected_trigger": null,
"expected_new_alerts": ["arm"],
"expected_scheduler_running": false
},
{
"frame": "calibration/frames/20260420_214908_light_green.png",
"expected_color": "light_green",
"expected_reason": "phase_skip",
"expected_state": "IDLE",
"expected_trigger": null,
"expected_new_alerts": ["phase_skip_fire"],
"expected_scheduler_running": false
}
]
},
{
"id": "sell_phase_skip",
"description": "Mirror: ARMED_SELL → light_red direct (dark_red missed).",
"steps": [
{
"frame": "calibration/frames/20260420_194505_yellow.png",
"expected_color": "yellow",
"expected_reason": "arm",
"expected_state": "ARMED_SELL",
"expected_trigger": null,
"expected_new_alerts": ["arm"],
"expected_scheduler_running": false
},
{
"frame": "calibration/frames/20260420_173004_light_red.png",
"expected_color": "light_red",
"expected_reason": "phase_skip",
"expected_state": "IDLE",
"expected_trigger": null,
"expected_new_alerts": ["phase_skip_fire"],
"expected_scheduler_running": false
}
]
},
{
"id": "buy_catchup",
"description": "Start with dark_green in IDLE (no arm observed). Catchup synth-feeds turquoise → emits arm+prime alerts. FSM ends in PRIMED_BUY.",
"steps": [
{
"frame": "calibration/frames/20260418_124645_dark_green.png",
"expected_color": "dark_green",
"expected_reason": "prime",
"expected_state": "PRIMED_BUY",
"expected_trigger": null,
"expected_new_alerts": ["arm", "prime"],
"expected_scheduler_running": true
}
]
},
{
"id": "sell_catchup",
"description": "Mirror: start with dark_red in IDLE. Catchup synth-yellow → arm+prime alerts.",
"steps": [
{
"frame": "calibration/frames/20260420_195701_dark_red.png",
"expected_color": "dark_red",
"expected_reason": "prime",
"expected_state": "PRIMED_SELL",
"expected_trigger": null,
"expected_new_alerts": ["arm", "prime"],
"expected_scheduler_running": true
}
]
},
{
"id": "buy_post_fire_suppression",
"description": "After BUY fire, residual dark_green in IDLE must NOT re-prime. User rule: new arming (turquoise) required before priming alerts become valid again.",
"steps": [
{
"frame": "calibration/frames/20260420_200002_turquoise.png",
"expected_color": "turquoise",
"expected_reason": "arm",
"expected_state": "ARMED_BUY",
"expected_trigger": null,
"expected_new_alerts": ["arm"],
"expected_scheduler_running": false
},
{
"frame": "calibration/frames/20260420_185102_dark_green.png",
"expected_color": "dark_green",
"expected_reason": "prime",
"expected_state": "PRIMED_BUY",
"expected_trigger": null,
"expected_new_alerts": ["prime"],
"expected_scheduler_running": true
},
{
"frame": "calibration/frames/20260420_163303_light_green.png",
"expected_color": "light_green",
"expected_reason": "fire",
"expected_state": "IDLE",
"expected_trigger": "BUY",
"expected_new_alerts": [],
"expected_scheduler_running": false
},
{
"frame": "calibration/frames/20260420_213706_dark_green.png",
"expected_color": "dark_green",
"expected_reason": "noise",
"expected_state": "IDLE",
"expected_trigger": null,
"expected_new_alerts": [],
"expected_scheduler_running": false
},
{
"frame": "calibration/frames/20260418_124645_dark_green.png",
"expected_color": "dark_green",
"expected_reason": "noise",
"expected_state": "IDLE",
"expected_trigger": null,
"expected_new_alerts": [],
"expected_scheduler_running": false
}
]
},
{
"id": "sell_post_fire_suppression",
"description": "Mirror: after SELL fire, residual dark_red must NOT re-prime until new yellow arming.",
"steps": [
{
"frame": "calibration/frames/20260420_171501_yellow.png",
"expected_color": "yellow",
"expected_reason": "arm",
"expected_state": "ARMED_SELL",
"expected_trigger": null,
"expected_new_alerts": ["arm"],
"expected_scheduler_running": false
},
{
"frame": "calibration/frames/20260420_172104_dark_red.png",
"expected_color": "dark_red",
"expected_reason": "prime",
"expected_state": "PRIMED_SELL",
"expected_trigger": null,
"expected_new_alerts": ["prime"],
"expected_scheduler_running": true
},
{
"frame": "calibration/frames/20260420_173004_light_red.png",
"expected_color": "light_red",
"expected_reason": "fire",
"expected_state": "IDLE",
"expected_trigger": "SELL",
"expected_new_alerts": [],
"expected_scheduler_running": false
},
{
"frame": "calibration/frames/20260420_195701_dark_red.png",
"expected_color": "dark_red",
"expected_reason": "noise",
"expected_state": "IDLE",
"expected_trigger": null,
"expected_new_alerts": [],
"expected_scheduler_running": false
},
{
"frame": "calibration/frames/20260420_210905_dark_red.png",
"expected_color": "dark_red",
"expected_reason": "noise",
"expected_state": "IDLE",
"expected_trigger": null,
"expected_new_alerts": [],
"expected_scheduler_running": false
}
]
},
{
"id": "buy_catchup_opposite_rearm_to_sell",
"description": "REGRESSION 2026-04-21: real trace — catchup pe dark_green la 17:00 → PRIMED_BUY (synth arm+prime), apoi yellow la 17:45 → ARMED_SELL via opposite_rearm. Înainte de fix, bug-ul era dispatch-ul tăcut pentru opposite_rearm (zero alert). Acum trebuie să emită kind=opposite_rearm și să oprească scheduler-ul.",
"steps": [
{
"frame": "calibration/frames/20260421_170045_dark_green.png",
"expected_color": "dark_green",
"expected_reason": "prime",
"expected_state": "PRIMED_BUY",
"expected_trigger": null,
"expected_new_alerts": ["arm", "prime"],
"expected_scheduler_running": true
},
{
"frame": "calibration/frames/20260421_174502_yellow.png",
"expected_color": "yellow",
"expected_reason": "opposite_rearm",
"expected_state": "ARMED_SELL",
"expected_trigger": null,
"expected_new_alerts": ["opposite_rearm"],
"expected_scheduler_running": false
}
]
},
{
"id": "buy_armed_gray_persist",
"description": "Gray între arm și prime nu pierde ARMED_BUY (reason=persist, scheduler inactiv).",
"steps": [
{
"frame": "calibration/frames/20260420_200002_turquoise.png",
"expected_color": "turquoise",
"expected_reason": "arm",
"expected_state": "ARMED_BUY",
"expected_trigger": null,
"expected_new_alerts": ["arm"],
"expected_scheduler_running": false
},
{
"frame": "calibration/frames/20260421_164210_gray.png",
"expected_color": "gray",
"expected_reason": "persist",
"expected_state": "ARMED_BUY",
"expected_trigger": null,
"expected_new_alerts": [],
"expected_scheduler_running": false
},
{
"frame": "calibration/frames/20260420_185102_dark_green.png",
"expected_color": "dark_green",
"expected_reason": "prime",
"expected_state": "PRIMED_BUY",
"expected_trigger": null,
"expected_new_alerts": ["prime"],
"expected_scheduler_running": true
}
]
},
{
"id": "buy_primed_gray_cooldown",
"description": "Gray după prime ucide ciclul (reason=cooled, IDLE, scheduler oprit). Design M2D: setup expiră dacă chart-ul tace după priming.",
"steps": [
{
"frame": "calibration/frames/20260420_200002_turquoise.png",
"expected_color": "turquoise",
"expected_reason": "arm",
"expected_state": "ARMED_BUY",
"expected_trigger": null,
"expected_new_alerts": ["arm"],
"expected_scheduler_running": false
},
{
"frame": "calibration/frames/20260420_185102_dark_green.png",
"expected_color": "dark_green",
"expected_reason": "prime",
"expected_state": "PRIMED_BUY",
"expected_trigger": null,
"expected_new_alerts": ["prime"],
"expected_scheduler_running": true
},
{
"frame": "calibration/frames/20260421_174804_gray.png",
"expected_color": "gray",
"expected_reason": "cooled",
"expected_state": "IDLE",
"expected_trigger": null,
"expected_new_alerts": [],
"expected_scheduler_running": false
}
]
}
]

View File

@@ -0,0 +1,100 @@
window_title = "m2d"
[dot_roi]
x = 0
y = 712
w = 1796
h = 35
[chart_roi]
x = 17
y = 125
w = 1767
h = 567
[colors]
[colors.turquoise]
rgb = [0, 153, 153]
tolerance = 60.0
[colors.yellow]
rgb = [153, 153, 0]
tolerance = 60.0
[colors.dark_green]
rgb = [0, 122, 0]
tolerance = 60.0
[colors.dark_red]
rgb = [128, 0, 0]
tolerance = 60.0
[colors.light_green]
rgb = [0, 171, 0]
tolerance = 60.0
[colors.light_red]
rgb = [171, 0, 0]
tolerance = 60.0
[colors.gray]
rgb = [128, 128, 128]
tolerance = 60.0
[colors.background]
rgb = [0, 0, 0]
tolerance = 25.0
[y_axis]
p1_y = 166
p1_price = 485.2
p2_y = 664
p2_price = 483.2
[canary]
baseline_phash = "fbe145390c1abec23204017757a326b8e37077288ef79947310a89c70e07ffff"
drift_threshold = 8
[canary.roi]
x = 26
y = 27
w = 197
h = 15
[chart_window_region]
x = 3
y = 0
w = 1918
h = 1029
# Secretele Discord/Telegram se setează în .env la rădăcina proiectului — vezi .env.example.
[options]
debounce_depth = 1
loop_interval_s = 5.0
heartbeat_min = 30
lockout_s = 240
low_conf_threshold = 0.2
low_conf_run = 3
phaseb_timeout_s = 600
dead_letter_path = "logs/dead_letter.jsonl"
[options.alerts]
fire_on_phase_skip = true
[options.operating_hours]
enabled = true
timezone = "America/New_York"
weekdays = ["MON", "TUE", "WED", "THU", "FRI"]
start_hhmm = "09:30"
stop_hhmm = "16:00"
# Per-kind screenshot-attach toggles. All default to true on upgrade.
# Accepts either a bare bool (legacy: attach_screenshots = true) or this table.
[options.attach_screenshots]
late_start = true # screenshot on startup-late alerts
catchup = true # screenshot on mid-session catchup arm + prime
arm = true # screenshot on normal arm (turquoise/yellow) — noisiest
prime = true # screenshot on normal prime (dark_green/dark_red)
trigger = true # screenshot on FIRE

View File

@@ -0,0 +1,98 @@
window_title = "m2d"
[dot_roi]
x = 0
y = 712
w = 1796
h = 35
[chart_roi]
x = 17
y = 125
w = 1767
h = 567
[colors]
[colors.turquoise]
rgb = [0, 253, 253]
tolerance = 60.0
[colors.yellow]
rgb = [253, 253, 0]
tolerance = 60.0
[colors.dark_green]
rgb = [0, 122, 0]
tolerance = 60.0
[colors.dark_red]
rgb = [128, 0, 0]
tolerance = 60.0
[colors.light_green]
rgb = [0, 255, 0]
tolerance = 60.0
[colors.light_red]
rgb = [255, 0, 0]
tolerance = 60.0
[colors.gray]
rgb = [128, 128, 128]
tolerance = 60.0
[colors.background]
rgb = [0, 0, 0]
tolerance = 25.0
[y_axis]
p1_y = 166
p1_price = 485.2
p2_y = 664
p2_price = 483.2
[canary]
baseline_phash = "fbe145390c1abec23204017757a326b8e37077288ef79947310a89c70e07ffff"
drift_threshold = 8
[canary.roi]
x = 26
y = 27
w = 197
h = 15
[chart_window_region]
x = 3
y = 0
w = 1918
h = 1029
# Secretele Discord/Telegram se setează în .env la rădăcina proiectului — vezi .env.example.
[options]
debounce_depth = 1
loop_interval_s = 5.0
heartbeat_min = 30
lockout_s = 240
low_conf_threshold = 0.2
low_conf_run = 3
phaseb_timeout_s = 600
dead_letter_path = "logs/dead_letter.jsonl"
[options.alerts]
fire_on_phase_skip = true
[options.operating_hours]
enabled = true
timezone = "America/New_York"
weekdays = ["MON", "TUE", "WED", "THU", "FRI"]
start_hhmm = "09:30"
stop_hhmm = "16:00"
[options.attach_screenshots]
late_start = true
catchup = true
arm = true
prime = true
trigger = true

1
configs/current.txt Normal file
View File

@@ -0,0 +1 @@
2026-04-21-recalib.toml

View File

@@ -64,12 +64,8 @@ y = 100
w = 100
h = 50
[discord]
webhook_url = "https://discord.com/api/webhooks/REPLACE_ME"
[telegram]
bot_token = "REPLACE_ME"
chat_id = "REPLACE_ME"
# Secretele (Discord webhook + Telegram bot/chat) se setează în `.env` la rădăcina
# proiectului — vezi `.env.example`. TOML-ul rămâne 100% public, doar calibrare.
[options]
debounce_depth = 1
@@ -81,6 +77,24 @@ low_conf_run = 3
phaseb_timeout_s = 600
dead_letter_path = "logs/dead_letter.jsonl"
# Alert-behavior toggles (not screenshot-attachment; see attach_screenshots below).
# fire_on_phase_skip: emit a backstop "PHASE SKIP" alert when the FSM observes
# ARMED → light_green/light_red directly (skipping the dark prime). Default on
# because missing a fire is worse than a false-positive phase-skip alert.
[options.alerts]
fire_on_phase_skip = true
# Operating hours — detection only runs on allowed weekdays + HH:MM window.
# Timezone is the source of truth (NYSE local); the runtime converts tick
# timestamps to this zone so DST rollovers stay aligned with the exchange.
# Override from CLI with --tz / --weekdays / --oh-start / --oh-stop.
[options.operating_hours]
enabled = false
timezone = "America/New_York"
weekdays = ["MON", "TUE", "WED", "THU", "FRI"]
start_hhmm = "09:30"
stop_hhmm = "16:00"
# Per-kind screenshot-attach toggles. All default to true on upgrade.
# Accepts either a bare bool (legacy: attach_screenshots = true) or this table.
[options.attach_screenshots]

View File

@@ -1,149 +0,0 @@
# Design: ATM — Automated Trading Monitor (M2D Strategy)
Generated by /office-hours on 2026-04-15
Branch: master
Repo: /workspace/atm (greenfield)
Status: APPROVED
Mode: Builder (personal live-trading tool, high-stakes)
## Problem Statement
User trades the M2D strategy on DIA (TradeStation chart with custom indicator) with execution on TradeLocker US30 CFD (prop firm account). Same strategy also applies to GLD → XAUUSD. Bridging signal source (TradeStation Windows app) with execution (TradeLocker web) currently requires user to watch both screens for 4 hours per evening. Goal: bot detects the trigger signal automatically and notifies user via Telegram/Discord with chart screenshot + SL/TP levels so user can execute the trade in TradeLocker.
## Strategy M2D — Full Spec
**Setup:** TradeStation, 3-minute chart, DIA (or GLD) symbol, custom indicator "M2D MAPS" that renders a horizontal strip of colored dots below the price panel. Dots are indexed by time, y-position is fixed.
### BUY sequence (sequential in time, rightmost N dots):
1. **Turquoise dot** — 15-minute buy trigger
2. **Dark green dot** — 3-minute sell
3. **Light green dot** — 3-minute buy → **TRIGGER**
At trigger:
- Execute BUY on TradeLocker, instrument US30 CFD
- Stop Loss 0.6%
- Volume 0.1 lots maximum
- TP1, TP2, SL are drawn automatically as horizontal lines on the TradeStation chart after entry
- User manual lifecycle: at TP1 close half, move SL to ~breakeven; at TP2 close remaining half
### SELL sequence (mirror):
1. **Yellow dot** — 15-minute sell (red 15min candle)
2. **Dark red dot** — 3-minute buy
3. **Light red dot** — 3-minute sell → **TRIGGER**
Same size (0.1 lots), same SL %, same TP management.
### Instrument mapping (intentional asymmetry):
- DIA chart (TradeStation) ↔ US30 CFD (TradeLocker)
- GLD chart (TradeStation) ↔ XAUUSD CFD (TradeLocker)
### Trading window:
- NY open first 2 hours + NY close last 2 hours
- RO summer time: 16:30-18:30 and 21:00-23:00
- Typical frequency: 1 trade per evening
## Constraints
- **Prop firm account on TradeLocker.** Faza 2 (auto-execution) requires reading prop TOS first — many prop firms prohibit automation or detect robotic timing patterns.
- No API on TradeLocker. No signal export on TradeStation for compiled custom indicator.
- Bot runs on the same Windows machine as TradeStation. Cross-machine (RDP/VNC) screenshot adds latency and fragility.
## Premises (agreed)
1. Screenshot + visual detection is the only viable bridge.
2. Notification-first (Faza 1) is the right sequencing. Zero-click MVP removes all financial bug risk.
3. M2D MAPS dot strip has stable y-position on fixed TradeStation layout → ROI color sampling is the right detection method.
4. DIA→US30 price divergence is acceptable risk (user's judgment, has been trading this pairing live).
5. Bot runs on the same Windows machine as TradeStation.
## Recommended Approach — B: Structured Service with Dry-Run and Audit Log
Python package on Windows, structured for clean extension to Faza 2.
### Components:
- **Detector core:** `mss` screenshot of TradeStation window (located by title via `pygetwindow`) → crop M2D MAPS ROI → scan rightmost N dot positions → classify each by closest-color match with tolerance → feed into state machine that tracks 3-dot sequences (turquoise→dark-green→light-green = BUY trigger; yellow→dark-red→light-red = SELL trigger).
- **Level extractor:** after trigger, scan chart region for horizontal colored lines (SL/TP1/TP2). Convert pixel y to price via calibration of y-axis scale.
- **Calibration tool (Tkinter):** interactive — user clicks on each dot color sample, captures RGB + tolerance, clicks on ROI corners, captures y-axis price references. Writes to `config.toml`.
- **Dry-run mode:** runs detector against a folder of saved screenshots (recorded during normal operation). Shows what notification WOULD have been sent for each. Used to validate new color thresholds or strategy tweaks without live risk.
- **Notifier abstraction:** interface with Discord webhook and Telegram bot implementations. Sends: annotated screenshot + decoded SL/TP1/TP2 prices + signal type (BUY/SELL) + timestamp.
- **Audit log (JSONL):** every detection cycle — timestamp, detected dots, classification, decision, notification sent y/n. Replayable, debuggable.
- **Scheduler:** Windows Task Scheduler entry, auto-start/stop at 16:30 / 18:30 / 21:00 / 23:00 local time (summer/winter offset aware).
### Structure:
```
atm/
├── pyproject.toml
├── config.toml # populated by calibration tool
├── src/atm/
│ ├── detector.py # screenshot + color classification + state machine
│ ├── levels.py # SL/TP1/TP2 pixel-to-price extraction
│ ├── notifier/
│ │ ├── __init__.py # abstract Notifier
│ │ ├── discord.py
│ │ └── telegram.py
│ ├── audit.py # JSONL logger
│ ├── calibrate.py # Tkinter UI
│ ├── dryrun.py # replay on saved screenshots
│ └── main.py # orchestration + scheduler hooks
├── samples/ # saved screenshots for dry-run corpus
└── logs/ # JSONL audit
```
### Detection algorithm (core loop):
1. Every 1 second during trading window:
- Locate TradeStation window
- If not foreground or minimized, log + skip
- Screenshot M2D MAPS ROI (fixed offsets from window bounds)
- For rightmost N=5 dot positions, sample center pixel, classify to nearest labeled color within tolerance
- Update rolling window of last 10 dots with their timestamps
- Evaluate state machine: did the last 3 classified dots (within a bounded time window) complete a BUY or SELL sequence?
- If trigger fired AND not already fired for this bar: extract SL/TP1/TP2 levels, send notification, log, mark fired.
### Anti-duplicate logic:
- Each trigger dot is keyed by (x-pixel position at capture, color). Once fired, stored in "recently fired" set with 10-minute TTL. Prevents re-fire if same dot persists across cycles.
### Sanity guards:
- If classification confidence (color distance) low for 3+ cycles in a row → push "bot lost sight" alert to user. Layout may have changed.
- If TradeStation window not found for 60 seconds → push "bot cannot find chart" alert.
## Open Questions (non-blocking)
- Exact color tolerance values — determined during calibration session, not a design question.
- GLD/XAUUSD: same M2D indicator on GLD chart? Assume yes, confirm during calibration.
- Multi-symbol monitoring — single window switched manually, or two TradeStation windows side by side? Defer; v1 = single chart at a time, user switches manually.
## Success Criteria (Faza 1)
- Over 20 live trading sessions, bot detects ≥95% of signals user also spotted manually.
- Zero false-positive notifications during the bot's first 5 sessions (tune tolerances aggressively).
- Notification delivered within 3 seconds of trigger dot appearing.
- Audit log lets user reproduce "why was no notification sent" for any missed signal.
## Distribution Plan
Personal tool, single user. No distribution channel needed — runs locally on user's Windows box. Git repo at `/workspace/atm`. `pyproject.toml` + `pip install -e .` for local dev. No CI/CD; user's own `scheduled task` starts/stops it.
## Risk Flag — Faza 2 (deferred)
Before extending to auto-execution in TradeLocker:
1. Read prop firm TOS (search for "EA", "automation", "bot", "copy trading", "external signal"). If prohibited, **Faza 2 is off the table** — tool stays notification-only.
2. If permitted, implement via Playwright browser automation against TradeLocker web UI.
3. Add human-like click timing randomization (100-400ms jitter) to avoid robotic detection.
4. Dry-run mode then becomes: "click coordinates resolved, action NOT sent" — user reviews the intended click before enabling live.
## Next Steps (concrete)
1. Init `/workspace/atm` as Python project. `pyproject.toml`, basic structure.
2. Build calibration tool first. Without calibrated config, nothing works.
3. Record 20-30 sample screenshots across several trading sessions (can start this today — doesn't need any code yet; just `mss` screenshot on a 5-second timer dumping to disk).
4. Build detector + state machine. Validate against recorded screenshots in dry-run mode.
5. Wire Discord webhook first (simpler than Telegram bot). Test end-to-end on live session.
6. Add audit log.
7. Schedule Windows task for trading hours.
## What I noticed about how you think
- You explicitly asked for dry-run before writing a line of code. "Să verific dacă vrea să apese corect, fără să apese efectiv." That's not a common instinct for someone building their own tool; it's the instinct of someone who has already had something break expensively.
- You phased the project yourself — "faza 2 după ce mă conving că merge." That's the right ordering and you arrived at it unprompted.
- When I challenged the API premise, you answered with specifics: the indicator is custom, the account doesn't support API. You knew the constraint, not guessed it.
- You flagged the prop account almost casually at the end. A lot of builders would have skipped that detail. It turned out to be the most important constraint in the entire design.

View File

@@ -1,43 +0,0 @@
# Plan: ATM Eng Review — Findings Applied
## Context
User ran `/plan-eng-review` on `partitioned-honking-unicorn.md` (ATM trading monitor, Faza 1). Eng review complete. All 4 decisions resolved, obvious fixes applied, plan file updated in place.
## Where the changes live
The reviewed plan (with all eng-review edits) is at:
**`/home/claude/.claude/plans/partitioned-honking-unicorn.md`**
Test plan artifact at:
**`~/.gstack/projects/romfast-workspace/claude-master-eng-review-test-plan-20260415-212932.md`**
## What changed in the reviewed plan
### 4 decisions (AskUserQuestion)
1. **Bar flicker** → debounce depth=1 (configurable); screenshot in alert = visual check.
2. **Phase A entry price** → dropped; Phase A is direction + screenshot only; user puts manual 0.6% SL in TradeLocker; Phase B sends real levels from chart.
3. **Notifier blocking** → fire-and-forget worker threads per backend, bounded queue, retry + dead-letter.
4. **Alert SPoF** → Discord + Telegram parallel from day 1.
### Obvious fixes (stated, applied)
- Exhaustive state transition table (default-noise rule, SELL mirror explicit, phase-skip handling).
- Python 3.11+ pin → drop `tomli`, use stdlib `tomllib`.
- Windows symlink replaced by `configs/current.txt` marker file.
- New `vision.py` shared module (ROI/hash/interp/Hough).
- `@dataclass Config` with load-time validation.
- DPI check added to calibrate + README note.
### Test coverage
Expanded from state-machine-only to: every module + 1 E2E replay harness. Acceptance gate unchanged (precision=100%, recall≥95% on labeled corpus).
## Verification (post-implementation)
Run the full verification checklist from `partitioned-honking-unicorn.md` (sections 1-9). Specifically:
- `pytest tests/` — all new unit tests + E2E replay pass.
- `atm dryrun ./samples` hits acceptance gate.
- Live 2-session test: both Discord and Telegram fire; kill one mid-session and confirm the other still delivers + dead-letter file gets the failed alert.
## Status
**CEO + ENG CLEARED.** No further reviews required before implementation. Design + DX reviews properly skipped (no UI scope; personal single-user tool). Run `/ship` after implementation.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

View File

@@ -1,258 +0,0 @@
# Plan: ATM — Automated Trading Monitor (M2D, Faza 1) — ENG-REVIEWED
**Source plan:** `/home/claude/.claude/plans/swirling-drifting-starfish.md`
**CEO plan artifact:** `~/.gstack/projects/romfast-workspace/ceo-plans/2026-04-15-atm-trading.md`
**Eng review mode:** FULL_REVIEW (4 decisions made, 0 unresolved)
**Design doc:** `~/.gstack/projects/romfast-workspace/claude-master-design-20260415-atm-trading.md` (APPROVED)
**Eng test plan:** `~/.gstack/projects/romfast-workspace/claude-master-eng-review-test-plan-20260415-212932.md`
---
## Context
User trades M2D strategy manually on DIA (TradeStation) with execution on TradeLocker US30 CFD (prop firm). Same strategy on GLD → XAUUSD. 4h/evening dual-screen monitoring. Faza 1 goal: bot auto-detects M2D trigger, sends Discord/Telegram notification with screenshot + SL/TP1/TP2 levels; user executes manually in TradeLocker. Faza 2 (auto-execution) deferred until prop firm TOS verified and Faza 1 proven over 20+ sessions.
**Review changed two things from the original plan:**
1. **State machine spec corrected.** Original "last 3 consecutive non-gray dots" is wrong. Actual M2D is phased: Phase 1 arming (turquoise → gray/dark-green) → Phase 2 trigger (light-green).
2. **Levels extraction corrected.** Original plan had levels.py extracting SL/TP at trigger. But those lines only appear on TradeStation chart *after* user enters trade in TradeLocker. Corrected to two-phase: spec-math at trigger, chart-scan after entry.
Plus 5 accepted expansions (labeled corpus, level fallback, layout canary, trade journal, TOS checklist).
---
## Approach: B (Structured Python service, dry-run, audit log) + CEO-reviewed additions
Runs on Windows machine alongside TradeStation. `mss` screenshots → ROI color-sample on M2D MAPS strip → phased state machine → Discord webhook + Telegram bot → JSONL audit + trade journal → dry-run replay against labeled corpus.
---
## State Machine Spec (corrected + exhaustive)
States:
- `IDLE`
- `ARMED_BUY` — turquoise seen
- `PRIMED_BUY` — turquoise + at least one dark-green seen
- `ARMED_SELL` — yellow seen
- `PRIMED_SELL` — yellow + at least one dark-red seen
**Default rule:** any (state, event) pair not listed below → stay in current state, no action, log as `noise`.
Transitions — BUY side:
| From | Event | To | Action |
|------|-------|-----|--------|
| IDLE | turquoise | ARMED_BUY | log arm_ts |
| IDLE | yellow | ARMED_SELL | log arm_ts (sell) |
| IDLE | dark-green / dark-red / light-green / light-red / gray | IDLE | noise (log phase-skip if light-green/light-red) |
| ARMED_BUY | gray | ARMED_BUY | persist |
| ARMED_BUY | turquoise | ARMED_BUY | refresh arm_ts |
| ARMED_BUY | dark-green | PRIMED_BUY | log prime_ts |
| ARMED_BUY | yellow | ARMED_SELL | opposite rearm |
| ARMED_BUY | dark-red | ARMED_BUY | ignore (minority noise) |
| ARMED_BUY | light-green | IDLE | **skip detected** — no FIRE, log phase_skip |
| ARMED_BUY | light-red | IDLE | skip detected, log |
| PRIMED_BUY | dark-green | PRIMED_BUY | accumulate |
| PRIMED_BUY | dark-red | PRIMED_BUY | ignore (minority noise) |
| PRIMED_BUY | **light-green** | IDLE | **FIRE BUY**, lockout(BUY)=4min |
| PRIMED_BUY | light-red | IDLE | skip detected (wrong trigger) |
| PRIMED_BUY | gray | IDLE | **COOLED** — signal dead, log |
| PRIMED_BUY | turquoise | ARMED_BUY | rearm fresh |
| PRIMED_BUY | yellow | ARMED_SELL | opposite rearm |
SELL side mirrors exactly: swap turquoise↔yellow, dark-green↔dark-red, light-green↔light-red, BUY↔SELL.
Notes:
- No time-based TTL on ARMED/PRIMED. State persists until trigger fires, cooled by gray after PRIMED, opposite-color rearm, or process restart (Windows Task Scheduler stops bot at session end → natural session-boundary reset).
- Cooling rule: "gray after dark-green" = signal racit (user's term). Gray during ARMED_BUY (before any dark-green) is OK.
- After FIRE: 4-minute lockout per-direction. BUY lockout doesn't block SELL and vice versa. Single timestamp per direction.
- Opposite-color-Phase-1 triggers rearm to opposite side (captures direction flip).
- Phase-skip (arming color → trigger color with no phase-2 step) → IDLE, no FIRE, logged. Would be legitimate only if indicator collapses phases, which it doesn't per observed behavior.
---
## Detection Details
- **Loop interval:** 5 seconds (36 cycles per 3-min bar; stays well inside notification-latency target).
- **Rightmost-dot detection:** scan ROI from right edge leftward, find first non-background pixel cluster → that's the rightmost dot. Don't hardcode x-pixel positions (chart scrolls; hardcoded positions drift).
- **Debounce:** configurable `debounce_depth` in config.toml (default `1` — single-read acceptance). Increase if future sessions show mid-bar color flicker. Screenshot-in-notification is the user's visual verification on top.
- **Rolling window:** keep last 20 classified dots with their detection timestamps. State machine consumes the newest *accepted* (post-debounce) dot per cycle.
- **Classification:** nearest-color match in RGB Euclidean distance, per-color tolerance from calibration. Report confidence = `1 - distance_nearest / distance_second_nearest`. Log confidence every cycle. If all distances > tolerance → `UNKNOWN`, state unchanged.
---
## Levels Extraction (two-phase, simplified)
**Phase A — at trigger (immediate alert to Discord + Telegram):**
- No entry-price compute. No spec-math SL/TP. User places a manual 0.6% SL in TradeLocker at entry; actual TP1/TP2/SL come in Phase B from the chart.
- Notification: `🟢 BUY signal DIA→US30 | 22:47:03` + annotated screenshot (detected dot highlighted).
**Phase B — after user trades (chart-scan confirmation):**
- After Phase A fires, detector keeps watching the chart ROI for horizontal colored lines (red=SL, green=TP1/TP2).
- When lines appear (user has entered trade in TradeLocker and TradeStation drew them) → scan y-pixels via Hough + color mask, convert via y-axis calibration → send second alert to both channels: `✅ Levels: SL=484.35 | TP1=485.20 | TP2=485.88`.
- If chart-line scan times out (no lines in 10 min) → silent (user didn't trade).
- If only 2 lines detected (user didn't set TP2 or line not rendered yet) → partial-result alert.
- Phase B overlap with next signal: guarded by per-direction lockout + Phase-B completion flag; a new FIRE cannot issue until prior Phase B closes (timeout or success).
---
## Dedup / Lockout
- Time-based lockout: after any FIRE, block re-fire for 4 minutes (one 3-min bar + 1 min safety).
- Tracked per-direction: BUY lockout doesn't block SELL.
- Stored as single timestamp per direction (not pixel-keyed).
---
## Observability
- **Heartbeat:** every 30 min to a separate Discord thread (not main alerts channel): `🟢 22:00 alive | 0 triggers | confidence avg 0.85 | chart OK`. Silence >35 min = watchdog concern (user notices).
- **Layout canary:** every 60 cycles (5 min), hash a stable reference region (axis labels, chart border). Stored baseline in config. On significant divergence (>threshold) → `⚠️ Layout changed — auto-paused, recalibrate` to alerts channel. Bot pauses detection until operator acknowledges (touch a pause-file or restart).
- **Low-confidence alert:** 3+ consecutive cycles with confidence below threshold → `⚠️ Bot lost sight` (already in original plan).
- **Window-lost alert:** TradeStation window not found for 60s → `⚠️ Cannot find chart`.
- **Audit JSONL:** per-cycle, daily rotation (`logs/YYYY-MM-DD.jsonl`), fields: `{ts, window_found, roi_ok, rightmost_dot_color, confidence, state, transition, trigger, notified, reason}`.
---
## Files to Create
- `/workspace/atm/pyproject.toml` — Python 3.11+ required. Deps: `mss`, `opencv-python`, `numpy`, `requests`, `pygetwindow`, `pywin32` (DPI + window capture), `rich` (CLI), `pillow` (screenshot annotation). **No `tomli` — use stdlib `tomllib`.**
- `/workspace/atm/config.toml` — populated by calibration tool (ROI coords, per-color RGB + tolerance, `debounce_depth`, y-axis scale, canary-region baseline hash, Discord webhook URL, Telegram bot token + chat_id)
- `/workspace/atm/src/atm/config.py`**[ENG-REVIEW]** `@dataclass Config` with `Config.load(path)` that validates on load (RGB tuples, positive tolerances, both notifier credentials present, y-axis 2-point pair). Fail fast at startup.
- `/workspace/atm/src/atm/vision.py`**[ENG-REVIEW]** shared primitives: ROI crop, perceptual hash, pixel-to-price linear interp, Hough line detection with color mask. Used by detector/canary/levels to avoid drift.
- `/workspace/atm/src/atm/detector.py` — screenshot loop, rightmost-dot scan, color classification, rolling window, debounce
- `/workspace/atm/src/atm/state_machine.py` — explicit phased state machine (spec above), exhaustive transition table
- `/workspace/atm/src/atm/levels.py` — Phase B chart-scan only (Phase A entry-price compute removed after ENG-REVIEW)
- `/workspace/atm/src/atm/canary.py` — layout fingerprint hash + drift check + auto-pause
- `/workspace/atm/src/atm/notifier/__init__.py` — abstract `Notifier` protocol: `send_alert()`, `send_heartbeat()`, `send_levels_confirm()`
- `/workspace/atm/src/atm/notifier/fanout.py`**[ENG-REVIEW]** `FanoutNotifier` wraps N backends, each with its own worker thread + bounded queue (size 50, drop-oldest on overflow) + retry with exponential backoff + dead-letter file on total failure. Main loop never blocks.
- `/workspace/atm/src/atm/notifier/discord.py` — webhook POST, annotated screenshot upload (multipart)
- `/workspace/atm/src/atm/notifier/telegram.py`**[ENG-REVIEW]** built in parallel with Discord (no longer deferred); bot API, photo upload
- `/workspace/atm/src/atm/audit.py` — JSONL logger with daily local-midnight rotation, line-buffered write for crash safety
- `/workspace/atm/src/atm/calibrate.py` — Tkinter: window pick → DPI check → ROI corners → per-color sample → y-axis scale → canary region → save versioned config
- `/workspace/atm/src/atm/labeler.py`**[EXPANSION]** Tkinter label UI → `labels.json`
- `/workspace/atm/src/atm/dryrun.py` — replay with precision/recall/confusion matrix when labels present
- `/workspace/atm/src/atm/journal.py`**[EXPANSION]** `atm journal` CLI → `trades.jsonl`
- `/workspace/atm/src/atm/report.py`**[EXPANSION]** weekly aggregation
- `/workspace/atm/src/atm/main.py` — CLI: `atm calibrate`, `atm label <dir>`, `atm dryrun <dir>`, `atm run [--duration Xh]`, `atm journal`, `atm report [--week YYYY-WW]`
- `/workspace/atm/tests/`**[ENG-REVIEW]** unit + E2E per test plan at `~/.gstack/projects/romfast-workspace/claude-master-eng-review-test-plan-20260415-212932.md`
- `/workspace/atm/samples/`, `/workspace/atm/logs/`
- `/workspace/atm/configs/` — versioned config archive. **[ENG-REVIEW]** No symlink (Windows admin-required); use `configs/current.txt` marker file storing the active filename. `Config.load()` reads the marker.
- `/workspace/atm/docs/phase2-prop-firm-audit.md` — structured TOS checklist
- `/workspace/atm/README.md` — setup, calibration workflow, per-session operating checklist, DPI/multi-monitor notes
---
## Build Order
1. **`pyproject.toml` + package scaffold** — Python 3.11+, `pip install -e .`, `atm --help` works.
2. **Standalone screenshot-dump script**`mss` timer dumps to `samples/` every 5s during trading sessions. Build corpus in parallel.
3. **`config.py` + `vision.py`** — Config dataclass with validation; shared vision primitives. Ship with unit tests for config load + pixel-to-price interp.
4. **`calibrate.py`** — versioned config in `configs/YYYY-MM-DD-HHMM.toml`; `configs/current.txt` marker file points at active. DPI check + canary region capture.
5. **`labeler.py`** — once ~30 samples exist, tag them. `labels.json` is ground truth.
6. **`state_machine.py`** + **unit tests** (clean BUY, clean SELL, cooling, opposite-rearm, lockout per-direction, noise, phase-skip, all state×color pairs via parameterized test).
7. **`detector.py`** + **unit tests** (empty/background ROI, rightmost-cluster, rolling window FIFO, debounce depth=1, classification edges including UNKNOWN).
8. **`canary.py`** + **unit tests** (drift threshold, pause-file gating).
9. **`levels.py`** (Phase B only) + **unit tests** (Hough line detection with color mask, 2 vs 3 lines, 10-min timeout, pixel-to-price roundtrip).
10. **`notifier/fanout.py` + `discord.py` + `telegram.py`** + **unit tests** (queue overflow drop-oldest, 429 backoff, dead-letter on total failure, fanout: one backend down still delivers). Both channels built in parallel — fire together from day 1.
11. **`audit.py`** + **unit tests** (daily rotation at local midnight, line-buffered flush crash safety).
12. **`dryrun.py`** — replay on `samples/` against `labels.json`. **Acceptance gate before live: precision = 100%, recall ≥ 95%.**
13. **E2E replay test** — feed `samples/` through detector → state_machine → notifier-mock → in-memory audit; assert labels match FIREs.
14. **`journal.py`**, **`report.py`**, **`main.py`** (unified CLI).
15. **Windows Task Scheduler setup** — 16:30→18:30, 21:00→23:00. `atm run --duration 2h`. Manual DST check twice yearly.
16. **`docs/phase2-prop-firm-audit.md`** — TOS checklist template.
---
## Existing Utilities to Reuse
Greenfield Python project. No internal utilities. External libs: `mss` (screenshot), `pygetwindow` (window locate), `opencv-python` (line detection in Phase B), `numpy` (color math), `requests` (Discord webhook), `tomli` (config parsing), `pillow` (annotated screenshots).
---
## Verification
End-to-end, in build order:
1. **State machine unit tests:** `pytest tests/test_state_machine.py` — all scenarios (clean BUY, clean SELL, cooling, rearm, lockout, noise) pass.
2. **Calibration:** `atm calibrate` → step through → `config.toml` populated with plausible RGBs for described colors + y-axis scale sane + canary region picked.
3. **Labeled corpus:** ≥30 screenshots in `samples/`, `atm label ./samples` tags each.
4. **Dry-run with metrics:** `atm dryrun ./samples` → precision + recall + confusion matrix printed. **Acceptance gate:** precision = 100%, recall ≥ 95%. If not met → tune tolerances, re-run.
5. **Live test notification-only (2 sessions):** `atm run`. Verify:
- Discord + Telegram notifications within 5s of trigger, both channels receive.
- Phase A message: direction + timestamp + annotated screenshot.
- Phase B levels-alert fires once TradeStation draws SL/TP lines; correct SL/TP1/TP2 prices.
- Heartbeat messages every 30 min in thread.
- Audit JSONL complete, state transitions visible.
- Kill one notifier (e.g. wrong token) → other still delivers, dead-letter file for failed one.
6. **Canary test:** manually move TradeStation window during session → layout-changed alert within 5 min. Move back → restart bot → resumes.
7. **Scheduler test:** Windows Task Scheduler starts bot at 16:30, stops at 18:30 cleanly, log rotates at midnight.
8. **Journal test:** after real trade, `atm journal` → prompt flow complete → `trades.jsonl` entry present.
9. **Report test:** after 1 week of live use, `atm report --week 2026-16` → precision per color, slippage distribution, P&L summary.
---
## Risk Register
- **Prop firm TOS (Faza 2 blocker):** read TOS using `docs/phase2-prop-firm-audit.md` checklist before any auto-execution work. If EA/automation prohibited → Faza 2 dead, stay on Faza 1 permanently.
- **TradeStation layout change:** canary catches it within 5 min → auto-pause. Recalibrate. Losing a session to a layout change is acceptable cost.
- **Calibration drift over time:** versioned configs in `configs/` let you roll back to last-known-good if new calibration misfires.
- **DIA↔US30 price divergence:** accepted (user's judgment). Phase 1 journal captures slippage per signal, feeding Faza 2 go/no-go.
- **Screen sharing / RDP during trading:** overlay can break classification. Low prob, documented in README as operator hygiene.
- **Windows Task Scheduler DST transitions:** twice per year, schedule may misfire. Manual check first week of each DST change.
---
## Out of Scope (Faza 1)
- Any automated click in TradeLocker (Faza 2 work)
- Multi-symbol concurrent monitoring (single chart at a time; user switches manually between DIA and GLD)
- Backtesting on historical data (strategy already manually validated)
- Web UI / dashboard (headless + Discord/Telegram only)
- Ack feedback loop (react-on-notification labeling) — deferred to TODOS.md as `P2-ack-loop`: shipping baseline first, adding feedback once detection quality verified
- Telegram notifier — built only after Discord is stable 5+ sessions
---
## Accepted Expansions (CEO review, SELECTIVE mode)
1.**Labeled sample corpus + dry-run metrics**`labeler.py`, `labels.json`, automated precision/recall in dryrun. Makes acceptance criteria ("false-positives = 0, false-negatives ≤ 5%") machine-checkable.
2.**Level-extractor fallback (spec-math)** — Phase A always uses spec-math; Phase B validates against chart. Redundancy on fragile piece.
3.**Layout canary + auto-pause**`canary.py` hashes stable UI region, auto-pauses on drift. Catches silent classification-with-wrong-positions failure mode.
4.**Trade journal CLI**`atm journal` + `trades.jsonl` + weekly report. Data for Faza 2 go/no-go decision.
5.**Prop-firm TOS audit checklist**`docs/phase2-prop-firm-audit.md`. Structured Faza 2 evaluation framework shipped now.
## Deferred to TODOS.md
- **Ack feedback loop** — Discord reaction emojis feeding precision tuning. High value, operationally heavier (bot vs webhook). Add after Faza 1 baseline stable.
---
## GSTACK REVIEW REPORT
| Review | Trigger | Why | Runs | Status | Findings |
|--------|---------|-----|------|--------|----------|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | CLEAR (SELECTIVE EXPANSION) | 6 proposals, 5 accepted, 1 deferred; 2 arch corrections |
| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — |
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | CLEAR (FULL_REVIEW) | 9 issues found, 0 critical gaps; 4 decisions made, 0 unresolved |
| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | SKIPPED (no UI scope — CLI + Discord/Telegram) |
| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | SKIPPED (personal tool, single user) |
**UNRESOLVED:** 0
**ENG REVIEW DECISIONS:**
1. **Bar flicker** → debounce depth=1 (configurable), rely on screenshot-in-notification for visual verification.
2. **Phase A entry price** → dropped. User places manual 0.6% SL in TradeLocker at entry. Phase A = direction + screenshot only. Phase B = real SL/TP1/TP2 from chart.
3. **Notifier blocking** → fire-and-forget worker threads per backend, bounded queue (size 50, drop-oldest), retry w/ backoff, dead-letter on total failure.
4. **Alert SPoF** → Discord + Telegram built in parallel from day 1, both fire together.
**ENG REVIEW OBVIOUS FIXES (stated, no decision):**
- Exhaustive state transition table (all state×color pairs, default-noise rule, SELL mirror explicit).
- Python 3.11+ pin, drop `tomli` dep, use stdlib `tomllib`.
- Windows symlink → `configs/current.txt` marker file.
- Shared `vision.py` module (ROI, hash, interp, Hough).
- `@dataclass Config` with fail-fast load-time validation.
- DPI check + multi-monitor note in calibrate + README.
**ENG REVIEW TEST SCOPE (accepted: FULL):** unit tests for every module (state_machine, detector, levels Phase B, canary, audit, notifier fanout/retry, calibrate roundtrip, config validate) + 1 E2E replay harness asserting labeled-corpus precision/recall. Test plan artifact: `~/.gstack/projects/romfast-workspace/claude-master-eng-review-test-plan-20260415-212932.md`.
**VERDICT:** CEO + ENG CLEARED — ready to implement. Run `/ship` after implementation. No further reviews required before build.

View File

@@ -1,74 +0,0 @@
# Plan: ATM — Automated Trading Monitor (M2D, Faza 1)
## Context
User tranzacționează manual strategia M2D pe DIA (TradeStation) cu execuție pe TradeLocker US30 CFD (cont prop firm). Aceeași strategie merge și pe GLD → XAUUSD. 4 ore/seară trebuie să urmărească 2 ecrane. Obiectiv Faza 1: bot detectează automat trigger-ul și trimite notificare Telegram/Discord cu screenshot + nivele SL/TP1/TP2, user execută manual în TradeLocker. Faza 2 (auto-execution) deferată până prop firm TOS verificat + Faza 1 dovedită.
Design doc complet salvat la `~/.gstack/projects/romfast-workspace/claude-master-design-20260415-atm-trading.md` (include strategia M2D cu toate detaliile).
## Approach: B — Structured Python service + dry-run + audit log
Rulează pe aceeași mașină Windows cu TradeStation. ROI color sampling pe strip-ul M2D MAPS, state machine pentru secvența 3-dot, notifier abstraction (Discord/Telegram), calibration Tkinter, dry-run pe screenshot-uri salvate.
## Files to Create
- `/workspace/atm/pyproject.toml` — packaging, deps: `mss`, `opencv-python`, `numpy`, `requests`, `pygetwindow`, `tomli`
- `/workspace/atm/config.toml` — populat de calibration tool (ROI coords, culori referință + toleranțe, y-axis scale)
- `/workspace/atm/src/atm/detector.py` — screenshot loop + color classification + state machine 3-dot
- `/workspace/atm/src/atm/levels.py` — extragere SL/TP1/TP2 din liniile orizontale (pixel y → preț)
- `/workspace/atm/src/atm/notifier/__init__.py` — interface `Notifier.send(signal, screenshot, levels)`
- `/workspace/atm/src/atm/notifier/discord.py` — webhook POST
- `/workspace/atm/src/atm/notifier/telegram.py` — bot API
- `/workspace/atm/src/atm/audit.py` — JSONL logger, fiecare ciclu
- `/workspace/atm/src/atm/calibrate.py` — Tkinter UI: click pe dot → capture RGB + tolerance; click pe colț ROI → salvează; click pe 2 puncte pe axa Y cu prețurile → calibrare scale
- `/workspace/atm/src/atm/dryrun.py` — replay detector pe folder de screenshot-uri
- `/workspace/atm/src/atm/main.py` — orchestration, CLI (`atm run`, `atm calibrate`, `atm dryrun <dir>`)
- `/workspace/atm/samples/` — director screenshot-uri pentru dry-run corpus
- `/workspace/atm/logs/` — director JSONL audit
- `/workspace/atm/README.md` — setup + calibration workflow
## Build Order
1. **`pyproject.toml` + scaffold package** — `pip install -e .`, `atm --help` funcționează.
2. **Script standalone de capture samples** (înainte de orice logică) — rulezi în timpul următoarelor sesiuni trading, dump screenshot la 5s interval în `samples/`. Ai corpus pentru dry-run.
3. **`calibrate.py`** — fără config calibrat, nimic nu merge. Tkinter cu: pas 1 (select TradeStation window by title), pas 2 (click pe colțuri ROI M2D MAPS), pas 3 (click pe fiecare culoare: turquoise, verde închis, verde deschis, galben, roșu închis, roșu deschis + gri neutru; capturează RGB + rază de toleranță implicită 20), pas 4 (2 click-uri pe axa Y + valori preț introduse → scale factor pixel→preț). Salvează `config.toml`.
4. **`detector.py`** — loop 1s: locate window, screenshot ROI, sample rightmost 5 dots pe pozițiile calibrate, clasifică fiecare la cea mai apropiată culoare (Euclidean in RGB cu toleranță). Rolling window ultimele 10 clasificări + timestamp. State machine: ultimele 3 non-gri consecutive = secvență BUY sau SELL? Fire o dată pe trigger (dedup set cu TTL 10min).
5. **`levels.py`** — după trigger, scan chart region pentru liniile orizontale roșii (SL) și verzi (TP1/TP2). Extrage y-pixel al fiecărei linii, convertește la preț folosind scale-ul calibrat.
6. **`notifier/discord.py`** — POST multipart cu screenshot adnotat + mesaj formatat: `🟢 BUY DIA→US30 | SL: 484.35 | TP1: 485.20 | TP2: 485.90 | 22:47:03`.
7. **`dryrun.py`** — iterează `samples/`, rulează detector, printează ce AR fi trimis. Validare logică detecție înainte de live.
8. **`audit.py`** — wrap detector loop, scrie JSONL: `{ts, window_found, roi_ok, dots:[...], classification:[...], trigger:null|"BUY"|"SELL", notified:true|false, reason}`.
9. **`main.py`** — CLI unificat. `atm calibrate`, `atm dryrun ./samples`, `atm run` (loop live cu audit).
10. **Windows Task Scheduler** — 2 task-uri: start 16:30 (stop 18:30), start 21:00 (stop 23:00). `atm run --duration 2h`.
11. **`notifier/telegram.py`** — opțional după ce Discord e stabil.
## Existing Utilities to Reuse
N/A — greenfield project. No internal utilities to reuse.
## Verification
End-to-end, în ordinea din build:
1. **Calibration workflow:** `atm calibrate` → urmezi pașii → rezultă `config.toml` complet. Verifică manual că RGB-urile sunt plauzibile pentru culorile descrise.
2. **Dry-run corpus:** ai ≥20 screenshot-uri din sesiuni reale în `samples/`. Rulezi `atm dryrun ./samples` → output per screenshot: clasificare + decizie trigger. Manual verifici că cazurile unde ai văzut tu semnal reali → trigger; cazurile neutre → no-trigger. False-positives = 0 țintă, false-negatives ≤ 5%.
3. **Live test notification-only (2 sesiuni):** `atm run` în fereastra trading. Verifici:
- Notificările Discord apar în 3s de când vezi trigger-ul pe chart.
- Screenshot atașat e clar, lizibil.
- SL/TP1/TP2 extrase sunt la ≤$0.05 de nivelele reale pe chart.
- Audit log (`logs/YYYY-MM-DD.jsonl`) conține fiecare ciclu; poți reproduce un missed signal.
4. **Sanity alerts:** mută/redimensionează fereastra TradeStation → bot detectează "window lost" în 60s → notificare. Restabilește fereastra → bot reia.
5. **Scheduler validation:** Windows Task Scheduler pornește `atm run` la 16:30, se oprește curat la 18:30, audit log salvează fără corupere.
## Risk Register
- **Prop firm TOS (Faza 2 blocker, NU Faza 1):** înainte de orice extensie spre auto-execution în TradeLocker, citești TOS-ul prop-ului, cauți "EA / automation / bot / copy trading / external signals". Dacă e interzis, Faza 2 e moartă și rămâi permanent pe Faza 1.
- **Indicator layout change:** dacă TradeStation update schimbă render-ul M2D MAPS → re-calibration. Audit log va arăta degradare graduală a confidence-ului → alert activ via "bot lost sight".
- **Price divergence DIA↔US30:** trigger-ul se dă pe DIA; poate fi o secundă unde US30 deja a mișcat diferit. Risc acceptabil (judgment user), dar monitorizat în Faza 2 prin slippage analysis.
- **Screenshot pe ecran sharing / AnyDesk / RDP:** dacă cineva se conectează remote la Windows-ul tău în timpul trading, screenshot-urile pot cuprinde overlay-uri nepotrivite. Mic, dar notabil.
## Out of Scope (Faza 1)
- Orice click automat în TradeLocker
- Multi-symbol concurrent monitoring (single chart la un moment dat)
- Backtesting pe date istorice (strategia e deja validată manual)
- UI / dashboard web — totul rulează headless cu notificări externe

0
logs/.gitkeep Normal file
View File

View File

@@ -13,6 +13,8 @@ dependencies = [
"pillow>=10.0",
"requests>=2.31",
"rich>=13.0",
"httpx>=0.27",
"tzdata>=2024.1; sys_platform == 'win32'",
]
[project.optional-dependencies]
@@ -24,6 +26,7 @@ windows = [
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"pytest-asyncio>=0.23",
]
[project.scripts]

View File

@@ -1,33 +0,0 @@
# calibration_labels.json — schema
Used by `atm validate-calibration` to check that the current color calibration
classifies known-good screenshots correctly before a live session.
## Schema
A JSON array of entries. Each entry:
| Field | Type | Required | Description |
|------------|---------|----------|----------------------------------------------------------------|
| `path` | string | yes | Path to a PNG frame (relative to CWD or absolute). |
| `expected` | string | yes | Expected color name: one of `turquoise`, `yellow`, `dark_green`, `dark_red`, `light_green`, `light_red`, `gray`. |
| `note` | string | no | Freeform annotation; shown in SUGGESTIONS output. |
## Usage
```bash
atm validate-calibration samples/calibration_labels.json
```
Exit codes:
- `0` — every sample PASS
- `1` — one or more FAIL
- `2` — label file missing or malformed JSON
## Adding new samples
1. Find a screenshot in `logs/fires/` whose dot color you can verify by eye.
2. Append an entry with `path`, `expected`, and an optional `note`.
3. Re-run validation. If it FAILs, the SUGGESTIONS section will tell you the
RGB distance between the observed pixel and the expected color's center —
use that as input for `atm calibrate`.

View File

@@ -1,17 +0,0 @@
[
{
"path": "logs/fires/20260417_201500_arm_sell.png",
"expected": "yellow",
"note": "first arm of SELL cycle 2026-04-17"
},
{
"path": "logs/fires/20260417_205302_ss.png",
"expected": "dark_red",
"note": "user confirmed via screenshot (missed live alert)"
},
{
"path": "logs/fires/20260417_210441_ss.png",
"expected": "light_red",
"note": "fire phase (missed live alert)"
}
]

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import threading
from datetime import datetime, date
from pathlib import Path
from typing import Callable, IO
@@ -16,21 +17,25 @@ class AuditLog:
self._clock: Callable[[], datetime] = clock or datetime.now
self._current_date: date | None = None
self._fh: IO[str] | None = None
self._lock = threading.Lock()
def log(self, event: dict) -> None:
now = self._clock()
today = now.date()
if today != self._current_date:
self._open(today)
if "ts" not in event:
event = {**event, "ts": now.isoformat()}
assert self._fh is not None
self._fh.write(json.dumps(event, separators=(",", ":")) + "\n")
with self._lock:
if today != self._current_date:
self._open(today)
assert self._fh is not None
self._fh.write(json.dumps(event, separators=(",", ":")) + "\n")
def close(self) -> None:
with self._lock:
self._close_locked()
def _close_locked(self) -> None:
"""Close file handle; must be called while holding self._lock."""
if self._fh is not None:
try:
self._fh.close()
@@ -47,7 +52,7 @@ class AuditLog:
return self._base_dir / f"{self._current_date}.jsonl"
def _open(self, today: date) -> None:
self.close()
self._close_locked() # already holding self._lock
self._base_dir.mkdir(parents=True, exist_ok=True)
path = self._base_dir / f"{today}.jsonl"
self._fh = open(path, "a", buffering=1, encoding="utf-8")

View File

@@ -1,7 +1,6 @@
"""Calibration wizard for chart window — Tk-based, safe to import headlessly."""
from __future__ import annotations
import os
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -447,18 +446,9 @@ def run_calibration(
data = wizard.run()
# ------------------------------------------------------------------
# 3. Inject notifier creds (env → placeholders otherwise)
# 3. Secrets live in .env at the project root — see .env.example.
# TOML stays 100% public (calibration only).
# ------------------------------------------------------------------
data["discord"] = {
"webhook_url": os.environ.get(
"ATM_DISCORD_URL",
"https://discord.com/api/webhooks/REPLACE_ME",
),
}
data["telegram"] = {
"bot_token": os.environ.get("ATM_TG_TOKEN", "REPLACE_ME"),
"chat_id": os.environ.get("ATM_TG_CHAT", "0"),
}
data.setdefault("options", {})
if chart_region is not None:

View File

@@ -1,14 +1,18 @@
"""Layout drift detector via perceptual hash comparison."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
import numpy as np
from .config import Config
from .vision import crop_roi, hamming_hex, phash
logger = logging.getLogger(__name__)
@dataclass
class CanaryResult:
@@ -28,10 +32,15 @@ class Canary:
self,
cfg: Config,
pause_flag_path: Path | None = None,
on_pause_callback: Callable[[int], None] | None = None,
) -> None:
self._cfg = cfg
self._pause_flag_path = pause_flag_path
self._paused = False
# Single-shot callback invoked exactly once per not_paused→paused transition.
# Wrapped in try/except at call site so a faulty notifier never breaks
# the detection cycle.
self._on_pause = on_pause_callback
def check(self, frame_bgr: np.ndarray) -> CanaryResult:
roi_img = crop_roi(frame_bgr, self._cfg.canary.roi)
@@ -43,6 +52,12 @@ class Canary:
self._paused = True
if self._pause_flag_path is not None:
self._pause_flag_path.write_text("paused", encoding="utf-8")
if self._on_pause is not None:
try:
self._on_pause(distance)
except Exception as exc:
# Never let a notifier hiccup abort the detection cycle.
logger.warning("canary on_pause_callback raised: %s", exc)
return CanaryResult(distance=distance, drifted=drifted, paused=self._paused)

177
src/atm/commands.py Normal file
View File

@@ -0,0 +1,177 @@
"""Telegram command poller + Command dataclass.
Uses httpx (async) for long-polling getUpdates. The sync TelegramNotifier
continues to use requests — this module is the only httpx consumer.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
import httpx
if TYPE_CHECKING:
from .config import TelegramCfg
logger = logging.getLogger(__name__)
CommandAction = Literal["set_interval", "stop", "status", "ss", "pause", "resume", "rebase", "help"]
_BASE = "https://api.telegram.org/bot{token}/{method}"
@dataclass
class Command:
action: CommandAction
value: int | None = None # seconds; only for set_interval
class TelegramPoller:
"""Long-poll Telegram getUpdates, emit Commands into asyncio.Queue.
Security: rejects messages from chat_ids not in cfg.allowed_chat_ids.
Degrades (stops polling) after 3 consecutive 401 responses and warns
via Discord (caller responsibility — poller only logs + sets degraded flag).
"""
def __init__(
self,
cfg: TelegramCfg,
cmd_queue: asyncio.Queue[Command],
audit, # _AuditLike
) -> None:
self._cfg = cfg
self._cmd_queue = cmd_queue
self._audit = audit
self._offset = 0
self._consecutive_401 = 0
self._degraded = False
# fallback: if allowed_chat_ids is empty, accept only the primary chat
self._allowed = set(cfg.allowed_chat_ids) or {cfg.chat_id}
@property
def degraded(self) -> bool:
return self._degraded
async def run(self) -> None:
async with httpx.AsyncClient() as client:
await self._drain(client)
while True:
if self._degraded:
await asyncio.sleep(5)
continue
try:
await self._poll_once(client)
except asyncio.CancelledError:
raise
except (httpx.HTTPError, httpx.TimeoutException) as exc:
self._audit.log({"event": "poller_error", "error": str(exc)})
await asyncio.sleep(5)
except Exception as exc: # json, unexpected
self._audit.log({"event": "poller_error", "error": str(exc)})
await asyncio.sleep(5)
async def _drain(self, client: httpx.AsyncClient) -> None:
"""Discard all pending updates at startup so stale commands don't replay."""
try:
resp = await client.get(
_BASE.format(token=self._cfg.bot_token, method="getUpdates"),
params={"timeout": 0, "offset": self._offset},
timeout=10,
)
body = resp.json()
if body.get("ok") and body.get("result"):
self._offset = body["result"][-1]["update_id"] + 1
except Exception as exc:
logger.warning("TelegramPoller startup drain failed: %s", exc)
async def _poll_once(self, client: httpx.AsyncClient) -> None:
resp = await client.get(
_BASE.format(token=self._cfg.bot_token, method="getUpdates"),
params={"timeout": self._cfg.poll_timeout_s, "offset": self._offset},
timeout=self._cfg.poll_timeout_s + 5,
)
if resp.status_code == 401:
self._consecutive_401 += 1
if self._consecutive_401 >= 3:
self._degraded = True
self._audit.log({"event": "poller_degraded", "reason": "3_consecutive_401"})
return
self._consecutive_401 = 0
body = resp.json()
if not body.get("ok"):
return
for update in body.get("result", []):
self._offset = update["update_id"] + 1
await self._process_update(update)
async def _process_update(self, update: dict) -> None:
if "callback_query" in update:
# Inline button pressed — may be expired; reply with fallback
cbq = update["callback_query"]
chat_id = str(cbq.get("from", {}).get("id", ""))
if chat_id not in self._allowed:
logger.info("Rejected callback_query from chat_id=%s", chat_id)
return
# Caller handles answerCallbackQuery; just note in audit
self._audit.log({"event": "command_received", "action": "callback_query", "chat_id": chat_id})
return
msg = update.get("message") or update.get("edited_message")
if not msg:
return
chat_id = str(msg.get("chat", {}).get("id", ""))
if chat_id not in self._allowed:
logger.info("Rejected message from chat_id=%s", chat_id)
return
text = (msg.get("text") or "").strip().lower()
cmd = self._parse_command(text)
if cmd is None:
return
self._audit.log({
"event": "command_received",
"action": cmd.action,
"value": cmd.value,
"chat_id": chat_id,
})
await self._cmd_queue.put(cmd)
def _parse_command(self, text: str) -> Command | None:
t = text.lstrip("/").strip()
if not t:
return None
if t in ("h", "help"):
return Command(action="help")
if t == "stop":
return Command(action="stop")
if t == "status":
return Command(action="status")
if t in ("ss", "screenshot"):
return Command(action="ss")
if t == "pause":
return Command(action="pause")
if t == "resume":
return Command(action="resume")
if t == "resume force":
# value=1 signals force: also lift canary drift-pause, not just user pause.
return Command(action="resume", value=1)
if t == "rebase":
return Command(action="rebase")
if t == "rebase confirm":
# value=1 applies the pending proposal; plain "rebase" captures+proposes.
return Command(action="rebase", value=1)
# "3" → set_interval 3 minutes → 180s; "interval 3" also accepted
parts = t.split()
if len(parts) == 1 and parts[0].isdigit():
return Command(action="set_interval", value=int(parts[0]) * 60)
if len(parts) == 2 and parts[0] in ("interval", "set_interval") and parts[1].isdigit():
return Command(action="set_interval", value=int(parts[1]) * 60)
return None

View File

@@ -1,10 +1,98 @@
"""Config dataclass with load-time validation. Fail fast."""
from __future__ import annotations
import os
import sys
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
_VALID_WEEKDAYS: tuple[str, ...] = ("MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN")
_SECRET_ENV_VARS: tuple[str, ...] = ("ATM_DISCORD_URL", "ATM_TG_TOKEN", "ATM_TG_CHAT")
_env_log_emitted: bool = False
def _find_env_file(start: Path | None = None) -> Path | None:
"""Walk up from `start` (default cwd) to find `.env` or a project-root sentinel.
Stops at `.env` if found, otherwise at the first dir containing `pyproject.toml`
(and returns the `.env` under it iff it exists). Returns None when nothing hits.
"""
cur = (start or Path.cwd()).resolve()
for parent in (cur, *cur.parents):
env = parent / ".env"
if env.is_file():
return env
if (parent / "pyproject.toml").is_file():
return None
return None
def _load_env_file(path: Path | None) -> tuple[int, int]:
"""Parse a simple KEY=value .env file into os.environ. Shell env wins.
Returns (loaded, overridden_by_shell). No-op if path is None/missing.
Raises ValueError on malformed lines (missing `=`, not a blank/comment).
"""
if path is None or not path.is_file():
return (0, 0)
loaded = 0
overridden = 0
text = path.read_text(encoding="utf-8")
for lineno, raw in enumerate(text.splitlines(), start=1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
raise ValueError(
f"{path}:{lineno}: malformed (expected KEY=value)"
)
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1]
if key in os.environ:
overridden += 1
continue
os.environ[key] = value
loaded += 1
return (loaded, overridden)
def _ensure_env_loaded() -> None:
"""Load .env into os.environ if found; shell values always win.
Called on every Config.load/load_current because monkeypatched tests may
erase env vars between runs. The shell-wins rule keeps this idempotent:
a second call is a no-op for any key already set. The startup log line
is emitted only the first time.
"""
global _env_log_emitted
path = _find_env_file()
if path is None:
return
loaded, overridden = _load_env_file(path)
if not _env_log_emitted and (loaded or overridden):
_env_log_emitted = True
print(
f"[atm.config] .env: loaded {loaded} vars ({overridden} overridden by shell)",
file=sys.stderr,
)
def _require_env(name: str) -> str:
val = os.environ.get(name)
if not val:
raise ValueError(
f"{name} not set — copy .env.example to .env at project root "
f"and fill in the value (or export {name} in your shell)"
)
return val
DotColor = Literal[
"turquoise", "yellow",
@@ -72,16 +160,32 @@ class DiscordCfg:
def __post_init__(self) -> None:
if not self.webhook_url.startswith("http"):
raise ValueError("discord webhook_url required")
if "REPLACE_ME" in self.webhook_url:
raise ValueError(
"discord webhook_url is still the placeholder — edit .env"
)
@dataclass(frozen=True)
class TelegramCfg:
bot_token: str
chat_id: str
allowed_chat_ids: tuple[str, ...] = ()
poll_timeout_s: int = 30
auto_poll_interval_s: int = 180
def __post_init__(self) -> None:
if not self.bot_token or not self.chat_id:
raise ValueError("telegram bot_token + chat_id required")
if self.bot_token == "REPLACE_ME" or "REPLACE_ME" in self.bot_token:
raise ValueError(
"telegram bot_token is still the placeholder — edit .env"
)
cid = self.chat_id.lstrip("-")
if not cid.isdigit():
raise ValueError(
f"telegram chat_id must be numeric (optionally with leading '-'), got {self.chat_id!r}"
)
@dataclass(frozen=True)
@@ -94,6 +198,43 @@ class AlertsCfg:
trigger: bool = True
@dataclass
class OperatingHoursCfg:
"""Session window: only run detection on allowed weekdays within HH:MM range.
Timezone is the source of truth for the exchange (default America/New_York
for NYSE). Start/stop are compared against the clock in that timezone.
Weekday check uses datetime.weekday() + a fixed MON..SUN list to stay
locale-independent (strftime('%a') returns localized names).
The ZoneInfo is cached at config load time so the detection loop doesn't
pay per-tick lookup cost.
NOTE: this dataclass is mutable (non-frozen) so Config._from_dict can stash
the resolved ZoneInfo onto `_tz_cache` after validation. Treat fields as
read-only at runtime.
"""
enabled: bool = False
timezone: str = "America/New_York"
weekdays: tuple[str, ...] = ("MON", "TUE", "WED", "THU", "FRI")
start_hhmm: str = "09:30"
stop_hhmm: str = "16:00"
# Populated by Config._from_dict; None for disabled or failed-load cases.
_tz_cache: ZoneInfo | None = None
@dataclass(frozen=True)
class AlertBehaviorCfg:
"""Alert behavior knobs (not screenshot toggles).
`fire_on_phase_skip`: backstop alert when FSM observes ARMED→light_{green,red}
directly (skipping the dark prime phase — often means dark color was
mis-classified as gray). Default True: missing a fire is worse than a noisy
phase-skip alert. Disable via `[options.alerts] fire_on_phase_skip = false`.
"""
fire_on_phase_skip: bool = True
@dataclass(frozen=True)
class Config:
window_title: str
@@ -114,6 +255,8 @@ class Config:
phaseb_timeout_s: int = 600
dead_letter_path: str = "logs/dead_letter.jsonl"
attach_screenshots: AlertsCfg = field(default_factory=AlertsCfg)
alerts: AlertBehaviorCfg = field(default_factory=AlertBehaviorCfg)
operating_hours: OperatingHoursCfg = field(default_factory=OperatingHoursCfg)
config_version: str = "unknown"
def __post_init__(self) -> None:
@@ -129,6 +272,7 @@ class Config:
@classmethod
def load(cls, path: str | Path) -> "Config":
_ensure_env_loaded()
p = Path(path)
data = tomllib.loads(p.read_text(encoding="utf-8"))
return cls._from_dict(data, version=p.stem)
@@ -136,6 +280,7 @@ class Config:
@classmethod
def load_current(cls, configs_dir: str | Path) -> "Config":
"""Resolve configs/current.txt → active config file."""
_ensure_env_loaded()
d = Path(configs_dir)
marker = d / "current.txt"
if not marker.exists():
@@ -155,10 +300,16 @@ class Config:
baseline_phash=data["canary"]["baseline_phash"],
drift_threshold=int(data["canary"].get("drift_threshold", 8)),
)
discord = DiscordCfg(webhook_url=data["discord"]["webhook_url"])
discord = DiscordCfg(webhook_url=_require_env("ATM_DISCORD_URL"))
tg = data.get("telegram", {}) or {}
tg_chat = _require_env("ATM_TG_CHAT")
_allowed = [str(c) for c in tg.get("allowed_chat_ids", [])] or [tg_chat]
telegram = TelegramCfg(
bot_token=data["telegram"]["bot_token"],
chat_id=str(data["telegram"]["chat_id"]),
bot_token=_require_env("ATM_TG_TOKEN"),
chat_id=tg_chat,
allowed_chat_ids=tuple(_allowed),
poll_timeout_s=int(tg.get("poll_timeout_s", 30)),
auto_poll_interval_s=int(tg.get("auto_poll_interval_s", 180)),
)
opts = data.get("options", {})
region = None
@@ -176,6 +327,36 @@ class Config:
)
else:
attach = AlertsCfg()
alerts_dict = opts.get("alerts", {}) or {}
alert_behavior = AlertBehaviorCfg(
fire_on_phase_skip=bool(alerts_dict.get("fire_on_phase_skip", True)),
)
oh_dict = opts.get("operating_hours", {}) or {}
oh_weekdays = tuple(
str(w).upper() for w in oh_dict.get("weekdays", ("MON", "TUE", "WED", "THU", "FRI"))
)
for wd in oh_weekdays:
if wd not in _VALID_WEEKDAYS:
raise ValueError(
f"operating_hours.weekdays contains invalid day {wd!r}; "
f"expected any of {_VALID_WEEKDAYS}"
)
oh = OperatingHoursCfg(
enabled=bool(oh_dict.get("enabled", False)),
timezone=str(oh_dict.get("timezone", "America/New_York")),
weekdays=oh_weekdays,
start_hhmm=str(oh_dict.get("start_hhmm", "09:30")),
stop_hhmm=str(oh_dict.get("stop_hhmm", "16:00")),
)
if oh.enabled:
try:
oh._tz_cache = ZoneInfo(oh.timezone)
except ZoneInfoNotFoundError as exc:
raise ValueError(
f"operating_hours.timezone {oh.timezone!r} invalid: {exc}"
) from exc
return cls(
window_title=data["window_title"],
dot_roi=roi,
@@ -195,5 +376,7 @@ class Config:
phaseb_timeout_s=int(opts.get("phaseb_timeout_s", 600)),
dead_letter_path=opts.get("dead_letter_path", "logs/dead_letter.jsonl"),
attach_screenshots=attach,
alerts=alert_behavior,
operating_hours=oh,
config_version=version,
)

View File

@@ -28,6 +28,7 @@ class DetectionResult:
match: ColorMatch | None # None if no dot
accepted: bool # post-debounce; True only when match repeats debounce_depth times
color: str | None # accepted color name (UNKNOWN excluded)
dot_pos_abs: tuple[int, int] | None = None # absolute (x, y) in frame; set when dot_found
class Detector:
@@ -60,8 +61,14 @@ class Detector:
self._debounce: deque[str | None] = deque(maxlen=cfg.debounce_depth)
self._rolling: deque[DetectionResult] = deque(maxlen=20)
def step(self, ts: float) -> DetectionResult:
frame = self._capture()
def step(self, ts: float, frame=None) -> DetectionResult:
"""Run one detection tick.
frame: pre-captured BGR ndarray (from asyncio.to_thread capture). When
None (default), calls self._capture() — preserving the sync-loop behaviour.
"""
if frame is None:
frame = self._capture()
if frame is None:
self._debounce.append(None)
@@ -117,6 +124,7 @@ class Detector:
match=match,
accepted=accepted,
color=color,
dot_pos_abs=(self._cfg.dot_roi.x + x, self._cfg.dot_roi.y + y),
)
self._rolling.append(r)
return r

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,13 @@ from typing import Protocol
@dataclass
class Alert:
kind: str # "trigger" | "heartbeat" | "levels" | "warn" | "arm" | "prime" | "late_start"
# flat union: "trigger"|"heartbeat"|"levels"|"warn"|"arm"|"prime"|"late_start"|"screenshot"|"status"
kind: str
title: str
body: str
image_path: Path | None = None # annotated screenshot
direction: str | None = None # "BUY"/"SELL" when kind=trigger
silent: bool = False # disable_notification for Telegram; ignored by Discord
class Notifier(Protocol):

View File

@@ -33,6 +33,7 @@ class TelegramNotifier:
"chat_id": self._chat_id,
"caption": text,
"parse_mode": "HTML",
"disable_notification": str(alert.silent).lower(),
},
files={"photo": fh},
timeout=10,
@@ -44,6 +45,7 @@ class TelegramNotifier:
"chat_id": self._chat_id,
"text": text,
"parse_mode": "HTML",
"disable_notification": alert.silent,
},
timeout=10,
)

118
src/atm/scheduler.py Normal file
View File

@@ -0,0 +1,118 @@
"""ScreenshotScheduler — periodic capture + annotate + send.
Runs as an asyncio task. capture() and cv2 work execute in asyncio.to_thread
to avoid blocking the event loop. Decision 13: scheduler calls capture()
directly, NOT via Detector.
"""
from __future__ import annotations
import asyncio
import logging
import time
from pathlib import Path
from typing import Callable
from .notifier import Alert
logger = logging.getLogger(__name__)
class ScreenshotScheduler:
"""Periodic screenshot sender.
Constructor params are explicit (decision 11 outside-voice finding).
"""
def __init__(
self,
capture: Callable, # () -> ndarray | None
save_fn: Callable, # (frame, label, now) -> Path | None
notifier, # _NotifierLike
audit, # _AuditLike
interval_s: int | None = None,
) -> None:
self._capture = capture
self._save_fn = save_fn
self._notifier = notifier
self._audit = audit
self._interval_s = interval_s
self._is_running = False
self._next_due: float | None = None # monotonic
# ------------------------------------------------------------------
# Public state
# ------------------------------------------------------------------
@property
def is_running(self) -> bool:
return self._is_running
@property
def interval_s(self) -> int | None:
return self._interval_s
@property
def next_due(self) -> float | None:
return self._next_due
# ------------------------------------------------------------------
# Control (called from async event loop)
# ------------------------------------------------------------------
def start(self, interval_s: int) -> None:
self._interval_s = interval_s
self._is_running = True
self._next_due = time.monotonic() + interval_s
def stop(self) -> None:
self._is_running = False
self._next_due = None
# ------------------------------------------------------------------
# Task body
# ------------------------------------------------------------------
async def run(self) -> None:
"""Runs until cancelled."""
while True:
await asyncio.sleep(1)
if not self._is_running or self._next_due is None:
continue
if time.monotonic() >= self._next_due:
await self._take_screenshot()
if self._is_running and self._interval_s is not None:
self._next_due = time.monotonic() + self._interval_s
async def _take_screenshot(self) -> None:
now = time.time()
try:
frame = await asyncio.to_thread(self._capture)
except Exception as exc:
logger.warning("ScreenshotScheduler capture failed: %s", exc)
self._audit.log({"ts": now, "event": "screenshot_sent", "status": "capture_failed", "error": str(exc)})
self._notifier.send(Alert(
kind="warn",
title="Captură eșuată — verificați fereastra TradeStation",
body="",
silent=True,
))
return
if frame is None:
self._notifier.send(Alert(
kind="warn",
title="Captură eșuată — verificați fereastra TradeStation",
body="",
silent=True,
))
return
path = await asyncio.to_thread(self._save_fn, frame, "poll", now)
self._audit.log({"ts": now, "event": "screenshot_sent", "path": str(path) if path else None})
self._notifier.send(Alert(
kind="screenshot",
title="Screenshot periodic",
body="",
image_path=path,
silent=True,
))

View File

@@ -232,3 +232,20 @@ class StateMachine:
if last is None:
return False
return (ts - last) < self._lockout_s
# ------------------------------------------------------------------
# Public lockout API — used by fire_on_phase_skip handler outside the
# FSM. Mirrors _is_locked / _last_fire without leaking private attrs.
# ------------------------------------------------------------------
def is_locked(self, direction: str, ts: float) -> bool:
"""True if a FIRE in `direction` at ts would be within the lockout window."""
return self._is_locked(direction, ts)
def record_fire(self, direction: str, ts: float) -> None:
"""Mark a FIRE for `direction` at ts, starting the lockout timer.
Used by backstop handlers (e.g. fire_on_phase_skip) that emit a
fire-equivalent alert without going through the natural FSM path.
"""
self._last_fire[direction] = ts

View File

@@ -122,8 +122,58 @@ def find_rightmost_dot(
best_idx = i
if best_idx is None:
return None
cx, cy = centroids[best_idx]
return (int(cx), int(cy))
# When erosion fails to sever anti-aliased bridges between adjacent dots
# (common on long, dense dot rows), the "rightmost" component spans
# several fused dots and its centroid lands on an interior dot — wrong
# colour. Detect fused blobs by width and anchor to the right edge
# instead; small isolated dots still use the centroid.
comp_w = int(stats[best_idx, cv2.CC_STAT_WIDTH])
right_edge = int(stats[best_idx, cv2.CC_STAT_LEFT]) + comp_w - 1
if comp_w > 12:
cx = max(right_edge - 2, 0)
else:
cx = int(centroids[best_idx][0])
cy = int(centroids[best_idx][1])
return (cx, cy)
def find_top_dots(
roi_img: np.ndarray,
bg_rgb: tuple[int, int, int],
bg_tol: float = 15.0,
min_cluster_px: int = 3,
n: int = 3,
) -> list[tuple[int, int]]:
"""Top-N rightmost non-background clusters as (cx, cy), sorted by right edge desc.
Same mask/erode/connectedComponents pipeline as `find_rightmost_dot`, but collects
all qualifying components and returns the top N by right-edge. Tie-break on equal
right_edge: smaller y first (deterministic for tests). Anchor logic identical —
fused blobs (comp_w > 12) anchor to `right_edge-2`, small isolated dots use centroid.
"""
bgr_bg = np.array([bg_rgb[2], bg_rgb[1], bg_rgb[0]], dtype=np.float32)
diff = np.linalg.norm(roi_img.astype(np.float32) - bgr_bg, axis=2)
mask = (diff > bg_tol).astype(np.uint8)
kernel = np.ones((3, 3), dtype=np.uint8)
mask = cv2.erode(mask, kernel, iterations=2)
n_labels, _labels, stats, centroids = cv2.connectedComponentsWithStats(
mask, connectivity=8,
)
candidates: list[tuple[int, int, int, int]] = [] # (-right_edge, y, cx, cy)
for i in range(1, n_labels): # skip background
if int(stats[i, cv2.CC_STAT_AREA]) < min_cluster_px:
continue
comp_w = int(stats[i, cv2.CC_STAT_WIDTH])
right_edge = int(stats[i, cv2.CC_STAT_LEFT]) + comp_w - 1
if comp_w > 12:
cx = max(right_edge - 2, 0)
else:
cx = int(centroids[i][0])
cy = int(centroids[i][1])
candidates.append((-right_edge, cy, cx, cy))
candidates.sort() # desc by right_edge, asc by y
return [(cx, cy) for (_neg_r, _y, cx, cy) in candidates[:n]]
def pixel_rgb(roi_img: np.ndarray, x: int, y: int, box: int = 3) -> tuple[int, int, int]:

View File

@@ -6,6 +6,13 @@ from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def _secrets_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ATM_DISCORD_URL", "https://example.com/hook")
monkeypatch.setenv("ATM_TG_TOKEN", "123:abc")
monkeypatch.setenv("ATM_TG_CHAT", "456")
def _minimal_config_data() -> dict:
return {
"window_title": "Test Chart",
@@ -26,8 +33,6 @@ def _minimal_config_data() -> dict:
"baseline_phash": "abc123",
"drift_threshold": 8,
},
"discord": {"webhook_url": "http://example.com/hook"},
"telegram": {"bot_token": "123:abc", "chat_id": "456"},
}
@@ -55,6 +60,16 @@ def test_write_config_and_marker(tmp_path: Path) -> None:
assert cfg2.window_title == cfg.window_title
def test_write_config_omits_secrets(tmp_path: Path) -> None:
"""Calibration output must NOT contain Discord/Telegram secret fields."""
from atm.calibrate import write_config
config_path = write_config(_minimal_config_data(), tmp_path)
text = config_path.read_text(encoding="utf-8")
for marker in ("[discord]", "[telegram]", "webhook_url", "bot_token", "chat_id"):
assert marker not in text, f"calibrated TOML leaked secret marker: {marker}"
def test_import_safe() -> None:
"""Importing atm.calibrate must succeed in a headless environment (no tkinter at top-level)."""
import importlib # noqa: F401

View File

@@ -140,6 +140,52 @@ def test_pause_file_written(tmp_path: Path) -> None:
assert flag.exists()
def test_canary_pause_callback_fires_once() -> None:
"""Single-shot: callback invoked exactly once per not_paused→paused edge."""
cfg = _cfg_with_baseline(BASELINE_FRAME)
calls: list[int] = []
canary = Canary(cfg, on_pause_callback=lambda d: calls.append(d))
canary.check(DRIFTED_FRAME) # transition → callback fires
canary.check(DRIFTED_FRAME) # still paused → no new callback
canary.check(BASELINE_FRAME) # clean but still paused → no new callback
assert len(calls) == 1
assert calls[0] > 0 # distance should be positive
def test_canary_resume_allows_new_pause_notification() -> None:
"""After resume, a fresh drift must re-fire the callback."""
cfg = _cfg_with_baseline(BASELINE_FRAME)
calls: list[int] = []
canary = Canary(cfg, on_pause_callback=lambda d: calls.append(d))
canary.check(DRIFTED_FRAME)
assert len(calls) == 1
canary.resume()
canary.check(DRIFTED_FRAME) # new pause transition
assert len(calls) == 2
def test_canary_pause_callback_exception_does_not_crash_check() -> None:
"""A failing callback must not break canary.check (detection cycle safety)."""
cfg = _cfg_with_baseline(BASELINE_FRAME)
def _boom(_d: int) -> None:
raise RuntimeError("notifier down")
canary = Canary(cfg, on_pause_callback=_boom)
# Must not raise — exception is swallowed + logged.
result = canary.check(DRIFTED_FRAME)
assert result.paused is True
assert canary.is_paused is True
def test_resume_deletes_pause_file(tmp_path: Path) -> None:
"""resume() deletes the pause flag file."""
flag = tmp_path / "paused.flag"

67
tests/test_commands.py Normal file
View File

@@ -0,0 +1,67 @@
"""Tests for atm.commands — /pause /resume parsing (Commit 5)."""
from __future__ import annotations
from unittest.mock import MagicMock
from atm.commands import Command, TelegramPoller
def _make_poller() -> TelegramPoller:
cfg = MagicMock()
cfg.bot_token = "tok"
cfg.chat_id = "123"
cfg.allowed_chat_ids = ("123",)
cfg.poll_timeout_s = 1
return TelegramPoller(cfg, MagicMock(), MagicMock())
def test_parse_pause():
p = _make_poller()
assert p._parse_command("pause") == Command(action="pause")
assert p._parse_command("/pause") == Command(action="pause")
def test_parse_resume_plain():
p = _make_poller()
assert p._parse_command("resume") == Command(action="resume")
assert p._parse_command("/resume") == Command(action="resume")
def test_parse_resume_force():
p = _make_poller()
# "resume force" → value=1 signals force-resume of canary drift
cmd = p._parse_command("resume force")
assert cmd is not None
assert cmd.action == "resume"
assert cmd.value == 1
def test_parse_rebase_plain():
p = _make_poller()
assert p._parse_command("rebase") == Command(action="rebase")
assert p._parse_command("/rebase") == Command(action="rebase")
def test_parse_rebase_confirm():
p = _make_poller()
cmd = p._parse_command("rebase confirm")
assert cmd is not None
assert cmd.action == "rebase"
assert cmd.value == 1
def test_parse_help():
p = _make_poller()
assert p._parse_command("h") == Command(action="help")
assert p._parse_command("/h") == Command(action="help")
assert p._parse_command("help") == Command(action="help")
assert p._parse_command("/help") == Command(action="help")
def test_parse_existing_commands_still_work():
"""Regression: adding pause/resume must not break stop/status/ss/interval."""
p = _make_poller()
assert p._parse_command("stop") == Command(action="stop")
assert p._parse_command("status") == Command(action="status")
assert p._parse_command("ss") == Command(action="ss")
assert p._parse_command("3") == Command(action="set_interval", value=180)

View File

@@ -1,6 +1,8 @@
"""Tests for atm.config — focused on attach_screenshots parsing (legacy bool vs new dict)."""
from __future__ import annotations
import pytest
from atm.config import AlertsCfg, Config
@@ -23,11 +25,17 @@ _BASE = {
"baseline_phash": "0" * 16,
"drift_threshold": 8,
},
"discord": {"webhook_url": "https://example.com/hook"},
"telegram": {"bot_token": "tok", "chat_id": "123"},
}
@pytest.fixture(autouse=True)
def _secrets_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Provide valid Discord/Telegram env vars for every test in this module."""
monkeypatch.setenv("ATM_DISCORD_URL", "https://example.com/hook")
monkeypatch.setenv("ATM_TG_TOKEN", "123:tok")
monkeypatch.setenv("ATM_TG_CHAT", "123")
def _with_opts(opts: dict) -> dict:
d = {k: v for k, v in _BASE.items()}
d["options"] = opts
@@ -97,3 +105,175 @@ def test_attach_screenshots_unknown_keys_ignored() -> None:
}))
assert cfg.attach_screenshots.arm is False
# Should not raise even with unknown key
# ---------------------------------------------------------------------------
# Commit 3: AlertBehaviorCfg (fire_on_phase_skip)
# ---------------------------------------------------------------------------
def test_alerts_default_fire_on_phase_skip_true() -> None:
cfg = Config._from_dict(_with_opts({}))
assert cfg.alerts.fire_on_phase_skip is True
def test_alerts_fire_on_phase_skip_can_be_disabled() -> None:
cfg = Config._from_dict(_with_opts({"alerts": {"fire_on_phase_skip": False}}))
assert cfg.alerts.fire_on_phase_skip is False
# ---------------------------------------------------------------------------
# Commit 4: OperatingHoursCfg parsing + tz cache
# ---------------------------------------------------------------------------
def test_operating_hours_default_disabled() -> None:
cfg = Config._from_dict(_with_opts({}))
assert cfg.operating_hours.enabled is False
assert cfg.operating_hours.timezone == "America/New_York"
assert cfg.operating_hours._tz_cache is None
def test_operating_hours_enabled_caches_tz() -> None:
cfg = Config._from_dict(_with_opts({
"operating_hours": {
"enabled": True,
"timezone": "America/New_York",
"weekdays": ["MON", "TUE", "WED", "THU", "FRI"],
"start_hhmm": "09:30",
"stop_hhmm": "16:00",
}
}))
assert cfg.operating_hours.enabled is True
assert cfg.operating_hours._tz_cache is not None
assert str(cfg.operating_hours._tz_cache) == "America/New_York"
def test_operating_hours_invalid_tz_raises_valueerror() -> None:
import pytest
with pytest.raises(ValueError, match="operating_hours.timezone"):
Config._from_dict(_with_opts({
"operating_hours": {"enabled": True, "timezone": "Not/A_Zone"},
}))
def test_operating_hours_invalid_weekday_raises_valueerror() -> None:
import pytest
with pytest.raises(ValueError, match="weekdays"):
Config._from_dict(_with_opts({
"operating_hours": {"enabled": True, "weekdays": ["XYZ"]},
}))
# ---------------------------------------------------------------------------
# Secrets migration: Discord + Telegram creds live in env vars, not TOML
# ---------------------------------------------------------------------------
def test_missing_discord_url_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ATM_DISCORD_URL", raising=False)
with pytest.raises(ValueError, match="ATM_DISCORD_URL"):
Config._from_dict(_with_opts({}))
def test_missing_tg_token_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ATM_TG_TOKEN", raising=False)
with pytest.raises(ValueError, match="ATM_TG_TOKEN"):
Config._from_dict(_with_opts({}))
def test_missing_tg_chat_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ATM_TG_CHAT", raising=False)
with pytest.raises(ValueError, match="ATM_TG_CHAT"):
Config._from_dict(_with_opts({}))
def test_placeholder_webhook_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ATM_DISCORD_URL", "https://discord.com/api/webhooks/REPLACE_ME")
with pytest.raises(ValueError, match="placeholder"):
Config._from_dict(_with_opts({}))
def test_placeholder_token_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ATM_TG_TOKEN", "REPLACE_ME")
with pytest.raises(ValueError, match="placeholder"):
Config._from_dict(_with_opts({}))
def test_chat_id_non_numeric_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ATM_TG_CHAT", "abc123")
with pytest.raises(ValueError, match="chat_id"):
Config._from_dict(_with_opts({}))
def test_chat_id_negative_groups_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ATM_TG_CHAT", "-5108062256")
cfg = Config._from_dict(_with_opts({}))
assert cfg.telegram.chat_id == "-5108062256"
def test_telegram_section_optional() -> None:
"""TOML without [telegram] still loads; secrets come from env; options use defaults."""
data = {k: v for k, v in _BASE.items()}
cfg = Config._from_dict(data)
assert cfg.telegram.bot_token == "123:tok"
assert cfg.telegram.chat_id == "123"
assert cfg.telegram.poll_timeout_s == 30
assert cfg.telegram.auto_poll_interval_s == 180
assert cfg.telegram.allowed_chat_ids == ("123",)
def test_telegram_non_secret_keys_from_toml() -> None:
data = {k: v for k, v in _BASE.items()}
data["telegram"] = {
"poll_timeout_s": 42,
"auto_poll_interval_s": 999,
"allowed_chat_ids": ["123", "456"],
}
cfg = Config._from_dict(data)
assert cfg.telegram.poll_timeout_s == 42
assert cfg.telegram.auto_poll_interval_s == 999
assert cfg.telegram.allowed_chat_ids == ("123", "456")
def test_regression_post_migration_load(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Loading a real post-migrate TOML (no [discord]/[telegram] secrets) works.
IRON RULE: prevents re-regression of the secret-in-TOML pattern.
"""
fixture = tmp_path / "post_migration_sample.toml"
fixture.write_text(
'window_title = "X"\n'
"\n"
"[dot_roi]\nx=0\ny=0\nw=10\nh=10\n"
"[chart_roi]\nx=0\ny=0\nw=100\nh=100\n"
"[colors.turquoise]\nrgb=[0,253,253]\ntolerance=60.0\n"
"[colors.yellow]\nrgb=[253,253,0]\ntolerance=60.0\n"
"[colors.dark_green]\nrgb=[0,122,0]\ntolerance=60.0\n"
"[colors.dark_red]\nrgb=[128,0,0]\ntolerance=60.0\n"
"[colors.light_green]\nrgb=[0,255,0]\ntolerance=60.0\n"
"[colors.light_red]\nrgb=[255,0,0]\ntolerance=60.0\n"
"[colors.gray]\nrgb=[128,128,128]\ntolerance=60.0\n"
"[colors.background]\nrgb=[0,0,0]\ntolerance=25.0\n"
"[y_axis]\np1_y=100\np1_price=485.0\np2_y=200\np2_price=484.0\n"
"[canary]\nbaseline_phash=\"abc\"\ndrift_threshold=8\n"
"[canary.roi]\nx=0\ny=0\nw=10\nh=10\n"
"[telegram]\npoll_timeout_s=30\nauto_poll_interval_s=180\n"
"[options]\ndebounce_depth=1\nloop_interval_s=5.0\n",
encoding="utf-8",
)
monkeypatch.setenv("ATM_DISCORD_URL", "https://disc.example/x")
monkeypatch.setenv("ATM_TG_TOKEN", "999:tok")
monkeypatch.setenv("ATM_TG_CHAT", "-42")
cfg = Config.load(fixture)
# Secrets sourced from env
assert cfg.discord.webhook_url == "https://disc.example/x"
assert cfg.telegram.bot_token == "999:tok"
assert cfg.telegram.chat_id == "-42"
# Non-secret telegram keys sourced from TOML
assert cfg.telegram.poll_timeout_s == 30
# Calibration values sourced from TOML
assert cfg.colors["turquoise"].rgb == (0, 253, 253)
# TOML does not contain any of the secret markers
text = fixture.read_text(encoding="utf-8")
for marker in ("webhook_url", "bot_token", "chat_id"):
assert marker not in text, f"TOML still contains secret marker: {marker}"

View File

@@ -196,3 +196,111 @@ def test_rolling_window() -> None:
assert len(det.rolling) <= 20
assert len(det.rolling) == 20
# ---------------------------------------------------------------------------
# Fused-blob regression: anti-aliased bridges merge adjacent dots into one
# connected component. The rightmost component's centroid then lands on an
# interior dot (wrong colour). find_rightmost_dot must anchor to the right
# edge for wide blobs so the truly-rightmost dot is sampled.
# See vision.find_rightmost_dot and logs/fires/20260420_210649_ss.png.
# ---------------------------------------------------------------------------
def _make_fused_stripe_frame(
gray_segments: int,
tail_bgr: tuple[int, int, int],
seg_w: int = 13,
stripe_h: int = 13,
) -> np.ndarray:
"""Continuous multi-colour stripe: N gray segments + one tail-colour segment.
Survives 2-iter erosion as a single component — exactly the failure mode on
real screenshots where anti-aliased bridges fuse the whole dot row into one
component. Centroid lands on an interior gray segment; the right edge lies
inside the tail colour.
"""
frame = np.full((100, 300, 3), BG_VAL, dtype=np.uint8)
y0 = DOT_ROI.y + (DOT_ROI.h - stripe_h) // 2
x0 = DOT_ROI.x + 40
gray_bgr = (128, 128, 128)
for i in range(gray_segments):
xs = x0 + i * seg_w
frame[y0:y0 + stripe_h, xs:xs + seg_w] = gray_bgr
xs = x0 + gray_segments * seg_w
frame[y0:y0 + stripe_h, xs:xs + seg_w] = tail_bgr
return frame
@pytest.mark.parametrize(
("screenshot", "expected"),
[
("logs/fires/20260420_210649_ss.png", "dark_red"),
("logs/fires/20260420_200603_poll.png", "dark_green"),
],
)
def test_real_screenshot_rightmost_dot(screenshot: str, expected: str) -> None:
"""Regression on live-capture frames where fused blobs hid the rightmost dot.
2026-04-20 live session missed both a dark_red (21:06:49) and a dark_green
(20:06:03) because find_rightmost_dot returned the centroid of a multi-dot
fused component. Skips cleanly if the sample PNG is not checked out locally
(logs/fires/ is gitignored).
"""
import cv2
from pathlib import Path
from atm.config import ROI
from atm.vision import classify_pixel, crop_roi, find_rightmost_dot, pixel_rgb
path = Path(screenshot)
if not path.exists():
pytest.skip(f"sample not available: {path}")
frame = cv2.imread(str(path))
assert frame is not None
# Matches configs/2026-04-18-1220.toml dot_roi — the live config that missed
# these alerts.
roi = ROI(x=0, y=712, w=1796, h=35)
crop = crop_roi(frame, roi)
dot = find_rightmost_dot(crop, bg_rgb=(0, 0, 0), bg_tol=25.0)
assert dot is not None, "rightmost dot must be found"
rgb = pixel_rgb(crop, *dot)
palette = {
"turquoise": ((0, 153, 153), 60.0),
"yellow": ((153, 153, 0), 60.0),
"dark_green": ((0, 122, 0), 60.0),
"dark_red": ((128, 0, 0), 60.0),
"light_green": ((0, 171, 0), 60.0),
"light_red": ((171, 0, 0), 60.0),
"gray": ((128, 128, 128), 60.0),
}
match = classify_pixel(rgb, palette)
assert match.name == expected, (
f"{path.name}: expected {expected}, got {match.name} at {dot} RGB={rgb}"
)
def test_fused_blob_samples_rightmost_dot() -> None:
"""Fused multi-colour stripe must classify the rightmost colour, not the
centroid colour. Pre-fix the centroid fell on an interior gray segment
on real screenshots (2026-04-20 dark_red/dark_green misses)."""
dark_red_bgr = (0, 0, 100) # BGR for dark_red RGB=(100,0,0)
frame = _make_fused_stripe_frame(gray_segments=7, tail_bgr=dark_red_bgr)
cfg = _make_cfg()
from atm.config import ColorSpec
cfg.colors["gray"] = ColorSpec(rgb=(128, 128, 128), tolerance=30.0)
cfg.colors["dark_red"] = ColorSpec(rgb=(100, 0, 0), tolerance=30.0)
det = Detector(cfg, capture=lambda: frame)
r = det.step(0.0)
assert r.dot_found is True
assert r.match is not None
assert r.match.name == "dark_red", (
f"expected dark_red (rightmost segment), got {r.match.name} at "
f"{r.dot_pos_abs} RGB={r.rgb} — centroid regression"
)

104
tests/test_env_loader.py Normal file
View File

@@ -0,0 +1,104 @@
"""Tests for the minimal .env loader (stdlib, no python-dotenv)."""
from __future__ import annotations
from pathlib import Path
import pytest
from atm.config import _find_env_file, _load_env_file
def test_no_file_returns_none(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
assert _find_env_file() is None
def test_finds_env_in_root(tmp_path: Path) -> None:
(tmp_path / "pyproject.toml").write_text("", encoding="utf-8")
(tmp_path / ".env").write_text("X=1\n", encoding="utf-8")
sub = tmp_path / "sub" / "deeper"
sub.mkdir(parents=True)
found = _find_env_file(sub)
assert found == (tmp_path / ".env").resolve()
def test_pyproject_sentinel_stops_walk(tmp_path: Path) -> None:
(tmp_path / "pyproject.toml").write_text("", encoding="utf-8")
sub = tmp_path / "sub"
sub.mkdir()
assert _find_env_file(sub) is None
def test_parses_simple_kv(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
for k in ("A", "B", "C"):
monkeypatch.delenv(k, raising=False)
env = tmp_path / ".env"
env.write_text(
"# comment\n"
"\n"
"A=1\n"
"B=hello world\n"
" # indented comment\n"
"C=with=equals=in=value\n",
encoding="utf-8",
)
loaded, overridden = _load_env_file(env)
assert loaded == 3
assert overridden == 0
import os
assert os.environ["A"] == "1"
assert os.environ["B"] == "hello world"
assert os.environ["C"] == "with=equals=in=value"
def test_parses_quoted_values(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
for k in ("SQ", "DQ"):
monkeypatch.delenv(k, raising=False)
env = tmp_path / ".env"
env.write_text("SQ='abc'\nDQ=\"def\"\n", encoding="utf-8")
_load_env_file(env)
import os
assert os.environ["SQ"] == "abc"
assert os.environ["DQ"] == "def"
def test_handles_crlf_and_whitespace(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
for k in ("K1", "K2"):
monkeypatch.delenv(k, raising=False)
env = tmp_path / ".env"
env.write_bytes(b"K1=v1\r\n K2 = v2 \r\n")
_load_env_file(env)
import os
assert os.environ["K1"] == "v1"
assert os.environ["K2"] == "v2"
def test_shell_env_wins(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("SHELLWINS", "from_shell")
env = tmp_path / ".env"
env.write_text("SHELLWINS=from_file\nOTHER=x\n", encoding="utf-8")
monkeypatch.delenv("OTHER", raising=False)
loaded, overridden = _load_env_file(env)
import os
assert os.environ["SHELLWINS"] == "from_shell"
assert os.environ["OTHER"] == "x"
assert loaded == 1
assert overridden == 1
def test_malformed_line_raises_with_lineno(tmp_path: Path) -> None:
env = tmp_path / ".env"
env.write_text("A=1\nOOPSNOEQUALS\n", encoding="utf-8")
with pytest.raises(ValueError, match=":2:"):
_load_env_file(env)
def test_missing_path_is_noop() -> None:
assert _load_env_file(None) == (0, 0)
assert _load_env_file(Path("/nonexistent/does-not-exist-xyz")) == (0, 0)

View File

@@ -10,6 +10,8 @@ Covers the six cases from the arm+prime notification plan:
"""
from __future__ import annotations
from types import SimpleNamespace
from atm.main import _handle_tick
from atm.notifier import Alert
from atm.state_machine import State, StateMachine
@@ -486,3 +488,248 @@ def test_save_annotated_frame_succeeds(tmp_path, monkeypatch):
assert "BUY" in result.name
assert len(written) == 1
assert not any(e.get("event") == "snapshot_fail" for e in audit.events)
# ---------------------------------------------------------------------------
# Commit 3: fire_on_phase_skip backstop
# ---------------------------------------------------------------------------
def _cfg_with_flag(enabled: bool):
return SimpleNamespace(alerts=SimpleNamespace(fire_on_phase_skip=enabled))
def test_phase_skip_fire_when_flag_on():
"""ARMED_SELL → light_red directly with flag=True → phase_skip_fire alert."""
fsm = StateMachine(lockout_s=240)
notif = FakeNotifier()
audit = FakeAudit()
# Arm SELL (yellow from IDLE)
_handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False,
cfg=_cfg_with_flag(True))
assert fsm.state == State.ARMED_SELL
notif.alerts.clear()
# ARMED_SELL → light_red (skips dark_red) → phase_skip_fire
tr = _handle_tick(fsm, "light_red", 2.0, notif, audit, first_accepted=False,
cfg=_cfg_with_flag(True))
assert tr is not None and tr.reason == "phase_skip"
ps_alerts = [a for a in notif.alerts if a.kind == "phase_skip_fire"]
assert len(ps_alerts) == 1
assert ps_alerts[0].direction == "SELL"
assert "SELL" in ps_alerts[0].title
def test_phase_skip_no_fire_when_flag_off():
"""Same scenario, flag=False → no phase_skip_fire emitted."""
fsm = StateMachine(lockout_s=240)
notif = FakeNotifier()
audit = FakeAudit()
_handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False,
cfg=_cfg_with_flag(False))
notif.alerts.clear()
_handle_tick(fsm, "light_red", 2.0, notif, audit, first_accepted=False,
cfg=_cfg_with_flag(False))
ps_alerts = [a for a in notif.alerts if a.kind == "phase_skip_fire"]
assert ps_alerts == []
def test_phase_skip_lockout_suppresses_spam():
"""Two phase_skip events within lockout_s → only the first emits an alert."""
fsm = StateMachine(lockout_s=240)
notif = FakeNotifier()
audit = FakeAudit()
cfg = _cfg_with_flag(True)
# First cycle
_handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False, cfg=cfg)
_handle_tick(fsm, "light_red", 2.0, notif, audit, first_accepted=False, cfg=cfg)
# Second arm + phase_skip well within 240s
_handle_tick(fsm, "yellow", 60.0, notif, audit, first_accepted=False, cfg=cfg)
_handle_tick(fsm, "light_red", 61.0, notif, audit, first_accepted=False, cfg=cfg)
ps_alerts = [a for a in notif.alerts if a.kind == "phase_skip_fire"]
assert len(ps_alerts) == 1, (
f"expected 1 phase_skip_fire (lockout), got {len(ps_alerts)}"
)
def test_state_machine_is_locked_and_record_fire_public_api():
"""Public lockout helpers mirror the private _is_locked / _last_fire behavior."""
fsm = StateMachine(lockout_s=100)
assert fsm.is_locked("BUY", 0.0) is False
fsm.record_fire("BUY", 10.0)
assert fsm.is_locked("BUY", 50.0) is True # within 100s
assert fsm.is_locked("BUY", 150.0) is False # past lockout
assert fsm.is_locked("SELL", 50.0) is False # other direction unaffected
# ---------------------------------------------------------------------------
# opposite_rearm — bug observat 2026-04-21 17:45
# PRIMED_BUY + yellow → ARMED_SELL; reason=opposite_rearm; must emit alert
# with screenshot attached. CRITICAL regression.
# ---------------------------------------------------------------------------
def test_opposite_rearm_primed_buy_to_armed_sell_emits_alert():
"""REGRESSION (2026-04-21): PRIMED_BUY + yellow → ARMED_SELL silently."""
from pathlib import Path
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
def snap(kind, label):
return Path(f"/tmp/{label}.png")
# Drive to PRIMED_BUY
_handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False, snapshot=snap)
_handle_tick(fsm, "dark_green", 2.0, notif, audit, first_accepted=False, snapshot=snap)
assert fsm.state == State.PRIMED_BUY
notif.alerts.clear()
# Yellow → ARMED_SELL via opposite_rearm
tr = _handle_tick(fsm, "yellow", 3.0, notif, audit, first_accepted=False, snapshot=snap)
assert tr is not None
assert tr.next == State.ARMED_SELL
assert tr.reason == "opposite_rearm"
assert len(notif.alerts) == 1
a = notif.alerts[0]
assert a.kind == "opposite_rearm"
assert a.direction == "SELL"
assert "yellow" in a.title
assert "opus" in a.title.lower()
assert a.image_path == Path("/tmp/opposite_rearm_sell.png")
def test_opposite_rearm_primed_sell_to_armed_buy_emits_alert():
"""Mirror: PRIMED_SELL + turquoise → ARMED_BUY."""
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
_handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False)
_handle_tick(fsm, "dark_red", 2.0, notif, audit, first_accepted=False)
assert fsm.state == State.PRIMED_SELL
notif.alerts.clear()
tr = _handle_tick(fsm, "turquoise", 3.0, notif, audit, first_accepted=False)
assert tr is not None
assert tr.next == State.ARMED_BUY
assert tr.reason == "opposite_rearm"
assert len(notif.alerts) == 1
assert notif.alerts[0].kind == "opposite_rearm"
assert notif.alerts[0].direction == "BUY"
def test_opposite_rearm_armed_buy_to_armed_sell_emits_alert():
"""Flip direct ARMED_BUY → ARMED_SELL (fără prime între)."""
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
_handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False)
assert fsm.state == State.ARMED_BUY
notif.alerts.clear()
tr = _handle_tick(fsm, "yellow", 2.0, notif, audit, first_accepted=False)
assert tr is not None
assert tr.next == State.ARMED_SELL
assert tr.reason == "opposite_rearm"
assert len(notif.alerts) == 1
assert notif.alerts[0].kind == "opposite_rearm"
assert notif.alerts[0].direction == "SELL"
# ---------------------------------------------------------------------------
# rearm — PRIMED_* + arm-color aceeași direcție → ARMED_* (reset ciclu)
# ---------------------------------------------------------------------------
def test_rearm_primed_buy_to_armed_buy_emits_alert():
from pathlib import Path
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
def snap(kind, label):
return Path(f"/tmp/{label}.png")
_handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False, snapshot=snap)
_handle_tick(fsm, "dark_green", 2.0, notif, audit, first_accepted=False, snapshot=snap)
assert fsm.state == State.PRIMED_BUY
notif.alerts.clear()
tr = _handle_tick(fsm, "turquoise", 3.0, notif, audit, first_accepted=False, snapshot=snap)
assert tr is not None
assert tr.next == State.ARMED_BUY
assert tr.reason == "rearm"
assert len(notif.alerts) == 1
a = notif.alerts[0]
assert a.kind == "rearm"
assert a.direction == "BUY"
assert "reluat" in a.title.lower()
assert a.image_path == Path("/tmp/rearm_buy.png")
def test_rearm_primed_sell_to_armed_sell_emits_alert():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
_handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False)
_handle_tick(fsm, "dark_red", 2.0, notif, audit, first_accepted=False)
assert fsm.state == State.PRIMED_SELL
notif.alerts.clear()
tr = _handle_tick(fsm, "yellow", 3.0, notif, audit, first_accepted=False)
assert tr is not None
assert tr.next == State.ARMED_SELL
assert tr.reason == "rearm"
assert len(notif.alerts) == 1
assert notif.alerts[0].kind == "rearm"
assert notif.alerts[0].direction == "SELL"
# ---------------------------------------------------------------------------
# _emit_arm_alert helper — unit test
# ---------------------------------------------------------------------------
def test_emit_arm_alert_helper_builds_expected_alert():
from pathlib import Path
from atm.main import _emit_arm_alert
notif = FakeNotifier()
calls: list[tuple[str, str]] = []
def snap(kind, label):
calls.append((kind, label))
return Path(f"/tmp/{label}.png")
_emit_arm_alert(
notif,
kind="opposite_rearm",
direction="SELL",
now=1700000000.0,
title="SELL re-armat (yellow) — ciclu opus",
snap=snap,
snap_kind="opposite_rearm",
snap_label="opposite_rearm_sell",
)
assert len(notif.alerts) == 1
a = notif.alerts[0]
assert a.kind == "opposite_rearm"
assert a.direction == "SELL"
assert a.title == "SELL re-armat (yellow) — ciclu opus"
assert a.image_path == Path("/tmp/opposite_rearm_sell.png")
assert calls == [("opposite_rearm", "opposite_rearm_sell")]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
"""Image-backed regression scenarios.
Each scenario in `calibration/scenarios.json` is a sequence of real PNG frames
fed through the full Detector → _handle_tick pipeline. Asserts per step:
- detector classifies the exact expected color (accepted=True)
- FSM transition reason/state + trigger match
- notifier receives exactly the expected new alert kinds
- scheduler-running flag (mirroring _handle_fsm_result) matches
Frames live in calibration/frames/ (self-contained, survives logs/fires/ purges).
"""
from __future__ import annotations
import json
from pathlib import Path
import cv2
import pytest
from atm.config import Config
from atm.detector import Detector
from atm.main import _handle_tick
from atm.state_machine import StateMachine
from tests.test_handle_tick import FakeNotifier, FakeAudit
_SCENARIOS_PATH = Path("calibration/scenarios.json")
_CONFIGS_DIR = Path("configs")
# Reasons that stop the screenshot scheduler (mirrors main.py:_handle_fsm_result).
_SCHEDULER_STOP_REASONS = {"fire", "cooled", "phase_skip", "opposite_rearm"}
def _load_scenarios() -> list[dict]:
return json.loads(_SCENARIOS_PATH.read_text(encoding="utf-8"))
@pytest.fixture(scope="module")
def cfg() -> Config:
return Config.load_current(_CONFIGS_DIR)
@pytest.mark.parametrize(
"scenario", _load_scenarios(), ids=lambda s: s["id"]
)
def test_scenario(scenario: dict, cfg: Config) -> None:
fsm = StateMachine(lockout_s=cfg.lockout_s)
notif = FakeNotifier()
audit = FakeAudit()
detector = Detector(cfg=cfg, capture=lambda: None)
scheduler_running = False
first_accepted = True
for i, step in enumerate(scenario["steps"]):
frame_path = Path(step["frame"])
assert frame_path.exists(), f"{scenario['id']}[{i}]: missing frame {frame_path}"
frame = cv2.imread(str(frame_path))
assert frame is not None, f"{scenario['id']}[{i}]: cv2.imread failed"
res = detector.step(ts=float(i), frame=frame)
assert res.accepted, (
f"{scenario['id']}[{i}]: detector rejected {frame_path.name} "
f"(match={res.match.name if res.match else None}, "
f"d={res.match.distance if res.match else None}, rgb={res.rgb})"
)
assert res.color == step["expected_color"], (
f"{scenario['id']}[{i}]: color mismatch — expected "
f"{step['expected_color']}, got {res.color}"
)
alerts_before = len(notif.alerts)
tr = _handle_tick(
fsm, res.color, float(i), notif, audit,
first_accepted=first_accepted, cfg=cfg,
)
first_accepted = False
assert tr is not None, f"{scenario['id']}[{i}]: _handle_tick returned None"
assert tr.reason == step["expected_reason"], (
f"{scenario['id']}[{i}]: reason mismatch — expected "
f"{step['expected_reason']}, got {tr.reason}"
)
assert tr.next.value == step["expected_state"], (
f"{scenario['id']}[{i}]: state mismatch — expected "
f"{step['expected_state']}, got {tr.next.value}"
)
assert tr.trigger == step["expected_trigger"], (
f"{scenario['id']}[{i}]: trigger mismatch — expected "
f"{step['expected_trigger']}, got {tr.trigger}"
)
new_alerts = [a.kind for a in notif.alerts[alerts_before:]]
assert new_alerts == step["expected_new_alerts"], (
f"{scenario['id']}[{i}]: alert mismatch — expected "
f"{step['expected_new_alerts']}, got {new_alerts}"
)
# Scheduler lifecycle (mirrors _handle_fsm_result main.py:953-957)
if tr.reason == "prime" and not scheduler_running:
scheduler_running = True
elif tr.reason in _SCHEDULER_STOP_REASONS and scheduler_running:
scheduler_running = False
# Also stops on trigger fire (main.py:960-964)
if tr.trigger and not tr.locked and scheduler_running:
scheduler_running = False
assert scheduler_running == step["expected_scheduler_running"], (
f"{scenario['id']}[{i}]: scheduler_running mismatch — expected "
f"{step['expected_scheduler_running']}, got {scheduler_running}"
)

73
tests/test_vision.py Normal file
View File

@@ -0,0 +1,73 @@
"""Unit tests for vision primitives (synthetic BGR masks, fast, deterministic)."""
from __future__ import annotations
import cv2
import numpy as np
from atm.vision import find_top_dots
BG_RGB = (18, 18, 18) # background in RGB
def _make_frame(h: int = 30, w: int = 100) -> np.ndarray:
"""Blank BGR frame filled with BG_RGB."""
bgr_bg = (BG_RGB[2], BG_RGB[1], BG_RGB[0])
frame = np.zeros((h, w, 3), dtype=np.uint8)
frame[:, :] = bgr_bg
return frame
def _paint_dot(frame: np.ndarray, cx: int, cy: int, radius: int = 5,
bgr: tuple[int, int, int] = (0, 255, 0)) -> None:
# radius ≥ 5 keeps blob above min_cluster_px after 2× erosion by 3x3 kernel.
cv2.circle(frame, (cx, cy), radius, bgr, -1)
def test_find_top_dots_happy_three_blobs_sorted_desc():
frame = _make_frame()
_paint_dot(frame, 10, 15)
_paint_dot(frame, 30, 15)
_paint_dot(frame, 50, 15)
result = find_top_dots(frame, BG_RGB, n=3)
assert len(result) == 3
# Sorted by right edge descending → x=50 first, then 30, then 10.
xs = [pt[0] for pt in result]
assert xs[0] > xs[1] > xs[2]
assert xs[0] >= 48 and xs[2] <= 12 # allow ±2px wobble from centroid
def test_find_top_dots_zero_blobs_returns_empty():
frame = _make_frame()
assert find_top_dots(frame, BG_RGB, n=3) == []
def test_find_top_dots_one_blob_n3_returns_one():
frame = _make_frame()
_paint_dot(frame, 25, 15)
result = find_top_dots(frame, BG_RGB, n=3)
assert len(result) == 1
cx, _cy = result[0]
assert 23 <= cx <= 27
def test_find_top_dots_fused_wide_blob_anchors_to_right_edge():
frame = _make_frame()
# Paint a wide stripe (width > 12) — simulates fused anti-aliased dots.
cv2.rectangle(frame, (20, 13), (60, 17), (0, 255, 0), -1)
result = find_top_dots(frame, BG_RGB, n=1)
assert len(result) == 1
cx, _cy = result[0]
# Anchor should be near right edge (~58 = 60-2), not centroid (~40).
assert cx >= 55
def test_find_top_dots_tie_break_by_y_ascending():
frame = _make_frame(h=40)
# Two dots at same right-edge x=50, different y.
_paint_dot(frame, 50, 10) # upper — should come first
_paint_dot(frame, 50, 30) # lower
result = find_top_dots(frame, BG_RGB, n=2)
assert len(result) == 2
# Tie-break: smaller y first.
assert result[0][1] < result[1][1]