From 68f14095e106e151f1b11c118289c45ff6b3b820 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 13 May 2026 13:10:02 +0300 Subject: [PATCH] manual_log: helper Python + simplified /m2d-log (6 fields obligatorii, restul derivate) --- .claude/commands/m2d-log.md | 179 ++++++++++++++++++++++-------------- scripts/manual_log.py | 134 +++++++++++++++++++++++++++ tests/test_manual_log.py | 175 +++++++++++++++++++++++++++++++++++ 3 files changed, 420 insertions(+), 68 deletions(-) create mode 100644 scripts/manual_log.py create mode 100644 tests/test_manual_log.py diff --git a/.claude/commands/m2d-log.md b/.claude/commands/m2d-log.md index a6a0154..a1aec7c 100644 --- a/.claude/commands/m2d-log.md +++ b/.claude/commands/m2d-log.md @@ -1,99 +1,142 @@ --- -description: Adaugă manual un rând în jurnal.csv (source=manual sau manual_calibration). Pentru calibrare P4 sau forward paper. -argument-hint: "[--calibration] " +description: Adaugă rapid un trade în jurnal.csv. 6 câmpuri minim, restul derivate auto. Manual entry rapidă pentru backtest/forward paper. +argument-hint: "[--calibration]" --- -# /m2d-log — manual M2D trade entry +# /m2d-log — quick manual M2D entry -Marius extrage manual TOATE câmpurile trade-ului. Folosit pentru calibration P4 (împreună cu `/backtest --calibration` pe același screenshot) sau ca log direct fără vision. +User-ul (Marius) loghează rapid un trade. 6 câmpuri esențiale obligatorii, restul derivate automat de `scripts/manual_log.py`. ## Workflow -1. **Parse `$ARGUMENTS`** — detectează flag `--calibration` și ``. Dacă `` lipsește, întreabă user-ul. Calculează `basename = basename()` și `basename_no_ext = basename` minus ultima extensie. +1. **Parse `$ARGUMENTS`** — flag `--calibration` produce `source=manual_calibration`; altfel `source=manual`. -2. **Promptează user-ul în română**, pe rând, pentru fiecare câmp din schema `M2DExtraction` (vezi `scripts/vision_schema.py`). Ordinea + opțiuni valide: +2. **Cere user-ului următoarele 6 câmpuri obligatorii** (afișează template-ul de mai jos și roagă să răspundă într-un singur mesaj, format `cheie: valoare`): - - `data` — `YYYY-MM-DD` - - `ora_utc` — `HH:MM` (conversie din RO local: EEST=UTC+3 vară, EET=UTC+2 iarnă; întreabă user-ul direct dacă nu e clar) - - `instrument` — `DIA` / `US30` / `other` - - `directie` — `Buy` / `Sell` - - `tf_mare` — `5min` / `15min` - - `tf_mic` — `1min` / `3min` - - `calitate` — `Clară` / `Mai mare ca impuls` / `Slabă` / `n/a` - - `entry`, `sl`, `tp0`, `tp1`, `tp2` — float-uri - - `risc_pct` — float (ex: `0.12` pentru 0.12%) - - `outcome_path` — `SL` / `TP0→SL` / `TP0→TP1` / `TP0→TP2` / `TP0→pending` / `pending` (UNICODE `→`) - - `max_reached` — `SL_first` / `TP0` / `TP1` / `TP2` - - `be_moved` — `true` / `false` - - `confidence` — default `high` (manual e by definition high) - - `note` — string opțional, default `""` - - `screenshot_file` se setează automat la `basename`; `ambiguities` se setează automat la `[]`. Dacă user-ul dă valoare invalidă, repetă întrebarea. - -3. **Construiește JSON-ul** complet, valid contra `M2DExtraction`. - -4. **Scrie JSON-ul** la `data/extractions/.manual.json` — pretty-print indent 2, UTF-8, newline final. Sufixul `.manual` previne coliziunea cu output-ul vision (`.json`). - -5. **Determină source**: `manual_calibration` dacă `--calibration` e prezent, altfel `manual`. - -6. **Append la CSV**: - - ```bash - python -c "from pathlib import Path; from scripts.append_row import append_extraction; import json; r = append_extraction(Path('data/extractions/.manual.json'), source=''); print(json.dumps(r, default=str))" + ``` + data: 2026-05-13 (format YYYY-MM-DD) + ora: 17:33 (ora RO local, HH:MM — DST gestionat automat) + dir: Sell (Buy sau Sell) + entry: 492.47 (prețul de intrare = close trigger candle) + sl: 492.77 (SL absolute price) + out: TP0→pending (SL | TP0→SL | TP0→TP1 | TP0→TP2 | TP0→pending | pending) ``` - Parsezi răspunsul JSON. + Opționale (utilizatorul le poate omite — defaults aplicate): + ``` + inst: DIA (default DIA — alternative: US30, other) + calitate: Clară (default n/a — Clară | Mai mare ca impuls | Slabă) + tf_mic: 1min (default 1min — alternativ: 3min) + tf_mare: 5min (default 5min — alternativ: 15min) + note: ... (default empty) + ``` -7. **Dacă `status == "ok"`**: +3. **Parsează răspunsul user-ului**. Tolerant la spații în jurul valorilor. Dacă lipsește un câmp obligatoriu → afișează ce lipsește și ceri din nou. + +4. **Construiește dict-ul** apelând helper-ul Python (printr-un singur `python -c`): + + ```bash + python -c " + import json + from scripts.manual_log import build_extraction + d = build_extraction( + data='', + ora_ro='', + directie='', + entry=, + sl=, + outcome_path='', + instrument='', + tf_mare='', + tf_mic='', + calitate='', + note='', + ) + import pathlib + basename_no_ext = d['screenshot_file'].rsplit('.', 1)[0] + p = pathlib.Path(f'data/extractions/{basename_no_ext}.manual.json') + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding='utf-8') + print(json.dumps({'json_path': str(p), 'screenshot_file': d['screenshot_file']}, default=str)) + " + ``` + + Helper-ul calculează automat: + - `ora_utc` din `ora_ro` (DST-aware Europe/Bucharest) + - `tp0` = entry ± 0.4·|entry−sl| (sign în funcție de directie) + - `tp1` = entry ± 0.6·|entry−sl| + - `tp2` = entry ± |entry−sl| (simetric SL) + - `risc_pct` = 100·|entry−sl|/entry + - `max_reached` din `outcome_path` (SL→SL_first, TP0→SL→TP0, TP0→TP1→TP1, ...) + - `be_moved` din `outcome_path` (False pentru SL/pending, True pentru orice TP0→...) + - `screenshot_file` generat dacă nu e prezent: `--.png` + - Cross-field ordering validat (Buy: sl'), source='') + print(json.dumps(r, default=str)) + " + ``` + +6. **Dacă `status == "ok"`**: ```bash python -m scripts.regenerate_md ``` - Apoi afișezi: + Apoi afișează concis: ``` - ✅ Trade adăugat la jurnal. ID: . Set: . P/L Marius: . outcome_path: . + ✅ Trade # adăugat — set=, outcome=, pl_marius=, pl_theoretical= ``` -8. **Dacă `status == "rejected"`**: +7. **Dacă `status == "rejected"`**: ``` - ❌ Trade respins: + ❌ Respins: ``` - NU regenera MD. Dacă `reason` conține "duplicate": - - pentru `--calibration`: spui user-ului că există deja rând `manual_calibration` pentru acest screenshot; nu poți avea două leg-uri manual de calibrare pe același screenshot. - - pentru `source=manual` simplu: user-ul decide dacă suprascrie (atunci șterge manual rândul din `data/jurnal.csv` și re-rulează). + Dacă `reason` conține "duplicate" → trade-ul cu acel `(screenshot_file, source)` există deja. Dacă vrei să-l suprascrii, șterge linia din `data/jurnal.csv` și re-rulează (sau cere user-ului să specifice `note: ` ca să forțeze basename diferit). + + Dacă `reason` conține "validation" → câmpurile au violat constraint-urile pydantic; reîntrebezi user-ul ce să corecteze. + +8. **Errori în parsing user**: dacă user-ul răspunde ambiguu (ex: lipsește `dir`, sau `entry` nu e număr), afișează ce trebuie corectat și revii la step 2 cu valorile parțiale păstrate. ## Reguli -- NU edita CSV direct — folosește `append_extraction`. -- NU regenera MD dacă append-ul a fost respins. +- NU edita CSV direct. +- NU regenera MD pe rejection. +- Helper-ul `build_extraction` ridică `ValueError` dacă: `entry == sl`, `Buy` cu `sl >= entry`, `Sell` cu `sl <= entry`. Propaga eroarea către user cu mesaj clar. -## Output skeleton JSON +## Exemple -```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": "" -} +**Cel mai scurt input valid**: ``` +data: 2026-05-13 +ora: 17:33 +dir: Sell +entry: 492.47 +sl: 492.77 +out: TP0→pending +``` +→ generează screenshot_file=`2026-05-13-dia-1733.png`, calculează ora_utc=14:33, tp0=492.35, tp1=492.29, tp2=492.17, risc_pct=0.0609, max_reached=TP0, be_moved=True, set=A2 (Mie 17:33). + +**Cu calitate și note**: +``` +data: 2026-05-13 +ora: 17:33 +dir: Sell +entry: 492.47 +sl: 492.77 +out: TP0→TP1 +calitate: Clară +note: bună retragere dimineața, news risc zero +``` + +**Calibrare**: `/m2d-log --calibration` → source=manual_calibration. Folosit cu `/backtest --calibration ` pe același screenshot pentru P4 mismatch report. diff --git a/scripts/manual_log.py b/scripts/manual_log.py new file mode 100644 index 0000000..04d2edd --- /dev/null +++ b/scripts/manual_log.py @@ -0,0 +1,134 @@ +"""Helper for manual M2D trade entry — derives full M2DExtraction dict from minimal user inputs. + +User provides 6 required fields: data, ora_ro, directie, entry, sl, outcome_path. +All other fields default or are computed: + - tp0 = entry ± 0.4 × |entry - sl| + - tp1 = entry ± 0.6 × |entry - sl| + - tp2 = entry ± 1.0 × |entry - sl| (symmetric with sl) + - risc_pct = 100 × |entry - sl| / entry + - ora_utc = ora_ro converted via Europe/Bucharest (DST-aware) + - max_reached derived from outcome_path + - be_moved = True if outcome contains TP0 else False + - tf_mare/tf_mic default 5min/1min + - calitate default 'n/a' + - confidence = 'high' (manual entry) + - screenshot_file generated if not provided: --.png +""" + +from __future__ import annotations + +from datetime import date, datetime, time +from typing import Literal +from zoneinfo import ZoneInfo + +RO_TZ = ZoneInfo("Europe/Bucharest") +UTC_TZ = ZoneInfo("UTC") + + +OUTCOME_TO_MAX_REACHED = { + "SL": "SL_first", + "TP0→SL": "TP0", + "TP0→TP1": "TP1", + "TP0→TP2": "TP2", + "TP0→pending": "TP0", + "pending": "SL_first", # placeholder; user can override +} + +OUTCOME_TO_BE_MOVED = { + "SL": False, + "TP0→SL": True, # BE move should have happened; True = rule-enforced + "TP0→TP1": True, + "TP0→TP2": True, + "TP0→pending": True, + "pending": False, +} + + +def ro_to_utc(data_iso: str, ora_ro_str: str) -> str: + """Convert (YYYY-MM-DD, HH:MM RO) -> HH:MM UTC string, DST-aware.""" + d = date.fromisoformat(data_iso) + t = datetime.strptime(ora_ro_str, "%H:%M").time() + dt_ro = datetime.combine(d, t, tzinfo=RO_TZ) + dt_utc = dt_ro.astimezone(UTC_TZ) + return dt_utc.strftime("%H:%M") + + +def build_extraction( + data: str, + ora_ro: str, + directie: Literal["Buy", "Sell"], + entry: float, + sl: float, + outcome_path: Literal["SL", "TP0→SL", "TP0→TP1", "TP0→TP2", "TP0→pending", "pending"], + instrument: Literal["DIA", "US30", "other"] = "DIA", + tf_mare: Literal["5min", "15min"] = "5min", + tf_mic: Literal["1min", "3min"] = "1min", + calitate: Literal["Clară", "Mai mare ca impuls", "Slabă", "n/a"] = "n/a", + max_reached: Literal["SL_first", "TP0", "TP1", "TP2"] | None = None, + be_moved: bool | None = None, + screenshot_file: str | None = None, + note: str = "", +) -> dict: + """Build a M2DExtraction-compatible dict from minimal manual inputs. + + Derived fields: + - ora_utc from ora_ro (DST-aware) + - tp0/tp1/tp2 from entry/sl geometry + - risc_pct from |entry-sl|/entry + - max_reached/be_moved from outcome_path (overridable) + - screenshot_file generated from data+instrument+ora_ro if not provided + + The returned dict satisfies scripts.vision_schema.M2DExtraction. + """ + if entry == sl: + raise ValueError("entry == sl — zero risk distance") + + risk_abs = abs(entry - sl) + risc_pct = round(100 * risk_abs / entry, 4) + + if directie == "Buy": + if sl >= entry: + raise ValueError(f"Buy: sl ({sl}) must be < entry ({entry})") + tp0 = round(entry + 0.4 * risk_abs, 4) + tp1 = round(entry + 0.6 * risk_abs, 4) + tp2 = round(entry + risk_abs, 4) + else: # Sell + if sl <= entry: + raise ValueError(f"Sell: sl ({sl}) must be > entry ({entry})") + tp0 = round(entry - 0.4 * risk_abs, 4) + tp1 = round(entry - 0.6 * risk_abs, 4) + tp2 = round(entry - risk_abs, 4) + + ora_utc = ro_to_utc(data, ora_ro) + + if max_reached is None: + max_reached = OUTCOME_TO_MAX_REACHED[outcome_path] + if be_moved is None: + be_moved = OUTCOME_TO_BE_MOVED[outcome_path] + + if screenshot_file is None: + ora_compact = ora_ro.replace(":", "") + screenshot_file = f"{data}-{instrument.lower()}-{ora_compact}.png" + + return { + "screenshot_file": screenshot_file, + "data": data, + "ora_utc": ora_utc, + "instrument": instrument, + "directie": directie, + "tf_mare": tf_mare, + "tf_mic": tf_mic, + "calitate": calitate, + "entry": round(float(entry), 4), + "sl": round(float(sl), 4), + "tp0": tp0, + "tp1": tp1, + "tp2": tp2, + "risc_pct": risc_pct, + "outcome_path": outcome_path, + "max_reached": max_reached, + "be_moved": be_moved, + "confidence": "high", + "ambiguities": [], + "note": note, + } diff --git a/tests/test_manual_log.py b/tests/test_manual_log.py new file mode 100644 index 0000000..006752f --- /dev/null +++ b/tests/test_manual_log.py @@ -0,0 +1,175 @@ +"""Tests for scripts.manual_log — derivation logic + vision_schema compatibility.""" + +from __future__ import annotations + +import pytest + +from scripts.manual_log import build_extraction, OUTCOME_TO_BE_MOVED, OUTCOME_TO_MAX_REACHED, ro_to_utc +from scripts.vision_schema import M2DExtraction + + +def test_buy_happy_path(): + d = build_extraction( + data="2026-05-13", + ora_ro="17:33", + directie="Buy", + entry=497.42, + sl=496.80, + outcome_path="TP0→TP1", + ) + # Satisfies pydantic + M2DExtraction.model_validate(d) + assert d["directie"] == "Buy" + assert d["entry"] == 497.42 + assert d["sl"] == 496.80 + # tp0 = entry + 0.4 * 0.62 = 497.42 + 0.248 = 497.668 + assert abs(d["tp0"] - 497.668) < 0.01 + assert abs(d["tp1"] - 497.792) < 0.01 + assert abs(d["tp2"] - 498.04) < 0.01 + assert d["max_reached"] == "TP1" + assert d["be_moved"] is True + + +def test_sell_happy_path(): + d = build_extraction( + data="2026-05-13", + ora_ro="17:33", + directie="Sell", + entry=492.47, + sl=492.77, + outcome_path="TP0→TP1", + ) + M2DExtraction.model_validate(d) + assert d["directie"] == "Sell" + # For Sell: tp0 = entry - 0.4 * 0.30 = 492.47 - 0.12 = 492.35 + assert abs(d["tp0"] - 492.35) < 0.01 + assert abs(d["tp1"] - 492.29) < 0.01 + assert abs(d["tp2"] - 492.17) < 0.01 + assert d["sl"] > d["entry"] > d["tp0"] > d["tp1"] > d["tp2"] + + +def test_ora_utc_dst_summer(): + # May = EEST = UTC+3 + d = build_extraction( + data="2026-05-13", ora_ro="17:33", directie="Sell", + entry=492.47, sl=492.77, outcome_path="pending", + ) + assert d["ora_utc"] == "14:33" + + +def test_ora_utc_dst_winter(): + # January = EET = UTC+2 + d = build_extraction( + data="2026-01-15", ora_ro="17:00", directie="Buy", + entry=400, sl=399, outcome_path="pending", + ) + assert d["ora_utc"] == "15:00" + + +def test_outcome_sl_max_reached_consistent(): + d = build_extraction( + data="2026-05-13", ora_ro="17:33", directie="Buy", + entry=400, sl=399, outcome_path="SL", + ) + M2DExtraction.model_validate(d) + assert d["max_reached"] == "SL_first" + assert d["be_moved"] is False + + +def test_outcome_tp0_pending_max_reached_tp0(): + d = build_extraction( + data="2026-05-13", ora_ro="17:33", directie="Buy", + entry=400, sl=399, outcome_path="TP0→pending", + ) + M2DExtraction.model_validate(d) + assert d["max_reached"] == "TP0" + assert d["be_moved"] is True + + +def test_outcome_tp0_sl_be_moved_true(): + d = build_extraction( + data="2026-05-13", ora_ro="17:33", directie="Buy", + entry=400, sl=399, outcome_path="TP0→SL", + ) + M2DExtraction.model_validate(d) + assert d["max_reached"] == "TP0" + assert d["be_moved"] is True # rule-enforced + + +def test_explicit_max_reached_override(): + d = build_extraction( + data="2026-05-13", ora_ro="17:33", directie="Buy", + entry=400, sl=399, outcome_path="pending", + max_reached="TP0", + ) + M2DExtraction.model_validate(d) + assert d["max_reached"] == "TP0" + + +def test_screenshot_file_generated(): + d = build_extraction( + data="2026-05-13", ora_ro="17:33", directie="Sell", + entry=492.47, sl=492.77, outcome_path="pending", + instrument="DIA", + ) + assert d["screenshot_file"] == "2026-05-13-dia-1733.png" + + +def test_screenshot_file_explicit(): + d = build_extraction( + data="2026-05-13", ora_ro="17:33", directie="Sell", + entry=492.47, sl=492.77, outcome_path="pending", + screenshot_file="custom.png", + ) + assert d["screenshot_file"] == "custom.png" + + +def test_risc_pct_computed(): + d = build_extraction( + data="2026-05-13", ora_ro="17:33", directie="Sell", + entry=500.00, sl=501.00, outcome_path="pending", + ) + assert abs(d["risc_pct"] - 0.20) < 0.001 # 1/500 = 0.002 = 0.20% + + +def test_entry_equals_sl_raises(): + with pytest.raises(ValueError, match="zero risk"): + build_extraction( + data="2026-05-13", ora_ro="17:33", directie="Buy", + entry=500, sl=500, outcome_path="pending", + ) + + +def test_buy_inverted_ordering_raises(): + with pytest.raises(ValueError, match="Buy"): + build_extraction( + data="2026-05-13", ora_ro="17:33", directie="Buy", + entry=400, sl=401, outcome_path="pending", # sl > entry for Buy + ) + + +def test_sell_inverted_ordering_raises(): + with pytest.raises(ValueError, match="Sell"): + build_extraction( + data="2026-05-13", ora_ro="17:33", directie="Sell", + entry=400, sl=399, outcome_path="pending", # sl < entry for Sell + ) + + +def test_optional_fields_defaults(): + d = build_extraction( + data="2026-05-13", ora_ro="17:33", directie="Buy", + entry=400, sl=399, outcome_path="pending", + ) + assert d["instrument"] == "DIA" + assert d["tf_mare"] == "5min" + assert d["tf_mic"] == "1min" + assert d["calitate"] == "n/a" + assert d["confidence"] == "high" + assert d["ambiguities"] == [] + assert d["note"] == "" + + +def test_ro_to_utc_helper(): + assert ro_to_utc("2026-05-13", "17:33") == "14:33" + assert ro_to_utc("2026-01-15", "10:00") == "08:00"