Files
momentum-system/tests/unit/test_nightly_tune_atomic.py
T
momentum devandClaude Fable 5 b737acfc2c flywheel: atomic learned_params write + boot-time staleness warning
- nightly_tune: write learned_params.json via tmp -> flush -> fsync ->
  os.replace so a crash mid-dump can't leave the live loop parsing half a JSON
  on its next boot (it reads this file on every restart).
- run_live_paper._load_learned: warns (boot log + one gated Telegram note) when
  the file is older than LEARNED_PARAMS_MAX_AGE_DAYS (default 3) — the missing
  signal that let the params sit 26 days stale. Loads anyway; never blocks boot.
  Staleness math factored into _learned_params_age_days (naive ts = UTC).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:45:04 -04:00

68 lines
2.6 KiB
Python

"""Atomic learned_params write (nightly_tune) + boot-time staleness warning
(run_live_paper._load_learned). Both are money-path adjacent: a torn write or a
silently-stale tuner degrade the live config without a signal.
"""
import json
import os
from datetime import datetime, timedelta, timezone
import run_live_paper
def test_atomic_write_leaves_no_tmp_and_valid_json(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
path = tmp_path / "learned_params.json"
out = {"min_bars": 3, "ready_score_pct": 60.0, "as_of": "2026-07-13T00:00:00+00:00"}
# inline the exact write nightly_tune.main performs (tmp -> fsync -> replace)
tmp = str(path) + ".tmp"
with open(tmp, "w") as f:
json.dump(out, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, str(path))
assert not os.path.exists(tmp) # tmp swapped away
assert json.loads(path.read_text()) == out # valid, complete JSON
def test_nightly_tune_uses_atomic_replace():
# guard against a regression back to a plain open(path, "w") torn write
src = (run_live_paper.__file__.rsplit("/", 1)[0] + "/nightly_tune.py")
body = open(src).read()
assert "os.replace(tmp, path)" in body
assert "os.fsync" in body
def test_staleness_warning_fires_past_max_age(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
as_of = datetime(2026, 7, 1, tzinfo=timezone.utc)
(tmp_path / "learned_params.json").write_text(
json.dumps({"min_bars": 3, "as_of": as_of.isoformat()}))
# 5 days later, default max age 3 -> warns, still loads
learned = run_live_paper._load_learned(now=as_of + timedelta(days=5))
assert learned["min_bars"] == 3
assert "stale" in capsys.readouterr().out.lower()
def test_staleness_silent_within_max_age(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
as_of = datetime(2026, 7, 1, tzinfo=timezone.utc)
(tmp_path / "learned_params.json").write_text(
json.dumps({"min_bars": 3, "as_of": as_of.isoformat()}))
learned = run_live_paper._load_learned(now=as_of + timedelta(days=2))
assert learned["min_bars"] == 3
assert "stale" not in capsys.readouterr().out.lower()
def test_age_helper_handles_missing_and_naive():
assert run_live_paper._learned_params_age_days(None) is None
assert run_live_paper._learned_params_age_days("not-a-date") is None
# naive timestamp treated as UTC
age = run_live_paper._learned_params_age_days(
"2026-07-01T00:00:00", now=datetime(2026, 7, 4, tzinfo=timezone.utc))
assert abs(age - 3.0) < 1e-6