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>
ATM — Automated Trading Monitor
Personal Faza-1 tool for the M2D strategy. Watches the M2D MAPS colored-dot strip on a TradeStation chart, runs a phased state machine (ARMED→PRIMED→FIRE), pushes Discord + Telegram alerts with an annotated screenshot on BUY/SELL. You execute the trade manually in TradeLocker.
No auto-execution. Faza 2 (auto-execute) is blocked on prop-firm TOS audit — see docs/phase2-prop-firm-audit.md.
Project layout
atm/
├── configs/ # calibration outputs + current.txt marker
├── 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/ # 184 pytest cases
└── TODOS.md # P1/P2/P3 backlog, Faza 2 items
Install
Python 3.11+ required. Clone, then:
pip install -e ".[windows]" # Windows: live capture + window focus
pip install -e . # Linux / macOS: dev / dryrun only (no live)
atm --help
[windows] pulls mss, pygetwindow, pywin32.
Calibration
One-time per chart layout. Run on the machine that will do live capture.
atm calibrate # 3s default countdown; use --delay 10 if you want more time
Flow:
- Dialog: substring of the chart window title (e.g.
TradeStationorDIA). Stored in config for later auto-focus. - "Ready?" message → click OK → 3s countdown in terminal. Alt-tab TradeStation to the foreground and minimize anything covering it.
- Full-desktop screenshot is captured and shown in a scaled Tk window.
- Drag a rectangle over the chart (include the M2D MAPS strip). Enter = confirm. Esc = cancel.
- 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)
- Save → writes
configs/YYYY-MM-DD-HHMM.toml+ markerconfigs/current.txt. Pulls Discord/Telegram creds from env (ATM_DISCORD_URL,ATM_TG_TOKEN,ATM_TG_CHAT) if set; otherwiseREPLACE_MEplaceholders — edit the TOML manually.
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_axislinear-interp pair.canary.baseline_phashof the canary ROI.
Sampling tips:
- Click colours that are actually present in the current dot history. If a colour isn't visible, skip it —
atm dryrunwill 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.
Smoke-test after calibration
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:
window_found: True
dot_found: True
rgb: (114, 114, 114)
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_roiboundaries capture too much; recalibrate narrower. color=None+UNKNOWN→ tolerances too tight OR sampled RGBs don't match real dots; recalibrate clicking on actual dots.
Live run
# Today's session 16:30–23:00 Romania local
atm run --start-at 16:30 --stop-at 23:00
# Indefinite
atm run
# Fixed duration (hours)
atm run --duration 2
# Linux / headless smoke (reads samples/*.png in a loop)
atm run --capture-stub --duration 0.05
Startup sequence:
- Wall-clock wait until
--start-at(if set). pygetwindow.activate()on the first window matchingcfg.window_title— brings TradeStation to the foreground automatically (restores if minimised).- 5s countdown (
--startup-delay). - Capture first frame + canary check. Status (
drift=X/Yorcapture_failed) is included in the startup ping. - "ATM started" ping on Discord + Telegram.
- Main loop: every
loop_interval_s(default 5s) — capture → canary → detect → state machine → maybe notify → maybe Phase-B. - At
--stop-at(or--duration): "ATM stopped" ping, then exit.
Per-cycle behaviour:
- Canary drift → auto-pause + single-shot Telegram alert (
⚠️ Canary drift=N — monitorizare pauzată). Clear via/resume forcein Telegram, or restart with 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,LevelsExtractorarmed. - Phase skip backstop (
fire_on_phase_skip=truedefault) → ARMED → light_red/light_green direct (dark_red/dark_green missed) still emits⚠️ PHASE SKIPalert with screenshot. FSM lockout suppresses spam. - Phase-B complete → "Levels SL=… TP1=… TP2=…" push.
- Heartbeat every
heartbeat_minminutes.
Operating hours window
Configure via [options.operating_hours] in TOML (source of truth: NYSE local time, timezone-aware so DST is handled automatically):
[options.operating_hours]
enabled = true
timezone = "America/New_York" # fail-fast validated at config load
weekdays = ["MON", "TUE", "WED", "THU", "FRI"]
start_hhmm = "09:30" # NYSE open
stop_hhmm = "16:00" # NYSE close
Out-of-window ticks are skipped (logged only on transition). On boundary crossings the bot emits market_open / market_closed Telegram status messages exactly once per transition. Startup in-window does not emit a spurious market_open alert.
CLI overrides (beat TOML):
atm run --tz America/New_York --weekdays MON,TUE,WED,THU,FRI --oh-start 09:30 --oh-stop 16:00
--oh-start / --oh-stopare different from--start-at / --stop-at. The--start-at / --stop-atpair controls wall-clock session bounds (when the process starts and quits);--oh-*controls the NYSE trading window inside the session (what hours detection actually runs). They compose.
Telegram commands
Send to the bot chat:
| Command | Effect |
|---|---|
/ss or /screenshot |
Take and send a screenshot now |
/status |
State + pause reason + window open/closed |
/pause |
Suspend detection (heartbeats continue) |
/resume |
Clear user pause only. If Canary is drift-paused it stays paused — use /resume force |
/resume force |
Also clear Canary drift-pause (use after recalibration) |
/3 or /interval 3 |
Set auto-screenshot interval to 3 min |
/stop |
Stop the scheduler |
Only allowed_chat_ids are accepted. After 3 consecutive 401s the poller enters degraded mode.
Calibration validation (offline gate)
Validate that the current calibration classifies known-labeled frames correctly without waiting for a live session:
atm validate-calibration samples/calibration_labels.json
Input JSON:
[
{"path": "logs/fires/20260417_201500_arm_sell.png", "expected": "yellow", "note": "first arm"},
{"path": "logs/fires/20260417_205302_ss.png", "expected": "dark_red"},
{"path": "logs/fires/20260417_210441_ss.png", "expected": "light_red"}
]
Output: per-sample PASS/FAIL with detected color + top-3 candidates by RGB distance + suggestion pixels for misclassifications.
Exit code: 0 if 100% PASS, 1 on any FAIL, 2 on malformed/missing input. Suitable for CI or a pre-atm run sanity check.
Keep PowerShell minimized during the session so it doesn't cover TradeStation.
After the session
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%
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.
Trade record-keeping:
atm journal # interactive entry after a real trade
atm report --week 2026-16 # weekly win rate + R PnL + slippage
DPI / multi-monitor notes
- 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:
msscan return black frames over RDP. Run locally on the same physical machine as TradeStation.
Troubleshooting
| Symptom | Likely cause | 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. |
Windows Task Scheduler (production)
For hands-off daily runs surviving reboots:
- Task Scheduler → Create Task → name
ATM M2D Monitor - General: "Run only when user is logged on", "Run with highest privileges"
- Triggers: New → Daily, Start
16:30 - Actions: New → Program
C:\path\to\python.exe, Arguments-m atm run --stop-at 23:00, Start inD:\PROIECTE\atm - Conditions: uncheck "Start only if AC power" (if laptop)
- 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).
Quick command reference
atm calibrate [--screenshot PATH] [--delay SEC] # Tk wizard
atm debug [--delay SEC] # one-shot capture + detect
atm label SAMPLES_DIR # Tk labeling
atm dryrun SAMPLES_DIR # corpus gate
atm validate-calibration LABEL_FILE.json # offline color-classification gate
atm run [--duration H] [--start-at HH:MM] [--stop-at HH:MM] [--startup-delay SEC] [--capture-stub]
[--tz TZNAME] [--weekdays MON,TUE,...] [--oh-start HH:MM] [--oh-stop HH:MM]
atm journal [--file PATH] # interactive trade entry
atm report [--week YYYY-WW] [--file PATH] # weekly summary
Exit codes:
atm dryrun— 0 pass, 1 fail.atm validate-calibration— 0 all PASS, 1 any FAIL, 2 bad input.- Others: standard convention.
Audit log events
Events written to logs/YYYY-MM-DD.jsonl. Added by the lifecycle+canary work:
| Event | Payload | When |
|---|---|---|
canary_drift_paused |
distance |
First drift tick after clean; emits Telegram alert |
user_paused |
— | /pause received |
user_resumed |
was_drift, was_user, force |
/resume or /resume force |
market_open / market_closed |
reason |
Operating-hours window boundary (once per transition; not at startup) |
phase_skip_fire |
direction |
Backstop alert when ARMED→light_* direct |
command_error |
action, error |
Dispatch exception (isolated from detection loop) |