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.
87 lines
3.6 KiB
Python
87 lines
3.6 KiB
Python
"""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()
|