Files
set50-system/backend/tests/test_forward_test.py
Kunthawat Greethong 6d9c283d9a [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.
2026-08-27 12:01:00 +07:00

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()