1 Commits

Author SHA1 Message Date
3cb647e084 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 11:54:48 +03:00
98 changed files with 910 additions and 7886 deletions

View File

@@ -1,9 +0,0 @@
# 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

21
.gitignore vendored
View File

@@ -46,15 +46,14 @@ ENV/
# ATM runtime artefacts # ATM runtime artefacts
logs/*.jsonl logs/*.jsonl
logs/dead_letter.jsonl logs/dead_letter.jsonl
logs/detections/
logs/fires
logs/pause.flag
samples/*.png samples/*.png
samples/*.jpg samples/*.jpg
samples/labels.json samples/labels.json
trades.jsonl trades.jsonl
# configs: now committable (secrets live in .env — see .env.example) # configs: keep template + current marker, not generated calibration
configs/*.toml
!configs/example.toml
# Claude scheduler state # Claude scheduler state
.claude/ .claude/
@@ -74,17 +73,3 @@ calibrate_capture_*.png
# Debug captures # Debug captures
debug_*.png debug_*.png
logs/*.png logs/*.png
# Test/dev scratch output
pytest-*.log
pytest-*.err
*.log
*.err
# Auto-captured calibration frames (use `git add -f` to commit selected ones)
calibration/frames/*.png
# Misc clutter
*.jpeg
*.jpg
!samples/*.jpg

View File

@@ -1,90 +0,0 @@
# 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
```
## Codex sandbox/tooling notes
On this Windows checkout, do not assume `rg` or the global `python` has project deps.
Use the repo venv for diagnostics that need Pillow/OpenCV:
```powershell
.\.venv\Scripts\python.exe scripts\inspect_image_pixels.py 6033117943853423831.jpg
.\.venv\Scripts\python.exe scripts\inspect_image_pixels.py 6033117943853423831.jpg --point 1780 725
```
If `rg` is missing, use PowerShell fallbacks:
`Get-ChildItem -Recurse -File src,tests,scripts | Select-String -Pattern "needle"`.
If a command fails due to sandbox permissions and is required for the task, rerun it
with an escalation request instead of stopping the investigation without a verdict.
## 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

View File

@@ -1,75 +0,0 @@
# 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,198 +1,98 @@
# ATM — Monitor Automat de Trading # ATM — Automated Trading Monitor
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.** 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.
Fără execuție automată. Faza 2 (auto-execute) e blocată de auditul TOS prop-firm — vezi `docs/phase2-prop-firm-audit.md`. No auto-execution. Faza 2 (auto-execute) is blocked on prop-firm TOS audit — see `docs/phase2-prop-firm-audit.md`.
--- ---
## Cum e organizat proiectul ## Project layout
``` ```
atm/ atm/
├── configs/ # calibrări + current.txt (marcaj care config e activ) ├── configs/ # calibration outputs + current.txt marker
├── 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/ ├── logs/
│ ├── YYYY-MM-DD.jsonl # audit zilnic, se rotește la miezul nopții local │ ├── YYYY-MM-DD.jsonl # per-cycle audit log, rotates at local midnight
│ ├── dead_letter.jsonl # alerte care au eșuat după retries │ ├── dead_letter.jsonl # alerts that failed after retries
│ ├── fires/ # screenshot-uri adnotate, unul per trigger BUY/SELL (tranzitoriu, se poate goli) │ ├── fires/ # annotated screenshots, one per BUY/SELL trigger
│ └── calibrate_capture_*.png / debug_*.png # artefacte debug (gitignored) │ └── calibrate_capture_*.png / debug_*.png # gitignored debug artifacts
├── samples/ # frame complet salvat automat la fiecare schimbare de culoare (tranzitoriu) ├── samples/ # full frames saved automatically on each colour change
├── src/atm/ # pachetul Python ├── src/atm/ # package
│ ├── config.py # dataclass + loader TOML │ ├── config.py # frozen dataclass + TOML loader
│ ├── vision.py # crop ROI, phash, pixel↔preț, Hough, componente conectate │ ├── vision.py # ROI crop, phash, pixel↔price, Hough, connected-components
│ ├── state_machine.py # FSM 5 stări + lockout per direcție │ ├── state_machine.py # 5-state phased FSM, per-direction lockout
│ ├── detector.py # capture → crop → găsește dot-ul rightmost → clasifică → debounce │ ├── detector.py # capture → crop → find rightmost dot → classify → debounce
│ ├── canary.py # watchdog layout via phash drift + flag de pauză │ ├── canary.py # layout phash drift watchdog with pause-file gating
│ ├── levels.py # extracție SL/TP pe Faza-B │ ├── levels.py # Phase-B SL/TP line extraction
│ ├── notifier/ # FanoutNotifier + webhook Discord + bot Telegram │ ├── notifier/ # FanoutNotifier + Discord webhook + Telegram bot
│ ├── audit.py # JSONL line-buffered, rotație zilnică │ ├── audit.py # line-buffered JSONL, daily rotation
│ ├── calibrate.py # wizard Tk (selectează regiune + click pe culori) │ ├── calibrate.py # Tk wizard (region-select + click-sample)
│ ├── labeler.py # UI Tk → labels.json │ ├── labeler.py # Tk UI → labels.json
│ ├── dryrun.py # replay pe corpus, gate precision/recall │ ├── dryrun.py # replay corpus, precision/recall gate
│ ├── validate.py # gate offline de clasificare a culorilor │ ├── journal.py # trade entries
│ ├── journal.py # înregistrări trade-uri │ ├── report.py # weekly R-multiple PnL
── report.py # raport săptămânal PnL în R ── main.py # unified CLI
│ └── main.py # CLI unificat ├── tests/ # 105 pytest cases
── tests/ # 192 teste pytest (184 core + 8 scenarii regresie) ── TODOS.md # P1/P2/P3 backlog, Faza 2 items
└── TODOS.md # backlog P1/P2/P3
``` ```
--- ---
## Instalare ## Install
Python 3.11+. Python 3.11+ required. Clone, then:
### Windows (producție) ```bash
pip install -e ".[windows]" # Windows: live capture + window focus
```powershell pip install -e . # Linux / macOS: dev / dryrun only (no live)
python -m venv .venv
.venv\Scripts\activate
pip install -e ".[windows]"
# → creează .venv\Scripts\atm.exe
atm --help atm --help
``` ```
`[windows]` aduce `mss`, `pygetwindow`, `pywin32`. Fără venv, `pip install -e ".[windows]"` direct în Python-ul global funcționează la fel. `[windows]` pulls `mss`, `pygetwindow`, `pywin32`.
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.
--- ---
## Secrets ## Calibration
Credențialele Discord/Telegram NU se țin în TOML — trăiesc în `.env` la rădăcina proiectului: One-time per chart layout. Run on the machine that will do live capture.
```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 ```powershell
atm calibrate # countdown 3s default; pune --delay 10 dacă vrei mai mult timp atm calibrate # 3s default countdown; use --delay 10 if you want more time
``` ```
Flow: Flow:
1. Dialog: substring din titlul ferestrei chart-ului (ex. `TradeStation` sau `DIA`). Se salvează în config pentru auto-focus ulterior. 1. Dialog: substring of the chart window title (e.g. `TradeStation` or `DIA`). Stored in config for later auto-focus.
2. **Mesaj "Ready?"** → click OK → countdown 3s în terminal. Alt-tab pe TradeStation, minimizează tot ce-l acoperă. 2. **"Ready?" message** → click OK → 3s countdown in terminal. Alt-tab TradeStation to the foreground and minimize anything covering it.
3. Se face screenshot full-desktop, apare o fereastră Tk scalată. 3. Full-desktop screenshot is captured and shown in a scaled Tk window.
4. **Trage un dreptunghi** peste chart (include și banda M2D MAPS). Enter = confirmă. Esc = anulează. 4. **Drag a rectangle** over the chart (include the M2D MAPS strip). Enter = confirm. Esc = cancel.
5. Click pas cu pas pe regiunea selectată: 5. Step-by-step clicks on the selected region:
- M2D MAPS strip: colț stânga-sus + colț dreapta-jos - M2D MAPS strip: top-left + bottom-right corners
- 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) - 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: colț stânga-sus + colț dreapta-jos (pentru detecția de linii în Faza-B) - Chart area: top-left + bottom-right (for Phase-B line detection)
- Două prețuri cunoscute pe axa Y (pixel y → introduci prețul) - Two known price levels on the y-axis (pixel y → enter price)
- Canary: colț stânga-sus + colț dreapta-jos pe un element UI **stabil** (etichetă axă, bară titlu) - Canary region: top-left + bottom-right on a stable UI element (axis label, title bar)
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). 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.
### ⚠️ Reguli critice la calibrare (evită incidentul 2026-04-17) 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.
**1. Click EXCLUSIV pe dot-ul din DREAPTA al strip-ului.** Sampling tips:
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). - 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.
**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 după calibrare ## Smoke-test after calibration
```powershell ```powershell
atm debug --delay 5 atm debug --delay 5
``` ```
Ia un frame. Salvează `logs/debug_full_<ts>.png`, `logs/debug_dot_roi_<ts>.png`, `logs/debug_annotated_<ts>.png`. Tipărește: Captures one frame. Saves `logs/debug_full_<ts>.png`, `logs/debug_dot_roi_<ts>.png`, `logs/debug_annotated_<ts>.png`. Prints:
``` ```
window_found: True window_found: True
@@ -202,322 +102,116 @@ classified: gray distance=24 confidence=0.79
accepted: True color=gray accepted: True color=gray
``` ```
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: 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:
- Cerc la mijloc de strip → alt window e sub regiunea de capture (adu TradeStation în față). - Circle mid-strip → wrong window under the capture region (bring TradeStation to front).
- Cerc pe element UI non-dot → `dot_roi` prea larg; recalibrează mai îngust. - Circle on a non-dot UI element → `dot_roi` boundaries capture too much; recalibrate narrower.
- `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. - `color=None` + `UNKNOWN` → tolerances too tight OR sampled RGBs don't match real dots; recalibrate clicking on actual dots.
--- ---
## Validare offline a calibrării ## Live run
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 ```powershell
atm label samples # UI Tk — etichetezi fiecare frame cu culoarea reală văzută pe chart # Today's session 16:3023:00 Romania local
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 atm run --start-at 16:30 --stop-at 23:00
# Fără limită # Indefinite
atm run atm run
# Durată fixă (ore) # Fixed duration (hours)
atm run --duration 2 atm run --duration 2
# Linux/WSL smoke (rulează pe fișiere din samples/) # Linux / headless smoke (reads samples/*.png in a loop)
atm run --capture-stub --duration 0.05 atm run --capture-stub --duration 0.05
``` ```
Startup: Startup sequence:
1. Așteptare wall-clock până la `--start-at` (dacă e setat). 1. Wall-clock wait until `--start-at` (if set).
2. `pygetwindow.activate()` pe prima fereastră care conține `cfg.window_title` — aduce TradeStation în față (restaurează dacă-i minimizată). 2. `pygetwindow.activate()` on the first window matching `cfg.window_title`brings TradeStation to the foreground automatically (restores if minimised).
3. Countdown 5s (`--startup-delay`). 3. 5s countdown (`--startup-delay`).
4. Primul frame + check canary. Status (`drift=X/Y` sau `capture_failed`) e inclus în ping-ul de start. 4. Capture first frame + canary check. Status (`drift=X/Y` or `capture_failed`) is included in the startup ping.
5. **Ping "ATM started"** pe Discord + Telegram. 5. **"ATM started" ping** on Discord + Telegram.
6. Loop principal: la fiecare `loop_interval_s` (default 5s) — capture → canary → detect → FSM → poate notifică → poate Faza-B. 6. Main loop: every `loop_interval_s` (default 5s) — capture → canary → detect → state machine → maybe notify → maybe Phase-B.
7. La `--stop-at` (sau `--duration`): **ping "ATM stopped"**, apoi exit. 7. At `--stop-at` (or `--duration`): **"ATM stopped" ping**, then exit.
Comportament per ciclu: Per-cycle behaviour:
- 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. - Canary drift → auto-pause (logs `paused`, skips detection). Clear by running `atm run` again with the pause-file removed.
- Detector raportează UNKNOWN → rămâne în starea curentă (loghează `noise`). - Detector reports UNKNOWN → stays in current state (logged as `noise`).
- Schimbare de culoare → frame complet salvat în `samples/YYYYMMDD_HHMMSS_<color>.png` (pentru corpus). - Colour change → full frame saved to `samples/YYYYMMDD_HHMMSS_<color>.png` (for corpus).
- FIRE (BUY/SELL, nu locked) → PNG adnotat salvat în `logs/fires/`, atașat la alertă, `LevelsExtractor` armed. - FIRE (BUY/SELL, not locked) → annotated PNG saved to `logs/fires/`, attached to the 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. - Phase-B complete → "Levels SL=… TP1=… TP2=…" push.
- Faza-B completă → push "Levels SL=… TP1=… TP2=…". - Heartbeat every `heartbeat_min` minutes.
- Heartbeat la fiecare `heartbeat_min` minute.
Ține PowerShell minimizat în timpul sesiunii ca să nu acopere TradeStation. Keep PowerShell minimized during the session so it doesn't cover 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.
--- ---
## După sesiune ## After the session
```powershell ```powershell
atm label samples # UI Tk — etichetezi fiecare frame salvat cu culoarea reală atm label samples # Tk UI — label each saved frame with true dot colour
atm dryrun samples # replay prin detector + FSM; exit 0 dacă precision=100%, recall95% atm dryrun samples # replay through detector + FSM; exits 0 if precision=100%, recall>=95%
``` ```
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.** 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.
Pentru calibrare fină a clasificării de culori (Flow A cu `/3`), vezi secțiunea **Validare offline a calibrării** de mai sus. Trade record-keeping:
Evidență trade-uri:
```powershell ```powershell
atm journal # înregistrare interactivă după un trade real atm journal # interactive entry after a real trade
atm report --week 2026-16 # win rate săptămânal + PnL în R + slippage atm report --week 2026-16 # weekly win rate + R PnL + slippage
``` ```
--- ---
## Note DPI / multi-monitor ## DPI / multi-monitor notes
- 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. - 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.
- Schimbi DPI scaling sau muți pe un alt monitor cu DPI diferit → recalibrezi. - Changing DPI scaling or moving to a different monitor with different DPI → recalibrate.
- RDP / desktop virtual: `mss` poate returna frame-uri negre peste RDP. Rulează local pe aceeași mașină fizică pe care e TradeStation. - RDP / virtual desktops: `mss` can return black frames over RDP. Run locally on the same physical machine as TradeStation.
--- ---
## Troubleshooting ## Troubleshooting
| Simptom | Cauză probabilă | Fix | | Symptom | Likely cause | Fix |
|---|---|---| |---|---|---|
| `capture_failed` în ping-ul de start | `chart_window_region` referă coords off-screen (alt layout monitor) | Recalibrează. | | `capture_failed` in startup ping | `chart_window_region` references coords off-screen (different monitor layout) | Recalibrate. |
| 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ă. | | 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'` la start | `cfg.window_title` nu prinde nimic | Editează `window_title` în TOML cu un substring unic pentru TradeStation. | | `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. |
| 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. | | 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. |
| 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 silent (or vice versa) | `logs/dead_letter.jsonl` contains failed alerts with error | Fix credentials in TOML, restart. |
| Discord OK, Telegram tace (sau invers) | `logs/dead_letter.jsonl` are alertele eșuate + eroarea | Fixezi credențiale în 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). |
| 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). | | 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. |
| 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. | | Wizard window is tiny / image not visible | Tk geometry default on Windows | Already fixed — `git pull`. Image is scaled to fit screen. |
| 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 (producție) ## Windows Task Scheduler (production)
Pentru rulare automată zilnică care supraviețuiește reboot-urilor: For hands-off daily runs surviving reboots:
1. Task Scheduler → Create Task → nume `ATM M2D Monitor` 1. Task Scheduler → Create Task → name `ATM M2D Monitor`
2. **General**: "Run only when user is logged on", "Run with highest privileges" 2. **General**: "Run only when user is logged on", "Run with highest privileges"
3. **Triggers**: New → Daily, Start `16:30` 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` 4. **Actions**: New → Program `C:\path\to\python.exe`, Arguments `-m atm run --stop-at 23:00`, Start in `D:\PROIECTE\atm`
5. **Conditions**: debifează "Start only if AC power" (dacă e laptop) 5. **Conditions**: uncheck "Start only if AC power" (if laptop)
6. **Settings**: "If task runs longer than 7 hours → stop" 6. **Settings**: "If task runs longer than 7 hours → stop"
Click-right → Run, să testezi manual. Check DST schimbare de două ori pe an (prima săptămână din martie / octombrie). Click-right → Run to test manually. Manual DST-change check twice a year (Mar / Oct first week).
--- ---
## Referință rapidă comenzi ## Quick command reference
``` ```
atm calibrate [--screenshot PATH] [--delay SEC] # wizard Tk atm calibrate [--screenshot PATH] [--delay SEC] # Tk wizard
atm debug [--delay SEC] # one-shot capture + detect atm debug [--delay SEC] # one-shot capture + detect
atm label SAMPLES_DIR # etichetare Tk atm label SAMPLES_DIR # Tk labeling
atm dryrun SAMPLES_DIR # gate pe corpus atm dryrun SAMPLES_DIR # corpus gate
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 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 journal [--file PATH] # înregistrare interactivă atm report [--week YYYY-WW] [--file PATH] # weekly summary
atm report [--week YYYY-WW] [--file PATH] # raport săptămânal
``` ```
Exit codes: Exit code: `atm dryrun` exits 0 if gate passes, 1 otherwise. Other commands follow standard convention.
- `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,20 +30,11 @@ Per-kind mute toggles for notifications in case arm/prime turn out too noisy in
- `cfg.notify.arm: bool = true` - `cfg.notify.arm: bool = true`
- `cfg.notify.prime: bool = true` - `cfg.notify.prime: bool = true`
- `cfg.notify.late_start: 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. 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. 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 ## 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. Auto-execution on TradeLocker. Blocked on TOS audit (see `docs/phase2-prop-firm-audit.md`). Not started until GO decision + 20+ Faza 1 sessions.
@@ -58,30 +49,9 @@ 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 ## Quality debt
- [x] **Integration test for run_live loop**: lifecycle async test added in `tests/test_main.py` (IDLE→ARMED→PRIMED auto-poll→FIRE auto-stop). - [ ] **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] **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. - [ ] **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. - [ ] **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. - [ ] **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
View File

@@ -1,15 +0,0 @@
@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 %*
)

View File

@@ -1,49 +0,0 @@
# 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

@@ -1,122 +0,0 @@
[
{
"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.

Before

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

View File

@@ -1,338 +0,0 @@
[
{
"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

@@ -1,100 +0,0 @@
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

@@ -1,98 +0,0 @@
window_title = "m2d"
[dot_roi]
x = 0
y = 720
w = 1796
h = 40
[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 = "c11f4a852ec09f3a8de4e4cf4ad76d84f10b19d3e708663c38f5b538877c6624"
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

View File

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

View File

@@ -64,8 +64,12 @@ y = 100
w = 100 w = 100
h = 50 h = 50
# Secretele (Discord webhook + Telegram bot/chat) se setează în `.env` la rădăcina [discord]
# proiectului — vezi `.env.example`. TOML-ul rămâne 100% public, doar calibrare. webhook_url = "https://discord.com/api/webhooks/REPLACE_ME"
[telegram]
bot_token = "REPLACE_ME"
chat_id = "REPLACE_ME"
[options] [options]
debounce_depth = 1 debounce_depth = 1
@@ -77,24 +81,6 @@ low_conf_run = 3
phaseb_timeout_s = 600 phaseb_timeout_s = 600
dead_letter_path = "logs/dead_letter.jsonl" 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. # Per-kind screenshot-attach toggles. All default to true on upgrade.
# Accepts either a bare bool (legacy: attach_screenshots = true) or this table. # Accepts either a bare bool (legacy: attach_screenshots = true) or this table.
[options.attach_screenshots] [options.attach_screenshots]

View File

@@ -0,0 +1,149 @@
# 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

@@ -0,0 +1,43 @@
# 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.

BIN
docs/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View File

@@ -0,0 +1,258 @@
# 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

@@ -0,0 +1,74 @@
# 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

View File

View File

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

View File

@@ -0,0 +1,33 @@
# 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

@@ -0,0 +1,17 @@
[
{
"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,133 +0,0 @@
r"""Diagnose why detect_strips finds 1 strip when there are 2 TS windows.
Reuses the most recent raw capture under logs/repro/. For each strip detected,
prints the connected-components vivid mask, gaps, and the contiguous run lengths
so we can see exactly where the threshold is killing the second strip.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
import cv2 # noqa: E402
import numpy as np # noqa: E402
from atm.config import Config # noqa: E402
from atm.layout import VIVID_COLORS, detect_strips # noqa: E402
from atm.vision import crop_roi, find_top_dots, classify_pixel, pixel_rgb # noqa: E402
def _latest_raw() -> Path:
candidates = sorted((ROOT / "logs" / "repro").glob("*_raw.png"))
if not candidates:
raise SystemExit("No *_raw.png in logs/repro — run scripts/repro_ss_resume.py first.")
return candidates[-1]
def main() -> int:
cfg = Config.load_current(ROOT / "configs")
raw_path = _latest_raw()
print(f"Using raw frame: {raw_path}")
frame = cv2.imread(str(raw_path), cv2.IMREAD_COLOR)
if frame is None:
raise SystemExit(f"Could not read {raw_path}")
palette = {
name: (spec.rgb, spec.tolerance)
for name, spec in cfg.colors.items()
if name != "background"
}
full_crop = crop_roi(frame, cfg.dot_roi)
h, w = full_crop.shape[:2]
print(f"dot_roi crop shape: {h}x{w} (cfg.dot_roi={cfg.dot_roi})")
# 1) Build the vivid mask used by detect_strips
mask = np.zeros((h, w), dtype=np.uint8)
img_f = full_crop.astype(np.float32)
per_color_pixels = {}
for name in VIVID_COLORS:
if name not in palette:
continue
rgb, tol = palette[name]
bgr = np.array([rgb[2], rgb[1], rgb[0]], dtype=np.float32)
diff = np.linalg.norm(img_f - bgr, axis=2)
m = (diff < tol).astype(np.uint8)
per_color_pixels[name] = int(m.sum())
mask |= m
print("\nVIVID_COLORS pixel counts in dot_roi crop (pre-morphology):")
for n, c in per_color_pixels.items():
print(f" {n:12s} {c:>7d} px")
print(f" total mask: {int(mask.sum()):>7d} px")
# 2) Column projection: which x columns have any vivid pixel?
col_has = mask.any(axis=0).astype(np.uint8)
runs = []
i = 0
while i < w:
if col_has[i] == 0:
i += 1
continue
j = i
while j < w and col_has[j] == 1:
j += 1
runs.append((i, j - 1, j - i))
i = j
print(f"\nContiguous vivid-column runs (raw, no morphology): {len(runs)}")
for x0, x1, run_w in runs[:25]:
print(f" x={x0:>4d}..{x1:>4d} width={run_w}")
if len(runs) > 25:
print(f" ... +{len(runs) - 25} more")
# 3) Apply same morphology + connected components as detect_strips
strip_h = cfg.dot_roi.h
min_strip_px = max(150, strip_h * 8)
min_gap_px = max(20, int(strip_h * 0.8))
kw = max(3, min_gap_px // 2)
print(f"\ndetect_strips params: min_strip_px={min_strip_px} min_gap_px={min_gap_px} morphology kw={kw}")
kernel = np.ones((1, kw), dtype=np.uint8)
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
n_labels, _labels, stats, _centroids = cv2.connectedComponentsWithStats(closed, connectivity=8)
print(f"\nConnected components after CLOSE (kw={kw}): n={n_labels - 1} (excluding bg)")
for i in range(1, n_labels):
x = int(stats[i, cv2.CC_STAT_LEFT])
y = int(stats[i, cv2.CC_STAT_TOP])
ww = int(stats[i, cv2.CC_STAT_WIDTH])
hh = int(stats[i, cv2.CC_STAT_HEIGHT])
passes = "PASS" if ww >= min_strip_px else "drop"
print(f" [{i:>2d}] x={x:>4d} y={y:>3d} w={ww:>4d} h={hh:>3d} -> {passes}")
strips = detect_strips(full_crop, palette, min_gap_px, min_strip_px)
print(f"\ndetect_strips() final result: {len(strips)} strip(s)")
for i, s in enumerate(strips):
print(f" strip[{i}] x={s.x} y={s.y} w={s.w} h={s.h}")
# 4) Run find_top_dots on the FULL dot_roi (single-chart fallback path used
# by /ss when ctx.charts < 2). This is what users see when the layout
# detector hasn't promoted the layout to multi-chart yet.
bg_rgb = cfg.colors["background"].rgb if "background" in cfg.colors else (18, 18, 18)
bg_tol = cfg.colors["background"].tolerance if "background" in cfg.colors else 15.0
full_dots = find_top_dots(full_crop, bg_rgb, bg_tol, n=3)
print(f"\nfind_top_dots on FULL dot_roi (n=3, single-chart fallback path):")
for i, (cx, cy) in enumerate(full_dots):
rgb = pixel_rgb(full_crop, cx, cy)
m = classify_pixel(rgb, palette)
print(f" c{i + 1}: pos=({cx + cfg.dot_roi.x},{cy + cfg.dot_roi.y}) rgb={rgb} -> {m.name} d={m.distance:.1f}")
# 5) Save the binary mask + closed mask for visual inspection
out_dir = ROOT / "logs" / "repro"
cv2.imwrite(str(out_dir / "diag_mask_raw.png"), (mask * 255).astype(np.uint8))
cv2.imwrite(str(out_dir / "diag_mask_closed.png"), (closed * 255).astype(np.uint8))
print(f"\nWrote diag_mask_raw.png + diag_mask_closed.png to {out_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,162 +0,0 @@
r"""Verify the two proposed fixes for detect_strips on the latest 2-window capture.
Fix A: include gray in VIVID_COLORS mask (cheapest change).
Fix B: use non-background mask (any pixel where diff(bg) > bg_tol).
Reuses the most recent raw capture in logs/repro/.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
import cv2 # noqa: E402
import numpy as np # noqa: E402
from atm.config import Config, ROI # noqa: E402
from atm.vision import crop_roi # noqa: E402
def _detect_strips_with_palette(
full_dot_crop, palette, color_names, min_gap_px, min_strip_px,
):
"""Same body as layout.detect_strips but with selectable color set."""
h, w = full_dot_crop.shape[:2]
mask = np.zeros((h, w), dtype=np.uint8)
img_f = full_dot_crop.astype(np.float32)
for name in color_names:
if name not in palette:
continue
rgb, tol = palette[name]
bgr = np.array([rgb[2], rgb[1], rgb[0]], dtype=np.float32)
diff = np.linalg.norm(img_f - bgr, axis=2)
mask |= (diff < tol).astype(np.uint8)
kw = max(3, min_gap_px // 2)
kernel = np.ones((1, kw), dtype=np.uint8)
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
n_labels, _labels, stats, _centroids = cv2.connectedComponentsWithStats(closed, connectivity=8)
out = []
for i in range(1, n_labels):
x = int(stats[i, cv2.CC_STAT_LEFT])
ww = int(stats[i, cv2.CC_STAT_WIDTH])
if ww < min_strip_px:
continue
out.append(ROI(x=x, y=0, w=ww, h=h))
out.sort(key=lambda r: r.x)
return out, mask, closed
def _detect_strips_non_bg(full_dot_crop, bg_rgb, bg_tol, min_gap_px, min_strip_px):
"""Fix B: any pixel different from background → strip mask."""
h, w = full_dot_crop.shape[:2]
bgr_bg = np.array([bg_rgb[2], bg_rgb[1], bg_rgb[0]], dtype=np.float32)
diff = np.linalg.norm(full_dot_crop.astype(np.float32) - bgr_bg, axis=2)
mask = (diff > bg_tol).astype(np.uint8)
kw = max(3, min_gap_px // 2)
kernel = np.ones((1, kw), dtype=np.uint8)
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
n_labels, _labels, stats, _centroids = cv2.connectedComponentsWithStats(closed, connectivity=8)
out = []
for i in range(1, n_labels):
x = int(stats[i, cv2.CC_STAT_LEFT])
ww = int(stats[i, cv2.CC_STAT_WIDTH])
if ww < min_strip_px:
continue
out.append(ROI(x=x, y=0, w=ww, h=h))
out.sort(key=lambda r: r.x)
return out, mask, closed
def _annotate(frame, strips_abs, label, out_dir):
annotated = frame.copy()
for r in strips_abs:
cv2.rectangle(annotated, (r.x, r.y), (r.x + r.w, r.y + r.h), (0, 255, 255), 2)
p = out_dir / f"diag_fix_{label}.png"
cv2.imwrite(str(p), annotated)
return p
def main() -> int:
cfg = Config.load_current(ROOT / "configs")
raws = sorted(p for p in (ROOT / "logs" / "repro").glob("*_raw.png") if p.name[0].isdigit())
if not raws:
raise SystemExit("Run scripts/repro_ss_resume.py first.")
raw_path = raws[-1]
print(f"Using: {raw_path}")
frame = cv2.imread(str(raw_path), cv2.IMREAD_COLOR)
palette = {n: (s.rgb, s.tolerance) for n, s in cfg.colors.items() if n != "background"}
bg_rgb = cfg.colors["background"].rgb
bg_tol = cfg.colors["background"].tolerance
full_crop = crop_roi(frame, cfg.dot_roi)
h, w = full_crop.shape[:2]
strip_h = cfg.dot_roi.h
min_strip_px = max(150, strip_h * 8)
min_gap_px = max(20, int(strip_h * 0.8))
print(f"params: min_strip_px={min_strip_px} min_gap_px={min_gap_px} crop={w}x{h}")
out_dir = ROOT / "logs" / "repro"
# Baseline (current code: vivid only, no gray)
vivid_only = ("turquoise", "yellow", "dark_green", "dark_red", "light_green", "light_red")
s0, m0, c0 = _detect_strips_with_palette(full_crop, palette, vivid_only, min_gap_px, min_strip_px)
print(f"\n[BASELINE vivid-only ] strips={len(s0)}")
for i, r in enumerate(s0):
print(f" [{i}] x={r.x:>4d} w={r.w:>4d}")
cv2.imwrite(str(out_dir / "diag_fix_BASELINE_mask_closed.png"), (c0 * 255).astype(np.uint8))
_annotate(frame, [ROI(x=cfg.dot_roi.x + r.x, y=cfg.dot_roi.y, w=r.w, h=r.h) for r in s0],
"BASELINE", out_dir)
# Fix A: include gray
fixA_palette = vivid_only + ("gray",)
sA, mA, cA = _detect_strips_with_palette(full_crop, palette, fixA_palette, min_gap_px, min_strip_px)
print(f"\n[FIX A vivid+gray ] strips={len(sA)}")
for i, r in enumerate(sA):
print(f" [{i}] x={r.x:>4d} w={r.w:>4d}")
cv2.imwrite(str(out_dir / "diag_fix_A_mask_closed.png"), (cA * 255).astype(np.uint8))
_annotate(frame, [ROI(x=cfg.dot_roi.x + r.x, y=cfg.dot_roi.y, w=r.w, h=r.h) for r in sA],
"A_vivid_plus_gray", out_dir)
# Fix B: any non-background
sB, mB, cB = _detect_strips_non_bg(full_crop, bg_rgb, bg_tol, min_gap_px, min_strip_px)
print(f"\n[FIX B non-bg ] strips={len(sB)}")
for i, r in enumerate(sB):
print(f" [{i}] x={r.x:>4d} w={r.w:>4d}")
cv2.imwrite(str(out_dir / "diag_fix_B_mask_closed.png"), (cB * 255).astype(np.uint8))
_annotate(frame, [ROI(x=cfg.dot_roi.x + r.x, y=cfg.dot_roi.y, w=r.w, h=r.h) for r in sB],
"B_non_background", out_dir)
# Sanity: where is the divider between the two TS windows?
# Project non-bg mask onto x; long zero-runs reveal the gap.
bgr_bg = np.array([bg_rgb[2], bg_rgb[1], bg_rgb[0]], dtype=np.float32)
diff = np.linalg.norm(full_crop.astype(np.float32) - bgr_bg, axis=2)
nonbg = (diff > bg_tol).astype(np.uint8)
col_any = nonbg.any(axis=0).astype(np.uint8)
# Find longest 0-run
longest = (0, 0, 0) # (length, x0, x1)
i = 0
while i < w:
if col_any[i] == 1:
i += 1
continue
j = i
while j < w and col_any[j] == 0:
j += 1
run = j - i
if run > longest[0]:
longest = (run, i, j - 1)
i = j
print(f"\nLongest empty (background-only) horizontal stretch in dot_roi: "
f"{longest[0]}px at x={longest[1]}..{longest[2]} "
f"(this is where the window divider sits)")
print(f"\nWrote diag_fix_*.png to {out_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,124 +0,0 @@
r"""Inspect ATM strip pixels without relying on shell pipelines.
Usage:
.\.venv\Scripts\python.exe scripts\inspect_image_pixels.py 6033117943853423831.jpg
.\.venv\Scripts\python.exe scripts\inspect_image_pixels.py image.jpg --point 1780 725
The script intentionally parses only the config fields needed for pixel inspection,
so it does not require Discord/Telegram secrets to be valid.
"""
from __future__ import annotations
import argparse
import json
import sys
import tomllib
from pathlib import Path
import cv2
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from atm.config import ROI # noqa: E402
from atm.vision import classify_pixel, crop_roi, find_top_dots, pixel_rgb # noqa: E402
def _load_probe_config(path: Path) -> dict:
data = tomllib.loads(path.read_text(encoding="utf-8"))
colors = {
name: (tuple(int(c) for c in spec["rgb"]), float(spec["tolerance"]))
for name, spec in data["colors"].items()
}
background = colors.pop("background", ((18, 18, 18), 15.0))
return {
"dot_roi": ROI(**data["dot_roi"]),
"colors": colors,
"background_rgb": background[0],
"background_tol": background[1],
}
def _as_jsonable_match(match) -> dict:
return {
"name": match.name,
"distance": round(float(match.distance), 3),
"confidence": round(float(match.confidence), 3),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Inspect ATM strip pixels in a JPG/PNG frame.")
parser.add_argument("image", type=Path, help="Frame image path.")
parser.add_argument(
"--config",
type=Path,
default=ROOT / "configs" / "2026-04-21-recalib.toml",
help="ATM TOML config path.",
)
parser.add_argument("--top", type=int, default=3, help="Number of rightmost dots to report.")
parser.add_argument(
"--point",
nargs=2,
type=int,
metavar=("X", "Y"),
help="Optional absolute pixel coordinate to sample.",
)
parser.add_argument("--box", type=int, default=3, help="Sampling radius for mean RGB.")
args = parser.parse_args()
frame = cv2.imread(str(args.image), cv2.IMREAD_COLOR)
if frame is None:
raise SystemExit(f"Could not read image: {args.image}")
probe = _load_probe_config(args.config)
roi = probe["dot_roi"]
roi_img = crop_roi(frame, roi)
dots = find_top_dots(
roi_img,
bg_rgb=probe["background_rgb"],
bg_tol=probe["background_tol"],
n=args.top,
)
result = {
"image": str(args.image),
"image_size": {"w": int(frame.shape[1]), "h": int(frame.shape[0])},
"config": str(args.config),
"dot_roi": {"x": roi.x, "y": roi.y, "w": roi.w, "h": roi.h},
"dots": [],
}
for x, y in dots:
rgb = pixel_rgb(roi_img, x, y, box=args.box)
match = classify_pixel(rgb, probe["colors"])
result["dots"].append(
{
"roi_xy": [int(x), int(y)],
"abs_xy": [int(roi.x + x), int(roi.y + y)],
"rgb": list(rgb),
"match": _as_jsonable_match(match),
}
)
if args.point:
px, py = args.point
if not (roi.x <= px < roi.x + roi.w and roi.y <= py < roi.y + roi.h):
raise SystemExit(f"Point {px},{py} is outside dot_roi")
rx, ry = px - roi.x, py - roi.y
rgb = pixel_rgb(roi_img, rx, ry, box=args.box)
result["point"] = {
"roi_xy": [rx, ry],
"abs_xy": [px, py],
"rgb": list(rgb),
"match": _as_jsonable_match(classify_pixel(rgb, probe["colors"])),
}
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,173 +0,0 @@
r"""Reproduce /ss and /resume Telegram screenshot pipelines for offline inspection.
Brings the `m2d` window to front (Win32 trick — same as live loop), captures via mss
using the active config's chart_window_region, then runs the EXACT same annotators
that /ss and /resume use:
- _save_inspect_frame → /ss path (top-3 dots per detected strip + caption)
- _save_annotated_frame → /resume path (cyan rect on dot_roi/sub_roi)
Outputs are saved under logs/repro/ alongside a JSON summary of detections.
Usage:
.\.venv\Scripts\python.exe scripts\repro_ss_resume.py
.\.venv\Scripts\python.exe scripts\repro_ss_resume.py --no-focus
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
import cv2 # noqa: E402
import numpy as np # noqa: E402
from atm.config import Config # noqa: E402
from atm.layout import detect_strips # noqa: E402
from atm.main import ( # noqa: E402
_focus_window_by_title,
_format_inspect_caption,
_save_annotated_frame,
_save_inspect_frame,
)
from atm.vision import crop_roi # noqa: E402
def _capture_via_region(cfg) -> np.ndarray | None:
import mss # type: ignore[import-untyped]
reg = cfg.chart_window_region
if reg is None:
# Fallback: grab full primary monitor
with mss.mss() as sct:
mon = sct.monitors[1]
img = sct.grab(mon)
return cv2.cvtColor(np.array(img), cv2.COLOR_BGRA2BGR)
with mss.mss() as sct:
mon = {"top": reg.y, "left": reg.x, "width": reg.w, "height": reg.h}
img = sct.grab(mon)
return cv2.cvtColor(np.array(img), cv2.COLOR_BGRA2BGR)
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--no-focus", action="store_true", help="Skip Win32 focus call.")
p.add_argument("--delay", type=float, default=0.5, help="Seconds to sleep after focus.")
p.add_argument(
"--out",
type=Path,
default=ROOT / "logs" / "repro",
help="Output directory for annotated frames + JSON.",
)
args = p.parse_args()
cfg = Config.load_current(ROOT / "configs")
args.out.mkdir(parents=True, exist_ok=True)
# 1) Focus
if not args.no_focus and cfg.window_title:
title = _focus_window_by_title(cfg.window_title)
print(f"[focus] needle={cfg.window_title!r} -> {title!r}")
if args.delay > 0:
time.sleep(args.delay)
# 2) Capture
frame = _capture_via_region(cfg)
if frame is None:
print("[capture] FAILED — no frame")
return 1
print(f"[capture] frame shape={frame.shape}")
now = time.time()
ts_str = time.strftime("%Y%m%d_%H%M%S")
# Save raw too so we can hand-inspect what mss actually grabbed
raw_path = args.out / f"{ts_str}_raw.png"
cv2.imwrite(str(raw_path), frame)
print(f"[raw] {raw_path}")
# 3) Detect strips (same logic as live multi-chart split)
strip_h = cfg.dot_roi.h
min_strip_px = max(150, strip_h * 8)
min_gap_px = max(20, int(strip_h * 0.8))
palette = {
name: (spec.rgb, spec.tolerance)
for name, spec in cfg.colors.items()
if name != "background"
}
full_dot_crop = crop_roi(frame, cfg.dot_roi)
raw_strips = detect_strips(full_dot_crop, palette, min_gap_px, min_strip_px)
# Translate back to absolute frame coords
from atm.config import ROI # noqa: E402
strips = [
ROI(x=cfg.dot_roi.x + r.x, y=cfg.dot_roi.y + r.y, w=r.w, h=r.h)
for r in raw_strips
]
print(f"[strips] dot_roi={cfg.dot_roi} detected={len(strips)} strips")
for i, s in enumerate(strips):
print(f" strip[{i}] x={s.x} y={s.y} w={s.w} h={s.h}")
# Also dump a copy of the full dot_roi crop for visual sanity-check
crop_path = args.out / f"{ts_str}_dot_roi_crop.png"
cv2.imwrite(str(crop_path), full_dot_crop)
print(f"[crop] {crop_path}")
# 4) /ss inspect-annotate (top-3 per strip, FSM-pick markers)
inspect_path, detections = _save_inspect_frame(
frame, cfg, args.out, now, audit=None,
strips=strips if strips else None,
)
caption = _format_inspect_caption(detections, cfg)
print(f"[ss] {inspect_path}")
print(f"[ss] caption:\n{caption}")
# 5) /resume annotate (cyan rect on dot_roi or first strip)
roi_for_resume = strips[0] if strips else cfg.dot_roi
resume_path = _save_annotated_frame(
frame, cfg, args.out, "resume_repro", now, audit=None, roi=roi_for_resume,
)
print(f"[resume] {resume_path}")
# 6) JSON summary
summary = {
"ts": ts_str,
"frame_shape": list(frame.shape),
"dot_roi": {"x": cfg.dot_roi.x, "y": cfg.dot_roi.y, "w": cfg.dot_roi.w, "h": cfg.dot_roi.h},
"strips": [
{"x": s.x, "y": s.y, "w": s.w, "h": s.h} for s in strips
],
"detections": [
{
"strip_idx": d["strip_idx"],
"idx": d["idx"],
"name": d["name"],
"rgb": list(d["rgb"]),
"distance": round(float(d["distance"]), 3),
"confidence": round(float(d["confidence"]), 3),
"pos_abs": list(d["pos_abs"]),
}
for d in detections
],
"files": {
"raw": str(raw_path),
"dot_roi_crop": str(crop_path),
"inspect": str(inspect_path) if inspect_path else None,
"resume": str(resume_path) if resume_path else None,
},
"ss_caption": caption,
}
json_path = args.out / f"{ts_str}_summary.json"
json_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
print(f"[json] {json_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

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

View File

@@ -1,6 +1,7 @@
"""Calibration wizard for chart window — Tk-based, safe to import headlessly.""" """Calibration wizard for chart window — Tk-based, safe to import headlessly."""
from __future__ import annotations from __future__ import annotations
import os
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -446,9 +447,18 @@ def run_calibration(
data = wizard.run() data = wizard.run()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 3. Secrets live in .env at the project root — see .env.example. # 3. Inject notifier creds (env → placeholders otherwise)
# 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", {}) data.setdefault("options", {})
if chart_region is not None: if chart_region is not None:

View File

@@ -1,18 +1,14 @@
"""Layout drift detector via perceptual hash comparison.""" """Layout drift detector via perceptual hash comparison."""
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable
import numpy as np import numpy as np
from .config import Config from .config import Config
from .vision import crop_roi, hamming_hex, phash from .vision import crop_roi, hamming_hex, phash
logger = logging.getLogger(__name__)
@dataclass @dataclass
class CanaryResult: class CanaryResult:
@@ -24,55 +20,32 @@ class CanaryResult:
class Canary: class Canary:
"""Compare live canary ROI phash against a known-good baseline. """Compare live canary ROI phash against a known-good baseline.
``check()`` is pure measurement — it never mutates ``_paused`` or fires Once drift is detected the instance stays paused until resume() is called,
side-effects. Callers decide whether to ``commit_pause()`` (real drift) or even if subsequent frames look clean again.
``rebase()`` (legitimate layout change). This split exists so the tick loop
can use a second signal (strip-count change) to disambiguate before pausing.
""" """
def __init__( def __init__(
self, self,
cfg: Config, cfg: Config,
pause_flag_path: Path | None = None, pause_flag_path: Path | None = None,
on_pause_callback: Callable[[int], None] | None = None,
) -> None: ) -> None:
self._cfg = cfg self._cfg = cfg
self._pause_flag_path = pause_flag_path self._pause_flag_path = pause_flag_path
self._paused = False 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: def check(self, frame_bgr: np.ndarray) -> CanaryResult:
roi_img = crop_roi(frame_bgr, self._cfg.canary.roi) roi_img = crop_roi(frame_bgr, self._cfg.canary.roi)
current_hash = phash(roi_img) current_hash = phash(roi_img)
distance = hamming_hex(current_hash, self._cfg.canary.baseline_phash) distance = hamming_hex(current_hash, self._cfg.canary.baseline_phash)
drifted = distance > self._cfg.canary.drift_threshold drifted = distance > self._cfg.canary.drift_threshold
if drifted and not self._paused:
self._paused = True
if self._pause_flag_path is not None:
self._pause_flag_path.write_text("paused", encoding="utf-8")
return CanaryResult(distance=distance, drifted=drifted, paused=self._paused) return CanaryResult(distance=distance, drifted=drifted, paused=self._paused)
def commit_pause(self, distance: int) -> None:
"""Transition to paused state. Idempotent — no-op if already paused."""
if self._paused:
return
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)
def rebase(self, new_phash: str) -> None:
"""Replace baseline_phash in the live cfg (in-memory mirror).
Caller is responsible for persisting to TOML separately. Does NOT touch
``_paused`` — used in the auto-rebase path where we never paused.
"""
object.__setattr__(self._cfg.canary, "baseline_phash", new_phash)
@property @property
def is_paused(self) -> bool: def is_paused(self) -> bool:
return self._paused return self._paused

View File

@@ -1,177 +0,0 @@
"""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,98 +1,10 @@
"""Config dataclass with load-time validation. Fail fast.""" """Config dataclass with load-time validation. Fail fast."""
from __future__ import annotations from __future__ import annotations
import os
import sys
import tomllib import tomllib
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Literal 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[ DotColor = Literal[
"turquoise", "yellow", "turquoise", "yellow",
@@ -160,32 +72,16 @@ class DiscordCfg:
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not self.webhook_url.startswith("http"): if not self.webhook_url.startswith("http"):
raise ValueError("discord webhook_url required") 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) @dataclass(frozen=True)
class TelegramCfg: class TelegramCfg:
bot_token: str bot_token: str
chat_id: 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: def __post_init__(self) -> None:
if not self.bot_token or not self.chat_id: if not self.bot_token or not self.chat_id:
raise ValueError("telegram bot_token + chat_id required") 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) @dataclass(frozen=True)
@@ -198,43 +94,6 @@ class AlertsCfg:
trigger: bool = True 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) @dataclass(frozen=True)
class Config: class Config:
window_title: str window_title: str
@@ -255,8 +114,6 @@ class Config:
phaseb_timeout_s: int = 600 phaseb_timeout_s: int = 600
dead_letter_path: str = "logs/dead_letter.jsonl" dead_letter_path: str = "logs/dead_letter.jsonl"
attach_screenshots: AlertsCfg = field(default_factory=AlertsCfg) attach_screenshots: AlertsCfg = field(default_factory=AlertsCfg)
alerts: AlertBehaviorCfg = field(default_factory=AlertBehaviorCfg)
operating_hours: OperatingHoursCfg = field(default_factory=OperatingHoursCfg)
config_version: str = "unknown" config_version: str = "unknown"
def __post_init__(self) -> None: def __post_init__(self) -> None:
@@ -272,7 +129,6 @@ class Config:
@classmethod @classmethod
def load(cls, path: str | Path) -> "Config": def load(cls, path: str | Path) -> "Config":
_ensure_env_loaded()
p = Path(path) p = Path(path)
data = tomllib.loads(p.read_text(encoding="utf-8")) data = tomllib.loads(p.read_text(encoding="utf-8"))
return cls._from_dict(data, version=p.stem) return cls._from_dict(data, version=p.stem)
@@ -280,7 +136,6 @@ class Config:
@classmethod @classmethod
def load_current(cls, configs_dir: str | Path) -> "Config": def load_current(cls, configs_dir: str | Path) -> "Config":
"""Resolve configs/current.txt → active config file.""" """Resolve configs/current.txt → active config file."""
_ensure_env_loaded()
d = Path(configs_dir) d = Path(configs_dir)
marker = d / "current.txt" marker = d / "current.txt"
if not marker.exists(): if not marker.exists():
@@ -300,16 +155,10 @@ class Config:
baseline_phash=data["canary"]["baseline_phash"], baseline_phash=data["canary"]["baseline_phash"],
drift_threshold=int(data["canary"].get("drift_threshold", 8)), drift_threshold=int(data["canary"].get("drift_threshold", 8)),
) )
discord = DiscordCfg(webhook_url=_require_env("ATM_DISCORD_URL")) discord = DiscordCfg(webhook_url=data["discord"]["webhook_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( telegram = TelegramCfg(
bot_token=_require_env("ATM_TG_TOKEN"), bot_token=data["telegram"]["bot_token"],
chat_id=tg_chat, chat_id=str(data["telegram"]["chat_id"]),
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", {}) opts = data.get("options", {})
region = None region = None
@@ -327,36 +176,6 @@ class Config:
) )
else: else:
attach = AlertsCfg() 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( return cls(
window_title=data["window_title"], window_title=data["window_title"],
dot_roi=roi, dot_roi=roi,
@@ -376,7 +195,5 @@ class Config:
phaseb_timeout_s=int(opts.get("phaseb_timeout_s", 600)), phaseb_timeout_s=int(opts.get("phaseb_timeout_s", 600)),
dead_letter_path=opts.get("dead_letter_path", "logs/dead_letter.jsonl"), dead_letter_path=opts.get("dead_letter_path", "logs/dead_letter.jsonl"),
attach_screenshots=attach, attach_screenshots=attach,
alerts=alert_behavior,
operating_hours=oh,
config_version=version, config_version=version,
) )

View File

@@ -7,7 +7,7 @@ from typing import Callable
import numpy as np import numpy as np
from .config import Config, ROI from .config import Config
from .vision import ( from .vision import (
ColorMatch, ColorMatch,
classify_pixel, classify_pixel,
@@ -28,7 +28,6 @@ class DetectionResult:
match: ColorMatch | None # None if no dot match: ColorMatch | None # None if no dot
accepted: bool # post-debounce; True only when match repeats debounce_depth times accepted: bool # post-debounce; True only when match repeats debounce_depth times
color: str | None # accepted color name (UNKNOWN excluded) 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: class Detector:
@@ -40,11 +39,9 @@ class Detector:
capture: ScreenCapture, capture: ScreenCapture,
bg_rgb: tuple[int, int, int] | None = None, bg_rgb: tuple[int, int, int] | None = None,
bg_tol: float | None = None, bg_tol: float | None = None,
dot_roi_override: ROI | None = None,
) -> None: ) -> None:
self._cfg = cfg self._cfg = cfg
self._capture = capture self._capture = capture
self._dot_roi = dot_roi_override if dot_roi_override is not None else cfg.dot_roi
# Prefer config-defined background; fall back to dark-grey default. # Prefer config-defined background; fall back to dark-grey default.
if "background" in cfg.colors: if "background" in cfg.colors:
spec = cfg.colors["background"] spec = cfg.colors["background"]
@@ -63,14 +60,8 @@ class Detector:
self._debounce: deque[str | None] = deque(maxlen=cfg.debounce_depth) self._debounce: deque[str | None] = deque(maxlen=cfg.debounce_depth)
self._rolling: deque[DetectionResult] = deque(maxlen=20) self._rolling: deque[DetectionResult] = deque(maxlen=20)
def step(self, ts: float, frame=None) -> DetectionResult: def step(self, ts: float) -> DetectionResult:
"""Run one detection tick. frame = self._capture()
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: if frame is None:
self._debounce.append(None) self._debounce.append(None)
@@ -86,7 +77,7 @@ class Detector:
self._rolling.append(r) self._rolling.append(r)
return r return r
roi_img = crop_roi(frame, self._dot_roi) roi_img = crop_roi(frame, self._cfg.dot_roi)
dot_pos = find_rightmost_dot(roi_img, self._bg_rgb, self._bg_tol) dot_pos = find_rightmost_dot(roi_img, self._bg_rgb, self._bg_tol)
if dot_pos is None: if dot_pos is None:
@@ -126,14 +117,10 @@ class Detector:
match=match, match=match,
accepted=accepted, accepted=accepted,
color=color, color=color,
dot_pos_abs=(self._dot_roi.x + x, self._dot_roi.y + y),
) )
self._rolling.append(r) self._rolling.append(r)
return r return r
def update_dot_roi(self, roi: ROI) -> None:
self._dot_roi = roi
@property @property
def rolling(self) -> list[DetectionResult]: def rolling(self) -> list[DetectionResult]:
return list(self._rolling) return list(self._rolling)

View File

@@ -1,48 +0,0 @@
import cv2
import numpy as np
from .config import ROI
VIVID_COLORS = ("turquoise", "yellow", "dark_green", "dark_red", "light_green", "light_red", "gray")
def detect_strips(
full_dot_crop: np.ndarray,
palette: dict[str, tuple[tuple[int, int, int], float]],
min_gap_px: int,
min_strip_px: int,
) -> list[ROI]:
"""Return list of sub-ROIs (relative to full_dot_crop) sorted left-to-right.
Empty list if no vivid pixels found."""
h, w = full_dot_crop.shape[:2]
mask = np.zeros((h, w), dtype=np.uint8)
img_f = full_dot_crop.astype(np.float32)
for name in VIVID_COLORS:
if name not in palette:
continue
rgb, tol = palette[name]
bgr = np.array([rgb[2], rgb[1], rgb[0]], dtype=np.float32)
diff = np.linalg.norm(img_f - bgr, axis=2)
mask |= (diff < tol).astype(np.uint8)
kw = max(3, min_gap_px // 2)
kernel = np.ones((1, kw), dtype=np.uint8)
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
n_labels, _labels, stats, _centroids = cv2.connectedComponentsWithStats(closed, connectivity=8)
strips: list[tuple[int, int]] = []
for i in range(1, n_labels):
x = int(stats[i, cv2.CC_STAT_LEFT])
ww = int(stats[i, cv2.CC_STAT_WIDTH])
if ww < min_strip_px:
continue
strips.append((x, x + ww))
strips.sort()
return [ROI(x=xs, y=0, w=xe - xs, h=h) for (xs, xe) in strips]
def _strips_match(a: list[ROI], b: list[ROI], tol: int = 10) -> bool:
if len(a) != len(b):
return False
return all(
abs(ra.x - rb.x) <= tol and abs((ra.x + ra.w) - (rb.x + rb.w)) <= tol
for ra, rb in zip(a, b)
)

File diff suppressed because it is too large Load Diff

View File

@@ -5,29 +5,11 @@ from typing import Protocol
@dataclass @dataclass
class Alert: class Alert:
# flat union: "trigger"|"heartbeat"|"levels"|"warn"|"arm"|"prime"|"late_start"|"screenshot"|"status" kind: str # "trigger" | "heartbeat" | "levels" | "warn" | "arm" | "prime" | "late_start"
kind: str
title: str title: str
body: str body: str
image_path: Path | None = None # annotated screenshot image_path: Path | None = None # annotated screenshot
direction: str | None = None # "BUY"/"SELL" when kind=trigger direction: str | None = None # "BUY"/"SELL" when kind=trigger
silent: bool = False # disable_notification for Telegram; ignored by Discord
chart_id: str = ""
def _alert_prefix(chart_id: str) -> str:
"""Return Telegram title prefix for chart_id. Empty for single-chart mode."""
if not chart_id:
return ""
if chart_id == "left":
return "[stânga] "
if chart_id == "right":
return "[dreapta] "
try:
n = int(chart_id.split("_")[1])
return f"[chart {n + 1}] "
except (IndexError, ValueError):
return f"[{chart_id}] "
class Notifier(Protocol): class Notifier(Protocol):

View File

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

View File

@@ -1,118 +0,0 @@
"""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,20 +232,3 @@ class StateMachine:
if last is None: if last is None:
return False return False
return (ts - last) < self._lockout_s 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,58 +122,8 @@ def find_rightmost_dot(
best_idx = i best_idx = i
if best_idx is None: if best_idx is None:
return None return None
# When erosion fails to sever anti-aliased bridges between adjacent dots cx, cy = centroids[best_idx]
# (common on long, dense dot rows), the "rightmost" component spans return (int(cx), int(cy))
# 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]: def pixel_rgb(roi_img: np.ndarray, x: int, y: int, box: int = 3) -> tuple[int, int, int]:

View File

@@ -6,13 +6,6 @@ from pathlib import Path
import pytest 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: def _minimal_config_data() -> dict:
return { return {
"window_title": "Test Chart", "window_title": "Test Chart",
@@ -33,6 +26,8 @@ def _minimal_config_data() -> dict:
"baseline_phash": "abc123", "baseline_phash": "abc123",
"drift_threshold": 8, "drift_threshold": 8,
}, },
"discord": {"webhook_url": "http://example.com/hook"},
"telegram": {"bot_token": "123:abc", "chat_id": "456"},
} }
@@ -60,16 +55,6 @@ def test_write_config_and_marker(tmp_path: Path) -> None:
assert cfg2.window_title == cfg.window_title 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: def test_import_safe() -> None:
"""Importing atm.calibrate must succeed in a headless environment (no tkinter at top-level).""" """Importing atm.calibrate must succeed in a headless environment (no tkinter at top-level)."""
import importlib # noqa: F401 import importlib # noqa: F401

View File

@@ -91,37 +91,24 @@ def test_no_drift() -> None:
assert canary.is_paused is False assert canary.is_paused is False
def test_check_does_not_auto_pause() -> None:
"""check() is pure measurement — never transitions to paused on its own."""
cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg)
result = canary.check(DRIFTED_FRAME)
assert result.drifted is True
assert result.paused is False # not committed
assert canary.is_paused is False
def test_drift_triggers_pause() -> None: def test_drift_triggers_pause() -> None:
"""check() detects drift; commit_pause() transitions state.""" """Drastically different canary ROI → drifted=True, paused=True."""
cfg = _cfg_with_baseline(BASELINE_FRAME) cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg) canary = Canary(cfg)
result = canary.check(DRIFTED_FRAME) result = canary.check(DRIFTED_FRAME)
assert result.drifted is True
canary.commit_pause(result.distance)
assert result.drifted is True
assert result.paused is True
assert canary.is_paused is True assert canary.is_paused is True
def test_persists_paused() -> None: def test_persists_paused() -> None:
"""After commit_pause, feeding back a clean frame keeps paused=True.""" """After drift, feeding back a clean frame keeps paused=True."""
cfg = _cfg_with_baseline(BASELINE_FRAME) cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg) canary = Canary(cfg)
r1 = canary.check(DRIFTED_FRAME) canary.check(DRIFTED_FRAME) # trigger pause
canary.commit_pause(r1.distance)
result = canary.check(BASELINE_FRAME) # clean frame, but still paused result = canary.check(BASELINE_FRAME) # clean frame, but still paused
assert result.paused is True assert result.paused is True
@@ -133,8 +120,7 @@ def test_resume_clears() -> None:
cfg = _cfg_with_baseline(BASELINE_FRAME) cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg) canary = Canary(cfg)
r = canary.check(DRIFTED_FRAME) canary.check(DRIFTED_FRAME) # pause
canary.commit_pause(r.distance)
canary.resume() canary.resume()
assert canary.is_paused is False assert canary.is_paused is False
@@ -144,110 +130,23 @@ def test_resume_clears() -> None:
def test_pause_file_written(tmp_path: Path) -> None: def test_pause_file_written(tmp_path: Path) -> None:
"""When pause_flag_path is provided, the file is created on commit_pause.""" """When pause_flag_path is provided, the file is created on drift."""
flag = tmp_path / "paused.flag" flag = tmp_path / "paused.flag"
cfg = _cfg_with_baseline(BASELINE_FRAME) cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg, pause_flag_path=flag) canary = Canary(cfg, pause_flag_path=flag)
assert not flag.exists() assert not flag.exists()
r = canary.check(DRIFTED_FRAME) canary.check(DRIFTED_FRAME)
assert not flag.exists() # check() alone does NOT write the flag
canary.commit_pause(r.distance)
assert flag.exists() 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))
r1 = canary.check(DRIFTED_FRAME)
canary.commit_pause(r1.distance) # transition → callback fires
canary.commit_pause(r1.distance) # idempotent → no new callback
r2 = canary.check(DRIFTED_FRAME)
canary.commit_pause(r2.distance) # 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_commit_pause_idempotent() -> None:
"""commit_pause is no-op when already paused — no flag re-write, no callback."""
cfg = _cfg_with_baseline(BASELINE_FRAME)
calls: list[int] = []
canary = Canary(cfg, on_pause_callback=lambda d: calls.append(d))
canary.commit_pause(100)
canary.commit_pause(200)
canary.commit_pause(300)
assert len(calls) == 1
assert calls[0] == 100
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))
r1 = canary.check(DRIFTED_FRAME)
canary.commit_pause(r1.distance)
assert len(calls) == 1
canary.resume()
r2 = canary.check(DRIFTED_FRAME)
canary.commit_pause(r2.distance) # new pause transition
assert len(calls) == 2
def test_canary_pause_callback_exception_does_not_crash_commit_pause() -> None:
"""A failing callback must not break commit_pause (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.
r = canary.check(DRIFTED_FRAME)
canary.commit_pause(r.distance)
assert canary.is_paused is True
def test_resume_deletes_pause_file(tmp_path: Path) -> None: def test_resume_deletes_pause_file(tmp_path: Path) -> None:
"""resume() deletes the pause flag file.""" """resume() deletes the pause flag file."""
flag = tmp_path / "paused.flag" flag = tmp_path / "paused.flag"
cfg = _cfg_with_baseline(BASELINE_FRAME) cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg, pause_flag_path=flag) canary = Canary(cfg, pause_flag_path=flag)
r = canary.check(DRIFTED_FRAME) canary.check(DRIFTED_FRAME)
canary.commit_pause(r.distance)
assert flag.exists() assert flag.exists()
canary.resume() canary.resume()
assert not flag.exists() assert not flag.exists()
def test_rebase_updates_baseline_in_memory() -> None:
"""rebase(new_h) mirrors hash into cfg.canary; subsequent check uses it."""
cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg)
# Compute the phash of the drifted frame; rebase to it.
drifted_hash = phash(crop_roi(DRIFTED_FRAME, CANARY_ROI))
assert cfg.canary.baseline_phash != drifted_hash
canary.rebase(drifted_hash)
assert cfg.canary.baseline_phash == drifted_hash
# Now the drifted frame reads as clean.
result = canary.check(DRIFTED_FRAME)
assert result.drifted is False
assert result.paused is False

View File

@@ -1,67 +0,0 @@
"""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,8 +1,6 @@
"""Tests for atm.config — focused on attach_screenshots parsing (legacy bool vs new dict).""" """Tests for atm.config — focused on attach_screenshots parsing (legacy bool vs new dict)."""
from __future__ import annotations from __future__ import annotations
import pytest
from atm.config import AlertsCfg, Config from atm.config import AlertsCfg, Config
@@ -25,17 +23,11 @@ _BASE = {
"baseline_phash": "0" * 16, "baseline_phash": "0" * 16,
"drift_threshold": 8, "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: def _with_opts(opts: dict) -> dict:
d = {k: v for k, v in _BASE.items()} d = {k: v for k, v in _BASE.items()}
d["options"] = opts d["options"] = opts
@@ -105,175 +97,3 @@ def test_attach_screenshots_unknown_keys_ignored() -> None:
})) }))
assert cfg.attach_screenshots.arm is False assert cfg.attach_screenshots.arm is False
# Should not raise even with unknown key # 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,167 +196,3 @@ def test_rolling_window() -> None:
assert len(det.rolling) <= 20 assert len(det.rolling) <= 20
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_dot_roi_override_uses_sub_roi() -> None:
"""dot_roi_override must be used instead of cfg.dot_roi for crop + offset.
Paint a yellow dot inside the override ROI but **outside** cfg.dot_roi.
The default DOT_ROI is (10,10,280,80); we override with an ROI placed
well to the right (x=200, w=80) so the painted dot only intersects the
override. If the detector still cropped from cfg.dot_roi the yellow dot
would land at the rightmost edge of the larger ROI as well — so we use
a frame that has nothing in the cfg.dot_roi region except inside the
override window, and assert dot_pos_abs falls inside the override.
"""
override = ROI(x=200, y=20, w=80, h=60)
# Background-only frame, then paint yellow only inside the override
frame = np.full((100, 300, 3), BG_VAL, dtype=np.uint8)
fy0, fy1 = override.y, override.y + override.h
fx0, fx1 = override.x + 50, override.x + override.w # right edge of override
frame[fy0:fy1, fx0:fx1] = YELLOW_BGR
cfg = _make_cfg(debounce_depth=1)
det = Detector(cfg, capture=lambda: frame, dot_roi_override=override)
r = det.step(0.0)
assert r.dot_found is True
assert r.match is not None
assert r.match.name == "yellow"
assert r.dot_pos_abs is not None
abs_x, abs_y = r.dot_pos_abs
assert override.x <= abs_x < override.x + override.w
assert override.y <= abs_y < override.y + override.h
def test_dot_pos_abs_with_offset() -> None:
"""dot_pos_abs must include the override ROI's (x, y) offset."""
override = ROI(x=100, y=20, w=50, h=40)
frame = np.full((100, 300, 3), BG_VAL, dtype=np.uint8)
# Paint a single full-height yellow stripe at roi-local x in [40, 50)
# so find_rightmost_dot lands somewhere inside that stripe.
fy0, fy1 = override.y, override.y + override.h
fx0, fx1 = override.x + 40, override.x + 50
frame[fy0:fy1, fx0:fx1] = YELLOW_BGR
cfg = _make_cfg(debounce_depth=1)
det = Detector(cfg, capture=lambda: frame, dot_roi_override=override)
r = det.step(0.0)
assert r.dot_found is True
assert r.dot_pos_abs is not None
abs_x, abs_y = r.dot_pos_abs
# Painted stripe: roi-local x in [40,50), y in [0, h). Absolute coords
# must be offset by override.(x, y).
assert override.x + 40 <= abs_x < override.x + 50
assert override.y <= abs_y < override.y + override.h
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"
)

View File

@@ -1,104 +0,0 @@
"""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,8 +10,6 @@ Covers the six cases from the arm+prime notification plan:
""" """
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace
from atm.main import _handle_tick from atm.main import _handle_tick
from atm.notifier import Alert from atm.notifier import Alert
from atm.state_machine import State, StateMachine from atm.state_machine import State, StateMachine
@@ -488,248 +486,3 @@ def test_save_annotated_frame_succeeds(tmp_path, monkeypatch):
assert "BUY" in result.name assert "BUY" in result.name
assert len(written) == 1 assert len(written) == 1
assert not any(e.get("event") == "snapshot_fail" for e in audit.events) 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")]

View File

@@ -1,163 +0,0 @@
import numpy as np
import pytest
from atm.config import ROI
from atm.layout import _strips_match, detect_strips
PALETTE = {
"turquoise": ((0, 253, 253), 60.0),
"yellow": ((253, 253, 0), 60.0),
"dark_green": ((0, 128, 0), 60.0),
"dark_red": ((128, 0, 0), 60.0),
"light_green": ((0, 255, 0), 60.0),
"light_red": ((255, 0, 0), 60.0),
}
def _blank(h: int = 20, w: int = 200) -> np.ndarray:
return np.zeros((h, w, 3), dtype=np.uint8)
def _paint(img: np.ndarray, x0: int, x1: int, rgb: tuple[int, int, int]) -> None:
"""Paint vivid color into BGR image (palette stores RGB)."""
bgr = (rgb[2], rgb[1], rgb[0])
img[:, x0:x1] = bgr
def test_single_strip():
img = _blank(20, 200)
_paint(img, 0, 200, (0, 253, 253)) # turquoise
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
assert len(out) == 1
assert out[0].x == 0
assert abs(out[0].w - 200) <= 1
assert out[0].h == 20
def test_split_50_50():
img = _blank(20, 230)
_paint(img, 0, 100, (0, 253, 253))
_paint(img, 130, 230, (253, 253, 0))
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
assert len(out) == 2
assert out[0].x < out[1].x # L->R
assert out[0].x == 0
assert abs(out[0].w - 100) <= 1
assert out[1].x == 130
assert abs(out[1].w - 100) <= 1
def test_split_asymmetric():
img = _blank(20, 230)
_paint(img, 0, 70, (0, 253, 253)) # 35% width
_paint(img, 100, 230, (253, 253, 0)) # 65% width
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
assert len(out) == 2
assert abs(out[0].w - 70) <= 1
assert abs(out[1].w - 130) <= 1
def test_gray_only_no_strip():
img = _blank(20, 200)
img[:, 50:150] = (128, 128, 128)
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
assert out == []
def test_cooldown_gray_dots_no_detect():
img = _blank(20, 200)
# scattered gray dots
for x in (20, 50, 80, 110, 140, 170):
img[8:12, x:x + 4] = (100, 100, 100)
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
assert out == []
def test_vivid_palette_match():
img = _blank(20, 200)
_paint(img, 50, 80, (0, 255, 0)) # light_green
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
assert len(out) == 1
def test_blank_frame():
img = _blank(20, 200)
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
assert out == []
def test_strip_too_narrow_filtered():
img = _blank(20, 200)
_paint(img, 50, 53, (0, 253, 253)) # only 3px wide
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=10)
assert out == []
def test_small_gap_fuses():
img = _blank(20, 200)
_paint(img, 30, 70, (0, 253, 253))
_paint(img, 75, 120, (0, 253, 253)) # 5px gap, < min_gap_px=20
out = detect_strips(img, PALETTE, min_gap_px=20, min_strip_px=5)
assert len(out) == 1
assert abs(out[0].x - 30) <= 2
assert abs((out[0].x + out[0].w) - 120) <= 2
def test_split_two_charts_with_interleaved_gray():
# Regresie 2 ferestre TS: fiecare row de buline e mix vivid + gri, separate
# de un gap larg de background (dividerul dintre ferestre). Înainte de fix
# detect_strips picka doar runs vivid contiguu și rata fereastra stângă.
palette = {**PALETTE, "gray": ((128, 128, 128), 60.0)}
img = _blank(35, 1796)
# Left chart: dots vivid + gray la fiecare 26px, x=0..820
for i, x in enumerate(range(0, 820, 26)):
rgb = (128, 128, 128) if i % 3 else (0, 128, 0)
_paint(img, x, x + 22, rgb)
# Window divider gap: x=820..910 rămâne background
# Right chart: same pattern, x=910..1796
for i, x in enumerate(range(910, 1790, 26)):
rgb = (128, 128, 128) if i % 3 else (0, 128, 0)
_paint(img, x, x + 22, rgb)
out = detect_strips(img, palette, min_gap_px=28, min_strip_px=280)
assert len(out) == 2, f"expected 2 strips, got {len(out)}: {out}"
assert out[0].x == 0
assert out[1].x >= 900 # right chart starts after divider
assert out[0].x + out[0].w < out[1].x # disjoint
def test_three_strips():
img = _blank(20, 300)
_paint(img, 0, 60, (0, 253, 253))
_paint(img, 100, 160, (253, 253, 0))
_paint(img, 220, 300, (0, 255, 0))
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
assert len(out) == 3
assert out[0].x < out[1].x < out[2].x
assert out[0].x == 0
assert out[1].x == 100
assert out[2].x == 220
def test_strips_match_identical():
a = [ROI(x=0, y=0, w=100, h=20), ROI(x=130, y=0, w=70, h=20)]
b = [ROI(x=0, y=0, w=100, h=20), ROI(x=130, y=0, w=70, h=20)]
assert _strips_match(a, b) is True
def test_strips_match_jitter_5px():
a = [ROI(x=0, y=0, w=100, h=20), ROI(x=130, y=0, w=70, h=20)]
b = [ROI(x=5, y=0, w=95, h=20), ROI(x=128, y=0, w=70, h=20)]
assert _strips_match(a, b, tol=10) is True
def test_strips_match_drift_12px():
a = [ROI(x=0, y=0, w=100, h=20)]
b = [ROI(x=12, y=0, w=100, h=20)]
assert _strips_match(a, b, tol=10) is False
def test_strips_match_count_different():
a = [ROI(x=0, y=0, w=100, h=20)]
b = [ROI(x=0, y=0, w=100, h=20), ROI(x=130, y=0, w=70, h=20)]
assert _strips_match(a, b) is False

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ from pathlib import Path
import pytest import pytest
from atm.notifier import Alert, _alert_prefix from atm.notifier import Alert
from atm.notifier.fanout import FanoutNotifier from atm.notifier.fanout import FanoutNotifier
@@ -358,32 +358,3 @@ def test_fanout_on_drop_exception_swallowed(tmp_path: Path) -> None:
s = fan.stats() s = fan.stats()
# Some alerts still went through # Some alerts still went through
assert s["slow"]["sent"] > 0 or s["slow"]["dropped"] > 0 assert s["slow"]["sent"] > 0 or s["slow"]["dropped"] > 0
# ---------------------------------------------------------------------------
# Alert.chart_id + _alert_prefix
# ---------------------------------------------------------------------------
def test_alert_chart_id_default() -> None:
assert Alert(kind="arm", title="t", body="b").chart_id == ""
def test_alert_chart_id_set() -> None:
assert Alert(kind="arm", title="t", body="b", chart_id="left").chart_id == "left"
def test_alert_prefix_empty() -> None:
assert _alert_prefix("") == ""
def test_alert_prefix_left() -> None:
assert _alert_prefix("left") == "[stânga] "
def test_alert_prefix_right() -> None:
assert _alert_prefix("right") == "[dreapta] "
def test_alert_prefix_chart_n() -> None:
assert _alert_prefix("chart_0") == "[chart 1] "
assert _alert_prefix("chart_1") == "[chart 2] "

View File

@@ -1,111 +0,0 @@
"""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}"
)

View File

@@ -1,73 +0,0 @@
"""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]