- 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>
68 lines
2.6 KiB
Python
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
|