Compare commits
10 Commits
ce80151c58
...
0e03d32004
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e03d32004 | ||
|
|
3a176253ab | ||
|
|
017921794e | ||
|
|
a73ec30c13 | ||
|
|
68f14095e1 | ||
|
|
d19e0331d8 | ||
|
|
52ec520f6e | ||
|
|
20e254810e | ||
|
|
34af5b631e | ||
|
|
26d084dc4b |
@@ -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ț ~400–500; `US30` dacă preț ~30000–45000; 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·|entry−sl| de la entry, TP1 ≈ 0.6·|entry−sl|).
|
|
||||||
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
18
.gitignore
vendored
@@ -8,21 +8,9 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
# Data — vision extractions and batch logs (regenerable)
|
# Excel temp/lock files
|
||||||
data/extractions/*.json
|
~$*.xlsx
|
||||||
data/extractions/*.log
|
*.xlsx.bak
|
||||||
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
|
|
||||||
|
|
||||||
# OS / editor
|
# OS / editor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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
70
CLAUDE.md
Normal 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
224
README.md
@@ -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) |
|
| 1min | 1 / 5 min |
|
||||||
| `jurnal.md` | Knowledge file principal — jurnalul de backtest (gol, pregătit cu headers) |
|
| 3min | 3 / 15 min |
|
||||||
| `strategie_M2D.md` | Knowledge file de referință — regulile strategiei |
|
| 15min | 15 / 60 min |
|
||||||
| `calendar_evenimente.md` | Knowledge file de referință — events economice recurente |
|
|
||||||
| `README.md` | Acest fișier — instrucțiuni setup |
|
|
||||||
|
|
||||||
---
|
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
|
Pe baza Data + Ora RO, regulile M2D (vezi `strategie_M2D.md`):
|
||||||
- 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"
|
|
||||||
|
|
||||||
### 2. Setează Custom Instructions
|
| Sesiune | Condiție |
|
||||||
- În proiectul creat, click pe **"Set custom instructions"**
|
|---|---|
|
||||||
- Deschide `0_SYSTEM_PROMPT.md` din acest ZIP
|
| **A1** | 16:35–17:00 RO, Mar/Mie/Joi |
|
||||||
- Copiază TOT textul (fără secțiunea cu titlul "# M2D Backtesting Assistant — System Instructions" dacă vrei)
|
| **A2** | 17:00–18:00 RO, Mar/Mie/Joi (sweet spot) |
|
||||||
- Lipește în câmpul de Custom Instructions
|
| **A3** | 18:00–19:00 RO, Mar/Mie/Joi |
|
||||||
- Save
|
| **B** | 22:00–22:45 RO, Mar/Mie/Joi (Power Hour) |
|
||||||
|
| **C** | 15:30–16: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
|
> Notă: zilele FOMC/NFP/CPI ar trebui marcate ca C, dar formula nu detectează evenimente — marchează manual în Notes dacă e zi news majoră.
|
||||||
- Î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`
|
|
||||||
|
|
||||||
### 4. Test inițial
|
## Configurare
|
||||||
- Î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
|
|
||||||
|
|
||||||
---
|
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
|
||||||
|
|
||||||
```
|
La schimbarea Account Size sau Risk %, toate sumele $ din Trades și Dashboard se recalculează.
|
||||||
1. Deschizi un screenshot cu setup M2D din TradeStation blackbox
|
|
||||||
2. Începi o conversație nouă (sau continui una) în proiectul Claude
|
## Formule R-multiples (referință)
|
||||||
3. Atașezi screenshot-ul + scrii "Backtest"
|
|
||||||
4. Claude îți răspunde cu:
|
`SL_%`, `TP0_%`, `TP1_%`, `TP2_%` sunt distanțe pozitive față de entry, exprimate în procente.
|
||||||
- Rândul de jurnal formatat în markdown
|
|
||||||
- Analiza scurtă (3 puncte cu ✅/❌)
|
Tabelul de mai jos arată R-multiple-ul rezultat pentru fiecare combinație (Outcome × Strategie):
|
||||||
- Întrebarea "Adaug la jurnal.md?"
|
|
||||||
5. Răspunzi "da" → Claude îți dă rândul curat de copy-paste
|
| Outcome | TP0 only | TP1 only | TP2 only | Hybrid + BE | Hybrid no BE |
|
||||||
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
|
| **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
|
## Fișiere
|
||||||
- **Î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:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
"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! 🚀
|
|
||||||
|
|||||||
@@ -1,41 +1,42 @@
|
|||||||
# STOPPING_RULE — M2D Backtesting
|
# STOPPING_RULE — Backtesting jurnal Excel
|
||||||
|
|
||||||
**Versiune**: 1
|
**Versiune**: 2
|
||||||
**Data**: 2026-05-13
|
**Data**: 2026-05-13
|
||||||
**Status**: draft — pentru semnătură Marius
|
**Status**: SIGNED — Marius
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Întrebarea de decis
|
## Î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ă)
|
- **GO LIVE** — pornesc forward paper trading cu 0.25R per trade (validare reală)
|
||||||
- **EXTEND COLLECTION** — mai colectez screenshot-uri, încă nu sunt date suficiente
|
- **EXTEND COLLECTION** — mai colectez trade-uri, încă nu sunt date suficiente
|
||||||
- **ABANDON** — strategia nu are edge măsurabil pe acest Set; renunț la Set sau la întreaga strategie
|
- **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
|
1. **N ≥ 40** trade-uri pe acea combinație (vezi Dashboard → PER SESIUNE / PER INDICATOR)
|
||||||
2. **WR ≥ 55%** (Wilson 95% CI lower bound ≥ 45%)
|
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 overlay-ul `pl_marius` (50% TP0 + BE + close ~TP1)
|
3. **Expectancy ≥ +0.20R** pe strategia aleasă (Hybrid 50/50+BE implicit; alternativ TP1-only OCO sau TP2-only OCO)
|
||||||
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`)
|
|
||||||
|
|
||||||
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:
|
Oricare e suficient:
|
||||||
|
|
||||||
- N ≥ 40 și WR < 45% → edge negativ; ABANDON acest Set
|
- N ≥ 40 și WR < 45% pe toate cele 3 strategii → edge negativ; ABANDON combinația
|
||||||
- N ≥ 40 și Expectancy ≤ −0.10R → ABANDON
|
- N ≥ 40 și Expectancy ≤ −0.10R pe toate cele 3 strategii → ABANDON
|
||||||
- Wilson 95% CI lower bound stabil sub 50% după N ≥ 60 → 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ă să tradez cu 0.25R", NU "edge confirmat statistic". Confirmarea reală vine din forward paper trading.
|
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ă să 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 să 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.05–0.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 că, î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
|
## 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.
|
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ă
|
## 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.
|
||||||
|
|||||||
86
WORKFLOW.md
86
WORKFLOW.md
@@ -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 că 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 să 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 că 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
|
|
||||||
@@ -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ă.
|
|
||||||
@@ -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
BIN
data/backtest.xlsx
Normal file
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 158 KiB |
40
jurnal.md
40
jurnal.md
@@ -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 |
|
|
||||||
|---|----------------|
|
|
||||||
| | |
|
|
||||||
@@ -1,25 +1,11 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "atm-backtesting"
|
name = "atm-backtesting"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "M2D backtesting system — vision extraction + stats"
|
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"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pydantic>=2.5",
|
"openpyxl>=3.1",
|
||||||
"pyyaml>=6.0",
|
|
||||||
"scipy>=1.11",
|
|
||||||
"numpy>=1.26",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[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]
|
[tool.setuptools]
|
||||||
packages = ["scripts"]
|
packages = ["scripts"]
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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"
|
|
||||||
731
scripts/generate_template.py
Normal file
731
scripts/generate_template.py
Normal 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 E–J (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())
|
||||||
@@ -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]
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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))
|
|
||||||
@@ -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 == ""
|
|
||||||
Reference in New Issue
Block a user