scripts: pl_calc, vision_schema, calendar_parse + tests (67 passing)
This commit is contained in:
88
tests/test_calendar_yaml.py
Normal file
88
tests/test_calendar_yaml.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""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
|
||||
89
tests/test_pl_calc.py
Normal file
89
tests/test_pl_calc.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""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
|
||||
88
tests/test_set_calc.py
Normal file
88
tests/test_set_calc.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""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))
|
||||
225
tests/test_vision_schema.py
Normal file
225
tests/test_vision_schema.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""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