[verified] Real forward-test frozen-signal lifecycle + durable run store
Replace the cosmetic 'forward' mode (which was the same single-pass backtest
with a mode string) with a genuine forward paper-portfolio lifecycle:
- backend/app/forward_test.py: ForwardTestStore — durable, thread-safe JSON
store of forward runs with an explicit status lifecycle:
frozen (signals snapshotted, immutable) -> executed (fills 50/20/30
buckets at post-freeze prices) -> marked (mark-to-market equity series ->
matured (net_return finalised).
Frozen signals can never be re-read/rewritten after creation, so later data
cannot retroactively change what the run decided.
- backend/app/__init__.py: GET /api/v1/forward (+<id>), POST /api/v1/forward
(create+execute, with use_pit to freeze PIT or current-board scores),
POST /<id>/mark, POST /<id>/mature. ForwardTestStore wired as an extension
backed by data/forward/runs.json (survives restarts).
- tests: lifecycle store (7) — full backend suite 280 passed. Live probe:
create->execute (2xx, real holdings), list, mark, mature all work and the
run persists.
Honest scope: the score source at CREATE time may be the current board
(non_pit=true, tagged); paper-only, no MT5 send. A PIT scorer only marks a run
non_pit=false when its scores assert pit_meta.pit=true.
This commit is contained in:
@@ -142,6 +142,10 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
|
||||
app.extensions["paper_ledger"] = ledger
|
||||
app.extensions["paper_sessions"] = paper_sessions
|
||||
app.extensions["allowed_symbols"] = allowed_symbols
|
||||
from .forward_test import ForwardTestStore
|
||||
app.extensions["forward_store"] = ForwardTestStore(
|
||||
app.config.get("FORWARD_STORE_PATH") or (data_root / "forward" / "runs.json")
|
||||
)
|
||||
|
||||
# shared in-process daily cache + app-internal data scheduler (independent of
|
||||
# Hermes — this app runs on its own server).
|
||||
@@ -781,6 +785,103 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
|
||||
runs = app.extensions.get("backtest_runs", [])
|
||||
return jsonify({"runs": runs})
|
||||
|
||||
@app.get("/api/v1/forward")
|
||||
def forward_list():
|
||||
"""List all durable forward-test runs (real lifecycle, not cosmetic)."""
|
||||
store = app.extensions["forward_store"]
|
||||
return jsonify({"runs": store.list()})
|
||||
|
||||
@app.get("/api/v1/forward/<run_id>")
|
||||
def forward_get(run_id: str):
|
||||
store = app.extensions["forward_store"]
|
||||
run = store.get(run_id)
|
||||
if run is None:
|
||||
return jsonify({"error": "unknown forward run"}), 404
|
||||
return jsonify(run)
|
||||
|
||||
@app.post("/api/v1/forward")
|
||||
def forward_create():
|
||||
"""CREATE + EXECUTE a forward run with frozen signals.
|
||||
|
||||
Body: {capital, use_pit: bool, as_of: "YYYY-MM-DD"}.
|
||||
Freezes the current (or PIT-as-of) combined per-symbol scores and fills
|
||||
the 50/20/30 buckets at post-freeze prices. The score set is immutable
|
||||
from this point (a real frozen-signal lifecycle).
|
||||
"""
|
||||
from .forward_test import ForwardError
|
||||
store = app.extensions["forward_store"]
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
capital = float(body.get("capital", 0))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "capital must be a number"}), 400
|
||||
if capital <= 0:
|
||||
return jsonify({"error": "capital must be > 0"}), 400
|
||||
use_pit = bool(body.get("use_pit"))
|
||||
as_of = body.get("as_of")
|
||||
try:
|
||||
from app import simulation as sim
|
||||
series = sim.load_price_snapshot()
|
||||
prices = sim.latest_prices(series)
|
||||
if use_pit:
|
||||
from .pit_scorer import PitScoreProvider, make_pit_score_fn
|
||||
from .factor_vintages import FactorVintageStore
|
||||
from .siamchart_vintages import SiamchartVintageStore
|
||||
froot = Path(__file__).resolve().parents[1] / "data"
|
||||
sstore = SiamchartVintageStore(froot)
|
||||
_seed_siamchart_vintage(sstore)
|
||||
provider = PitScoreProvider(FactorVintageStore(froot),
|
||||
_load_siamchart_snapshot(),
|
||||
siamchart_store=sstore)
|
||||
score_fn = make_pit_score_fn(provider)
|
||||
score_by_symbol = score_fn(symbols=[], as_of=as_of) or {}
|
||||
non_pit = not any(
|
||||
isinstance(m, dict) and isinstance(m.get("pit_meta"), dict)
|
||||
and bool(m.get("pit_meta", {}).get("pit"))
|
||||
for m in score_by_symbol.values()
|
||||
)
|
||||
else:
|
||||
from .dashboard import default_scores
|
||||
score_by_symbol = default_scores(None) or {}
|
||||
non_pit = True
|
||||
frozen = {}
|
||||
for sym, meta in (score_by_symbol or {}).items():
|
||||
if sym in prices:
|
||||
frozen[sym] = {
|
||||
"combined": float(meta.get("combined", 0.0)),
|
||||
"is_dividend": bool(meta.get("is_dividend")),
|
||||
"dividend_yield": float(meta.get("dividend_yield") or 0.0),
|
||||
}
|
||||
run = store.create(capital, frozen, as_of=as_of or "now", non_pit=non_pit)
|
||||
executed = store.execute(run["id"], prices)
|
||||
return jsonify(executed), 201
|
||||
except (ForwardError, sim.SimulationError, OSError) as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.post("/api/v1/forward/<run_id>/mark")
|
||||
def forward_mark(run_id: str):
|
||||
from .forward_test import ForwardError
|
||||
from app import simulation as sim
|
||||
store = app.extensions["forward_store"]
|
||||
try:
|
||||
series = sim.load_price_snapshot()
|
||||
prices = sim.latest_prices(series)
|
||||
return jsonify(store.mark(run_id, prices))
|
||||
except (ForwardError, sim.SimulationError, OSError) as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.post("/api/v1/forward/<run_id>/mature")
|
||||
def forward_mature(run_id: str):
|
||||
from .forward_test import ForwardError
|
||||
from app import simulation as sim
|
||||
store = app.extensions["forward_store"]
|
||||
try:
|
||||
series = sim.load_price_snapshot()
|
||||
prices = sim.latest_prices(series)
|
||||
return jsonify(store.mature(run_id, prices))
|
||||
except (ForwardError, sim.SimulationError, OSError) as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.get("/api/v1/data/last-refresh")
|
||||
def last_refresh():
|
||||
"""Status of the in-app automatic data refresh (independent of Hermes)."""
|
||||
|
||||
188
backend/app/forward_test.py
Normal file
188
backend/app/forward_test.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Forward-test paper-portfolio lifecycle with frozen signals (real, not cosmetic).
|
||||
|
||||
The earlier "forward" mode was cosmetic: it ran the same single-pass
|
||||
backtest as "backtest", differing only in the `mode` string. This module
|
||||
implements a genuine forward lifecycle:
|
||||
|
||||
1. CREATE (freeze): capture the per-symbol combined scores / dividend
|
||||
flags **at a specific as_of** and persist them. The
|
||||
signal set is immutable from this point — later data
|
||||
cannot change what the run decided.
|
||||
2. EXECUTE (paper): use prices known *after* the freeze date to fill
|
||||
the 50/20/30 dividend buckets (the engine does NOT
|
||||
reach back to pick trades, it fills what the frozen
|
||||
signal said).
|
||||
3. MARK (mark-to-market): recompute portfolio equity against later
|
||||
prices, recording an equity series.
|
||||
|
||||
Each step is persisted to a durable store (JSON, atomic write) so runs
|
||||
survive restarts — replacing the in-memory backtest_runs list.
|
||||
|
||||
Honesty: a frozen forward run is paper and labelled as such. It uses the
|
||||
score source supplied at CREATE time; if that source is the current board it
|
||||
is non-PIT and the run is tagged `non_pit=true`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import datetime as dt
|
||||
import math
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
FORWARD_SCHEMA_VERSION = 1
|
||||
|
||||
# lifecycle statuses in order
|
||||
_ORDER = ["frozen", "executed", "marked", "matured"]
|
||||
|
||||
|
||||
class ForwardError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
class ForwardTestStore:
|
||||
"""Durable, thread-safe JSON store of forward-test runs."""
|
||||
|
||||
def __init__(self, path: Path | str | None = None) -> None:
|
||||
self.path = Path(path).resolve() if path else None
|
||||
self._runs: dict[str, dict] = {}
|
||||
self._lock = threading.Lock()
|
||||
if self.path and self.path.is_file():
|
||||
self._load()
|
||||
|
||||
# -- persistence ------------------------------------------------------
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ForwardError(f"forward store unreadable: {exc}") from exc
|
||||
if not isinstance(payload, dict) or payload.get("schema_version") != FORWARD_SCHEMA_VERSION:
|
||||
raise ForwardError("unsupported forward store schema")
|
||||
self._runs = deepcopy(payload.get("runs", {}))
|
||||
|
||||
def _persist(self) -> None:
|
||||
if not self.path:
|
||||
return
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"schema_version": FORWARD_SCHEMA_VERSION, "runs": self._runs}
|
||||
tmp = self.path.with_name(f".{self.path.name}.tmp")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")
|
||||
tmp.replace(self.path)
|
||||
|
||||
# -- lifecycle --------------------------------------------------------
|
||||
def create(self, capital: float, frozen: dict[str, dict], as_of: str,
|
||||
non_pit: bool, note: str = "") -> dict:
|
||||
"""CREATE: persist an immutable frozen-signal run."""
|
||||
with self._lock:
|
||||
if capital <= 0:
|
||||
raise ForwardError("capital must be > 0")
|
||||
run_id = f"fwd_{uuid4().hex[:12]}"
|
||||
run = {
|
||||
"id": run_id,
|
||||
"created_at": _now(),
|
||||
"capital": capital,
|
||||
"status": "frozen",
|
||||
"as_of": as_of,
|
||||
"non_pit": bool(non_pit),
|
||||
"note": note,
|
||||
"frozen_signals": deepcopy(frozen),
|
||||
"execution_prices": {},
|
||||
"holdings": {},
|
||||
"invested": 0.0,
|
||||
"equity_history": [{"date": as_of, "equity": capital}],
|
||||
}
|
||||
self._runs[run_id] = run
|
||||
self._persist()
|
||||
return deepcopy(run)
|
||||
|
||||
def execute(self, run_id: str, execution_prices: dict[str, float]) -> dict:
|
||||
"""EXECUTE: fill the frozen signals at prices provided (post-freeze).
|
||||
|
||||
Uses the canonical 50/20/30 dividend allocation over the frozen
|
||||
combined scores (mirrors simulation.allocate_capital). Frozen signals
|
||||
are NOT re-readable/changeable here — only execution prices vary.
|
||||
"""
|
||||
with self._lock:
|
||||
run = self._runs.get(run_id)
|
||||
if run is None:
|
||||
raise ForwardError(f"unknown forward run: {run_id}")
|
||||
if run["status"] != "frozen":
|
||||
raise ForwardError("run already executed")
|
||||
from . import simulation as sim
|
||||
candidates = []
|
||||
for sym, meta in (run.get("frozen_signals") or {}).items():
|
||||
price = execution_prices.get(sym)
|
||||
if price is None or price <= 0:
|
||||
continue
|
||||
candidates.append(sim.Candidate(
|
||||
symbol=sym, price=price,
|
||||
combined_score=float(meta.get("combined", 0.0)),
|
||||
is_dividend=bool(meta.get("is_dividend")),
|
||||
dividend_yield=float(meta.get("dividend_yield") or 0.0),
|
||||
))
|
||||
alloc = sim.allocate_capital(run["capital"], candidates)
|
||||
holdings = {o.symbol: o.qty for o in alloc.orders}
|
||||
invested = alloc.invested
|
||||
run["status"] = "executed"
|
||||
run["execution_prices"] = {s: float(execution_prices.get(s, 0)) for s in holdings}
|
||||
run["holdings"] = holdings
|
||||
run["invested"] = invested
|
||||
run["equity_history"].append({"date": _now(), "equity": invested})
|
||||
self._persist()
|
||||
return deepcopy(run)
|
||||
|
||||
def mark(self, run_id: str, prices: dict[str, float]) -> dict:
|
||||
"""MARK: recompute equity at provided (latest) prices and append."""
|
||||
with self._lock:
|
||||
run = self._runs.get(run_id)
|
||||
if run is None:
|
||||
raise ForwardError(f"unknown forward run: {run_id}")
|
||||
if run["status"] not in ("executed", "marked"):
|
||||
raise ForwardError("run not executed yet")
|
||||
equity = run["capital"]
|
||||
for sym, qty in (run.get("holdings") or {}).items():
|
||||
px = prices.get(sym)
|
||||
if px is None:
|
||||
continue
|
||||
equity += qty * (px - run["execution_prices"].get(sym, 0))
|
||||
run["status"] = "marked"
|
||||
run["equity_history"].append({"date": _now(), "equity": round(equity, 2)})
|
||||
self._persist()
|
||||
return deepcopy(run)
|
||||
|
||||
def mature(self, run_id: str, final_prices: dict[str, float]) -> dict:
|
||||
"""MATURE: close the run at final prices, freeze the outcome."""
|
||||
with self._lock:
|
||||
run = self._runs.get(run_id)
|
||||
if run is None:
|
||||
raise ForwardError(f"unknown forward run: {run_id}")
|
||||
if run["status"] not in ("executed", "marked"):
|
||||
raise ForwardError("run cannot mature before execution")
|
||||
equity = run["capital"]
|
||||
for sym, qty in (run.get("holdings") or {}).items():
|
||||
px = final_prices.get(sym)
|
||||
if px is None:
|
||||
continue
|
||||
equity += qty * (px - run["execution_prices"].get(sym, 0))
|
||||
run["status"] = "matured"
|
||||
run["final_equity"] = round(equity, 2)
|
||||
run["net_return"] = round((equity - run["capital"]) / run["capital"], 4) if run["capital"] else 0.0
|
||||
run["equity_history"].append({"date": _now(), "equity": round(equity, 2)})
|
||||
self._persist()
|
||||
return deepcopy(run)
|
||||
|
||||
# -- reads ------------------------------------------------------------
|
||||
def get(self, run_id: str) -> Optional[dict]:
|
||||
run = self._runs.get(run_id)
|
||||
return deepcopy(run) if run else None
|
||||
|
||||
def list(self) -> list[dict]:
|
||||
return [deepcopy(run) for run in sorted(self._runs.values(), key=lambda r: r.get("created_at", ""))]
|
||||
86
backend/tests/test_forward_test.py
Normal file
86
backend/tests/test_forward_test.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Tests for the forward-test frozen-signal paper lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.forward_test import ForwardError, ForwardTestStore
|
||||
|
||||
|
||||
def _frozen():
|
||||
return {
|
||||
"A": {"combined": 1.0, "is_dividend": True, "dividend_yield": 5.0},
|
||||
"B": {"combined": 0.8, "is_dividend": False, "dividend_yield": 0.0},
|
||||
}
|
||||
|
||||
|
||||
class ForwardTestStoreTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.path = Path(self._tmp.name) / "forward.json"
|
||||
self.store = ForwardTestStore(self.path)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_create_freezes_and_persists(self):
|
||||
run = self.store.create(1_000_000, _frozen(), as_of="2026-06-01", non_pit=True)
|
||||
self.assertEqual(run["status"], "frozen")
|
||||
self.assertEqual(run["capital"], 1_000_000)
|
||||
# reloaded from disk still has the frozen signals
|
||||
reloaded = ForwardTestStore(self.path)
|
||||
got = reloaded.get(run["id"])
|
||||
self.assertEqual(got["frozen_signals"]["A"]["combined"], 1.0)
|
||||
self.assertEqual(got["status"], "frozen")
|
||||
|
||||
def test_execute_fills_frozen_signals_at_provided_prices(self):
|
||||
run = self.store.create(100_000, _frozen(), as_of="2026-06-01", non_pit=True)
|
||||
prices = {"A": 10.0, "B": 20.0}
|
||||
ex = self.store.execute(run["id"], prices)
|
||||
self.assertEqual(ex["status"], "executed")
|
||||
# A is dividend (bucket1 50%, 50k/10=5000); B non-dividend (bucket2 20%, 20k/20=1000)
|
||||
self.assertEqual(ex["holdings"].get("A"), 5000)
|
||||
self.assertEqual(ex["holdings"].get("B"), 1000)
|
||||
|
||||
def test_mark_changes_equity_with_prices(self):
|
||||
run = self.store.create(100_000, _frozen(), as_of="2026-06-01", non_pit=True)
|
||||
ex = self.store.execute(run["id"], {"A": 10.0, "B": 20.0})
|
||||
# B rises 20 -> 22 => +1000*2 = +2000
|
||||
marked = self.store.mark(run["id"], {"A": 10.0, "B": 22.0})
|
||||
last_equity = marked["equity_history"][-1]["equity"]
|
||||
self.assertEqual(last_equity, 100_000 + 2_000)
|
||||
|
||||
def test_mature_computes_net_return(self):
|
||||
run = self.store.create(100_000, _frozen(), as_of="2026-06-01", non_pit=True)
|
||||
self.store.execute(run["id"], {"A": 10.0, "B": 20.0})
|
||||
matured = self.store.mature(run["id"], {"A": 10.0, "B": 22.0})
|
||||
self.assertEqual(matured["status"], "matured")
|
||||
self.assertAlmostEqual(matured["final_equity"], 102_000.0, places=2)
|
||||
self.assertAlmostEqual(matured["net_return"], 0.02, places=4)
|
||||
|
||||
def test_frozen_signals_cannot_be_re_frozen_on_execute(self):
|
||||
run = self.store.create(100_000, _frozen(), as_of="2026-06-01", non_pit=True)
|
||||
self.store.execute(run["id"], {"A": 10.0, "B": 20.0})
|
||||
# a second execute must fail (lifecycle - signals already locked)
|
||||
with self.assertRaises(ForwardError):
|
||||
self.store.execute(run["id"], {"A": 11.0, "B": 20.0})
|
||||
|
||||
def test_execute_before_create_unknown_run(self):
|
||||
with self.assertRaises(ForwardError):
|
||||
self.store.execute("fwd_nope", {"A": 10.0})
|
||||
|
||||
def test_double_mark_allowed_and_appends(self):
|
||||
run = self.store.create(100_000, _frozen(), as_of="2026-06-01", non_pit=True)
|
||||
self.store.execute(run["id"], {"A": 10.0, "B": 20.0})
|
||||
self.store.mark(run["id"], {"A": 10.0, "B": 21.0})
|
||||
self.store.mark(run["id"], {"A": 10.0, "B": 22.0})
|
||||
marked = self.store.get(run["id"])
|
||||
self.assertEqual(marked["status"], "marked")
|
||||
# init + execute + 2 marks = 4 entries
|
||||
self.assertEqual(len(marked["equity_history"]), 4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user