pytest-Suite: 13 Tests in ~1 s, mutations-geprueft
Im Projekt sind ueber Monate dutzende Szenario-Tests entstanden ("mit 10
Szenarien getestet", "mit 18 synthetischen Szenarien") — alle als
Wegwerf-Skripte, keiner lief je ein zweites Mal. Bei einem System, das man vor
jeder Aenderung neu starten und live verifizieren muss, ist das der teuerste
Teil der Schleife.
Erfasst sind die beiden juengsten Pfade:
· _check_pending_fill (6 Faelle) — u.a. "fremde Position NICHT taggen" und
"derselbe Fill zaehlt nur einmal"
· Selbst-Kalibrierung (7 Faelle) — Episoden-Dedup, WARTEN re-armt, Auswertung
in beide Richtungen inkl. Broker-Offset, fehlende Bar bleibt offen,
Aggregation
Drei harte Regeln in tests/conftest.py: keine Live-DB (frische Temp-Datei je
Test), kein MT5 / kein laufender Server (gestubbt), kein Netz. Das Schema legen
die ECHTEN Konstruktoren an — HistoryLogger UND CandleLogger, denn candles_m1
gehoert nicht zum History-Schema. Ein handgebautes Test-Schema wuerde
irgendwann vom Produktivstand abweichen.
Warum Temp-DB statt Kopie der Live-DB: am 06.08. haben echte candles_m1-Zeilen
in einem vermeintlich leeren Fenster einen Test verfaelscht — zweimal sah es
nach einem Code-Fehler aus, es waren Testfehler.
MUTATIONS-GEPRUEFT, weil eine Suite die immer gruen ist wertlos waere: eine
absichtlich eingebaute "fremde Position wird doch getaggt"-Regression wird
gefangen. Nebenbefund: der Fill-Dedup ist DOPPELT gesichert (_pending_tagged
und der pop aus _pending_tickets) — nur eines zu entfernen faellt nicht auf,
beides zusammen bricht sofort zwei Tests. Gewollte Redundanz, kein
ungetesteter Zweig.
tests_rec_outcomes.py (Wegwerf-Fassung von gestern) und die verwaiste
test_pbreak.db entfernt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
79f3e8efe1
commit
9887e3d5da
@@ -259,6 +259,33 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche:
|
||||
Anteil, WR-Verfall, PF<1, Einzelverlust). **Wöchentlich** laufen lassen (B4/B5).
|
||||
- **Vor „fertig":** immer `python -m py_compile <datei>`; JS grob via Klammern-
|
||||
Balance prüfen (kein node im Env).
|
||||
- **⚠⚠ TEST-SUITE (`pytest`, `tests/`, seit 2026-08-07) — 13 Tests, ~1 s.**
|
||||
Im Projekt sind über Monate **dutzende Szenario-Tests** entstanden („mit 10
|
||||
Szenarien getestet", „mit 18 synthetischen Szenarien"), alle als
|
||||
Wegwerf-Skripte. **Keiner davon lief je ein zweites Mal.** Bei einem System, das
|
||||
man vor jeder Änderung neu starten und live verifizieren muss, ist das der
|
||||
teuerste Teil der Schleife.
|
||||
Aufruf: **`python -m pytest`** (Konfiguration in `pytest.ini`).
|
||||
Erfasst sind zunächst die beiden jüngsten Pfade: **`_check_pending_fill`**
|
||||
(6 Fälle, u. a. „fremde Position NICHT taggen" und „derselbe Fill zählt nur
|
||||
einmal") und die **Selbst-Kalibrierung** (7 Fälle: Episoden-Dedup, WARTEN
|
||||
re-armt, Auswertung in beide Richtungen inkl. Broker-Offset, fehlende Bar bleibt
|
||||
offen, Aggregation).
|
||||
⚠ **Drei harte Regeln in `tests/conftest.py`**, damit die Suite jederzeit
|
||||
gefahrlos läuft: **keine Live-DB** (jeder Test bekommt eine frische Temp-Datei),
|
||||
**kein MT5, kein laufender Server** (alles Broker-Nahe gestubbt), **kein Netz**.
|
||||
⚠ Das Schema wird über die **echten** Konstruktoren angelegt — `HistoryLogger`
|
||||
UND `CandleLogger` (`candles_m1` gehört nicht zum History-Schema). Ein von Hand
|
||||
nachgebautes Test-Schema würde irgendwann vom Produktivstand abweichen.
|
||||
⚠ **Warum die Temp-DB und nicht eine Kopie der Live-DB:** am 06.08. haben ECHTE
|
||||
`candles_m1`-Zeilen in einem vermeintlich leeren Fenster einen Test verfälscht —
|
||||
zweimal hintereinander sah es nach einem Code-Fehler aus, es waren Testfehler.
|
||||
✅ **Mutations-geprüft** (eine Suite, die immer grün ist, ist wertlos): eine
|
||||
absichtlich eingebaute „fremde Position wird doch getaggt"-Regression wird
|
||||
gefangen. ⚠ Nebenbefund dabei: der Fill-Dedup ist **doppelt** gesichert
|
||||
(`_pending_tagged` UND der `pop` aus `_pending_tickets`) — nur eines von beiden
|
||||
zu entfernen fällt nicht auf, beides zusammen bricht sofort zwei Tests. Das ist
|
||||
gewollte Redundanz, kein ungetesteter Zweig.
|
||||
- **⚠⚠ KONFIDENZINTERVALLE statt Bauchgefühl (`backtest_cost_gate.ci95`, seit
|
||||
2026-08-07).** Das Projekt entschied bis dahin über **Punktschätzungen** plus die
|
||||
2-Stichproben-Regel; ob ein ØR von +0,12 bei n=30 überhaupt von null zu
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = -q --strict-markers
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Gemeinsame Fixtures für die Szenario-Tests.
|
||||
|
||||
⚠⚠ DREI HARTE REGELN, damit diese Suite jederzeit gefahrlos läuft:
|
||||
1. **Nie die Live-DB anfassen.** Jeder Test bekommt eine frische Temp-Datei;
|
||||
`HistoryLogger.__init__` ruft `_init_db()` und legt das komplette Schema an.
|
||||
Die 15,7-MB-Live-DB zu kopieren wäre nicht nur langsam, sondern auch
|
||||
unzuverlässig — echte Zeilen haben mir am 06.08. einen Test verfälscht
|
||||
(echte `candles_m1` im vermeintlich leeren Fenster).
|
||||
2. **Kein MT5, kein laufender Server.** Alles, was den Broker berührt, wird
|
||||
gestubbt. Die Tests prüfen die LOGIK, nicht die Anbindung.
|
||||
3. **Keine Netzwerkzugriffe.**
|
||||
|
||||
Die Engine wird über `__new__` ohne `__init__` erzeugt: der echte Konstruktor
|
||||
baut MT5-Verbindung, Threads und Config auf. Für die geprüften Methoden reichen
|
||||
wenige Attribute — was fehlt, fällt im Test sofort als AttributeError auf und ist
|
||||
damit dokumentiert, statt still zu passieren.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from core.candle_logger import CandleLogger # noqa: E402
|
||||
from core.engine import TradingEngine # noqa: E402
|
||||
from core.history import HistoryLogger # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
"""Frische, vollständig angelegte Test-Datenbank.
|
||||
|
||||
⚠ `candles_m1` gehört NICHT zum `HistoryLogger`-Schema, sondern wird vom
|
||||
`CandleLogger` angelegt (`core/candle_logger.py`). Beide werden hier über
|
||||
ihre ECHTEN Konstruktoren aufgerufen — ein von Hand nachgebautes Test-Schema
|
||||
würde irgendwann vom Produktivstand abweichen, und der Test prüfte dann etwas
|
||||
anderes als das, was live läuft.
|
||||
"""
|
||||
p = tmp_path / "test.db"
|
||||
HistoryLogger(p) # legt trades, recommendations, rec_outcomes, …
|
||||
CandleLogger(str(p)) # legt candles_m1
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def history(db):
|
||||
return HistoryLogger(db)
|
||||
|
||||
|
||||
class FakeTrader:
|
||||
"""Minimaler Trader-Ersatz: liefert genau den Snapshot, den der Test setzt."""
|
||||
|
||||
def __init__(self):
|
||||
self.snap: dict = {}
|
||||
self.offset = 3 * 3600
|
||||
|
||||
def snapshot(self):
|
||||
return self.snap
|
||||
|
||||
def _broker_offset_s(self, sym=None):
|
||||
return self.offset
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(history):
|
||||
"""Nackte Engine ohne `__init__` — nur die Attribute, die geprüft werden."""
|
||||
e = TradingEngine.__new__(TradingEngine)
|
||||
e.history = history
|
||||
e.trader = FakeTrader()
|
||||
e.data = types.SimpleNamespace(symbol="XTIUSD")
|
||||
e.cfg = {"telegram": {"enabled": "false"}}
|
||||
# Zustand der geprüften Pfade
|
||||
e._pending_tickets = {}
|
||||
e._pending_tagged = set()
|
||||
e._bot_open_ticket = None
|
||||
e._bot_open_source = None
|
||||
e._squeeze_entry_count = 0
|
||||
e._signal_entry_count = 0
|
||||
e._rec_epi_sig = None
|
||||
e._rec_eval_ts = 0.0
|
||||
return e
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_umleiten(db, monkeypatch):
|
||||
"""Leitet das modulweite `HISTORY_DB_FILE` der Engine auf die Test-DB um.
|
||||
|
||||
⚠ Nötig, weil `_evaluate_rec_outcomes` eine EIGENE sqlite3-Verbindung öffnet
|
||||
(bewusst — die Auswertung läuft außerhalb des HistoryLogger-Locks).
|
||||
"""
|
||||
import core.engine as CE
|
||||
monkeypatch.setattr(CE, "HISTORY_DB_FILE", str(db))
|
||||
return db
|
||||
@@ -0,0 +1,74 @@
|
||||
"""`engine._check_pending_fill` — ordnet eine gefüllte Stop-Order dem Bot zu.
|
||||
|
||||
HINTERGRUND (2026-08-06): eine ruhende Stop-Order füllt IM BROKER, nicht über
|
||||
`engine._open`. `trader._refresh_locked` adoptiert die Position dann per
|
||||
magic-match und loggt sie OHNE `setup` — vier reale Squeeze-Trades waren dadurch
|
||||
von manuellen nicht unterscheidbar, und B4-Monitor, `squeeze_b5` sowie
|
||||
`squeeze_entry_gap` zählten **null**. Deployment-Drift Fall 8: nicht die Strategie
|
||||
driftete, sondern ihre Beobachtbarkeit.
|
||||
|
||||
Die Zuordnung ist eindeutig, weil bei einem Pending-Fill die POSITIONS-Nummer
|
||||
identisch mit der ORDER-Nummer ist (an allen vier Broker-Datensätzen verifiziert).
|
||||
"""
|
||||
|
||||
|
||||
def test_squeeze_fill_wird_getaggt(engine):
|
||||
engine._pending_tickets = {49483981: "squeeze"}
|
||||
engine.trader.snap = {"ticket": 49483981, "order_type": 1,
|
||||
"entry_price": 75.127}
|
||||
engine._check_pending_fill()
|
||||
|
||||
assert engine._squeeze_entry_count == 1
|
||||
assert engine._bot_open_ticket == 49483981
|
||||
assert engine._bot_open_source == "auto_squeeze"
|
||||
assert 49483981 not in engine._pending_tickets # Merker verbraucht
|
||||
|
||||
|
||||
def test_signal_fill_long(engine):
|
||||
engine._pending_tickets = {777: "signal"}
|
||||
engine.trader.snap = {"ticket": 777, "order_type": 0, "entry_price": 75.2}
|
||||
engine._check_pending_fill()
|
||||
|
||||
assert engine._signal_entry_count == 1
|
||||
assert engine._squeeze_entry_count == 0
|
||||
assert engine._bot_open_source == "auto_signal"
|
||||
|
||||
|
||||
def test_fremde_position_wird_nicht_getaggt(engine):
|
||||
"""Der gefährliche Fall: ein manueller Trade darf NICHT als Bot-Trade gelten —
|
||||
sonst verfälscht er genau die Monitore, die der Fix reparieren soll."""
|
||||
engine._pending_tickets = {111: "squeeze"}
|
||||
engine.trader.snap = {"ticket": 999, "order_type": 0, "entry_price": 75.0}
|
||||
engine._check_pending_fill()
|
||||
|
||||
assert engine._squeeze_entry_count == 0
|
||||
assert engine._bot_open_ticket is None
|
||||
assert engine._pending_tickets == {111: "squeeze"} # bleibt liegen
|
||||
|
||||
|
||||
def test_derselbe_fill_zaehlt_nur_einmal(engine):
|
||||
"""Der `_pos_loop` läuft im Sekundentakt — ohne Dedup zählte ein Trade
|
||||
hundertfach und der B4-Monitor wäre wertlos."""
|
||||
engine._pending_tickets = {222: "squeeze"}
|
||||
engine.trader.snap = {"ticket": 222, "order_type": 1, "entry_price": 74.9}
|
||||
for _ in range(5):
|
||||
engine._check_pending_fill()
|
||||
|
||||
assert engine._squeeze_entry_count == 1
|
||||
|
||||
|
||||
def test_flat_ist_ein_no_op(engine):
|
||||
engine._pending_tickets = {333: "squeeze"}
|
||||
engine.trader.snap = {"ticket": None}
|
||||
engine._check_pending_fill()
|
||||
|
||||
assert engine._squeeze_entry_count == 0
|
||||
assert engine._pending_tickets == {333: "squeeze"}
|
||||
|
||||
|
||||
def test_ohne_pending_merker_sofort_raus(engine):
|
||||
"""Der häufigste Fall — es darf keine DB-Arbeit anfallen."""
|
||||
engine.trader.snap = {"ticket": 444, "order_type": 0}
|
||||
engine._check_pending_fill()
|
||||
|
||||
assert engine._bot_open_ticket is None
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Selbst-Kalibrierung: Episoden-Logging und Auswertung der Empfehlungen.
|
||||
|
||||
HINTERGRUND (2026-08-06): die Qualität der Gesamtempfehlung war unsichtbar und
|
||||
musste auf Nachfrage rückwirkend gemessen werden (48–51 % Treffer über 1.071
|
||||
Episoden). `rec_outcomes` schließt die Lücke — dasselbe Muster wie
|
||||
`pbreak_predictions`, das den Ausfall des P(break)-Modells LIVE aufgedeckt hat.
|
||||
|
||||
⚠ Die zwei Fehlschläge beim ersten Schreiben dieser Tests waren TESTFEHLER, keine
|
||||
Code-Fehler: die Test-Kerzen des Vorabschnitts lagen im Fenster des nächsten, und
|
||||
die damals kopierte Live-DB enthielt ECHTE `candles_m1` im vermeintlich leeren
|
||||
Fenster. Beides ist hier konstruktiv ausgeschlossen — jeder Test bekommt eine
|
||||
frische, leere DB (s. `conftest.py`).
|
||||
"""
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
OFF = 3 * 3600 # candles_m1.time ist ROHE Broker-Zeit (UTC+3)
|
||||
|
||||
|
||||
def _episode(engine, sig):
|
||||
"""Der Episoden-Teil aus `_run_analysis`, isoliert nachgestellt."""
|
||||
if sig in ("LONG", "SHORT") and sig != engine._rec_epi_sig:
|
||||
engine.history.log_rec_outcome(signal=sig, conf_pct=70, tf="M5",
|
||||
price=75.0, atr=0.2)
|
||||
if sig in ("LONG", "SHORT", "WARTEN"):
|
||||
engine._rec_epi_sig = sig if sig != "WARTEN" else None
|
||||
|
||||
|
||||
def _zeilen(db):
|
||||
with sqlite3.connect(db) as c:
|
||||
return c.execute("SELECT COUNT(*) FROM rec_outcomes").fetchone()[0]
|
||||
|
||||
|
||||
# ── Episoden-Bildung ─────────────────────────────────────────────────────
|
||||
def test_gleiche_richtung_erzeugt_eine_zeile(engine, db):
|
||||
"""⚠ Der Kern: die Empfehlung wird ~1x/Minute geloggt. Wer die Zeilen zählt,
|
||||
hält 20.000 Fälle für eine Stichprobe, obwohl es 1.000 sind."""
|
||||
for _ in range(5):
|
||||
_episode(engine, "LONG")
|
||||
assert _zeilen(db) == 1
|
||||
|
||||
|
||||
def test_flip_ist_eine_neue_episode(engine, db):
|
||||
for _ in range(3):
|
||||
_episode(engine, "LONG")
|
||||
for _ in range(3):
|
||||
_episode(engine, "SHORT")
|
||||
assert _zeilen(db) == 2
|
||||
|
||||
|
||||
def test_warten_re_armt(engine, db):
|
||||
_episode(engine, "SHORT")
|
||||
_episode(engine, "WARTEN")
|
||||
_episode(engine, "WARTEN")
|
||||
assert _zeilen(db) == 1 # WARTEN selbst erzeugt nichts
|
||||
_episode(engine, "SHORT")
|
||||
assert _zeilen(db) == 2 # danach ist SHORT wieder neu
|
||||
|
||||
|
||||
# ── Auswertung gegen candles_m1 ──────────────────────────────────────────
|
||||
def _setze(db, *, ts, signal, price, atr, kerzen):
|
||||
with sqlite3.connect(db) as c:
|
||||
c.execute("INSERT INTO rec_outcomes (ts,signal,conf_pct,tf,price,atr) "
|
||||
"VALUES (?,?,?,?,?,?)", (ts, signal, 70, "M5", price, atr))
|
||||
for minuten, kurs in kerzen:
|
||||
c.execute("INSERT OR REPLACE INTO candles_m1 "
|
||||
"(time,o,h,l,c,spread,tick_volume,symbol) "
|
||||
"VALUES (?,?,?,?,?,?,?,?)",
|
||||
(ts + minuten * 60 + OFF, kurs, kurs, kurs, kurs,
|
||||
20, 1, "TESTSYM"))
|
||||
c.commit()
|
||||
|
||||
|
||||
def _ergebnis(db):
|
||||
with sqlite3.connect(db) as c:
|
||||
return c.execute("SELECT ret30,hit30,ret60,hit60,done "
|
||||
"FROM rec_outcomes").fetchone()
|
||||
|
||||
|
||||
def test_long_auswertung_beide_horizonte(engine, db_umleiten):
|
||||
db = db_umleiten
|
||||
t0 = int(time.time()) - 7200 # beide Horizonte fällig
|
||||
_setze(db, ts=t0, signal="LONG", price=75.00, atr=0.20,
|
||||
kerzen=((30, 75.10), (60, 74.90)))
|
||||
engine._evaluate_rec_outcomes()
|
||||
|
||||
ret30, hit30, ret60, hit60, done = _ergebnis(db)
|
||||
assert round(ret30, 3) == 0.5 and hit30 == 1 # +0,10 / 0,20 ATR
|
||||
assert round(ret60, 3) == -0.5 and hit60 == 0
|
||||
assert done == 1
|
||||
|
||||
|
||||
def test_short_ist_spiegelbildlich(engine, db_umleiten):
|
||||
db = db_umleiten
|
||||
t0 = int(time.time()) - 7200
|
||||
_setze(db, ts=t0, signal="SHORT", price=75.00, atr=0.20,
|
||||
kerzen=((30, 74.90), (60, 74.90)))
|
||||
engine._evaluate_rec_outcomes()
|
||||
|
||||
ret30, hit30, *_ = _ergebnis(db)
|
||||
assert round(ret30, 3) == 0.5 and hit30 == 1 # fallender Kurs = Treffer
|
||||
|
||||
|
||||
def test_fehlende_bar_bleibt_offen(engine, db_umleiten):
|
||||
"""⚠ Lieber unbewertet als falsch bewertet: fehlt die Bar (Wochenende,
|
||||
Broker-Ausfall), darf die Zeile NICHT mit einem Nachbarwert gefüllt werden."""
|
||||
db = db_umleiten
|
||||
t0 = int(time.time()) - 7200
|
||||
_setze(db, ts=t0, signal="SHORT", price=75.00, atr=0.20,
|
||||
kerzen=((30, 74.90),)) # für 60 min bewusst keine
|
||||
engine._evaluate_rec_outcomes()
|
||||
|
||||
ret30, hit30, ret60, _, done = _ergebnis(db)
|
||||
assert round(ret30, 3) == 0.5 and hit30 == 1
|
||||
assert ret60 is None
|
||||
assert not done
|
||||
|
||||
|
||||
# ── Aggregation ──────────────────────────────────────────────────────────
|
||||
def test_rec_accuracy_rechnet_richtig(engine, db, history):
|
||||
with sqlite3.connect(db) as c:
|
||||
for ret, hit in ((0.5, 1), (0.3, 1), (-0.4, 0), (-0.2, 0)):
|
||||
c.execute("INSERT INTO rec_outcomes (ts,signal,conf_pct,tf,price,"
|
||||
"atr,ret30,hit30,ret60,hit60,done) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,1)",
|
||||
(int(time.time()), "LONG", 70, "M5", 75.0, 0.2,
|
||||
ret, hit, ret, hit))
|
||||
c.commit()
|
||||
|
||||
a = history.rec_accuracy(200)
|
||||
assert a["n"] == 4
|
||||
assert a["wr30"] == 50.0 # ⚠ gegen 50 % lesen, nicht gegen 0
|
||||
assert a["avg30"] == 0.05
|
||||
@@ -1,163 +0,0 @@
|
||||
"""Szenario-Test der Selbst-Kalibrierung (rec_outcomes) — ohne MT5, auf DB-Kopie.
|
||||
|
||||
Geprueft werden die Faelle, die live schiefgehen koennen:
|
||||
1) Episode wird EINMAL geloggt, nicht je Minute
|
||||
2) WARTEN re-armt → die naechste Richtung ist eine neue Episode
|
||||
3) Auswertung rechnet den Forward-Return korrekt (inkl. Broker-Offset)
|
||||
4) fehlende M1-Bars → Zeile bleibt offen statt falsch bewertet
|
||||
5) rec_accuracy aggregiert Trefferquote und Ø-Return richtig
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
|
||||
sys.path.insert(0, r"c:\Users\ah\Downloads\tradingrobot")
|
||||
|
||||
from core.engine import TradingEngine
|
||||
from core.history import HistoryLogger
|
||||
|
||||
QUELL = r"c:\Users\ah\Downloads\tradingrobot\oil_widget_history.db"
|
||||
TMP = os.path.join(tempfile.mkdtemp(), "t.db")
|
||||
shutil.copy(QUELL, TMP)
|
||||
con = sqlite3.connect(TMP)
|
||||
con.execute("DELETE FROM rec_outcomes")
|
||||
con.commit()
|
||||
|
||||
h = HistoryLogger(TMP)
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruef(lbl, ist, soll):
|
||||
ok = ist == soll
|
||||
if not ok:
|
||||
fehler.append(lbl)
|
||||
print(f" {'OK ' if ok else 'FEHLER'} {lbl:<50} ist={ist!r:<14} soll={soll!r}")
|
||||
|
||||
|
||||
print("=" * 86)
|
||||
print(" 1) Episoden-Logging: eine Zeile je Richtungswechsel, nicht je Minute")
|
||||
print("=" * 86)
|
||||
|
||||
|
||||
class E:
|
||||
"""Nur der Episoden-Teil aus `_run_analysis`, isoliert nachgestellt."""
|
||||
def __init__(self):
|
||||
self._rec_epi_sig = None
|
||||
self.history = h
|
||||
|
||||
def tick(self, sig):
|
||||
if sig in ("LONG", "SHORT") and sig != self._rec_epi_sig:
|
||||
self.history.log_rec_outcome(signal=sig, conf_pct=70, tf="M5",
|
||||
price=75.0, atr=0.2)
|
||||
if sig in ("LONG", "SHORT", "WARTEN"):
|
||||
self._rec_epi_sig = sig if sig != "WARTEN" else None
|
||||
|
||||
|
||||
e = E()
|
||||
for s in ["LONG"] * 5: # dieselbe Episode, 5 Minuten lang
|
||||
e.tick(s)
|
||||
pruef("5x LONG hintereinander → 1 Zeile",
|
||||
con.execute("SELECT COUNT(*) FROM rec_outcomes").fetchone()[0], 1)
|
||||
for s in ["SHORT"] * 3: # Flip = neue Episode
|
||||
e.tick(s)
|
||||
pruef("Flip auf SHORT → 2 Zeilen",
|
||||
con.execute("SELECT COUNT(*) FROM rec_outcomes").fetchone()[0], 2)
|
||||
e.tick("WARTEN"); e.tick("WARTEN")
|
||||
pruef("WARTEN erzeugt keine Zeile",
|
||||
con.execute("SELECT COUNT(*) FROM rec_outcomes").fetchone()[0], 2)
|
||||
e.tick("SHORT")
|
||||
pruef("nach WARTEN ist SHORT eine NEUE Episode",
|
||||
con.execute("SELECT COUNT(*) FROM rec_outcomes").fetchone()[0], 3)
|
||||
|
||||
print()
|
||||
print("=" * 86)
|
||||
print(" 2) Auswertung gegen candles_m1 (mit Broker-Offset)")
|
||||
print("=" * 86)
|
||||
con.execute("DELETE FROM rec_outcomes")
|
||||
con.execute("DELETE FROM candles_m1 WHERE symbol='TESTSYM'")
|
||||
OFF = 3 * 3600
|
||||
t0 = int(time.time()) - 7200 # vor 2 h → beide Horizonte faellig
|
||||
# LONG @ 75.00, ATR 0.20; nach 30 min steht 75.10 (+0,5 ATR), nach 60 min 74.90
|
||||
con.execute("INSERT INTO rec_outcomes (ts,signal,conf_pct,tf,price,atr) "
|
||||
"VALUES (?,?,?,?,?,?)", (t0, "LONG", 70, "M5", 75.00, 0.20))
|
||||
for minuten, kurs in ((30, 75.10), (60, 74.90)):
|
||||
con.execute("INSERT OR REPLACE INTO candles_m1 (time,o,h,l,c,spread,tick_volume,symbol)"
|
||||
" VALUES (?,?,?,?,?,?,?,?)",
|
||||
(t0 + minuten * 60 + OFF, kurs, kurs, kurs, kurs, 20, 1, "TESTSYM"))
|
||||
con.commit()
|
||||
|
||||
eng = TradingEngine.__new__(TradingEngine)
|
||||
eng._rec_eval_ts = 0.0
|
||||
eng.trader = types.SimpleNamespace(_broker_offset_s=lambda: OFF)
|
||||
import core.engine as CE
|
||||
_alt = CE.HISTORY_DB_FILE
|
||||
CE.HISTORY_DB_FILE = TMP
|
||||
eng._evaluate_rec_outcomes()
|
||||
CE.HISTORY_DB_FILE = _alt
|
||||
|
||||
r = con.execute("SELECT ret30,hit30,ret60,hit60,done FROM rec_outcomes").fetchone()
|
||||
pruef("ret30 = (75.10-75.00)/0.20 = +0.5", round(r[0], 3), 0.5)
|
||||
pruef("hit30 = 1 (lief in Signalrichtung)", r[1], 1)
|
||||
pruef("ret60 = (74.90-75.00)/0.20 = -0.5", round(r[2], 3), -0.5)
|
||||
pruef("hit60 = 0", r[3], 0)
|
||||
pruef("done = 1", r[4], 1)
|
||||
|
||||
print()
|
||||
print("=" * 86)
|
||||
print(" 3) SHORT-Richtung spiegelbildlich · fehlende Bars → bleibt offen")
|
||||
print("=" * 86)
|
||||
con.execute("DELETE FROM rec_outcomes")
|
||||
# ⚠ Test-Kerzen aus Abschnitt 2 MUESSEN weg — sonst faellt die dortige
|
||||
# 60-Minuten-Bar in das Fenster dieses Abschnitts (beide Anker liegen 2 h
|
||||
# zurueck) und der Fall "keine Bar" waere gar nicht getestet.
|
||||
con.execute("DELETE FROM candles_m1 WHERE symbol='TESTSYM'")
|
||||
t1 = int(time.time()) - 7200
|
||||
con.execute("INSERT INTO rec_outcomes (ts,signal,conf_pct,tf,price,atr) "
|
||||
"VALUES (?,?,?,?,?,?)", (t1, "SHORT", 70, "M5", 75.00, 0.20))
|
||||
con.execute("INSERT OR REPLACE INTO candles_m1 (time,o,h,l,c,spread,tick_volume,symbol)"
|
||||
" VALUES (?,?,?,?,?,?,?,?)",
|
||||
(t1 + 1800 + OFF, 74.9, 74.9, 74.9, 74.9, 20, 1, "TESTSYM"))
|
||||
# fuer 60 min BEWUSST keine Bar → Zeile darf nicht faelschlich bewertet werden.
|
||||
# ⚠ Dafuer muss das 60-Minuten-Fenster WIRKLICH leer sein: die DB-Kopie enthaelt
|
||||
# ECHTE candles_m1 aus dem Live-Betrieb, und die liegen genau hier. Ohne dieses
|
||||
# DELETE testet man nicht "keine Bar", sondern "echte Bar" (erster Versuch lieferte
|
||||
# ret60 = -14,985 = der reale Kurs ~78).
|
||||
con.execute("DELETE FROM candles_m1 WHERE time > ? AND time <= ?",
|
||||
(t1 + 3600 + OFF - 900, t1 + 3600 + OFF))
|
||||
con.commit()
|
||||
eng._rec_eval_ts = 0.0
|
||||
CE.HISTORY_DB_FILE = TMP
|
||||
eng._evaluate_rec_outcomes()
|
||||
CE.HISTORY_DB_FILE = _alt
|
||||
r = con.execute("SELECT ret30,hit30,ret60,done FROM rec_outcomes").fetchone()
|
||||
pruef("SHORT: Kurs faellt → ret30 = +0.5", round(r[0], 3), 0.5)
|
||||
pruef("SHORT: hit30 = 1", r[1], 1)
|
||||
pruef("ret60 ohne Bar bleibt NULL", r[2], None)
|
||||
pruef("done bleibt offen (0/None)", bool(r[3]), False)
|
||||
|
||||
print()
|
||||
print("=" * 86)
|
||||
print(" 4) rec_accuracy aggregiert")
|
||||
print("=" * 86)
|
||||
con.execute("DELETE FROM rec_outcomes")
|
||||
for ret, hit in ((0.5, 1), (0.3, 1), (-0.4, 0), (-0.2, 0)):
|
||||
con.execute("INSERT INTO rec_outcomes (ts,signal,conf_pct,tf,price,atr,"
|
||||
"ret30,hit30,ret60,hit60,done) VALUES (?,?,?,?,?,?,?,?,?,?,1)",
|
||||
(int(time.time()), "LONG", 70, "M5", 75.0, 0.2, ret, hit, ret, hit))
|
||||
con.commit()
|
||||
a = h.rec_accuracy(200)
|
||||
pruef("n = 4", a["n"], 4)
|
||||
pruef("Trefferquote 30' = 50 %", a["wr30"], 50.0)
|
||||
pruef("Ø-Return 30' = +0.05", a["avg30"], 0.05)
|
||||
|
||||
con.close()
|
||||
print()
|
||||
print("=" * 86)
|
||||
print(" ERGEBNIS: " + ("alle Szenarien korrekt" if not fehler
|
||||
else f"{len(fehler)} FEHLER: {fehler}"))
|
||||
print("=" * 86)
|
||||
sys.exit(1 if fehler else 0)
|
||||
Reference in New Issue
Block a user