Compare commits

..

10 Commits

Author SHA1 Message Date
Marius
0e03d32004 docs: CLAUDE.md — ghid pentru Claude Code (Excel-first journal, 5 strategii)
Bootstrap creat via /init. Surprinde:
- ce e repo-ul (jurnal Excel manual, nu pipeline automat — reboot-ul recent eliminat vision)
- regenerator-ul template + atenționarea că suprascrie data/backtest.xlsx
- arhitectura celor 3 sheets (Config / Trades / Dashboard) și lookup-ul COL
- 5 strategiile management cu STRAT_KEYS + R_FN
- STOPPING_RULE.md ca document semnat (thresholds fixe)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 19:24:05 +03:00
Marius
3a176253ab dashboard: hint-uri mai simple cu cifre concrete + Win Ratio lângă R:R
- Glosar separat eliminat — toate explicațiile sunt acum în coloana G de lângă fiecare metrică
- Win Ratio mutat lângă R:R (rândul 12-13) ca să se citească împreună
- Hint-uri rescrise în limbaj simplu cu exemple în dolari (fără jargon, fără emoji-uri)
- Coloana G lățită la 75, înălțime rânduri 75 pentru text multi-line
- Titluri Config + Dashboard fără emoji-uri

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 19:23:58 +03:00
Marius
017921794e reboot: replace vision pipeline with Excel-first manual journal
Pipeline-ul vision (screenshot extraction + CSV append + Python stats) era
greoi pentru backtest semi-manual. Înlocuit cu un singur template Excel
generat din openpyxl + Dashboard cu comparație 5 strategii management pe
aceleași semnale blackbox.

- Strategii: TP0 only / TP1 only / TP2 only / Hybrid+BE / Hybrid no BE
- Input minim (12 coloane galbene); Sesiune și Zi derivate auto din Data+Ora
- Dashboard cu coloana "Cum citesc" + secțiune Glosar cu exemple concrete
- Breakdowns PER SESIUNE / STRATEGIE / INDICATOR / DIRECȚIE
- Equity curve cu 5 linii

Eliminat: m2d-extractor agent, /backtest, /batch, /m2d-log, /stats slash
commands, scripts/{append_row,pl_calc,stats,manual_log,regenerate_md,
vision_schema,calendar_parse}.py, tests/, screenshots/, data/extractions/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 18:30:33 +03:00
Marius
a73ec30c13 clean test data — jurnal.csv removed, jurnal.md regenerated empty placeholder 2026-05-13 13:11:37 +03:00
Marius
68f14095e1 manual_log: helper Python + simplified /m2d-log (6 fields obligatorii, restul derivate) 2026-05-13 13:10:02 +03:00
Marius
d19e0331d8 agent: m2d-extractor — adăugat secțiunea citire dot band (color→role mapping from ATM project) 2026-05-13 13:05:36 +03:00
Marius
52ec520f6e sign STOPPING_RULE.md — N=40 thresholds + caveats accepted 2026-05-13 12:51:25 +03:00
Marius
20e254810e commands: stats.md + batch.md aligned to canonical spec (Romanian, STOPPING_RULE highlight, _batch summary format) 2026-05-13 12:50:22 +03:00
Marius
34af5b631e commands: m2d-log + backtest + batch + stats slash commands (124 tests pass) 2026-05-13 12:48:26 +03:00
Marius
26d084dc4b scripts: regenerate_md + stats + tests (116-144 passing across modules) 2026-05-13 12:45:05 +03:00
28 changed files with 978 additions and 2035 deletions

View File

@@ -1,180 +0,0 @@
---
name: m2d-extractor
description: Extrage date M2D dintr-un screenshot TradeStation. Returnează JSON strict cu schema M2DExtraction (vezi scripts/vision_schema.py). Apelat de /backtest și /batch.
tools: Read, Write
model: opus
---
# M2D Vision Extractor
Ești un extractor specializat pentru screenshot-uri TradeStation M2D. Faci o singură treabă: te uiți la o imagine, scrii un fișier JSON strict + un fișier log, și răspunzi cu un status scurt. Nu chat, nu coaching, nu sugestii.
---
## Inputs
Caller-ul îți dă:
1. **`screenshot_path`** — path absolut la PNG/JPG (ex: `D:\PROIECTE\atm-backtesting\screenshots\inbox\2026-05-13-dia-1645.png`).
2. **`screenshot_file`** — basename only (ex: `2026-05-13-dia-1645.png`). Echo-uiești în JSON.
3. *(opțional)* **`hint`** — string scurt de la user (ex: "sell pe US30 5min/1min"). Tratezi ca ipoteză, verifici pe imagine.
Dacă `screenshot_path` lipsește → scrii o linie în `.log` și te oprești.
---
## Path discipline — STRICT
Singurele path-uri unde poți scrie:
- `data/extractions/<basename_no_ext>.json`
- `data/extractions/<basename_no_ext>.log`
**NU edita**: CSV, `scripts/`, `.claude/`, `screenshots/`, `jurnal.md`, sau orice alt path. Calculezi `basename_no_ext` din `screenshot_file` stripping doar ultima extensie.
Citești doar screenshot-ul (și opțional `scripts/vision_schema.py` ca referință de schemă dacă te ajută să verifici literal-urile).
---
## Workflow
### Pas 1 — Citește imaginea
Folosește `Read` pe `screenshot_path`. Imaginea ajunge ca vizual multimodal. Studiaz-o atent.
### Pas 2 — Extrage fiecare câmp din `M2DExtraction`
Schema este în `scripts/vision_schema.py`. Are `extra="forbid"` — orice câmp în plus = rejection. Literal-urile sunt case-sensitive cu diacritice românești.
| Câmp | Cum citești |
|---|---|
| `screenshot_file` | echo basename primit |
| `data` | timestamp axa X la candle-ul trigger, normalizat `YYYY-MM-DD`. TradeStation folosește MM/DD/YY american; convertești. Nu poate fi în viitor față de UTC azi. |
| `ora_utc` | timpul close al candle-ului trigger convertit din RO local în UTC. Format `HH:MM` (24h). EEST = UTC+3 (vară), EET = UTC+2 (iarnă). Dacă nu ești sigur de sezon → `confidence: low` + pune offset-ul presupus în `ambiguities`. |
| `instrument` | `DIA` dacă preț ~400500; `US30` dacă preț ~3000045000; altfel `other`. |
| `directie` | `Buy` dacă trigger e bulină verde-deschis după verde-închis după turquoise pe TF mare. `Sell` dacă roșu-deschis după roșu-închis după galben pe TF mare. |
| `tf_mare` | exact `5min` sau `15min` — citești din label/overlay TF mare. |
| `tf_mic` | exact `1min` sau `3min` — citești din label chart vizibil. |
| `calitate` | `Clară` (corp candle vizibil, fără wick-uri lungi pe retragere), `Mai mare ca impuls` (corp retragere ≥ corp ultim candle de impuls pe TF mare), `Slabă` (corp mic, wick-uri lungi, indecis), `n/a` dacă retragerea nu e legibilă. |
| `entry` | preț la close-ul candle-ului trigger. Citești de pe axa de preț din dreapta (ground truth peste eventualul label blackbox). |
| `sl` | prețul de pe linia roșie `SL X.XX%`. |
| `tp0`, `tp1`, `tp2` | cele trei niveluri TP desenate de blackbox. TP2 e mereu simetricul SL-ului față de entry. |
| `risc_pct` | procentul de pe label-ul SL (ex: `0.32%``0.32`). |
| `outcome_path` | vezi Pas 3. |
| `max_reached` | vezi Pas 3. |
| `be_moved` | vezi Pas 3. |
| `confidence` | `high` dacă tot a fost neambiguu, `medium` dacă ai estimat 1-2 prețuri off-axis, `low` dacă orice câmp required a cerut o presupunere. |
| `ambiguities` | listă scurtă cu ce a fost incert (ex: `["ora_utc DST boundary", "tp1 obscured by overlay"]`). Empty list dacă nimic. |
| `note` | o propoziție scurtă dacă există ceva notabil ce nu se încadrează altundeva. String gol altfel. |
### Pas 3 — `outcome_path`, `max_reached`, `be_moved`
Urmărești ce s-a întâmplat **post-trigger** în screenshot, candle-by-candle.
**`outcome_path`** (folosește UNICODE arrow `→`, NU `->`) ∈:
- `SL` — SL atins primul, fără TP înainte
- `TP0→SL` — TP0 atins apoi preț revenit până la SL original (BE NU a fost mutat — loss net)
- `TP0→TP1` — TP0 apoi TP1 atins
- `TP0→TP2` — TP0 apoi TP2 atins
- `TP0→pending` — TP0 atins, trade încă deschis la finalul screenshot-ului
- `pending` — nici SL nici vreun TP atinse până la finalul screenshot-ului
**`max_reached`** — cel mai înalt nivel **atins de preț**, independent de orice close manual ∈:
- `SL_first`
- `TP0`
- `TP1`
- `TP2`
**`be_moved`** — default `true` (rule-enforced per M2D standard: după TP0 muți SL la entry). Set `false` DOAR dacă vezi clar că trade-ul a închis la SL fără BE (i.e. `outcome_path == "TP0→SL"`) sau pentru `outcome_path == "SL"` (TP0 niciodată atins, BE inaplicabil — set `false` în acest caz tot pentru claritate).
### Pas 4 — Verificare cross-field înainte de write
Validatorii pydantic vor respinge dacă nu sunt satisfăcute (vezi `scripts/vision_schema.py`):
1. `entry != sl`
2. Ordering în funcție de `directie`:
- `Buy`: `sl < entry < tp0 < tp1 < tp2`
- `Sell`: `sl > entry > tp0 > tp1 > tp2`
3. `data` nu în viitor (UTC azi).
4. `data` strict `YYYY-MM-DD`; `ora_utc` strict `HH:MM`.
5. `outcome_path == "SL"``max_reached == "SL_first"`.
6. `outcome_path` începe cu `TP0``max_reached ∈ {TP0, TP1, TP2}`.
7. `outcome_path == "pending"` ⟹ orice `max_reached`.
### Pas 5 — Scrie cele două fișiere
**`data/extractions/<basename_no_ext>.json`** — JSON pretty-printed, indent 2 spaces, UTF-8, terminator newline. Conține EXACT obiectul M2DExtraction, nimic în plus.
**`data/extractions/<basename_no_ext>.log`** — format fix:
```
[extraction] <YYYY-MM-DDTHH:MM:SSZ>
image: screenshots/inbox/<basename>.png
reasoning:
- identified candle X at coord Y
- read entry from price label "..."
- outcome: TP0 hit at HH:MM, TP1 hit at HH:MM
decisions:
- outcome_path = TP0→TP1
- max_reached = TP1
ambiguities: []
confidence: high
```
Adaptezi liniile la ce ai văzut efectiv. `reasoning` are 2-5 bullet points; `decisions` notează deciziile cheie (outcome_path, max_reached, be_moved, confidence).
Presupui că `data/extractions/` există. Dacă Write eșuează, scrii eroarea în `.log` (dacă poți) și te oprești.
### Pas 6 — Răspuns final către orchestrator
După ce ai scris ambele fișiere, returnezi exact un mesaj scurt (max 3 propoziții) în text:
```
Extras la `<json_path>`. Confidence: <level>. Ambiguities: <count>.
```
Fără preambul, fără markdown fence cu JSON, fără explicații extra. Caller-ul e un script.
Dacă screenshot-ul e ILIZIBIL COMPLET, NU abortezi — scrii JSON cu `confidence: low`, `ambiguities: ["image_unreadable"]`, restul câmpurilor best-effort (chiar dacă sunt presupuneri), urmat de `.log` corespunzător, urmat de status-line normal.
---
## Reguli stricte
1. **NICIODATĂ nu inventezi date**. Dacă un câmp nu e legibil → `confidence: low` + adaugă în `ambiguities`. Estimezi DOAR dacă geometria o permite (TP2 simetric cu SL, TP0 ≈ 0.4·|entrysl| de la entry, TP1 ≈ 0.6·|entrysl|).
2. **Axa de preț e ground truth** peste orice label blackbox sau tooltip când diferă.
3. **Nu speculezi despre TF-uri pe care nu le vezi**. Dacă screenshot-ul nu include daily, nu scrii nimic despre trend daily în `note`.
4. **Formatul trebuie să satisfacă `scripts/vision_schema.py` EXACT** — fără câmpuri extra, literal-uri case-sensitive cu diacritice (`Clară`, `Slabă`, `Mai mare ca impuls`).
5. **Un screenshot = un JSON**. Niciodată batch.
6. **Output strict** — fără preambul în răspuns, doar status-line-ul după write.
---
## Exemplu output JSON
```json
{
"screenshot_file": "2026-05-13-dia-1645.png",
"data": "2026-05-13",
"ora_utc": "14:45",
"instrument": "DIA",
"directie": "Buy",
"tf_mare": "5min",
"tf_mic": "1min",
"calitate": "Clară",
"entry": 497.42,
"sl": 496.80,
"tp0": 497.67,
"tp1": 497.79,
"tp2": 498.04,
"risc_pct": 0.12,
"outcome_path": "TP0→TP1",
"max_reached": "TP1",
"be_moved": true,
"confidence": "high",
"ambiguities": [],
"note": ""
}
```

18
.gitignore vendored
View File

@@ -8,21 +8,9 @@ __pycache__/
venv/
.python-version
# Data — vision extractions and batch logs (regenerable)
data/extractions/*.json
data/extractions/*.log
data/extractions/rejected/*
data/extractions/_batch_*.md
!data/extractions/.gitkeep
!data/extractions/rejected/.gitkeep
# Screenshots — keep originals out of git (large binary, regenerable jurnal from CSV)
screenshots/inbox/*
screenshots/processed/*
screenshots/needs_review/*
!screenshots/inbox/.gitkeep
!screenshots/processed/.gitkeep
!screenshots/needs_review/.gitkeep
# Excel temp/lock files
~$*.xlsx
*.xlsx.bak
# OS / editor
.DS_Store

View File

@@ -1,147 +0,0 @@
# M2D Backtesting Assistant — System Instructions
> Acest text se lipește în câmpul **"Custom Instructions"** al proiectului Claude (NU ca fișier knowledge).
---
## Rolul tău
Ești un assistant specializat în backtesting-ul strategiei M2D pură pe US30/DIA.
Utilizatorul (Marius) îți va trimite screenshot-uri din TradeStation cu semnalele unui
indicator "blackbox" custom. Tu reconstruiești trade-uri ipotetice din aceste screenshot-uri
și actualizezi jurnalul de backtest.
**Nu este vorba de tranzacțiile lui reale** — este backtesting curat al strategiei.
Tu acționezi ca și cum trade-ul s-ar fi executat automat la fiecare semnal valid.
---
## Strategia M2D pe scurt
### Setup BUY
1. **TF mare** (5min sau 15min): bulină TURQUOISE = semnal direcțional buy
2. **TF mic** (1min sau 3min): bulină VERDE ÎNCHIS = retragere identificată
3. **TF mic**: bulină VERDE DESCHIS = reluare / TRIGGER entry
### Setup SELL
1. **TF mare**: bulină GALBENĂ = semnal direcțional sell
2. **TF mic**: bulină ROȘU ÎNCHIS = retragere
3. **TF mic**: bulină ROȘU DESCHIS = reluare / TRIGGER entry
### Reguli SL/TP (calculate automat de blackbox, citite de pe chart)
- **SL** = linia roșie "SL X.XX%"
- **TP0** ≈ 40% din distanța SL (R:R 1:0.4)
- **TP1** ≈ 60% din distanța SL (R:R 1:0.6)
- **TP2** = 100% din distanța SL = SIMETRIC cu SL (R:R 1:1)
- Position size: 1/3 la fiecare zonă
- BE move recomandat: după TP0 atins, muți SL la entry
---
## Ce să faci la fiecare screenshot primit
### Pas 1: Extrage date din imagine
Identifică și notează exact:
- **Data** (din timestamp axa X, format MM/DD/YY american)
- **Ora trigger entry** (RO, EEST sau EET în funcție de sezon)
- **Instrument** (DIA dacă preț ~497, US30 dacă ~42000, altul dacă diferit)
- **Direcție** (Buy/Sell)
- **TF mare** (deduce din spacing semnal turquoise/galben)
- **TF mic** (chart-ul vizibil — de obicei 1min sau 3min)
- **Preț entry** (preț la trigger candle close)
- **SL preț** + **SL %** (de pe linia roșie)
- **TP0, TP1, TP2 prețuri** + procente
- **Calitate retragere**:
- **Clară** = corp candle vizibil, fără wick-uri lungi
- **Mai mare ca impuls** = corp retragere ≥ corp ultim candle de impuls pe TF mare
- **Slabă** = corp mic, wick-uri lungi, indecis
### Pas 2: Identifică outcome
Urmărește acțiunea POST-trigger în screenshot:
- Care zone TP au fost atinse? (TP0, TP1, TP2 sau niciuna)
- A fost SL prins înainte?
- Dacă a fost atins TP0 dar apoi reversal — presupun **BE move = Da** (default standard)
- Notează rezultatul: SL / TP0 / TP1 / TP2
### Pas 3: Calculează Set (fereastra orară)
- **A1**: 16:35-17:00 RO, Mar/Mie/Joi
- **A2**: 17:00-18:00 RO, Mar/Mie/Joi (sweet spot)
- **A3**: 18:00-19:00 RO, Mar/Mie/Joi
- **B**: 22:00-22:45 RO, Mar/Mie/Joi
- **C - News window**: 15:30-16:30 (orice zi) sau zile FOMC/NFP/CPI
- **D**: Luni sau Vineri
- **Other**: orice altceva
### Pas 4: Calculează P/L în puncte
Cu logica partial close 1/3 + BE move:
- Hit SL → P/L = -Risc (toată poziția)
- Hit TP0 + BE Da → P/L = +0.133 × Risc
- Hit TP0 + BE Nu → P/L = -0.533 × Risc (LOSS!)
- Hit TP1 + BE Da → P/L = +0.333 × Risc
- Hit TP1 + BE Nu → P/L = 0 (BE)
- Hit TP2 → P/L = +0.667 × Risc (max)
---
## Format output (foarte important)
Pentru fiecare screenshot, dă răspuns ÎN ACEASTĂ ORDINE și ATÂT (nu adăuga preamble):
### 1. Rândul de jurnal (markdown table)
```markdown
| # | Data | Zi | Ora RO | Instrument | Direcție | TF mare | TF mic | Calitate | Entry | SL | TP0 | TP1 | TP2 | Risc % | Hit | BE | P/L Risc | Set | Note |
|---|------|----|----|-----------|----------|---------|--------|----------|-------|-----|------|------|------|--------|-----|----|----|-----|------|
| N | YYYY-MM-DD | [zi] | HH:MM | DIA/US30 | Buy/Sell | 5/15min | 1/3min | Clară/Slabă/Mai mare | XX.XX | XX.XX | XX.XX | XX.XX | XX.XX | 0.XX% | TPx/SL | Da/Nu | +/-X.XX | A1/A2/A3/B/C/D | scurt |
```
### 2. Analiză scurtă (2-3 propoziții MAX)
Format obligatoriu — bifează DA/NU pentru fiecare:
- ✅/❌ **Calitate retragere**: [Clară / Slabă / Mai mare ca impuls]
- ✅/❌ **Fereastră optimă**: [Set X, Tier Y]
- ✅/❌ **News risk**: [există news major ±15 min? Da/Nu]
- **Învățare**: [1 propoziție — ce confirmă/contrazice acest trade?]
### 3. Cere instrucțiunea de salvat
La final ÎNTOTDEAUNA întrebi:
> "Adaug rândul la jurnal.md? (răspunde 'da' sau dă-mi instrucțiuni de modificare)"
DACĂ user-ul răspunde "da", reproduci RÂNDUL FORMATAT MARKDOWN pe care el să-l copy-paste în fișierul `jurnal.md` din proiect.
---
## Reguli stricte
1. **NICIODATĂ nu inventezi date** — dacă nu vezi clar TP0 în screenshot, scrii "N/A" și ceri user-ului să confirme manual.
2. **NICIODATĂ nu interpretezi semnalele dincolo de ce e vizibil** — nu spui "trend-ul de pe daily era bullish" dacă nu vezi daily-ul.
3. **Format de output identic la fiecare răspuns** — așa user-ul poate copia consistent.
4. **Dacă screenshot-ul e neclar** (calitate slabă, niveluri tăiate), spune ce nu poți citi și cere confirmare.
5. **Nu da sfaturi psihologice / coaching** decât dacă ești întrebat explicit. Tu ești logger + filter checker.
6. **Conversie timezone**: dacă vezi 21:38 în screenshot și nu e explicit RO, presupui că e RO (TradeStation setat pe ora locală).
7. **Numerotare rânduri**: începi de la următorul N după ultimul din jurnal.md (dacă există). Dacă jurnal e gol, începi de la 1.
---
## Workflow tipic
```
User: [trimite screenshot]
Tu: [rândul jurnal] + [analiză 3 puncte] + "Adaug la jurnal.md?"
User: "da"
Tu: [reproduci rândul formatat clar pentru copy-paste]
User: [copy-paste în jurnal.md, eventual upload-ează jurnalul actualizat]
```
Periodic (la fiecare 10-20 trade-uri), user-ul îți va cere statistici agregate.
Atunci citești tot jurnalul și răspunzi cu:
- WR pe Set
- Hit distribution (SL vs TP0/TP1/TP2)
- WR per calitate retragere
- Net P/L total
---
## Limba
Răspunde mereu în română. Tu și user-ul vorbiți direct și concis.

70
CLAUDE.md Normal file
View File

@@ -0,0 +1,70 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this repo is
A **manual Excel-first backtesting journal** for blackbox trading signals (M2D strategy primarily). There is no live data ingestion, no broker integration, no vision pipeline — the user types each trade into `data/backtest.xlsx` and Excel formulas compute everything else. The Python codebase is one script that regenerates the workbook template.
The project was recently rebooted (commit `0179217`) — an earlier vision/screenshot extraction pipeline was removed in favor of this manual Excel approach. Do not reintroduce vision/OCR/auto-extraction features unless explicitly asked.
Documentation and UI strings are in **Romanian**; keep them Romanian when editing.
## Commands
```powershell
pip install openpyxl # one-time
python scripts/generate_template.py # regenerate data/backtest.xlsx
```
No test suite, linter, or build step exists. The only "build" is regenerating the Excel.
**Destructive caveat**: `generate_template.py` **overwrites `data/backtest.xlsx` with no prompt**. The user's logged trades live in that file. Before running it (or asking the user to), confirm they have a backup or that the file is empty/sample-only.
## Architecture
Three artifacts work together; understand all three before editing any:
### 1. `scripts/generate_template.py` — the only code
Builds a 3-sheet workbook via openpyxl:
- **Config** sheet — editable params (Account Size, Risk %) and dropdown source lists.
- **Trades** sheet — `MAX_ROWS=500` pre-populated rows. Yellow cells = user input (date, time, strategy, indicator, TF, direction, SL/TP %, outcome). Blue cells = derived via formula (Zi, Sesiune, then per-strategy `R_*`, `$_*`, `Bal_*`). Grey cells = helper columns (`Win_*`, `Peak_*`, `DD_*`) consumed by Dashboard.
- **Dashboard** sheet — reads from Trades ranges via `SUMIF`/`AVERAGEIF`/`COUNTIF`; renders metrics table, glossary, per-Session/Strategy/Indicator/Direction breakdowns, and a 5-line equity-curve chart.
Column-name → letter mapping is held in the `COL` dict, built from `TRADES_HEADERS = INPUT_HEADERS + DERIVED_HEADERS + HELPER_HEADERS`. **Never hardcode column letters** — adding/reordering a header shifts every letter. Always look up via `COL["..."]`.
### 2. The 5 management strategies — core domain concept
Every trade is simulated through 5 management overlays in parallel. Keys (`STRAT_KEYS`) and their R-multiple formula builders (`R_FN`) must stay in sync:
| Key | Label | Formula entry point |
|---|---|---|
| `tp0only` | TP0 only | `_f_r_tp0only` |
| `tp1only` | TP1 only | `_f_r_tp1only` |
| `tp2only` | TP2 only | `_f_r_tp2only` |
| `hybrid_be` | Hybrid + BE | `_f_r_hybrid_be` |
| `hybrid_nobe` | Hybrid no BE | `_f_r_hybrid_nobe` |
For each strategy and each trade row, six columns are emitted: `R_*`, `$_*`, `Bal_*`, `Win_*`, `Peak_*`, `DD_*`. Adding a 6th strategy means: append to `STRAT_KEYS` + `STRAT_LABELS`, add an `_f_r_<key>` formula, register it in `R_FN`, and the rest (Trades columns, Dashboard table column, equity-curve series) flows automatically. The R-multiple formulas encode trader assumptions — the truth table is documented in `README.md` and `STOPPING_RULE.md`; changing one is a strategy decision, not a refactor.
### 3. Session derivation (M2D-specific)
The `Sesiune` column is computed by `_f_session` from `Data` + `Ora RO` (Romanian time) using nested IFs encoding the M2D session windows (A1/A2/A3/B/C/D/Other). The buckets and times come from `strategie_M2D.md`. If asked to add a new session bucket, edit `_f_session` and `SESSIONS` together — Dashboard's PER SESIUNE breakdown iterates over `SESSIONS`.
## Decision gates — `STOPPING_RULE.md`
`STOPPING_RULE.md` is a **signed document** (the user committed it as a commitment). It defines GO LIVE / EXTEND / ABANDON thresholds: `N≥40`, `WR≥55%`, `Expectancy≥+0.20R`. Treat these numbers as fixed unless the user explicitly asks to renegotiate them — do not "improve" them in passing.
## Reference docs
- `strategie_M2D.md` — M2D setup rules (color-coded dot bands on TF mare/mic, SL/TP placement, session filters).
- `calendar_evenimente.yaml` — manual news calendar (FOMC/NFP/CPI). Read-only context; not parsed by code.
- `README.md` — user-facing workflow + R-multiple truth table.
## Working in this repo
- The user is **Marius**, a discretionary trader doing his own backtest, not an engineer building a product. Prefer minimal, legible changes to the Excel template over abstractions. The audience is one person logging trades, not a team.
- When changing formulas in `generate_template.py`, regenerate the workbook and inspect at least one row's outputs before declaring done — a formula typo silently breaks the entire downstream Dashboard.
- The git user is `Marius` on branch `master` (not `main`); PRs are not the workflow here, direct commits to `master` are.

224
README.md
View File

@@ -1,122 +1,144 @@
# M2D Backtesting — Setup Proiect Claude
# atm-backtesting
## 📂 Conținut
Jurnal Excel manual pentru backtesting pe semnale blackbox (entry / SL / TP precalculate de alt trader sau de un indicator), cu comparație **5 strategii de management** side-by-side.
Acest ZIP conține tot ce ai nevoie pentru a configura proiectul Claude de backtesting M2D:
## Ce face
| Fișier | Rol |
Introduci datele de bază ale fiecărui trade — dată, oră, strategie, indicator, TF, direcție, SL/TP0/TP1/TP2 în %, outcome — și Excel calculează automat:
- **R-multiples** și **$ P&L** pentru 5 strategii paralele
- **Dashboard**: Win Ratio, Average Win/Loss, Profit Factor, Risk:Reward, Expectancy, HWM Balance, Max Drawdown — câte un set per strategie
- **Breakdown** per Sesiune, Strategie, Indicator, Direcție
- **Equity curve** (5 linii — câte una per strategie)
Cele 5 strategii de management comparate:
| # | Strategie | Comportament |
|---|---|---|
| 1 | **TP0 only** | 100% poziție, close la TP0. Foarte conservator (bird in hand). |
| 2 | **TP1 only** | 100% poziție, OCO la SL sau TP1, fără intervenție. |
| 3 | **TP2 only** | 100% poziție, OCO la SL sau TP2 (let it ride). |
| 4 | **Hybrid + BE** | 50% close la TP0, mut SL la BE, 50% close la TP1. Recomandat de trader. |
| 5 | **Hybrid no BE** | 50% close la TP0, **fără** BE, 50% close la TP1. Compară direct cu #4 ca să vezi dacă BE-ul aduce valoare. |
Rezultatul: pe aceleași semnale, vezi care metodă de management produce cel mai mare expectancy, profit factor și cea mai mică drawdown.
### De ce 2 variante Hybrid (cu/fără BE)
BE move este o **regulă teoretică** de management, nu o decizie istorică pe fiecare trade. Așa că nu o marchezi manual pe rând — în schimb, ambele variante (cu și fără BE) se calculează automat pentru fiecare trade. Compari direct dacă regula BE-ului adaugă R-uri sau le scoate.
## Setup (o singură dată)
```powershell
pip install openpyxl
python scripts/generate_template.py
```
Se generează `data/backtest.xlsx`. Deschide-l în Excel sau LibreOffice Calc.
## Workflow zilnic
1. Deschide `data/backtest.xlsx` și du-te în sheet-ul **Trades**.
2. Adaugă un rând nou (continuă imediat sub ultimul completat — **nu lăsa goluri** între rânduri).
3. Completează coloanele galbene (input) — restul (albastre) se calculează automat:
- **Data**, **Ora RO** (sesiunea + ziua se derivă automat de aici)
- **Strategie** (M2D / EMA cross / ... — dropdown, editabil în Config)
- **Indicator** (DIA / SPY / US30 / ... — dropdown)
- **TF** (1min / 3min / 15min — dropdown; e TF-ul de entry, vezi mai jos)
- **Direcție** (Buy / Sell — dropdown)
- **SL %**, **TP0 %**, **TP1 %**, **TP2 %** — distanțe față de entry, în procente (ex. 0.30 pentru 0.30%)
- **Outcome** (SL / TP0 only / TP1 / TP2 — dropdown)
- **Notes** (opțional)
4. Coloanele albastre derivate (Zi, Sesiune, R_*, $_*, Bal_* pentru cele 5 strategii) se umplu automat.
5. Mergi la sheet-ul **Dashboard** — metricile, breakdown-urile și equity curve se actualizează live.
### Coloana TF
Pentru M2D, TF-ul mic este TF-ul de entry, iar TF-ul mare e implicit:
| TF (input) | Perechea M2D |
|---|---|
| `0_SYSTEM_PROMPT.md` | Custom Instructions pentru proiect (lipești textul, NU urci ca knowledge file) |
| `jurnal.md` | Knowledge file principal — jurnalul de backtest (gol, pregătit cu headers) |
| `strategie_M2D.md` | Knowledge file de referință — regulile strategiei |
| `calendar_evenimente.md` | Knowledge file de referință — events economice recurente |
| `README.md` | Acest fișier — instrucțiuni setup |
| 1min | 1 / 5 min |
| 3min | 3 / 15 min |
| 15min | 15 / 60 min |
---
Dacă folosești o altă strategie cu pereche diferită, descrie în Notes.
## 🚀 Pași de configurare
### Cum se calculează Sesiunea automat
### 1. Creează un proiect nou în Claude.ai
- Mergi la **claude.ai** → click pe **Projects** în sidebar → **+ New Project**
- Nume sugerat: **"M2D Backtest"**
- Descriere (opțional): "Backtesting strategie scalping M2D pe US30/DIA prin analiză screenshot-uri TradeStation blackbox"
Pe baza Data + Ora RO, regulile M2D (vezi `strategie_M2D.md`):
### 2. Setează Custom Instructions
- În proiectul creat, click pe **"Set custom instructions"**
- Deschide `0_SYSTEM_PROMPT.md` din acest ZIP
- Copiază TOT textul (fără secțiunea cu titlul "# M2D Backtesting Assistant — System Instructions" dacă vrei)
- Lipește în câmpul de Custom Instructions
- Save
| Sesiune | Condiție |
|---|---|
| **A1** | 16:3517:00 RO, Mar/Mie/Joi |
| **A2** | 17:0018:00 RO, Mar/Mie/Joi (sweet spot) |
| **A3** | 18:0019:00 RO, Mar/Mie/Joi |
| **B** | 22:0022:45 RO, Mar/Mie/Joi (Power Hour) |
| **C** | 15:3016:30 RO (pre-NY chop / news risk), orice zi |
| **D** | Luni sau Vineri |
| **Other** | În afara ferestrelor de mai sus |
### 3. Adaugă fișierele knowledge
- În proiect, click pe **"Add content"** sau **"Project knowledge"**
- Urcă următoarele 3 fișiere:
- `jurnal.md`
- `strategie_M2D.md`
- `calendar_evenimente.md`
- NU urca `0_SYSTEM_PROMPT.md` (acela e doar pentru Custom Instructions)
- NU urca acest `README.md`
> Notă: zilele FOMC/NFP/CPI ar trebui marcate ca C, dar formula nu detectează evenimente — marchează manual în Notes dacă e zi news majoră.
### 4. Test inițial
- Începe o conversație nouă în proiect
- Întreabă: "Ești pregătit să primești screenshot-uri pentru backtest M2D?"
- Răspunsul ar trebui să confirme că înțelege strategia și formatul jurnal
## Configurare
---
Sheet-ul **Config** permite editarea:
## 📋 Workflow zilnic
- **Account Size Start ($)** — balanța inițială (default $10,000)
- **Risk per Trade (%)** — % din account riscat per trade (default 1.0%)
- Listele pentru dropdown-uri: Strategii, Indicatori, TF, Direcție, Outcome
```
1. Deschizi un screenshot cu setup M2D din TradeStation blackbox
2. Începi o conversație nouă (sau continui una) în proiectul Claude
3. Atașezi screenshot-ul + scrii "Backtest"
4. Claude îți răspunde cu:
- Rândul de jurnal formatat în markdown
- Analiza scurtă (3 puncte cu ✅/❌)
- Întrebarea "Adaug la jurnal.md?"
5. Răspunzi "da" → Claude îți dă rândul curat de copy-paste
6. Deschizi jurnal.md local (sau de oriunde îl ții) și adaugi rândul
7. La 20+ trade-uri: re-uploadezi jurnal.md actualizat în Project Knowledge
La schimbarea Account Size sau Risk %, toate sumele $ din Trades și Dashboard se recalculează.
## Formule R-multiples (referință)
`SL_%`, `TP0_%`, `TP1_%`, `TP2_%` sunt distanțe pozitive față de entry, exprimate în procente.
Tabelul de mai jos arată R-multiple-ul rezultat pentru fiecare combinație (Outcome × Strategie):
| Outcome | TP0 only | TP1 only | TP2 only | Hybrid + BE | Hybrid no BE |
|---------|----------|----------|----------|-------------|--------------|
| **SL** | 1 | 1 | 1 | 1 | 1 |
| **TP0 only** | +TP0/SL | 1 | 1 | +0.5·TP0/SL | +0.5·TP0/SL 0.5 |
| **TP1** | +TP0/SL | +TP1/SL | 1 | +0.5·(TP0+TP1)/SL | +0.5·(TP0+TP1)/SL |
| **TP2** | +TP0/SL | +TP1/SL | +TP2/SL | +0.5·(TP0+TP1)/SL | +0.5·(TP0+TP1)/SL |
**Citirea Outcome-ului**:
- `SL` — prețul a atins SL fără să atingă vreodată TP0 (loss complet).
- `TP0 only` — prețul a atins TP0, dar nu și TP1 (ulterior fie a venit înapoi la SL, fie a fost închis la BE pentru variantele cu BE move).
- `TP1` — prețul a atins TP1 (a trecut prin TP0).
- `TP2` — prețul a atins TP2 (a trecut prin TP0 și TP1).
**Asumpții de simulare**:
- `TP1 only` și `TP2 only` simulează OCO pur, fără intervenție manuală. Outcome=`TP0 only` se închide la SL (presupunere worst-case).
- `TP2 only` cu Outcome=`TP1` se închide la SL (TP1 a fost atins, dar SL ar fi venit înainte de TP2).
- Diferența dintre Hybrid + BE și Hybrid no BE apare doar când Outcome=`TP0 only`; la TP1/TP2 ambele dau identic.
## Regenerare template
Dacă strici structura Excel-ului accidental sau modifici `scripts/generate_template.py`:
```powershell
python scripts/generate_template.py
```
---
Atenție: **suprascrie** `data/backtest.xlsx`. Fă backup la rândurile tale înainte (copy-paste într-un alt fișier sau export CSV).
## 🔄 Sincronizare jurnal
## Decizii GO LIVE / ABANDON
Claude vede doar versiunea de `jurnal.md` care e în Project Knowledge. Deci:
Vezi `STOPPING_RULE.md` — threshold-urile semnate (N≥40, WR≥55%, Expectancy≥+0.20R) și caveat-urile metodologice.
- **După fiecare câteva trade-uri** (ex: 5-10): re-uploadează `jurnal.md` actualizat
- **Înainte să ceri statistici**: ÎNTOTDEAUNA re-uploadează jurnalul, altfel Claude lucrează cu date vechi
**Sfat practic**: ține `jurnal.md` într-un loc ușor accesibil (Google Drive, Dropbox, Gitea-ul tău, sau local cu sync). Mai ușor de updatat.
---
## 📊 Cereri tipice pe care le poți face
După ce ai 20+ trade-uri în jurnal:
## Fișiere
```
"Dă-mi statisticile complete: WR pe Set, hit distribution, WR per calitate retragere"
atm-backtesting/
├── data/
│ └── backtest.xlsx # source of truth — jurnalul tău
├── scripts/
│ └── generate_template.py # regenerator template
├── strategie_M2D.md # referință reguli M2D (Buy/Sell setup, SL/TP, sesiuni)
├── calendar_evenimente.yaml # calendar news (FOMC/NFP/CPI etc.) pentru identificare manuală sesiune
├── STOPPING_RULE.md # threshold-uri decizie + caveats semnate
├── pyproject.toml # dependență: openpyxl
└── README.md # acest fișier
```
```
"Care e Set-ul cu cel mai mare expectancy? Și cel mai prost?"
```
```
"Pe ultimele 30 trade-uri, ce procent au fost cu calitate retragere Slabă?
Au influențat negativ WR-ul?"
```
```
"Compară performanța pe DIA vs US30"
```
```
"Sunt câteva trade-uri Set C (news days). Dacă le exclud, cum se schimbă WR-ul global?"
```
---
## ⚠️ Limitări de știut
1. **Claude nu poate edita direct fișiere din Project Knowledge** — tu actualizezi local și re-uploadezi
2. **Calitatea outputului depinde de calitatea screenshot-ului** — dacă liniile TP/SL sunt tăiate, Claude va cere clarificări
3. **Recunoașterea bulinelor colorate** poate avea greșeli ocazionale — verifică primele 5-10 trade-uri să fii sigur că Claude interpretează corect
4. **Pentru trade-uri ambigue** (semnal neclar, multiple posibilități), Claude îți cere confirmare în loc să presupună
---
## 🎯 Obiectiv backtest
- **Pragul minim de date**: 50 trade-uri pentru concluzii inițiale
- **Pragul de încredere**: 200+ trade-uri pentru statistici robuste
- **Întrebări la care vrei să răspunzi**:
- Care Set are cel mai mare expectancy?
- WR > 55% pe Set A2 (sweet spot)?
- Filtrul de calitate retragere are impact real?
- News days chiar trebuie excluse?
- DIA sau US30 e mai bun pentru M2D?
Mult succes la backtest! 🚀

View File

@@ -1,41 +1,42 @@
# STOPPING_RULE — M2D Backtesting
# STOPPING_RULE — Backtesting jurnal Excel
**Versiune**: 1
**Versiune**: 2
**Data**: 2026-05-13
**Status**: draft — pentru semnătură Marius
**Status**: SIGNED — Marius
---
## Întrebarea de decis
Pentru fiecare Set candidat (A1, A2, A3, B), decidem una din trei:
Pentru fiecare combinație (Indicator × Sesiune) candidată, decidem una din trei:
- **GO LIVE** — pornesc forward paper trading cu 0.25R per trade (validare reală)
- **EXTEND COLLECTION** — mai colectez screenshot-uri, încă nu sunt date suficiente
- **ABANDON** — strategia nu are edge măsurabil pe acest Set; renunț la Set sau la întreaga strategie
- **EXTEND COLLECTION** — mai colectez trade-uri, încă nu sunt date suficiente
- **ABANDON** — strategia nu are edge măsurabil pe această combinație
Decizia se ia separat pentru fiecare strategie de management (Hybrid 50/50+BE / TP1-only OCO / TP2-only OCO) — Dashboard-ul din `data/backtest.xlsx` afișează metrici side-by-side.
---
## Threshold-uri obligatorii pentru GO LIVE pe un Set
## Threshold-uri obligatorii pentru GO LIVE pe o combinație Indicator × Sesiune
Toate condițiile trebuie satisfăcute simultan:
Toate condițiile trebuie satisfăcute simultan, pentru cel puțin o strategie de management:
1. **N ≥ 40** trade-uri non-pending pe acel Set
2. **WR ≥ 55%** (Wilson 95% CI lower bound ≥ 45%)
3. **Expectancy ≥ +0.20R** pe overlay-ul `pl_marius` (50% TP0 + BE + close ~TP1)
4. **Calibration P4 PASS** — pe primele 10 trade-uri double-extracted (manual + vision), mismatch rate ≤10% pe câmpuri core (`entry`, `sl`, `tp0/1/2`, `outcome_path`, `max_reached`, `directie`)
1. **N ≥ 40** trade-uri pe acea combinație (vezi Dashboard → PER SESIUNE / PER INDICATOR)
2. **WR ≥ 55%** (rule-of-thumb: 95% CI lower bound trebuie să fie ≥ 45% — calculează manual sau folosește calculator Wilson extern)
3. **Expectancy ≥ +0.20R** pe strategia aleasă (Hybrid 50/50+BE implicit; alternativ TP1-only OCO sau TP2-only OCO)
Dacă oricare condiție pică → **EXTEND COLLECTION** sau **ABANDON** (vezi mai jos).
Dacă oricare condiție pică → **EXTEND COLLECTION** sau **ABANDON**.
---
## Threshold-uri pentru ABANDON pe un Set
## Threshold-uri pentru ABANDON
Oricare e suficient:
- N ≥ 40 și WR < 45% edge negativ; ABANDON acest Set
- N 40 și Expectancy 0.10R ABANDON
- Wilson 95% CI lower bound stabil sub 50% după N 60 ABANDON
- N ≥ 40 și WR < 45% pe toate cele 3 strategii edge negativ; ABANDON combinația
- N 40 și Expectancy 0.10R pe toate cele 3 strategii ABANDON
- WR observat stabil sub 50% după N 60 ABANDON
---
@@ -45,30 +46,51 @@ Oricare e suficient:
1. **N=40 = directional evidence, NU scientific proof**. Intervalul de încredere 95% pentru WR la N=40, WR observat 55%, este aproximativ [40%, 70%]. "Validated" la N=40 înseamnă "merită tradez cu 0.25R", NU "edge confirmat statistic". Confirmarea reală vine din forward paper trading.
2. **Selection bias rezidual**. Chiar și cu scroll protocol (vezi `WORKFLOW.md`), eu am ales perioada pe care scroll-uiesc. Acest bias e parțial mitigat, NU eliminat.
2. **Selection bias rezidual**. Chiar dacă păstrez disciplina loghez toate trigger-urile (inclusiv loss-urile clare), eu aleg ce perioadă scroll-uiesc. Acest bias e parțial mitigat prin trade-by-trade logging, NU eliminat.
3. **Lookahead bias pe `calitate`**. Câmpul `calitate ∈ {Clară, Mai mare ca impuls, Slabă}` este clasificat post-outcome (am văzut chart-ul întreg). DECI: NU folosesc `calitate` ca filtru în stopping rule. Rămâne descriptor în jurnal, NU criteriu de tradare.
3. **Lookahead bias pe calitate subiectivă**. Nu mai există coloană `calitate` în jurnalul Excel clasificarea subiectivă post-outcome a fost eliminată ca sursă de bias. Note-urile rămân pentru context, dar NU sunt folosite ca filtru de decizie.
4. **Backtest = upper bound al expectancy real**. Execuția live va avea slippage, latențe, emoții. Expectancy real probabil 0.05-0.15R sub backtest. De aceea pragul `+0.20R` în backtest = aproximativ break-even-cu-edge-mic în live.
4. **Backtest = upper bound al expectancy real**. Execuția live va avea slippage, latențe, emoții, mouse-trip-uri. Expectancy real probabil 0.050.15R sub backtest. De aceea pragul `+0.20R` în backtest break-even-cu-edge-mic în live.
5. **Indicator drift**. Dacă indicatorul blackbox se update-ează, trade-urile vechi devin istoric irelevant. Trackuit prin coloana `indicator_version` în CSV; reset stat-uri la schimbare.
5. **Indicator drift**. Dacă indicatorul blackbox se update-ează, trade-urile vechi devin istoric irelevant. Trackează manual versiunea indicatorului în coloana Notes; reset stats la schimbare semnificativă.
---
## Comparația de strategii — context
Cele 5 overlay-uri calculate automat per trade în `Trades` sheet:
| Strategie | Descriere |
|---|---|
| **TP0 only** | 100% poziție, close la TP0. Maxim conservator. |
| **TP1 only** | 100% poziție, OCO la SL sau TP1, fără intervenție manuală. |
| **TP2 only** | 100% poziție, OCO la SL sau TP2 (let it ride). |
| **Hybrid + BE** | 50% close la TP0, mut SL la BE, 50% close la TP1. Recomandat de trader. |
| **Hybrid no BE** | 50% close la TP0, fără BE, 50% close la TP1. Control pentru izolarea valorii BE-ului. |
**Decizie de management se ia DUPĂ ce ai N≥40 trade-uri**: alegi strategia cu cel mai bun Expectancy + Profit Factor pe combinația de Indicator × Sesiune care a trecut gate-urile.
**Asumpții de simulare**:
- `TP1 only` și `TP2 only` simulează OCO pur, fără intervenție; Outcome=`TP0 only` se închide la SL.
- `TP2 only` cu Outcome=`TP1`: TP2 nu a fost atins presupunem , în lipsa time-stop-ului, SL ar fi venit ulterior; tratat ca 1R. Asumpția subestimează ușor TP2-only dacă în realitate trade-ul s-ar fi închis profitabil prin alt mecanism, dar e prudent.
- BE este parametru teoretic nu un input per trade. Hybrid + BE vs Hybrid no BE diferă strict când Outcome=`TP0 only` (diferența = +0.5R în favoarea BE).
---
## Post-GO LIVE protocol
După un Set primește GO LIVE:
După o combinație Indicator × Sesiune × Strategie primește GO LIVE:
1. Forward paper trading cu 0.25R per trade pe acel Set.
1. Forward paper trading cu 0.25R per trade.
2. Minimum 20 trade-uri live înainte de a urca sizing-ul la 0.5R sau full.
3. Dacă WR live diverge >10pp de backtest în prima 20 trade-uri → review (probabil execuție defectă sau bias subestimat).
3. Dacă WR live diverge >10pp de backtest în primele 20 trade-uri → review (probabil execuție defectă sau bias subestimat).
---
## Semnătură
```
Marius — _________________________ — data: ___________
Marius — semnat prin commit git — data: 2026-05-13
```
(prin commit-ul acestui fișier cu modificări la status "signed" + nume în istoric git)
Toate cele 5 caveats înțelese și acceptate.

View File

@@ -1,86 +0,0 @@
# WORKFLOW — colectare screenshot-uri M2D
**Scop**: minimizează **selection bias** când colectezi screenshot-uri istorice din TradeStation.
> **Premisa ascunsă pe care o atacăm**: dacă faci screenshot doar la trade-urile care "arată interesant", WR-ul măsurat va fi structural mai mare decât WR-ul real. Setup-urile care arată "dubios" la trigger sunt mai des losers — dacă le filtrezi inconștient, statisticile mint.
---
## Regula de aur
> **Dacă vezi o bulină verde-deschis (BUY trigger) sau roșu-deschis (SELL trigger) pe TF mic care urmează unei bulin verde-închis/roșu-închis după turquoise/galben pe TF mare → screenshot, indiferent cum arată setupul.**
NU evaluezi calitatea ÎNAINTE de screenshot. Calitatea o pune extractorul (manual sau vision) pe baza imaginii, nu tu pe baza memoriei.
---
## Scroll protocol
### 1. Alege perioada în avans
- Definește o fereastră calendaristică concretă: "Mar/Mie/Joi între 16:35-18:00 RO, din 2025-09-01 până în 2025-12-31".
- **Scrie perioada aleasă într-un comentariu sau fișier înainte de a începe scroll-ul**. Așa nu poți extinde sau restrânge perioada în funcție de ce vezi.
### 2. Configurare TradeStation
- TF mic vizibil (1min sau 3min).
- TF mare overlay sau pe ecran separat — important să vezi semnalul direcțional.
- Indicator blackbox activ pe ambele TF-uri.
- Zoom suficient ca să distingi culorile bulinelor clar.
### 3. Scroll candle-by-candle
- Începi cu prima zi a perioadei, ora 16:35 (sau începutul fereastrei alese).
- Avansezi candle-by-candle (sau cu pas mic). **NU sări** peste perioade lungi "pentru că arată plat" — chiar și acolo pot fi trigger-e ratate.
- La fiecare bulină verde-deschis / roșu-deschis pe TF mic în fereastra orară a perioadei → **STOP. Screenshot.**
- Verifică post-screenshot că setup-ul respectă pre-condițiile (turquoise/galben pe TF mare anterior, retragere intermediară). Dacă NU respectă → este un trigger fără setup valid; documentează cu nume `<basename>-invalid.png` (sau șterge), dar **logează decizia în WORKFLOW_LOG.md de mai jos**.
### 4. Workflow log (audit trail anti-bias)
Creează un fișier `data/workflow_log.md` la pornirea fiecărei sesiuni de scroll:
```markdown
## 2026-05-13 — sesiune scroll perioada 2025-09-01 → 2025-09-15
- Start: 14:32 RO
- Perioada scroll-uită: 2025-09-01 → 2025-09-15, Mar/Mie/Joi, 16:35-18:00
- Trigger-e găsite: 12
- Screenshot-uri făcute: 12
- Screenshot-uri NEFĂCUTE (cu motiv):
- 2025-09-04 17:14 — bulina verde-deschis dar fără verde-închis înainte → NU e M2D valid (trigger fals)
- End: 15:48 RO
```
**Regula**: dacă rata screenshot/trigger < 95% pe o sesiune, ai un motiv documentat pentru cele lipsă. Dacă nu ai un bias.
### 5. Cazuri ambigue (cum eviți cherry-picking)
- "Nu sunt sigur e M2D" screenshot oricum, log la `data/workflow_log.md` ca "ambiguous: motiv". Extractorul (manual/vision) decide final.
- "Imaginea e neclară" screenshot oricum; vision poate returneze `confidence:low` și merge la `needs_review/`.
- "Setupul arată slab" IRRELEVANT la momentul screenshot. Screenshot. Calitate o pune extractorul. (Da, calitate e descriptor biased vezi STOPPING_RULE.md punct 3 dar NU folosim calitate ca filtru.)
- "Trade-ul a pierdut clar" IRRELEVANT. Screenshot. Asta e exact biasul pe care îl evităm.
---
## Anti-pattern-uri (NU FACE asta)
- "Scrol până găsesc un trade frumos" biased.
- "Sar peste ziua asta, n-a fost nimic interesant" biased.
- "Refac screenshot-ul, primul a ieșit prost" ok dacă primul e ilizibil; NU ok dacă vrei un screenshot "mai clar" pe un trade winning.
- "Văd e SL clar, nu merită screenshot" exact opusul a ce vrei.
---
## Calibration trades (primele 10)
Pentru cele 10 trade-uri de calibrare (P4 gate):
- **Tu** (Marius) extragi manual TOATE câmpurile prin `/m2d-log` (source=`manual_calibration`).
- **Apoi** rulezi `/backtest screenshot.png` pentru extracție vision (source=`vision_calibration`).
- `/stats --calibration` compară field-by-field.
- Acceptance: 10% mismatch pe câmpurile core. >10% → fix promptul vision agent (`.claude/agents/m2d-extractor.md`) și re-rulează.
---
## Versiune
- v1 (2026-05-13) — draft inițial

View File

@@ -1,38 +0,0 @@
# Calendar evenimente economice — recurente
## Lunar
| Eveniment | Frecvență | Ora RO | Impact |
|---|---|---|---|
| **NFP** | Prima vineri din lună | 15:30 | EXTREM |
| **CPI** | Mid-luna (10-15) | 15:30 | EXTREM |
| **PPI** | După CPI | 15:30 | MARE |
| **Retail Sales** | ~15 lună | 15:30 | MARE |
| **PCE Price Index** | Ultimele zile lună | 15:30 | MARE |
| **ADP Employment** | Miercuri pre-NFP | 15:15 | MEDIU |
| **JOLTS Job Openings** | 1-a săptămână | 17:00 | MEDIU |
| **ISM Manuf/Services** | 1-a săptămână | 17:00 | MEDIU |
## Săptămânal
| Eveniment | Zi | Ora RO | Impact |
|---|---|---|---|
| **EIA Crude Oil Inventories** | Miercuri | 17:30 | MEDIU pe US30 (indirect) |
| **Initial Jobless Claims** | Joi | 15:30 | MIC-MEDIU |
## FOMC 2026 (8 ședințe/an, miercuri 21:00 RO, *cu excepții DST*)
| Data | Comentariu |
|---|---|
| 28 ianuarie | Statement 21:00 RO + Powell 21:30 |
| 18 martie | ATENȚIE: 20:00 RO (Europa pe EET, US deja DST) + SEP/dot plot |
| 29 aprilie | 21:00 RO + Powell 21:30 |
| 17 iunie | + SEP/dot plot |
| 29 iulie | 21:00 RO |
| 16 septembrie | + SEP/dot plot |
| 28 octombrie | 21:00 RO |
| 9 decembrie | + SEP/dot plot |
## Regulă pentru backtesting
**Trade-urile din ferestrele ± 15 min față de NFP/CPI/FOMC** → marcate ca **Set C - News window** și analizate SEPARAT, NU în statistica principală.

View File

@@ -1,18 +0,0 @@
schema_version: 1
# Versions stamped on every CSV row. Update only when behavior changes.
indicator_version: "v-2026-05"
pl_overlay_version: "marius-v1" # 50% TP0 + BE + close ~TP1
csv_schema_version: 1
calendar_schema_version: 1
# Tier 1 / Tier 2 trading windows (informational; Set calc uses calendar_parse.py logic)
tier_1_window:
start_ro: "16:35"
end_ro: "18:00"
days: ["Tue", "Wed", "Thu"]
tier_2_window:
start_ro: "22:00"
end_ro: "22:45"
days: ["Tue", "Wed", "Thu"]

BIN
data/backtest.xlsx Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -1,40 +0,0 @@
# Jurnal Backtest M2D — US30 / DIA
**Strategie**: M2D pură (2 timeframe-uri, semnale blackbox TradeStation)
**Începută**: [completează data primului trade]
**Status**: backtest în derulare
---
## Statistici curente
> Acest sumar se actualizează manual la fiecare ~10 trade-uri.
- **Total trade-uri**: 0
- **Win Rate**: -
- **Net P/L (% Risc)**: -
- **Ultimul update**: -
---
## Trade-uri
| # | Data | Zi | Ora RO | Instrument | Direcție | TF mare | TF mic | Calitate | Entry | SL | TP0 | TP1 | TP2 | Risc % | Hit | BE | P/L Risc | Set | Note |
|---|------|----|----|-----------|----------|---------|--------|----------|-------|-----|------|------|------|--------|-----|----|----|-----|------|
| | | | | | | | | | | | | | | | | | | | |
---
## Note generale (observații din backtest)
-
---
## Excluderi din statistici
Trade-uri care au fost identificate dar excluse din analiza principală (de obicei zile news, condiții anormale):
| # | Motiv excludere |
|---|----------------|
| | |

View File

@@ -1,25 +1,11 @@
[project]
name = "atm-backtesting"
version = "0.1.0"
description = "M2D backtesting system — vision extraction + stats"
version = "0.2.0"
description = "Jurnal Excel manual pentru backtesting M2D + comparație 3 strategii de management (Hybrid 50/50+BE / TP1-only OCO / TP2-only OCO)"
requires-python = ">=3.11"
dependencies = [
"pydantic>=2.5",
"pyyaml>=6.0",
"scipy>=1.11",
"numpy>=1.26",
"openpyxl>=3.1",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4",
"pytest-cov>=4.1",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-ra -q"
[tool.setuptools]
packages = ["scripts"]

View File

@@ -1,237 +0,0 @@
"""Append a validated M2D extraction to ``data/trades.csv``.
Pipeline:
JSON file --> pydantic validate (M2DExtraction)
--> load data/_meta.yaml (versions + schema)
--> compute ora_ro, zi, set, pl_marius, pl_theoretical
--> dedup on (screenshot_file, source)
--> atomic CSV write (temp file + os.replace)
Source values
- ``manual`` : Marius logged by hand
- ``vision`` : produced by the vision subagent
- ``manual_calibration`` : calibration P4 — manual leg
- ``vision_calibration`` : calibration P4 — vision leg
A row with ``source=manual_calibration`` and a row with ``source=vision_calibration``
for the *same* screenshot are allowed to coexist (different dedup keys); a
duplicate ``(screenshot_file, source)`` pair is rejected (or skipped — see
``append_row`` ``on_duplicate`` argument).
"""
from __future__ import annotations
import csv
import json
import os
import tempfile
from pathlib import Path
from typing import Any, Literal
import yaml
from scripts.calendar_parse import calc_set, load_calendar, utc_to_ro
from scripts.pl_calc import pl_marius, pl_theoretical
from scripts.vision_schema import M2DExtraction, parse_extraction_dict
__all__ = [
"CSV_COLUMNS",
"VALID_SOURCES",
"build_row",
"read_rows",
"append_row",
"append_row_from_json",
]
Source = Literal["manual", "vision", "manual_calibration", "vision_calibration"]
VALID_SOURCES: frozenset[str] = frozenset(
{"manual", "vision", "manual_calibration", "vision_calibration"}
)
CSV_COLUMNS: tuple[str, ...] = (
"screenshot_file",
"source",
"data",
"ora_utc",
"ora_ro",
"zi",
"set",
"instrument",
"directie",
"tf_mare",
"tf_mic",
"calitate",
"entry",
"sl",
"tp0",
"tp1",
"tp2",
"risc_pct",
"outcome_path",
"max_reached",
"be_moved",
"confidence",
"ambiguities",
"note",
"pl_marius",
"pl_theoretical",
"indicator_version",
"pl_overlay_version",
"csv_schema_version",
)
def _load_meta(meta_path: Path) -> dict[str, Any]:
with meta_path.open("r", encoding="utf-8") as fh:
meta = yaml.safe_load(fh) or {}
required = ("indicator_version", "pl_overlay_version", "csv_schema_version")
missing = [k for k in required if k not in meta]
if missing:
raise ValueError(f"_meta.yaml missing required keys: {missing}")
return meta
def _format_optional(value: float | None) -> str:
return "" if value is None else f"{value:.4f}"
def build_row(
extraction: M2DExtraction,
source: str,
meta: dict[str, Any],
calendar: list[dict[str, Any]],
) -> dict[str, str]:
"""Compute the full CSV row dict for one extraction."""
if source not in VALID_SOURCES:
raise ValueError(
f"invalid source {source!r}; must be one of {sorted(VALID_SOURCES)}"
)
d_ro, t_ro, zi = utc_to_ro(extraction.data, extraction.ora_utc)
set_label = calc_set(d_ro, t_ro, zi, calendar)
pl_m = pl_marius(extraction.outcome_path, extraction.be_moved)
pl_t = pl_theoretical(extraction.max_reached)
return {
"screenshot_file": extraction.screenshot_file,
"source": source,
"data": extraction.data,
"ora_utc": extraction.ora_utc,
"ora_ro": t_ro.strftime("%H:%M"),
"zi": zi,
"set": set_label,
"instrument": extraction.instrument,
"directie": extraction.directie,
"tf_mare": extraction.tf_mare,
"tf_mic": extraction.tf_mic,
"calitate": extraction.calitate,
"entry": f"{extraction.entry}",
"sl": f"{extraction.sl}",
"tp0": f"{extraction.tp0}",
"tp1": f"{extraction.tp1}",
"tp2": f"{extraction.tp2}",
"risc_pct": f"{extraction.risc_pct}",
"outcome_path": extraction.outcome_path,
"max_reached": extraction.max_reached,
"be_moved": "true" if extraction.be_moved else "false",
"confidence": extraction.confidence,
"ambiguities": json.dumps(extraction.ambiguities, ensure_ascii=False),
"note": extraction.note,
"pl_marius": _format_optional(pl_m),
"pl_theoretical": _format_optional(pl_t),
"indicator_version": str(meta["indicator_version"]),
"pl_overlay_version": str(meta["pl_overlay_version"]),
"csv_schema_version": str(meta["csv_schema_version"]),
}
def read_rows(csv_path: Path) -> list[dict[str, str]]:
"""Read existing rows; return [] if the file does not exist or is empty."""
if not csv_path.exists() or csv_path.stat().st_size == 0:
return []
with csv_path.open("r", encoding="utf-8", newline="") as fh:
reader = csv.DictReader(fh)
return list(reader)
def _atomic_write(csv_path: Path, rows: list[dict[str, str]]) -> None:
csv_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
prefix=csv_path.name + ".",
suffix=".tmp",
dir=str(csv_path.parent),
)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=list(CSV_COLUMNS))
writer.writeheader()
for r in rows:
writer.writerow({k: r.get(k, "") for k in CSV_COLUMNS})
os.replace(tmp_name, csv_path)
except Exception:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
def append_row(
extraction: M2DExtraction,
source: str,
csv_path: Path,
meta_path: Path,
calendar_path: Path,
on_duplicate: Literal["raise", "skip"] = "raise",
) -> dict[str, str]:
"""Append one extraction to the CSV.
Dedup key: ``(screenshot_file, source)``. If a row with the same key
already exists, behaviour is controlled by ``on_duplicate``:
- ``"raise"`` (default): raise ``ValueError``.
- ``"skip"``: leave the CSV untouched and return the *existing* row.
"""
meta = _load_meta(meta_path)
calendar = load_calendar(calendar_path)
row = build_row(extraction, source, meta, calendar)
existing = read_rows(csv_path)
key = (row["screenshot_file"], row["source"])
for r in existing:
if (r.get("screenshot_file"), r.get("source")) == key:
if on_duplicate == "skip":
return r
raise ValueError(
f"duplicate row: screenshot_file={key[0]!r} source={key[1]!r} "
f"already exists in {csv_path}"
)
existing.append(row)
_atomic_write(csv_path, existing)
return row
def append_row_from_json(
json_path: Path,
source: str,
csv_path: Path,
meta_path: Path,
calendar_path: Path,
on_duplicate: Literal["raise", "skip"] = "raise",
) -> dict[str, str]:
"""Convenience wrapper: load JSON, validate, append."""
with Path(json_path).open("r", encoding="utf-8") as fh:
payload = json.load(fh)
extraction = parse_extraction_dict(payload)
return append_row(
extraction=extraction,
source=source,
csv_path=csv_path,
meta_path=meta_path,
calendar_path=calendar_path,
on_duplicate=on_duplicate,
)

View File

@@ -1,181 +0,0 @@
"""Calendar parsing + Set classification for M2D backtesting.
Each trade is tagged with a ``Set`` derived from its date, RO-local time, and the
economic-event calendar:
- ``A1``: 16:35-17:00 RO, Tue/Wed/Thu
- ``A2``: 17:00-18:00 RO, Tue/Wed/Thu (sweet spot)
- ``A3``: 18:00-19:00 RO, Tue/Wed/Thu
- ``B`` : 22:00-22:45 RO, Tue/Wed/Thu
- ``C`` : inside the window of an event with severity in {extrem, mare}
- ``D`` : Mon or Fri
- ``Other``: anything else
Priority: C > D > A1/A2/A3/B > Other.
"""
from __future__ import annotations
from datetime import date, datetime, time
from pathlib import Path
from typing import Any
import yaml
from zoneinfo import ZoneInfo
__all__ = [
"RO_TZ",
"UTC_TZ",
"utc_to_ro",
"load_calendar",
"is_in_news_window",
"calc_set",
]
RO_TZ = ZoneInfo("Europe/Bucharest")
UTC_TZ = ZoneInfo("UTC")
_DAY_SHORT = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
_HIGH_SEVERITY = frozenset({"extrem", "mare"})
_WEEKLY_DAY_MAP = {
"monday": 0,
"tuesday": 1,
"wednesday": 2,
"thursday": 3,
"friday": 4,
"saturday": 5,
"sunday": 6,
}
def utc_to_ro(date_str: str, ora_utc_str: str) -> tuple[date, time, str]:
"""Convert ``(YYYY-MM-DD, HH:MM UTC)`` to ``(date_ro, time_ro, day_short)``.
DST-aware via :mod:`zoneinfo`. ``day_short`` is one of
``Mon Tue Wed Thu Fri Sat Sun``.
"""
dt_utc = datetime.strptime(f"{date_str} {ora_utc_str}", "%Y-%m-%d %H:%M").replace(
tzinfo=UTC_TZ
)
dt_ro = dt_utc.astimezone(RO_TZ)
return dt_ro.date(), dt_ro.time().replace(second=0, microsecond=0), _DAY_SHORT[dt_ro.weekday()]
def load_calendar(path: Path | str = "calendar_evenimente.yaml") -> list[dict[str, Any]]:
"""Load a YAML calendar file.
Validates ``schema_version == 1`` and returns the list of event dicts under
the top-level ``events`` key.
"""
p = Path(path)
with p.open("r", encoding="utf-8") as fh:
doc = yaml.safe_load(fh)
if not isinstance(doc, dict):
raise ValueError(f"calendar file {p} is not a mapping")
version = doc.get("schema_version")
if version != 1:
raise ValueError(
f"unsupported calendar schema_version: {version!r} (expected 1)"
)
events = doc.get("events") or []
if not isinstance(events, list):
raise ValueError(f"calendar events must be a list, got {type(events).__name__}")
return events
def _minutes(t: time) -> int:
return t.hour * 60 + t.minute
def _parse_hhmm(s: str) -> time:
return datetime.strptime(s, "%H:%M").time()
def _is_first_friday_of_month(d: date) -> bool:
return d.weekday() == 4 and d.day <= 7
def _event_matches_date(event: dict[str, Any], d: date) -> bool:
cadence = event.get("cadence", "")
if cadence == "scheduled":
ev_date_raw = event.get("date")
if isinstance(ev_date_raw, date):
ev_date = ev_date_raw
elif isinstance(ev_date_raw, str):
ev_date = datetime.strptime(ev_date_raw, "%Y-%m-%d").date()
else:
return False
return ev_date == d
if cadence == "first_friday_monthly":
return _is_first_friday_of_month(d)
if cadence.startswith("weekly_"):
day_name = cadence[len("weekly_") :].lower()
target = _WEEKLY_DAY_MAP.get(day_name)
if target is None:
return False
return d.weekday() == target
# cadences below are not pinned down to a precise calendar day yet, so we
# do not trigger Set C for them. ADP pre-NFP is also explicitly deferred.
return False
def is_in_news_window(d: date, t: time, calendar: list[dict[str, Any]]) -> bool:
"""Return True iff ``(d, t)`` falls inside the window of a high-severity event.
Window: ``[time_ro - window_before_min, time_ro + window_after_min]`` (inclusive
on both ends). Only events with ``severity`` in ``{extrem, mare}`` count.
Cadences honoured: ``scheduled``, ``first_friday_monthly``, ``weekly_<day>``.
Other cadences (``monthly_mid``, ``monthly_end``, ``monthly_15``,
``wednesday_pre_nfp``, ``monthly_first_week`` etc.) are deferred and never
trigger Set C.
"""
t_min = _minutes(t)
for event in calendar:
if event.get("severity") not in _HIGH_SEVERITY:
continue
if not _event_matches_date(event, d):
continue
ev_time_raw = event.get("time_ro")
if isinstance(ev_time_raw, time):
ev_time = ev_time_raw
elif isinstance(ev_time_raw, str):
ev_time = _parse_hhmm(ev_time_raw)
else:
continue
center = _minutes(ev_time)
before = int(event.get("window_before_min", 0))
after = int(event.get("window_after_min", 0))
if center - before <= t_min <= center + after:
return True
return False
def _in_range(t: time, lo: time, hi: time) -> bool:
"""Half-open ``[lo, hi)`` containment."""
return _minutes(lo) <= _minutes(t) < _minutes(hi)
def calc_set(d: date, t: time, day_of_week: str, calendar: list[dict[str, Any]]) -> str:
"""Classify a trade into one of ``A1 A2 A3 B C D Other``.
Priority: ``C`` (news) > ``D`` (Mon/Fri) > ``A1/A2/A3/B`` (time bands on
Tue/Wed/Thu) > ``Other``.
"""
if is_in_news_window(d, t, calendar):
return "C"
if day_of_week in ("Mon", "Fri"):
return "D"
if day_of_week in ("Tue", "Wed", "Thu"):
if _in_range(t, time(16, 35), time(17, 0)):
return "A1"
if _in_range(t, time(17, 0), time(18, 0)):
return "A2"
if _in_range(t, time(18, 0), time(19, 0)):
return "A3"
if _in_range(t, time(22, 0), time(22, 45)):
return "B"
return "Other"

View File

@@ -0,0 +1,731 @@
"""Generator pentru data/backtest.xlsx.
5 strategii de management comparate side-by-side pe semnale blackbox:
- TP0 only : 100% close la TP0
- TP1 only : 100% OCO la SL/TP1
- TP2 only : 100% OCO la SL/TP2
- Hybrid + BE : 50% TP0 + mut SL la BE + 50% TP1 (recomandat de trader)
- Hybrid no BE : 50% TP0 + 50% TP1, fără BE (control pentru a izola valoarea BE-ului)
Rulare:
pip install openpyxl
python scripts/generate_template.py
"""
from __future__ import annotations
from datetime import date, time
from pathlib import Path
from openpyxl import Workbook
from openpyxl.chart import LineChart, Reference
from openpyxl.formatting.rule import CellIsRule
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.datavalidation import DataValidation
OUTPUT = Path(__file__).resolve().parent.parent / "data" / "backtest.xlsx"
MAX_ROWS = 500 # rânduri pre-completate cu formule în sheet-ul Trades
# ---------------------------------------------------------------------------
# Styles
# ---------------------------------------------------------------------------
HEADER_FILL = PatternFill("solid", fgColor="1F3864")
HEADER_FONT = Font(name="Calibri", size=11, bold=True, color="FFFFFF")
INPUT_FILL = PatternFill("solid", fgColor="FFF8E1")
DERIVED_FILL = PatternFill("solid", fgColor="E8F1FA")
HIDDEN_FILL = PatternFill("solid", fgColor="F0F0F0")
TITLE_FONT = Font(name="Calibri", size=16, bold=True, color="1F3864")
SUBTITLE_FONT = Font(name="Calibri", size=12, bold=True, color="1F3864")
THIN = Side(border_style="thin", color="BFBFBF")
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
CENTER = Alignment(horizontal="center", vertical="center")
LEFT = Alignment(horizontal="left", vertical="center")
RIGHT = Alignment(horizontal="right", vertical="center")
# ---------------------------------------------------------------------------
# Lists
# ---------------------------------------------------------------------------
STRATEGIES = ["M2D", "EMA cross", "Order block", "Liquidity sweep", "Custom"]
SESSIONS = ["A1", "A2", "A3", "B", "C", "D", "Other"]
INDICATORS = ["DIA", "US30", "SPY", "QQQ", "ES", "NQ"]
TIMEFRAMES = ["1min", "3min", "15min"]
DIRECTIONS = ["Buy", "Sell"]
OUTCOMES = ["SL", "TP0 only", "TP1", "TP2"]
# Cele 5 strategii de management (sufix folosit în numele coloanelor) + label friendly
STRAT_KEYS = ["tp0only", "tp1only", "tp2only", "hybrid_be", "hybrid_nobe"]
STRAT_LABELS = {
"tp0only": "TP0 only",
"tp1only": "TP1 only",
"tp2only": "TP2 only",
"hybrid_be": "Hybrid + BE",
"hybrid_nobe": "Hybrid no BE",
}
# ---------------------------------------------------------------------------
# Trades sheet — schema
# ---------------------------------------------------------------------------
INPUT_HEADERS = [
"#", "Data", "Ora RO", "Zi", "Sesiune",
"Strategie", "Indicator", "TF",
"Direcție", "SL %", "TP0 %", "TP1 %", "TP2 %",
"Outcome", "Notes",
]
DERIVED_HEADERS = (
[f"R_{s}" for s in STRAT_KEYS]
+ [f"$_{s}" for s in STRAT_KEYS]
+ [f"Bal_{s}" for s in STRAT_KEYS]
)
HELPER_HEADERS = (
[f"Win_{s}" for s in STRAT_KEYS]
+ [f"Peak_{s}" for s in STRAT_KEYS]
+ [f"DD_{s}" for s in STRAT_KEYS]
)
TRADES_HEADERS = INPUT_HEADERS + DERIVED_HEADERS + HELPER_HEADERS
# Mapă nume → literă coloană Excel
COL = {name: get_column_letter(i + 1) for i, name in enumerate(TRADES_HEADERS)}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _col_to_int(letter: str) -> int:
n = 0
for ch in letter:
n = n * 26 + (ord(ch) - ord("A") + 1)
return n
# ---------------------------------------------------------------------------
# Config sheet
# ---------------------------------------------------------------------------
def build_config(wb: Workbook) -> None:
ws = wb.create_sheet("Config", 0)
ws.sheet_view.showGridLines = False
ws["A1"] = "Config — editează doar celulele galbene"
ws["A1"].font = TITLE_FONT
ws.merge_cells("A1:C1")
ws["A3"] = "Setting"
ws["B3"] = "Value"
ws["C3"] = "Note"
for c in ("A3", "B3", "C3"):
ws[c].font = HEADER_FONT
ws[c].fill = HEADER_FILL
ws[c].alignment = CENTER
ws["A4"] = "Account Size Start ($)"
ws["B4"] = 10000
ws["C4"] = "Balanța inițială pentru calcule $ și HWM"
ws["A5"] = "Risk per Trade (%)"
ws["B5"] = 1.0
ws["C5"] = "% din account riscat per trade (= -1R)"
ws["A6"] = "Risk per Trade ($)"
ws["B6"] = "=B4*B5/100"
ws["C6"] = "Auto — derivat din B4 și B5"
for r in (4, 5):
ws.cell(row=r, column=2).fill = INPUT_FILL
ws.cell(row=r, column=2).border = BORDER
ws["B6"].fill = DERIVED_FILL
ws["B6"].border = BORDER
ws["B4"].number_format = "$#,##0"
ws["B5"].number_format = '0.0"%"'
ws["B6"].number_format = "$#,##0.00"
# Liste dropdown — coloanele EJ (6 coloane)
list_columns = [
("Strategii", STRATEGIES),
("Sesiuni (auto)", SESSIONS),
("Indicatori", INDICATORS),
("TF", TIMEFRAMES),
("Direcție", DIRECTIONS),
("Outcome", OUTCOMES),
]
for col_idx, (label, values) in enumerate(list_columns, start=5):
cell = ws.cell(row=3, column=col_idx, value=label)
cell.font = HEADER_FONT
cell.fill = HEADER_FILL
cell.alignment = CENTER
for row_idx, v in enumerate(values, start=4):
c = ws.cell(row=row_idx, column=col_idx, value=v)
c.alignment = CENTER
widths = {
"A": 24, "B": 14, "C": 38, "D": 2,
"E": 14, "F": 14, "G": 13, "H": 10, "I": 10, "J": 12,
}
for col, w in widths.items():
ws.column_dimensions[col].width = w
# ---------------------------------------------------------------------------
# Formula builders pentru Trades sheet
# ---------------------------------------------------------------------------
def _f_day(r: int) -> str:
d = f'{COL["Data"]}{r}'
return (
f'=IF({d}="","",'
f'CHOOSE(WEEKDAY({d},2),"Lu","Ma","Mi","Jo","Vi","Sa","Du"))'
)
def _f_session(r: int) -> str:
"""Derivă Sesiunea M2D din Data + Ora RO."""
d = f'{COL["Data"]}{r}'
t = f'{COL["Ora RO"]}{r}'
wd = f"WEEKDAY({d},2)"
mid_week = f"AND({wd}>=2,{wd}<=4)"
return (
f'=IF(OR({d}="",{t}=""),"",'
f"IF(OR({wd}=1,{wd}=5),\"D\","
f'IF(AND({t}>=TIME(15,30,0),{t}<TIME(16,30,0)),"C",'
f'IF(AND({mid_week},{t}>=TIME(16,35,0),{t}<TIME(17,0,0)),"A1",'
f'IF(AND({mid_week},{t}>=TIME(17,0,0),{t}<TIME(18,0,0)),"A2",'
f'IF(AND({mid_week},{t}>=TIME(18,0,0),{t}<TIME(19,0,0)),"A3",'
f'IF(AND({mid_week},{t}>=TIME(22,0,0),{t}<TIME(22,45,0)),"B",'
f'"Other")))))))'
)
def _f_r_tp0only(r: int) -> str:
o = f'{COL["Outcome"]}{r}'
sl = f'{COL["SL %"]}{r}'
tp0 = f'{COL["TP0 %"]}{r}'
return f'=IF({o}="","",IF({o}="SL",-1,{tp0}/{sl}))'
def _f_r_tp1only(r: int) -> str:
o = f'{COL["Outcome"]}{r}'
sl = f'{COL["SL %"]}{r}'
tp1 = f'{COL["TP1 %"]}{r}'
return (
f'=IF({o}="","",'
f'IF(OR({o}="SL",{o}="TP0 only"),-1,{tp1}/{sl}))'
)
def _f_r_tp2only(r: int) -> str:
o = f'{COL["Outcome"]}{r}'
sl = f'{COL["SL %"]}{r}'
tp2 = f'{COL["TP2 %"]}{r}'
return f'=IF({o}="","",IF({o}="TP2",{tp2}/{sl},-1))'
def _f_r_hybrid_be(r: int) -> str:
o = f'{COL["Outcome"]}{r}'
sl = f'{COL["SL %"]}{r}'
tp0 = f'{COL["TP0 %"]}{r}'
tp1 = f'{COL["TP1 %"]}{r}'
return (
f'=IF({o}="","",'
f'IF({o}="SL",-1,'
f'IF({o}="TP0 only",0.5*{tp0}/{sl},'
f'0.5*({tp0}+{tp1})/{sl})))'
)
def _f_r_hybrid_nobe(r: int) -> str:
o = f'{COL["Outcome"]}{r}'
sl = f'{COL["SL %"]}{r}'
tp0 = f'{COL["TP0 %"]}{r}'
tp1 = f'{COL["TP1 %"]}{r}'
return (
f'=IF({o}="","",'
f'IF({o}="SL",-1,'
f'IF({o}="TP0 only",0.5*{tp0}/{sl}-0.5,'
f'0.5*({tp0}+{tp1})/{sl})))'
)
R_FN: dict[str, callable] = {
"tp0only": _f_r_tp0only,
"tp1only": _f_r_tp1only,
"tp2only": _f_r_tp2only,
"hybrid_be": _f_r_hybrid_be,
"hybrid_nobe": _f_r_hybrid_nobe,
}
def _f_dollar(r: int, r_col: str) -> str:
rc = f"{COL[r_col]}{r}"
return f'=IF({rc}="","",{rc}*Config!$B$6)'
def _f_balance(r: int, dollar_col: str) -> str:
dc = COL[dollar_col]
return f'=IF({dc}{r}="","",Config!$B$4 + SUM(${dc}$2:{dc}{r}))'
def _f_win(r: int, r_col: str) -> str:
rc = f"{COL[r_col]}{r}"
return f'=IF({rc}="","",IF({rc}>0,1,0))'
def _f_peak(r: int, balance_col: str, peak_col: str) -> str:
bc = COL[balance_col]
pc = COL[peak_col]
if r == 2:
return f'=IF({bc}{r}="","",{bc}{r})'
return (
f'=IF({bc}{r}="","",'
f'IF({pc}{r-1}="",{bc}{r},MAX({pc}{r-1},{bc}{r})))'
)
def _f_drawdown(r: int, peak_col: str, balance_col: str) -> str:
pc = f"{COL[peak_col]}{r}"
bc = f"{COL[balance_col]}{r}"
return f'=IF({bc}="","",{pc}-{bc})'
# ---------------------------------------------------------------------------
# Trades sheet
# ---------------------------------------------------------------------------
def build_trades(wb: Workbook) -> None:
ws = wb.create_sheet("Trades", 1)
ws.sheet_view.showGridLines = False
ws.freeze_panes = "B2"
# Headers
for col_idx, header in enumerate(TRADES_HEADERS, start=1):
cell = ws.cell(row=1, column=col_idx, value=header)
cell.font = HEADER_FONT
cell.fill = HEADER_FILL
cell.alignment = CENTER
cell.border = BORDER
# Formule pe toate rândurile pre-pregătite
for r in range(2, MAX_ROWS + 2):
ws.cell(row=r, column=1, value="=ROW()-1")
ws[f'{COL["Zi"]}{r}'] = _f_day(r)
ws[f'{COL["Sesiune"]}{r}'] = _f_session(r)
for strat in STRAT_KEYS:
ws[f'{COL[f"R_{strat}"]}{r}'] = R_FN[strat](r)
ws[f'{COL[f"$_{strat}"]}{r}'] = _f_dollar(r, f"R_{strat}")
ws[f'{COL[f"Bal_{strat}"]}{r}'] = _f_balance(r, f"$_{strat}")
ws[f'{COL[f"Win_{strat}"]}{r}'] = _f_win(r, f"R_{strat}")
ws[f'{COL[f"Peak_{strat}"]}{r}'] = _f_peak(
r, f"Bal_{strat}", f"Peak_{strat}"
)
ws[f'{COL[f"DD_{strat}"]}{r}'] = _f_drawdown(
r, f"Peak_{strat}", f"Bal_{strat}"
)
# Sample row 2
ws["B2"] = date(2026, 5, 13)
ws["C2"] = time(17, 33)
ws[f'{COL["Strategie"]}2'] = "M2D"
ws[f'{COL["Indicator"]}2'] = "DIA"
ws[f'{COL["TF"]}2'] = "1min"
ws[f'{COL["Direcție"]}2'] = "Sell"
ws[f'{COL["SL %"]}2'] = 0.30
ws[f'{COL["TP0 %"]}2'] = 0.10
ws[f'{COL["TP1 %"]}2'] = 0.15
ws[f'{COL["TP2 %"]}2'] = 0.30
ws[f'{COL["Outcome"]}2'] = "TP1"
ws[f'{COL["Notes"]}2'] = "Exemplu — șterge când începi"
# Number formats
for col_name in ("SL %", "TP0 %", "TP1 %", "TP2 %"):
for r in range(2, MAX_ROWS + 2):
ws[f"{COL[col_name]}{r}"].number_format = '0.000"%"'
for strat in STRAT_KEYS:
for r in range(2, MAX_ROWS + 2):
ws[f"{COL[f'R_{strat}']}{r}"].number_format = "+0.000;-0.000;0.000"
for prefix in ("$_", "Bal_", "Peak_", "DD_"):
ws[f"{COL[f'{prefix}{strat}']}{r}"].number_format = '"$"#,##0.00'
for r in range(2, MAX_ROWS + 2):
ws[f"B{r}"].number_format = "yyyy-mm-dd"
# Coloring
input_letters = {
COL[n]
for n in (
"Data", "Ora RO", "Strategie", "Indicator", "TF",
"Direcție", "SL %", "TP0 %", "TP1 %", "TP2 %",
"Outcome", "Notes",
)
}
derived_letters = {COL["Zi"], COL["Sesiune"]}
for strat in STRAT_KEYS:
derived_letters.add(COL[f"R_{strat}"])
derived_letters.add(COL[f"$_{strat}"])
derived_letters.add(COL[f"Bal_{strat}"])
helper_letters = set()
for strat in STRAT_KEYS:
for prefix in ("Win_", "Peak_", "DD_"):
helper_letters.add(COL[f"{prefix}{strat}"])
for r in range(2, MAX_ROWS + 2):
for cl in input_letters:
ws[f"{cl}{r}"].fill = INPUT_FILL
for cl in derived_letters:
ws[f"{cl}{r}"].fill = DERIVED_FILL
for cl in helper_letters:
ws[f"{cl}{r}"].fill = HIDDEN_FILL
# Column widths
widths = {
"A": 5, "B": 12, "C": 9, "D": 5, "E": 9,
"F": 12, "G": 11, "H": 8, "I": 9,
"J": 9, "K": 9, "L": 9, "M": 9,
"N": 11, "O": 28,
}
for col, w in widths.items():
ws.column_dimensions[col].width = w
# Derived + helper: width 11
for strat in STRAT_KEYS:
for prefix in ("R_", "$_", "Bal_", "Win_", "Peak_", "DD_"):
ws.column_dimensions[COL[f"{prefix}{strat}"]].width = 11
# Data validation dropdowns
def _add_dv(col_name: str, source: str) -> None:
cl = COL[col_name]
dv = DataValidation(
type="list", formula1=source,
allow_blank=True, showErrorMessage=True,
)
dv.error = "Valoare invalidă — folosește dropdown-ul."
dv.errorTitle = "Input invalid"
dv.add(f"{cl}2:{cl}{MAX_ROWS + 1}")
ws.add_data_validation(dv)
# Config columns: E=Strategii, F=Sesiuni, G=Indicatori, H=TF, I=Direcție, J=Outcome
_add_dv("Strategie", "=Config!$E$4:$E$8")
_add_dv("Indicator", "=Config!$G$4:$G$9")
_add_dv("TF", "=Config!$H$4:$H$6")
_add_dv("Direcție", "=Config!$I$4:$I$5")
_add_dv("Outcome", "=Config!$J$4:$J$7")
# Conditional formatting pe coloanele R (5 strategii)
green_fill = PatternFill("solid", fgColor="C6EFCE")
red_fill = PatternFill("solid", fgColor="FFC7CE")
grey_fill = PatternFill("solid", fgColor="D9D9D9")
for strat in STRAT_KEYS:
cl = COL[f"R_{strat}"]
rng = f"{cl}2:{cl}{MAX_ROWS + 1}"
ws.conditional_formatting.add(
rng, CellIsRule(operator="greaterThan", formula=["0"], fill=green_fill)
)
ws.conditional_formatting.add(
rng, CellIsRule(operator="lessThan", formula=["0"], fill=red_fill)
)
ws.conditional_formatting.add(
rng, CellIsRule(operator="equal", formula=["0"], fill=grey_fill)
)
# ---------------------------------------------------------------------------
# Dashboard sheet
# ---------------------------------------------------------------------------
def _range(col_name: str) -> str:
cl = COL[col_name]
return f"Trades!${cl}$2:${cl}${MAX_ROWS + 1}"
METRIC_HINTS: dict[str, str] = {
"Trades Placed": (
"Câte trade-uri ai logat în total.\n"
"Cu cât N e mai mare, cu atât celelalte metrici sunt mai de încredere.\n"
"Exemplu: la N=10 Win Ratio e zgomot pur, la N=40 începe să aibă semnal, la N=100 e solid."
),
"Wins": (
"Câte trade-uri s-au închis pe plus (R > 0).\n"
"Singur nu spune nimic — privește-l raportat la total (vezi Win Ratio mai jos)."
),
"Win Ratio": (
"Procentul de trade-uri câștigătoare. WR = 60% înseamnă 6 wins din 10 trade-uri.\n"
"Singur NU spune dacă strategia e profitabilă — citește-l împreună cu R:R de pe rândul următor."
),
"Average Win ($)": (
"Câștigul mediu pe trade-urile pozitive.\n"
"Comparat cu Average Loss îți spune cât de mari sunt câștigurile vs pierderile.\n"
"Exemplu: 4 wins de $50 și 2 wins de $80 — Average Win = $60."
),
"Average Loss ($)": (
"Pierderea medie pe trade-urile negative (cifra apare cu minus).\n"
"Cu Risk per Trade fix, ar trebui să fie aproape de 1R în dolari.\n"
"Dacă e mult mai mare decât Risk per Trade, ai SL-uri sărite (slippage, gap-uri)."
),
"Best Trade ($)": (
"Cel mai mare câștig individual.\n"
"Dacă majoritatea profitului total vine dintr-un singur trade outlier, edge-ul e fragil — "
"elimini acel trade și strategia devine pierzătoare."
),
"Worst Trade ($)": (
"Cea mai mare pierdere individuală.\n"
"Ar trebui să fie aproximativ egală cu 1R (Risk per Trade din Config).\n"
"Dacă e semnificativ mai mare, ai depășit risk-ul plănuit — SL ratat, slippage, gap overnight."
),
"Profit Factor": (
"Total bani câștigați împărțit la total bani pierduți (în valoare absolută).\n"
"Sub 1.0 = pierzi pe ansamblu. Peste 1.5 = solid. Peste 2.0 = câștigi de 2× cât pierzi.\n"
"Exemplu: 4 wins de $50 (= $200) + 6 losses de $30 (= $180) — PF = 200÷180 = 1.11, profitabil marginal."
),
"Risk:Reward": (
"De câte ori e mai mare câștigul mediu decât pierderea medie.\n"
"R:R = 2 înseamnă: când câștigi, câștigi $2; când pierzi, pierzi $1.\n"
"Cu R:R mare poți avea Win Ratio mic și tot să faci bani."
),
"Expectancy (R)": (
"Cât bani câștigi în medie pe UN trade (în R; 1R = Risk per Trade, default $100).\n"
"+0.30R = câștigi $30 pe trade. Pe 100 trade-uri: +$3.000.\n"
"0.10R = pierzi $10 pe trade. Pe 100 trade-uri: $1.000.\n"
"Pragul de GO LIVE: +0.20R sau mai mult."
),
"Expectancy ($)": (
"Aceeași expectancy convertită în dolari, folosind Risk per Trade din Config.\n"
"Util ca să vezi cât câștigi în medie pe trade în bani reali, nu doar în R."
),
"Cumulative P&L ($)": (
"Suma profitului și pierderii pe toate trade-urile logate.\n"
"E ce-ai avea în plus (sau minus) față de balanța de start din Config."
),
"HWM Balance ($)": (
"Highest Watermark — cea mai mare balanță atinsă vreodată în jurnal.\n"
"Punct de referință pentru calculul drawdown-ului."
),
"Max Drawdown ($)": (
"Cea mai mare cădere ($) din vârf la fundul ulterior al balanței. Măsoară durerea psihologică maximă.\n"
"Exemplu: ai urcat la $11,500, ai coborât la $9,800 — DD = $1,700, adică 17% din peak.\n"
"Un drawdown mare la backtest e foarte greu de tolerat în live cu bani reali — așteaptă-te să renunți."
),
}
def build_dashboard(wb: Workbook) -> None:
ws = wb.create_sheet("Dashboard", 2)
ws.sheet_view.showGridLines = False
ws["A1"] = "Backtest Dashboard"
ws["A1"].font = TITLE_FONT
ws.merge_cells("A1:G1")
ws["A2"] = (
"Comparație 5 strategii management — pe aceleași semnale blackbox"
)
ws["A2"].font = Font(name="Calibri", size=10, italic=True, color="595959")
ws.merge_cells("A2:G2")
# Row 4: headers (5 columns B-F pentru strategii + G pentru "Cum citesc")
ws["A4"] = "Metric"
strat_cols = {} # strat_key → column letter (B/C/D/E/F)
for i, strat in enumerate(STRAT_KEYS):
letter = get_column_letter(2 + i)
strat_cols[strat] = letter
ws[f"{letter}4"] = STRAT_LABELS[strat]
ws["G4"] = "Cum citesc"
for letter in ["A"] + list(strat_cols.values()) + ["G"]:
c = ws[f"{letter}4"]
c.font = HEADER_FONT
c.fill = HEADER_FILL
c.alignment = CENTER
c.border = BORDER
# Ranges per strategie
R = {s: _range(f"R_{s}") for s in STRAT_KEYS}
D = {s: _range(f"$_{s}") for s in STRAT_KEYS}
W = {s: _range(f"Win_{s}") for s in STRAT_KEYS}
BAL = {s: _range(f"Bal_{s}") for s in STRAT_KEYS}
DD = {s: _range(f"DD_{s}") for s in STRAT_KEYS}
OUTCOME_RANGE = _range("Outcome")
# Metric rows — fiecare metric e un dict cu per-strategy formula + format
metrics: list[tuple[str, callable, str]] = [
# (label, fn(strat_key) -> formula, number_format)
("Trades Placed", lambda s: f'=COUNTA({OUTCOME_RANGE})', "0"),
("Wins", lambda s: f'=COUNTIF({W[s]},1)', "0"),
("Average Win ($)", lambda s: f'=IFERROR(AVERAGEIF({D[s]},">0"),0)', '"$"#,##0.00'),
("Average Loss ($)", lambda s: f'=IFERROR(AVERAGEIF({D[s]},"<0"),0)', '"$"#,##0.00'),
("Best Trade ($)", lambda s: f'=IFERROR(MAX({D[s]}),0)', '"$"#,##0.00'),
("Worst Trade ($)", lambda s: f'=IFERROR(MIN({D[s]}),0)', '"$"#,##0.00'),
("Profit Factor", lambda s: f'=IFERROR(SUMIF({D[s]},">0")/ABS(SUMIF({D[s]},"<0")),0)', "0.00"),
# Win Ratio: depends on Wins + Trades Placed — handled after metrics list (placeholder)
("Win Ratio", lambda s: None, "0.0%"),
# Risk:Reward — placeholder; bazat pe rândurile Avg Win/Loss
("Risk:Reward", lambda s: None, "0.00"),
("Expectancy (R)", lambda s: f'=IFERROR(AVERAGE({R[s]}),0)', "+0.000;-0.000;0.000"),
("Expectancy ($)", lambda s: f'=IFERROR(AVERAGE({D[s]}),0)', '"$"#,##0.00'),
("Cumulative P&L ($)", lambda s: f'=SUM({D[s]})', '"$"#,##0.00'),
# HWM — placeholder cu ref la Trades Placed (row 5)
("HWM Balance ($)", lambda s: None, '"$"#,##0.00'),
("Max Drawdown ($)", lambda s: f'=IFERROR(MAX({DD[s]}),0)', '"$"#,##0.00'),
]
# Determine row indexes pentru formule speciale (depind de poziție)
label_to_row = {label: 5 + idx for idx, (label, _, _) in enumerate(metrics)}
trades_row = label_to_row["Trades Placed"]
wins_row = label_to_row["Wins"]
avg_win_row = label_to_row["Average Win ($)"]
avg_loss_row = label_to_row["Average Loss ($)"]
for idx, (label, fn, fmt) in enumerate(metrics):
r = 5 + idx
ws[f"A{r}"] = label
ws[f"A{r}"].font = Font(name="Calibri", size=11, bold=True)
ws[f"A{r}"].border = BORDER
ws[f"A{r}"].alignment = LEFT
for strat in STRAT_KEYS:
letter = strat_cols[strat]
if label == "Win Ratio":
formula = f"=IFERROR({letter}{wins_row}/{letter}{trades_row},0)"
elif label == "Risk:Reward":
formula = f"=IFERROR({letter}{avg_win_row}/ABS({letter}{avg_loss_row}),0)"
elif label == "HWM Balance ($)":
formula = (
f"=IF({letter}{trades_row}=0,Config!$B$4,MAX({BAL[strat]}))"
)
else:
formula = fn(strat)
cell = ws[f"{letter}{r}"]
cell.value = formula
cell.number_format = fmt
cell.fill = DERIVED_FILL
cell.border = BORDER
cell.alignment = RIGHT
# Coloana G — interpretare narativă + exemplu numeric
hint_cell = ws[f"G{r}"]
hint_cell.value = METRIC_HINTS.get(label, "")
hint_cell.font = Font(name="Calibri", size=10, color="595959")
hint_cell.alignment = Alignment(horizontal="left", vertical="top", wrap_text=True)
hint_cell.border = BORDER
# Helper pentru a emite un block breakdown (per Sesiune / Strategie / etc.)
def _emit_breakdown(
start_row: int, title: str, first_col_label: str,
items: list[str], item_range: str, overlay_strat: str,
) -> int:
ws[f"A{start_row}"] = title
ws[f"A{start_row}"].font = SUBTITLE_FONT
ws.merge_cells(f"A{start_row}:F{start_row}")
headers = [first_col_label, "N", "Wins", "WR", "Expectancy R", "Cum $"]
for col_idx, h in enumerate(headers, start=1):
c = ws.cell(row=start_row + 1, column=col_idx, value=h)
c.font = HEADER_FONT
c.fill = HEADER_FILL
c.alignment = CENTER
c.border = BORDER
for i, item in enumerate(items):
r = start_row + 2 + i
ws[f"A{r}"] = item
ws[f"B{r}"] = f'=COUNTIF({item_range},"{item}")'
ws[f"C{r}"] = f'=COUNTIFS({item_range},"{item}",{W[overlay_strat]},1)'
ws[f"D{r}"] = f"=IFERROR(C{r}/B{r},0)"
ws[f"E{r}"] = (
f'=IFERROR(AVERAGEIFS({R[overlay_strat]},{item_range},"{item}"),0)'
)
ws[f"F{r}"] = f'=SUMIFS({D[overlay_strat]},{item_range},"{item}")'
ws[f"B{r}"].number_format = "0"
ws[f"C{r}"].number_format = "0"
ws[f"D{r}"].number_format = "0.0%"
ws[f"E{r}"].number_format = "+0.000;-0.000;0.000"
ws[f"F{r}"].number_format = '"$"#,##0.00'
for c in ("A", "B", "C", "D", "E", "F"):
ws[f"{c}{r}"].border = BORDER
ws[f"{c}{r}"].alignment = RIGHT if c != "A" else LEFT
return start_row + 2 + len(items)
# Breakdowns — toate folosesc overlay-ul Hybrid+BE (recomandat de trader)
overlay = "hybrid_be"
start = 5 + len(metrics) + 2 # 2 rânduri spațiu după tabelul de metrici
after_sess = _emit_breakdown(
start, "PER SESIUNE (overlay: Hybrid + BE)", "Sesiune",
SESSIONS, _range("Sesiune"), overlay,
)
after_strat = _emit_breakdown(
after_sess + 2, "PER STRATEGIE (overlay: Hybrid + BE)", "Strategie",
STRATEGIES, _range("Strategie"), overlay,
)
after_ind = _emit_breakdown(
after_strat + 2, "PER INDICATOR (overlay: Hybrid + BE)", "Indicator",
INDICATORS, _range("Indicator"), overlay,
)
_emit_breakdown(
after_ind + 2, "PER DIRECȚIE (overlay: Hybrid + BE)", "Direcție",
DIRECTIONS, _range("Direcție"), overlay,
)
# Column widths
widths = {"A": 22, "B": 14, "C": 14, "D": 14, "E": 16, "F": 16, "G": 75}
for col, w in widths.items():
ws.column_dimensions[col].width = w
# Row height pentru rândurile cu hint (cu wrap) — explicații multi-line
for r in range(5, 5 + len(metrics)):
ws.row_dimensions[r].height = 75
# Equity curve chart — 5 linii
chart = LineChart()
chart.title = "Equity Curve — 5 strategii"
chart.style = 12
chart.y_axis.title = "Balance ($)"
chart.x_axis.title = "Trade #"
chart.height = 12
chart.width = 24
data = Reference(
wb["Trades"],
min_col=_col_to_int(COL[f"Bal_{STRAT_KEYS[0]}"]),
max_col=_col_to_int(COL[f"Bal_{STRAT_KEYS[-1]}"]),
min_row=1,
max_row=MAX_ROWS + 1,
)
chart.add_data(data, titles_from_data=True)
cats = Reference(
wb["Trades"], min_col=1, max_col=1,
min_row=2, max_row=MAX_ROWS + 1,
)
chart.set_categories(cats)
ws.add_chart(chart, "H4")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def build_workbook() -> Workbook:
wb = Workbook()
default = wb.active
wb.remove(default)
build_config(wb)
build_trades(wb)
build_dashboard(wb)
wb.active = wb.sheetnames.index("Dashboard")
return wb
def main() -> int:
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
wb = build_workbook()
wb.save(OUTPUT)
print(f"Wrote {OUTPUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,76 +0,0 @@
"""P/L overlays for M2D backtesting.
Two overlays computed from the same trade outcome:
- ``pl_marius``: real overlay used by the trader. 50% closed at TP0 (+0.2 R),
BE move on the remaining half, then close 50% of that at ~TP1 (+0.3 R total
contribution) or at SL/BE depending on outcome. TP1 is treated as the final
exit even when the chart subsequently reaches TP2.
- ``pl_theoretical``: reference 1/3-1/3-1/3 overlay that holds to TP2. Used
as an opportunity-cost benchmark vs. ``pl_marius``.
Returns are expressed in multiples of R (risk per trade). ``None`` from
``pl_marius`` denotes a still-pending trade.
"""
from __future__ import annotations
__all__ = [
"PL_MARIUS_TABLE",
"PL_THEORETICAL_TABLE",
"pl_marius",
"pl_theoretical",
]
PL_MARIUS_TABLE: dict[tuple[str, bool], float | None] = {
("SL", True): -1.0,
("SL", False): -1.0,
("TP0->SL", True): 0.20,
("TP0->SL", False): -0.30,
("TP0->TP1", True): 0.50,
("TP0->TP1", False): 0.50,
("TP0->TP2", True): 0.50,
("TP0->TP2", False): 0.50,
("TP0->pending", True): None,
("TP0->pending", False): None,
("pending", True): None,
("pending", False): None,
}
PL_THEORETICAL_TABLE: dict[str, float] = {
"SL_first": -1.0,
"TP0": 0.133,
"TP1": 0.333,
"TP2": 0.667,
}
_VALID_OUTCOME_PATHS: frozenset[str] = frozenset(
{"SL", "TP0->SL", "TP0->TP1", "TP0->TP2", "TP0->pending", "pending"}
)
def _normalize_outcome_path(outcome_path: str) -> str:
return outcome_path.replace("", "->").replace("", "->")
def pl_marius(outcome_path: str, be_moved: bool) -> float | None:
"""Return the P/L (in R) for the real Marius overlay.
Accepts both ASCII arrow ``"TP0->TP1"`` and unicode arrow ``"TP0→TP1"``.
Returns ``None`` for pending outcomes.
"""
normalized = _normalize_outcome_path(outcome_path)
if normalized not in _VALID_OUTCOME_PATHS:
raise ValueError(f"invalid outcome_path: {outcome_path!r}")
return PL_MARIUS_TABLE[(normalized, be_moved)]
def pl_theoretical(max_reached: str) -> float:
"""Return the P/L (in R) for the theoretical 1/3-1/3-1/3 hold-to-TP2 overlay."""
if max_reached not in PL_THEORETICAL_TABLE:
raise ValueError(f"invalid max_reached: {max_reached!r}")
return PL_THEORETICAL_TABLE[max_reached]

View File

@@ -1,125 +0,0 @@
"""Pydantic schema for the M2D vision-extraction JSON returned by the vision subagent."""
from __future__ import annotations
import re
from datetime import date as date_type, datetime, timezone
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
_DATA_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_ORA_PATTERN = re.compile(r"^\d{2}:\d{2}$")
class M2DExtraction(BaseModel):
model_config = ConfigDict(extra="forbid")
screenshot_file: str
data: str
ora_utc: str
instrument: Literal["DIA", "US30", "other"]
directie: Literal["Buy", "Sell"]
tf_mare: Literal["5min", "15min"]
tf_mic: Literal["1min", "3min"]
calitate: Literal["Clară", "Mai mare ca impuls", "Slabă", "n/a"]
entry: float
sl: float
tp0: float
tp1: float
tp2: float
risc_pct: float
outcome_path: Literal[
"SL", "TP0→SL", "TP0→TP1", "TP0→TP2", "TP0→pending", "pending"
]
max_reached: Literal["SL_first", "TP0", "TP1", "TP2"]
be_moved: bool
confidence: Literal["high", "medium", "low"]
ambiguities: list[str] = Field(default_factory=list)
note: str = ""
@model_validator(mode="after")
def _validate_data_format(self) -> "M2DExtraction":
if not _DATA_PATTERN.match(self.data):
raise ValueError(
f"data must match YYYY-MM-DD, got {self.data!r}"
)
try:
parsed = date_type.fromisoformat(self.data)
except ValueError as exc:
raise ValueError(f"data is not a valid ISO date: {self.data!r}") from exc
today = datetime.now(timezone.utc).date()
if parsed > today:
raise ValueError(
f"data {self.data!r} is in the future (today UTC: {today.isoformat()})"
)
return self
@model_validator(mode="after")
def _validate_ora_utc_format(self) -> "M2DExtraction":
if not _ORA_PATTERN.match(self.ora_utc):
raise ValueError(
f"ora_utc must match HH:MM, got {self.ora_utc!r}"
)
try:
datetime.strptime(self.ora_utc, "%H:%M")
except ValueError as exc:
raise ValueError(
f"ora_utc is not a valid HH:MM time: {self.ora_utc!r}"
) from exc
return self
@model_validator(mode="after")
def _validate_entry_ne_sl(self) -> "M2DExtraction":
if self.entry == self.sl:
raise ValueError("entry must not equal sl (zero risk distance)")
return self
@model_validator(mode="after")
def _validate_tp_ordering(self) -> "M2DExtraction":
if self.directie == "Buy":
if not (self.sl < self.entry < self.tp0 < self.tp1 < self.tp2):
raise ValueError(
"for Buy, required: sl < entry < tp0 < tp1 < tp2 "
f"(got sl={self.sl}, entry={self.entry}, tp0={self.tp0}, "
f"tp1={self.tp1}, tp2={self.tp2})"
)
else:
if not (self.sl > self.entry > self.tp0 > self.tp1 > self.tp2):
raise ValueError(
"for Sell, required: sl > entry > tp0 > tp1 > tp2 "
f"(got sl={self.sl}, entry={self.entry}, tp0={self.tp0}, "
f"tp1={self.tp1}, tp2={self.tp2})"
)
return self
@model_validator(mode="after")
def _validate_outcome_max_consistency(self) -> "M2DExtraction":
op = self.outcome_path
mr = self.max_reached
if op == "SL":
if mr != "SL_first":
raise ValueError(
f"outcome_path='SL' requires max_reached='SL_first', got {mr!r}"
)
elif op.startswith("TP0"):
if mr not in {"TP0", "TP1", "TP2"}:
raise ValueError(
f"outcome_path={op!r} requires max_reached in "
f"{{TP0, TP1, TP2}}, got {mr!r}"
)
# op == "pending" → any max_reached accepted
return self
def parse_extraction(json_str: str) -> M2DExtraction:
"""Parse a JSON string into an M2DExtraction.
Raises pydantic.ValidationError on invalid input.
"""
return M2DExtraction.model_validate_json(json_str)
def parse_extraction_dict(d: dict) -> M2DExtraction:
"""Validate a dict against the M2DExtraction schema."""
return M2DExtraction.model_validate(d)

View File

@@ -1,258 +0,0 @@
"""Tests for scripts/append_row.py."""
from __future__ import annotations
import csv
import json
import sys
from pathlib import Path
import pytest
from pydantic import ValidationError
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scripts.append_row import ( # noqa: E402
CSV_COLUMNS,
VALID_SOURCES,
append_row,
append_row_from_json,
build_row,
read_rows,
)
from scripts.vision_schema import parse_extraction_dict # noqa: E402
REPO_ROOT = Path(__file__).resolve().parent.parent
CALENDAR_PATH = REPO_ROOT / "calendar_evenimente.yaml"
META_PATH = REPO_ROOT / "data" / "_meta.yaml"
# ---------------------------------------------------------------------------
# fixtures / payload helpers
# ---------------------------------------------------------------------------
def _buy_payload(**overrides) -> dict:
# 2026-05-13 14:23 UTC == 17:23 RO (EEST, Wed) → Set A2.
base = {
"screenshot_file": "dia-2026-05-13-1.png",
"data": "2026-05-13",
"ora_utc": "14:23",
"instrument": "DIA",
"directie": "Buy",
"tf_mare": "5min",
"tf_mic": "1min",
"calitate": "Clară",
"entry": 400.0,
"sl": 399.0,
"tp0": 400.5,
"tp1": 401.0,
"tp2": 402.0,
"risc_pct": 0.25,
"outcome_path": "TP0→TP1",
"max_reached": "TP1",
"be_moved": True,
"confidence": "high",
"ambiguities": [],
"note": "",
}
base.update(overrides)
return base
@pytest.fixture
def csv_path(tmp_path: Path) -> Path:
return tmp_path / "trades.csv"
# ---------------------------------------------------------------------------
# build_row — computed fields
# ---------------------------------------------------------------------------
class TestBuildRow:
def setup_method(self) -> None:
import yaml
with META_PATH.open("r", encoding="utf-8") as fh:
self.meta = yaml.safe_load(fh)
from scripts.calendar_parse import load_calendar
self.calendar = load_calendar(CALENDAR_PATH)
def test_happy_path_computed_fields(self) -> None:
extr = parse_extraction_dict(_buy_payload())
row = build_row(extr, "manual", self.meta, self.calendar)
# 14:23 UTC on 2026-05-13 = 17:23 RO (EEST), Wed → A2
assert row["ora_ro"] == "17:23"
assert row["zi"] == "Wed"
assert row["set"] == "A2"
# pl_marius for TP0->TP1 with be_moved=True is +0.50R
assert float(row["pl_marius"]) == pytest.approx(0.50)
# pl_theoretical for max_reached=TP1 is 0.333
assert float(row["pl_theoretical"]) == pytest.approx(0.333)
# version stamps copied from meta
assert row["indicator_version"] == str(self.meta["indicator_version"])
assert row["pl_overlay_version"] == str(self.meta["pl_overlay_version"])
assert row["csv_schema_version"] == str(self.meta["csv_schema_version"])
def test_pending_overlay_is_blank(self) -> None:
extr = parse_extraction_dict(
_buy_payload(outcome_path="pending", max_reached="TP0")
)
row = build_row(extr, "vision", self.meta, self.calendar)
# pl_marius returns None for pending → empty string in CSV
assert row["pl_marius"] == ""
# pl_theoretical always concrete
assert row["pl_theoretical"] != ""
def test_invalid_source_rejected(self) -> None:
extr = parse_extraction_dict(_buy_payload())
with pytest.raises(ValueError):
build_row(extr, "auto_magic", self.meta, self.calendar)
def test_all_valid_sources_accepted(self) -> None:
extr = parse_extraction_dict(_buy_payload())
for s in VALID_SOURCES:
row = build_row(extr, s, self.meta, self.calendar)
assert row["source"] == s
# ---------------------------------------------------------------------------
# append_row — happy path, dedup, atomic writes
# ---------------------------------------------------------------------------
class TestAppendRow:
def test_happy_path_writes_header_and_row(self, csv_path: Path) -> None:
extr = parse_extraction_dict(_buy_payload())
row = append_row(extr, "manual", csv_path, META_PATH, CALENDAR_PATH)
assert csv_path.exists()
with csv_path.open("r", encoding="utf-8", newline="") as fh:
reader = csv.DictReader(fh)
assert reader.fieldnames == list(CSV_COLUMNS)
rows = list(reader)
assert len(rows) == 1
assert rows[0]["screenshot_file"] == row["screenshot_file"]
assert rows[0]["set"] == "A2"
assert rows[0]["source"] == "manual"
def test_two_distinct_rows(self, csv_path: Path) -> None:
e1 = parse_extraction_dict(_buy_payload(screenshot_file="a.png"))
e2 = parse_extraction_dict(_buy_payload(screenshot_file="b.png"))
append_row(e1, "manual", csv_path, META_PATH, CALENDAR_PATH)
append_row(e2, "manual", csv_path, META_PATH, CALENDAR_PATH)
rows = read_rows(csv_path)
assert len(rows) == 2
assert {r["screenshot_file"] for r in rows} == {"a.png", "b.png"}
def test_dedup_raises(self, csv_path: Path) -> None:
extr = parse_extraction_dict(_buy_payload())
append_row(extr, "manual", csv_path, META_PATH, CALENDAR_PATH)
with pytest.raises(ValueError, match="duplicate"):
append_row(extr, "manual", csv_path, META_PATH, CALENDAR_PATH)
# CSV still contains exactly the one row
assert len(read_rows(csv_path)) == 1
def test_dedup_skip(self, csv_path: Path) -> None:
extr = parse_extraction_dict(_buy_payload())
first = append_row(extr, "manual", csv_path, META_PATH, CALENDAR_PATH)
# Mutate the extraction; the existing row should be returned untouched.
extr2 = parse_extraction_dict(_buy_payload(note="changed"))
existing = append_row(
extr2, "manual", csv_path, META_PATH, CALENDAR_PATH, on_duplicate="skip"
)
assert existing["note"] == first["note"] == ""
assert len(read_rows(csv_path)) == 1
def test_calibration_coexistence(self, csv_path: Path) -> None:
"""manual_calibration + vision_calibration on the SAME screenshot must coexist."""
extr = parse_extraction_dict(_buy_payload())
append_row(extr, "manual_calibration", csv_path, META_PATH, CALENDAR_PATH)
# Vision leg may differ slightly — change entry by 0.1, still valid.
extr_vision = parse_extraction_dict(
_buy_payload(entry=400.1, confidence="medium")
)
append_row(
extr_vision, "vision_calibration", csv_path, META_PATH, CALENDAR_PATH
)
rows = read_rows(csv_path)
assert len(rows) == 2
sources = {r["source"] for r in rows}
assert sources == {"manual_calibration", "vision_calibration"}
# Same screenshot, different source ⇒ no dedup collision.
files = {r["screenshot_file"] for r in rows}
assert files == {extr.screenshot_file}
def test_calibration_duplicate_same_source_rejected(
self, csv_path: Path
) -> None:
extr = parse_extraction_dict(_buy_payload())
append_row(extr, "manual_calibration", csv_path, META_PATH, CALENDAR_PATH)
with pytest.raises(ValueError, match="duplicate"):
append_row(
extr, "manual_calibration", csv_path, META_PATH, CALENDAR_PATH
)
# ---------------------------------------------------------------------------
# Cross-field invalid input
# ---------------------------------------------------------------------------
class TestInvalidInput:
def test_buy_with_inverted_tp_rejected_before_append(
self, csv_path: Path
) -> None:
# tp1 < tp0 violates Buy ordering: caught at validation, not by append_row.
with pytest.raises(ValidationError):
parse_extraction_dict(
_buy_payload(tp0=401.0, tp1=400.5, tp2=402.0)
)
assert not csv_path.exists() # nothing written
def test_outcome_path_sl_with_tp1_max_rejected(self, csv_path: Path) -> None:
with pytest.raises(ValidationError):
parse_extraction_dict(
_buy_payload(outcome_path="SL", max_reached="TP1")
)
assert not csv_path.exists()
def test_append_row_from_json_invalid_payload(
self, tmp_path: Path, csv_path: Path
) -> None:
bad = tmp_path / "bad.json"
payload = _buy_payload(directie="Long") # invalid Literal
bad.write_text(json.dumps(payload), encoding="utf-8")
with pytest.raises(ValidationError):
append_row_from_json(
bad, "vision", csv_path, META_PATH, CALENDAR_PATH
)
assert not csv_path.exists()
# ---------------------------------------------------------------------------
# Atomic write: no temp file remains on disk
# ---------------------------------------------------------------------------
class TestAtomicWrite:
def test_no_temp_file_left_behind(self, csv_path: Path) -> None:
extr = parse_extraction_dict(_buy_payload())
append_row(extr, "manual", csv_path, META_PATH, CALENDAR_PATH)
leftovers = [
p for p in csv_path.parent.iterdir() if p.name.endswith(".tmp")
]
assert leftovers == []
def test_append_row_from_json_roundtrip(
self, tmp_path: Path, csv_path: Path
) -> None:
good = tmp_path / "good.json"
good.write_text(json.dumps(_buy_payload()), encoding="utf-8")
row = append_row_from_json(
good, "vision", csv_path, META_PATH, CALENDAR_PATH
)
assert row["source"] == "vision"
assert read_rows(csv_path)[0]["screenshot_file"] == row["screenshot_file"]

View File

@@ -1,88 +0,0 @@
"""Tests for the YAML loader and news-window logic in calendar_parse."""
from __future__ import annotations
import sys
import textwrap
from datetime import date, time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scripts.calendar_parse import ( # noqa: E402
is_in_news_window,
load_calendar,
)
REPO_ROOT = Path(__file__).resolve().parent.parent
CALENDAR_PATH = REPO_ROOT / "calendar_evenimente.yaml"
def test_load_calendar() -> None:
events = load_calendar(CALENDAR_PATH)
assert isinstance(events, list)
assert len(events) > 0
required = {"name", "cadence", "time_ro", "severity", "window_before_min", "window_after_min"}
for ev in events:
missing = required - set(ev.keys())
assert not missing, f"event {ev.get('name')!r} missing fields: {missing}"
def test_load_calendar_bad_version(tmp_path: Path) -> None:
bad = tmp_path / "bad.yaml"
bad.write_text(
textwrap.dedent(
"""
schema_version: 99
events: []
"""
).strip()
+ "\n",
encoding="utf-8",
)
with pytest.raises(ValueError):
load_calendar(bad)
def _scheduled(date_str: str, time_str: str, before: int, after: int, severity: str = "extrem") -> dict:
return {
"name": "Test",
"cadence": "scheduled",
"date": date_str,
"time_ro": time_str,
"severity": severity,
"window_before_min": before,
"window_after_min": after,
}
class TestWindowBoundaries:
def setup_method(self) -> None:
self.cal = [_scheduled("2026-05-06", "15:30", 15, 15)]
self.d = date(2026, 5, 6)
def test_window_inside_boundary(self) -> None:
assert is_in_news_window(self.d, time(15, 14), self.cal) is False # 1 min before lower bound
assert is_in_news_window(self.d, time(15, 15), self.cal) is True # lower bound inclusive
assert is_in_news_window(self.d, time(15, 45), self.cal) is True # upper bound inclusive
def test_window_outside(self) -> None:
assert is_in_news_window(self.d, time(15, 14), self.cal) is False
assert is_in_news_window(self.d, time(15, 46), self.cal) is False
def test_severity_filter_mediu_excluded() -> None:
# JOLTS-like event with severity 'mediu' at 17:00 — even smack on time, no Set C trigger.
cal = [_scheduled("2026-05-06", "17:00", 10, 10, severity="mediu")]
assert is_in_news_window(date(2026, 5, 6), time(17, 0), cal) is False
assert is_in_news_window(date(2026, 5, 6), time(17, 5), cal) is False
def test_fomc_powell_window() -> None:
"""Real FOMC Powell Press Apr from calendar_evenimente.yaml (2026-04-29 21:30 RO, 0/45)."""
cal = load_calendar(CALENDAR_PATH)
assert is_in_news_window(date(2026, 4, 29), time(21, 35), cal) is True
assert is_in_news_window(date(2026, 4, 29), time(22, 16), cal) is False

View File

@@ -1,89 +0,0 @@
"""Tests for scripts/pl_calc.py."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scripts.pl_calc import ( # noqa: E402
PL_MARIUS_TABLE,
PL_THEORETICAL_TABLE,
pl_marius,
pl_theoretical,
)
class TestPlMarius:
def test_sl(self) -> None:
assert pl_marius("SL", be_moved=True) == -1.0
assert pl_marius("SL", be_moved=False) == -1.0
def test_tp0_sl_be_moved(self) -> None:
assert pl_marius("TP0->SL", be_moved=True) == pytest.approx(0.20)
def test_tp0_sl_no_be(self) -> None:
assert pl_marius("TP0->SL", be_moved=False) == pytest.approx(-0.30)
def test_tp0_tp1(self) -> None:
assert pl_marius("TP0->TP1", be_moved=True) == pytest.approx(0.50)
assert pl_marius("TP0->TP1", be_moved=False) == pytest.approx(0.50)
def test_tp0_tp2_closes_at_tp1(self) -> None:
assert pl_marius("TP0->TP2", be_moved=True) == pytest.approx(0.50)
assert pl_marius("TP0->TP2", be_moved=False) == pytest.approx(0.50)
def test_tp0_pending_returns_none(self) -> None:
assert pl_marius("TP0->pending", be_moved=True) is None
assert pl_marius("TP0->pending", be_moved=False) is None
def test_pending_returns_none(self) -> None:
assert pl_marius("pending", be_moved=True) is None
assert pl_marius("pending", be_moved=False) is None
def test_unicode_arrow_accepted(self) -> None:
assert pl_marius("TP0→TP1", be_moved=True) == pytest.approx(0.50)
assert pl_marius("TP0→SL", be_moved=False) == pytest.approx(-0.30)
def test_invalid_outcome_path(self) -> None:
with pytest.raises(ValueError):
pl_marius("nonsense", be_moved=True)
with pytest.raises(ValueError):
pl_marius("TP3", be_moved=False)
with pytest.raises(ValueError):
pl_marius("", be_moved=True)
class TestPlTheoretical:
def test_sl_first(self) -> None:
assert pl_theoretical("SL_first") == -1.0
def test_tp0(self) -> None:
assert pl_theoretical("TP0") == pytest.approx(0.133)
def test_tp1(self) -> None:
assert pl_theoretical("TP1") == pytest.approx(0.333)
def test_tp2(self) -> None:
assert pl_theoretical("TP2") == pytest.approx(0.667)
def test_invalid_max_reached(self) -> None:
with pytest.raises(ValueError):
pl_theoretical("TP3")
with pytest.raises(ValueError):
pl_theoretical("sl_first") # case-sensitive
with pytest.raises(ValueError):
pl_theoretical("")
class TestTables:
def test_marius_table_exported(self) -> None:
assert ("SL", True) in PL_MARIUS_TABLE
assert PL_MARIUS_TABLE[("TP0->TP1", True)] == pytest.approx(0.50)
def test_theoretical_table_exported(self) -> None:
assert PL_THEORETICAL_TABLE["TP2"] == pytest.approx(0.667)
assert PL_THEORETICAL_TABLE["SL_first"] == -1.0

View File

@@ -1,88 +0,0 @@
"""Tests for calc_set + utc_to_ro in calendar_parse."""
from __future__ import annotations
import sys
from datetime import date, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scripts.calendar_parse import ( # noqa: E402
calc_set,
load_calendar,
utc_to_ro,
)
REPO_ROOT = Path(__file__).resolve().parent.parent
CALENDAR_PATH = REPO_ROOT / "calendar_evenimente.yaml"
def _cal():
return load_calendar(CALENDAR_PATH)
# Reference weekdays used below (verified via datetime):
# 2026-05-13 Wed 2026-05-12 Tue 2026-05-14 Thu
# 2026-05-11 Mon 2026-05-15 Fri
# 2026-04-29 Wed (FOMC Powell Press Apr — Set C trigger)
def test_a1_mid() -> None:
assert calc_set(date(2026, 5, 13), time(16, 50), "Wed", _cal()) == "A1"
def test_a1_boundary_low() -> None:
assert calc_set(date(2026, 5, 12), time(16, 35), "Tue", _cal()) == "A1"
def test_a1_boundary_high() -> None:
assert calc_set(date(2026, 5, 14), time(16, 59), "Thu", _cal()) == "A1"
def test_a2_sweet_spot() -> None:
assert calc_set(date(2026, 5, 13), time(17, 30), "Wed", _cal()) == "A2"
def test_a3() -> None:
assert calc_set(date(2026, 5, 12), time(18, 30), "Tue", _cal()) == "A3"
def test_b() -> None:
assert calc_set(date(2026, 5, 14), time(22, 15), "Thu", _cal()) == "B"
def test_c_fomc() -> None:
# 2026-04-29 is Wed; would otherwise hit a time band — but FOMC Powell Press window dominates.
assert calc_set(date(2026, 4, 29), time(21, 35), "Wed", _cal()) == "C"
def test_d_mon() -> None:
assert calc_set(date(2026, 5, 11), time(17, 0), "Mon", _cal()) == "D"
def test_d_fri() -> None:
assert calc_set(date(2026, 5, 15), time(17, 0), "Fri", _cal()) == "D"
def test_other() -> None:
# Tue 13:00 — not Mon/Fri, no news, before any A-band.
assert calc_set(date(2026, 5, 12), time(13, 0), "Tue", _cal()) == "Other"
def test_dst_boundary_oct_2026() -> None:
"""DST ends on Sun 2026-10-25 at 04:00 RO (clocks go back to 03:00).
Just before the shift, 00:30 UTC = 03:30 RO (EEST, UTC+3). The conversion must
pick the pre-shift offset and yield 03:30 — not 02:30 (which would be an
off-by-one-hour bug from naive +2h).
"""
d_ro, t_ro, dow = utc_to_ro("2026-10-25", "00:30")
assert d_ro == date(2026, 10, 25)
assert t_ro == time(3, 30)
assert dow == "Sun"
# After the shift, 01:30 UTC also maps to 03:30 RO (EET, UTC+2) — sanity check.
d_ro2, t_ro2, _ = utc_to_ro("2026-10-25", "01:30")
assert (d_ro2, t_ro2) == (date(2026, 10, 25), time(3, 30))

View File

@@ -1,225 +0,0 @@
"""Tests for scripts.vision_schema.M2DExtraction."""
from __future__ import annotations
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
import pytest
from pydantic import ValidationError
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.vision_schema import ( # noqa: E402
M2DExtraction,
parse_extraction,
parse_extraction_dict,
)
def _buy_payload(**overrides) -> dict:
base = {
"screenshot_file": "dia-1min-example.png",
"data": "2026-05-13",
"ora_utc": "14:23",
"instrument": "DIA",
"directie": "Buy",
"tf_mare": "5min",
"tf_mic": "1min",
"calitate": "Clară",
"entry": 400.0,
"sl": 399.0,
"tp0": 400.5,
"tp1": 401.0,
"tp2": 402.0,
"risc_pct": 0.25,
"outcome_path": "TP0→TP1",
"max_reached": "TP1",
"be_moved": True,
"confidence": "high",
"ambiguities": [],
"note": "",
}
base.update(overrides)
return base
def _sell_payload(**overrides) -> dict:
base = {
"screenshot_file": "dia-sell.png",
"data": "2026-05-13",
"ora_utc": "15:00",
"instrument": "US30",
"directie": "Sell",
"tf_mare": "15min",
"tf_mic": "3min",
"calitate": "Mai mare ca impuls",
"entry": 400.0,
"sl": 401.0,
"tp0": 399.5,
"tp1": 399.0,
"tp2": 398.0,
"risc_pct": 0.3,
"outcome_path": "TP0→TP2",
"max_reached": "TP2",
"be_moved": False,
"confidence": "medium",
"ambiguities": ["entry overlap with wick"],
"note": "nothing",
}
base.update(overrides)
return base
def test_happy_path_buy():
m = parse_extraction_dict(_buy_payload())
assert m.directie == "Buy"
assert m.entry == 400.0
def test_happy_path_sell():
m = parse_extraction_dict(_sell_payload())
assert m.directie == "Sell"
assert m.sl > m.entry > m.tp0 > m.tp1 > m.tp2
def test_parse_extraction_from_json_str():
payload = _buy_payload()
m = parse_extraction(json.dumps(payload))
assert isinstance(m, M2DExtraction)
@pytest.mark.parametrize(
"field,bad_value",
[
("directie", "Long"),
("instrument", "SPY"),
("tf_mare", "30min"),
("tf_mic", "2min"),
("calitate", "Bună"),
("outcome_path", "BE"),
("max_reached", "BE"),
("confidence", "very-high"),
],
)
def test_each_literal_rejection(field, bad_value):
payload = _buy_payload(**{field: bad_value})
with pytest.raises(ValidationError):
parse_extraction_dict(payload)
def test_entry_equals_sl():
with pytest.raises(ValidationError):
parse_extraction_dict(_buy_payload(entry=399.0, sl=399.0))
def test_buy_tp_inverted():
# tp1 < tp0 violates ordering
with pytest.raises(ValidationError):
parse_extraction_dict(_buy_payload(tp0=401.0, tp1=400.5, tp2=402.0))
def test_buy_sl_above_entry_rejected():
with pytest.raises(ValidationError):
parse_extraction_dict(_buy_payload(sl=400.5))
def test_sell_order_correct():
m = parse_extraction_dict(_sell_payload())
assert m.sl > m.entry
assert m.entry > m.tp0
assert m.tp0 > m.tp1 > m.tp2
def test_sell_order_inverted_rejected():
# using Buy-ordering values with directie=Sell
with pytest.raises(ValidationError):
parse_extraction_dict(
_sell_payload(sl=399.0, entry=400.0, tp0=400.5, tp1=401.0, tp2=402.0)
)
def test_data_in_future():
with pytest.raises(ValidationError):
parse_extraction_dict(_buy_payload(data="2099-01-01"))
def test_data_today_ok():
today = datetime.now(timezone.utc).date().isoformat()
m = parse_extraction_dict(_buy_payload(data=today))
assert m.data == today
def test_outcome_path_sl_max_reached_inconsistent():
with pytest.raises(ValidationError):
parse_extraction_dict(
_buy_payload(outcome_path="SL", max_reached="TP1")
)
def test_outcome_path_sl_max_reached_sl_first_ok():
m = parse_extraction_dict(
_buy_payload(outcome_path="SL", max_reached="SL_first")
)
assert m.outcome_path == "SL"
def test_outcome_path_tp0_max_reached_sl_first_rejected():
with pytest.raises(ValidationError):
parse_extraction_dict(
_buy_payload(outcome_path="TP0→TP1", max_reached="SL_first")
)
def test_outcome_path_pending_any_max_reached_ok():
m = parse_extraction_dict(
_buy_payload(outcome_path="pending", max_reached="SL_first")
)
assert m.outcome_path == "pending"
def test_extra_field_forbidden():
payload = _buy_payload()
payload["unexpected_field"] = "x"
with pytest.raises(ValidationError):
parse_extraction_dict(payload)
def test_data_bad_format():
with pytest.raises(ValidationError):
parse_extraction_dict(_buy_payload(data="2026/05/13"))
def test_data_bad_format_short():
with pytest.raises(ValidationError):
parse_extraction_dict(_buy_payload(data="26-05-13"))
def test_ora_utc_bad_format():
with pytest.raises(ValidationError):
parse_extraction_dict(_buy_payload(ora_utc="14:23:00"))
def test_ora_utc_bad_format_no_colon():
with pytest.raises(ValidationError):
parse_extraction_dict(_buy_payload(ora_utc="1423"))
def test_ora_utc_invalid_hour():
with pytest.raises(ValidationError):
parse_extraction_dict(_buy_payload(ora_utc="25:00"))
def test_ambiguities_default_empty():
payload = _buy_payload()
del payload["ambiguities"]
m = parse_extraction_dict(payload)
assert m.ambiguities == []
def test_note_default_empty():
payload = _buy_payload()
del payload["note"]
m = parse_extraction_dict(payload)
assert m.note == ""