- cockpit_models.py: ForwardNavRow.date changed from String(10) to Date so
TimescaleDB create_hypertable('forward_nav', by_range('date')) accepts the
column; Mapped type updated to dt.date accordingly.
- forward_nav.py: upsert_rows converts DTO ISO str → dt.date.fromisoformat()
before writing; nav_history converts dt.date → .isoformat() when building
NavRowDTO, preserving the ISO-string public contract end-to-end.
StaticPool comment corrected: it is only applied for in-memory SQLite, not Postgres.
- state_reader.py: _welford now uses sample variance (/(n-1)) with the
two-moment formula to match _sharpe; added clarifying comment that sumsq_r
is raw sum-of-squares, not a Welford M2 accumulator.
- tests/unit/test_state_reader.py: moved import json / math / ForwardStateReader
to top of file; added test_welford_sample_variance_sharpe to lock the
variance formula against a known small fixture (days=2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115 lines
4.5 KiB
Python
115 lines
4.5 KiB
Python
"""The forward read-model DTOs and the state_reader that normalizes heterogeneous tracker state files."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
|
|
from fxhnt.adapters.persistence.state_reader import ForwardStateReader
|
|
from fxhnt.application.forward_models import ForwardNavRow, ForwardSummary
|
|
|
|
|
|
def test_forward_summary_total_return_pct() -> None:
|
|
s = ForwardSummary(strategy_id="x", as_of="2026-06-12", days=7, nav=1.05,
|
|
total_return=0.05, sharpe=1.2, maxdd=-0.04)
|
|
assert round(s.total_return_pct, 2) == 5.0
|
|
|
|
|
|
def test_forward_nav_row_is_immutable() -> None:
|
|
r = ForwardNavRow(strategy_id="x", date="2026-06-05", ret=0.01, nav=1.01)
|
|
assert r.strategy_id == "x" and r.nav == 1.01
|
|
|
|
|
|
def _write(tmp_path, name, obj):
|
|
p = tmp_path / f"{name}.json"
|
|
p.write_text(json.dumps(obj))
|
|
return p
|
|
|
|
|
|
def test_reads_days_list_family(tmp_path) -> None:
|
|
_write(tmp_path, "fxhnt_combined_forward", {
|
|
"inception": "2026-06-04", "last_date": "2026-06-06", "nav": 1.0201,
|
|
"days": [
|
|
{"date": "2026-06-05", "ret": 0.01, "nav": 1.01},
|
|
{"date": "2026-06-06", "ret": 0.0099, "nav": 1.0201},
|
|
],
|
|
})
|
|
reader = ForwardStateReader()
|
|
summary, rows = reader.read(tmp_path / "fxhnt_combined_forward.json", "combined")
|
|
assert summary.strategy_id == "combined"
|
|
assert summary.days == 2
|
|
assert summary.as_of == "2026-06-06"
|
|
assert math.isclose(summary.nav, 1.0201, rel_tol=1e-9)
|
|
assert [r.date for r in rows] == ["2026-06-05", "2026-06-06"]
|
|
assert all(r.strategy_id == "combined" for r in rows)
|
|
|
|
|
|
def test_reads_welford_equity_family(tmp_path) -> None:
|
|
_write(tmp_path, "multistrat_state", {
|
|
"last_date": "2026-06-12", "days": 5, "sum_r": -0.0007245543702343088,
|
|
"sumsq_r": 1.8807003187214318e-05, "equity": 0.9992663073361069,
|
|
"peak": 1.001482883251981, "max_dd": -0.004599760548340659,
|
|
})
|
|
reader = ForwardStateReader()
|
|
summary, rows = reader.read(tmp_path / "multistrat_state.json", "multistrat")
|
|
assert summary.days == 5
|
|
assert summary.as_of == "2026-06-12"
|
|
assert math.isclose(summary.nav, 0.9992663073361069, rel_tol=1e-9)
|
|
assert math.isclose(summary.total_return, -0.0007336926638931, rel_tol=1e-6)
|
|
assert math.isclose(summary.maxdd, -0.004599760548340659, rel_tol=1e-9)
|
|
# snapshot-only: one forward point at as_of with nav == equity
|
|
assert len(rows) == 1 and rows[0].date == "2026-06-12"
|
|
assert math.isclose(rows[0].nav, 0.9992663073361069, rel_tol=1e-9)
|
|
|
|
|
|
def test_reads_cumulative_funding_family(tmp_path) -> None:
|
|
_write(tmp_path, "crossvenue_state", {
|
|
"positions": {"LAB": 0.2, "TON": 0.2}, "cum_gross": 0.0046, "cum_net": -6.11e-05,
|
|
"days": 8, "last_run_date": "2026-06-14",
|
|
})
|
|
reader = ForwardStateReader()
|
|
summary, rows = reader.read(tmp_path / "crossvenue_state.json", "crossvenue")
|
|
assert summary.days == 8
|
|
assert summary.as_of == "2026-06-14"
|
|
assert math.isclose(summary.total_return, -6.11e-05, rel_tol=1e-9)
|
|
assert math.isclose(summary.nav, 1.0 - 6.11e-05, rel_tol=1e-9)
|
|
assert summary.sharpe == 0.0 # not derivable from cumulative-only
|
|
assert len(rows) == 1 and rows[0].date == "2026-06-14"
|
|
|
|
|
|
def test_unknown_shape_raises_valueerror(tmp_path) -> None:
|
|
_write(tmp_path, "weird", {"foo": 1})
|
|
reader = ForwardStateReader()
|
|
try:
|
|
reader.read(tmp_path / "weird.json", "weird")
|
|
assert False, "expected ValueError"
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
def test_welford_sample_variance_sharpe(tmp_path) -> None:
|
|
"""Verify _welford uses SAMPLE variance (/(n-1)) matching the days-list _sharpe path.
|
|
|
|
Fixture: days=2, r1=0.01, r2=0.03
|
|
sum_r = 0.04
|
|
sumsq_r = 0.01**2 + 0.03**2 = 0.001
|
|
mean = 0.02
|
|
sample var = (0.001 - 0.04**2/2) / (2-1) = (0.001 - 0.0008) / 1 = 0.0002
|
|
std = sqrt(0.0002)
|
|
sharpe = (0.02 / sqrt(0.0002)) * sqrt(365)
|
|
"""
|
|
sum_r = 0.04
|
|
sumsq_r = 0.01 ** 2 + 0.03 ** 2 # = 0.001
|
|
mean = sum_r / 2
|
|
sample_var = (sumsq_r - sum_r ** 2 / 2) / (2 - 1)
|
|
expected_sharpe = mean / math.sqrt(sample_var) * math.sqrt(365.0)
|
|
|
|
_write(tmp_path, "welford_fixture", {
|
|
"last_date": "2026-06-10", "days": 2,
|
|
"sum_r": sum_r, "sumsq_r": sumsq_r,
|
|
"equity": 1.04, "max_dd": 0.0,
|
|
})
|
|
reader = ForwardStateReader()
|
|
summary, _ = reader.read(tmp_path / "welford_fixture.json", "w")
|
|
assert summary.sharpe > 0, "sharpe must be positive for two positive returns"
|
|
assert math.isclose(summary.sharpe, expected_sharpe, rel_tol=1e-9)
|