Files
momentum-system/plans/004-learning-flywheel.md
T
momentum devandClaude Fable 5 653f13a7d9 milestone 2026-07: plans 001-007 + ideation artifacts + architecture diagrams
Recon (8 clusters) -> ideation (48->14->7, all verified) -> plans, cold-read
verified by 8 fresh-context reviewers, 33 plan defects fixed pre-execution.

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

16 KiB
Raw Blame History

Plan 004: Resurrect the learning flywheel on one shared, look-ahead-free population

Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in the "STOP conditions" section occurs, stop and report — do not improvise. When done, update the status row for this plan in plans/README.md.

Drift check (run first): git diff --stat d54a2a9..HEAD -- learn_params.py nightly_tune.py eod_replay.py trading_execution.py research/labeler.py docker-compose.yml deploy/docker/truenas-compose.yml On any in-scope drift, compare "Current state" excerpts before proceeding; mismatch = STOP. Diffs introduced by plans 001–003 are EXPECTED — see plans/README.md "Expected drift".

Status

  • Priority: P1
  • Effort: M
  • Risk: MED (extracting the live population gate needs characterization tests first)
  • Depends on: 001 (CI gate), 002 recommended (smaller surface)
  • Category: correctness / research-integrity
  • Planned at: commit d54a2a9, 2026-07-13

Why this matters

The project's own memory claims "nightly_tune.py self-tunes params into learned_params.json, applied on boot." In production that is currently fiction: the TrueNAS scheduler runs only research.premarket_eval (verified — one python -m research.premarket_eval line in the scheduler loop, nothing else), data/learned_params.json is stamped as_of 2026-06-17 (26 days stale at plan time), and eod_replay — the job that grows the whole-market forward dataset — is also unscheduled, so dataset growth is selection-biased to the ~20 names watched live. Worse, the offline population diverges from the live gate: the tuner admits sessions on day-end volume (outcome-time look-ahead, the exact class the owner corrected on 2026-07-08), and eod_replay screens $1–5 under a comment saying "the live verified config" while production trades $2–20. Every backtest, tune, and (future) experiment verdict is untrustworthy until all consumers share the live gate through one module.

Current state

  • Live gate (authoritative): trading_execution.py — _population_reject(gap_pct, cum_volume, price=…, rvol=…) (used by BOTH entry paths; slow path at ~910, fast path at ~1222). Unit contract: gap_pct is a FRACTION (0.12 = 12% — the function formats {g:.1%} and compares against 0.35/1.50 literals). Gates: static open gap ≥ min_entry_gap_pct (env TRADING_MIN_ENTRY_GAP_PCT, code default "0.0" at trading_execution.py:291 — production sets 0.10; the ENTIRE gap logic is nested inside if min_entry_gap_pct > 0), dead band 0.35 < g < 1.50 and squeeze g >= 1.50 and rvol >= 5.0 are HARDCODED literals in the function (trading_execution.py:~813-816) — the TRIGGER_GAP_MAX/TRIGGER_GAP_SQUEEZE_* env keys configure the SEPARATE ArmedTriggerBook watcher gate (runtime/triggers.py), not this function — plus price band (TRADING_PRICE_MIN/MAX, defaults 2.0/20.0) and day-volume floor (TRADING_MIN_DAY_VOLUME, default 0.0). Read the function at HEAD before extracting — the excerpt here is the shape, not the code.
  • Tuner population, learn_params.py:56-65 (look-ahead):
    gap = (float(bars["open"].iloc[0]) - pc) / pc if pc else 0.0
    day_rvol = (float(bars["volume"].sum()) / adv) if adv else 0.0   # full-day sum = outcome-time
    ...
    if not (gap >= 0.03 or day_rvol >= 2.0):
        continue
    
  • Replay population, eod_replay.py:43-48 (stale + look-ahead):
    # the live verified config
    PRICE_MIN = float(os.environ.get("WATCHER_PRICE_MIN", "1"))
    PRICE_MAX = float(os.environ.get("WATCHER_PRICE_MAX", "5"))
    GAP_MIN = float(os.environ.get("TRIGGER_GAP_MIN", "3"))
    GAP_MAX = float(os.environ.get("TRIGGER_GAP_MAX", "60"))
    
    and _daily_gappers (52–76) filters on t_close (day-end price — look-ahead) with GAP_MIN <= gap <= GAP_MAX (no squeeze exception; hard cap excludes the >150% names production explicitly re-admits). Production values (deploy/docker/truenas-compose.yml env block): TRADING_PRICE_MIN=2, TRADING_PRICE_MAX=20.
  • Non-atomic write, nightly_tune.py:89-92:
    path = os.path.join(os.environ.get("DATA_DIR", "./data"), "learned_params.json")
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    with open(path, "w") as f:
        json.dump(out, f, indent=2)
    
  • Stale artifact: data/learned_params.json as_of: 2026-06-17… (26 days before plan date). Boot loads it best-effort (_load_learned in run_live_paper.py returns {} on any exception — silent).
  • Scheduler: root docker-compose.yml:119-140 and deploy/docker/truenas-compose.yml (~line 190) — shell loop firing python -m research.premarket_eval at 20:00 ET Mon–Fri (plus eod_reconcile if plan 003 landed; plan 001 adds a separate db-backup service — expected drift, not a mismatch).
  • eod_replay.sh points at a dead venv path (/home/philip/.venvs/momentum) — cron-era wrapper, unused by compose.
  • Convention: pure strategy logic lives in strategy/ (import-clean, no env reads at import time); env parsing happens at construction (ExitConfig.from_env() pattern, strategy/exits.py).

Commands you will need

Purpose Command Expected
Suite DATABASE_URL=<pg url> python -m pytest -q all pass
Targeted DATABASE_URL=<pg url> python -m pytest tests/unit/strategy/ tests/unit/test_population*.py -q all pass
Replay smoke DATABASE_URL=<pg url> python eod_replay.py <recent weekday date> runs; population line printed
Tune smoke DATABASE_URL=<pg url> python nightly_tune.py writes learned_params.json atomically (needs stored minute_bars — on an empty dev DB it returns early without writing; use the atomic-write unit test instead)

Scope

In scope:

  • strategy/population.py (create — the shared module)
  • trading_execution.py (delegate _population_reject internals to the module; behavior byte-identical)
  • learn_params.py, eod_replay.py (consume the module)
  • nightly_tune.py (atomic write + as_of staleness)
  • Both compose scheduler blocks (add eod_replay + nightly_tune lines)
  • run_live_paper.py (ONLY the _load_learned staleness warning — nothing else)
  • eod_replay.sh (delete — dead venv path; compose is the runner)
  • tests as listed
  • .env.example (any new keys)

Out of scope:

  • Changing ANY gate threshold or the population definition itself — this plan moves code, it does not retune.
  • The entry-path unification (plan 005) — this plan touches only _population_reject's internals, not the gate call sites.
  • The experiment harness (plan 006) — it becomes the module's 5th consumer later.
  • sweep_exits.py / sweep_filters.py population (fold-in later; log as deferred).

Git workflow

  • Branch: advisor/004-learning-flywheel
  • Commits: characterization tests → module extraction → consumer switches (one each) → scheduler wiring.
  • Do NOT push or open a PR.

Steps

Step 1: Characterization tests for the live gate (BEFORE any extraction)

New tests/unit/test_population_gate.py: table-driven cases pinning CURRENT _population_reject behavior — all gap values as FRACTIONS — in-band pass ($2–20, gap_pct=0.12, volume ok), below-gap reject (0.05 with min 0.10), dead-band reject (gap_pct=0.40), squeeze-exception pass (gap_pct=1.60, rvol 6), squeeze-rvol-fail reject (gap_pct=1.60, rvol 3), price-band rejects (both sides), volume-floor reject, missing-data fail-open (None gap/volume ⇒ pass), unit-pin case (gap_pct=0.40 → dead-band reject proves fraction semantics; a percent-passing caller would see 40.0 → squeeze-unverified reject instead), and gap-gate-disabled case (min_entry_gap_pct=0 ⇒ all gap checks skipped — the nesting). Drive via a minimally-constructed TradingExecutionService or by calling the private method on an instance built the way existing execution tests do it (copy their fixture).

Verify: targeted pytest → all characterization tests pass against UNMODIFIED code.

Step 2: Extract strategy/population.py

Create a pure module:

@dataclass(frozen=True)
class PopulationConfig:
    price_min: float = 2.0
    price_max: float = 20.0
    min_entry_gap_pct: float = 0.0      # FRACTION; env TRADING_MIN_ENTRY_GAP_PCT (code default 0.0 — gap logic disabled at 0, matching live)
    min_day_volume: float = 0.0         # env TRADING_MIN_DAY_VOLUME
    # Hardcoded in the live gate — module CONSTANTS, no env read (do NOT wire TRIGGER_* keys here; those belong to the watcher gate):
    gap_dead_band_lo: float = 0.35
    gap_dead_band_hi: float = 1.50
    squeeze_rvol_min: float = 5.0
    @classmethod
    def from_env(cls): ...   # reads ONLY TRADING_MIN_ENTRY_GAP_PCT, TRADING_PRICE_MIN/MAX, TRADING_MIN_DAY_VOLUME — same keys+defaults as trading_execution today

def population_reject(cfg, *, gap_pct, price, volume, rvol, as_of="open") -> str | None:
    """Return None if inside the tradeable population, else the reject reason.
    UNIT CONTRACT: gap_pct is a FRACTION (0.12 = 12%), matching the live call
    site (signal.get("gap_pct")). CONTRACT: every input must be knowable at
    evaluation time (as_of="open" = static open gap, premarket/at-entry
    volume). Day-end fields are forbidden."""

Move the logic verbatim from trading_execution._population_reject; the service keeps a thin wrapper calling the module (its settings → PopulationConfig). Fail-open semantics preserved exactly. Offline consumers must pass production thresholds explicitly (e.g. PopulationConfig(min_entry_gap_pct=0.10, min_day_volume=<prod value>)) — a bare from_env() in a shell without the production env has min_entry_gap_pct=0.0, which disables the whole gap gate and admits everything.

Verify: full suite + step-1 characterization tests still green (they now exercise the extracted path through the same public seam).

Step 3: Switch the offline consumers

  • learn_params.py:56-65: replace the gap >= 0.03 or day_rvol >= 2.0 admit with population_reject(cfg, gap_pct=gap, price=<OPEN price, not close>, volume=<premarket/open-knowable volume — if only day-end volume exists in stored daily bars, pass volume=None (fail-open) and note it>, rvol=None) is None. gap at learn_params.py:60 is already a FRACTION — pass it as-is, do NOT multiply by 100. Delete the day_rvol full-day sum from the admission path (it may remain as a reported metric, never a filter). Use production thresholds (min_entry_gap_pct=0.10).
  • eod_replay.py: delete the PRICE_*/GAP_*/RVOL_MIN constants (lines 43–48 only — KEEP RISK_PCT at line 49, it is position sizing used at line 132, not population) and rewrite _daily_gappers to (a) filter on open price not t_close, (b) call population_reject — NOTE its local gap at line 72 is computed in PERCENT (*100): divide by 100 (or compute as a fraction) before the call, (c) therefore inherit the squeeze exception. Keep the daily-bars-first cheap scan structure.
  • research/labeler.py: dropped from this plan. The labeler tracks have no locatable price/gap admission screen (they label all sessions and stratify post-hoc), and the premarket track's pre-9:30 semantics arguably should not use the open-gap gate. The labeler becomes the module's consumer via plan 006 (experiment populations); log as deferred alongside sweep_exits/sweep_filters.

Verify: python eod_replay.py <date> on a stored session → runs, and its printed population is consistent with production values (a $15 name with 12% open gap now qualifies; a $4 name with 45% gap does not unless ≥150%+rvol5). Full suite green.

Step 4: Atomic write + staleness

  • nightly_tune.py:89-92 → write path + ".tmp", json.dump, f.flush()+os.fsync, os.replace(tmp, path).
  • run_live_paper.py _load_learned: after successful load, if as_of is older than LEARNED_PARAMS_MAX_AGE_DAYS (default 3), log a WARNING and send one Telegram note ("learned_params is N days stale — tuner not running?"). Load anyway (never block boot).

Verify: python nightly_tune.py (dev DB) → file replaced atomically (.tmp absent after); unit test for staleness warning passes.

Step 5: Schedule the flywheel

In BOTH compose scheduler blocks, after the premarket_eval line (and after plan 001/003 additions if present), add:

python eod_replay.py || echo "[scheduler] eod_replay FAILED"
python nightly_tune.py || echo "[scheduler] nightly_tune FAILED"

Order matters: replay grows the dataset first, tuner fits after. Delete eod_replay.sh (dead venv path). Note in the commit message that 20:00 ET is after close — replay of "today" is complete data by then, and this is NOT look-ahead because replay is a labeling job, not a live gate.

Verify: grep -n "eod_replay FAILED\|nightly_tune FAILED" docker-compose.yml deploy/docker/truenas-compose.yml → 2 hits per file (the bare script names also appear in the root compose's on-demand job blocks at lines ~151/158 — grepping those would pass vacuously).

Test plan

  • tests/unit/test_population_gate.py (step 1, ~9 characterization cases) — the load-bearing file.
  • tests/unit/strategy/test_population_module.py: module-level — from_env defaults match production keys; as_of contract docstring exists; day-end input rejection is by-convention (document, don't runtime-enforce).
  • tests/unit/test_nightly_tune_atomic.py: tmp+replace write (tmp absent after; content valid JSON); staleness warning fires at >3 days (inject clock).
  • Verification: DATABASE_URL=… python -m pytest -q → green, ~14 new tests.

Done criteria

  • strategy/population.py exists; grep -n "population_reject" trading_execution.py learn_params.py eod_replay.py → all three consume it
  • grep -n "volume.*sum()" learn_params.py → not in the admission path
  • grep -n "WATCHER_PRICE_MIN\|TRIGGER_RVOL_MIN" eod_replay.py → gone
  • grep -n "os.replace" nightly_tune.py → atomic write present
  • Both compose scheduler blocks run replay + tune; test -f eod_replay.sh → absent
  • Full suite green including characterization tests
  • plans/README.md updated

STOP conditions

  • _population_reject at HEAD contains logic not describable by the PopulationConfig fields above (the gate grew) — extend the characterization tests FIRST, then re-plan the extraction; report before proceeding.
  • Characterization tests reveal the live gate's fail-open behavior differs from the recon description — pin actual behavior, report the difference, do not "fix" it here.
  • The stored daily/minute data cannot supply an open-knowable volume proxy for the tuner admission — pass volume=None (fail-open) and REPORT it prominently; do not silently substitute day-end volume.
  • eod_replay smoke run diverges wildly (e.g. 10x more qualifying names than the live day watched) — report the count delta before scheduling it.

Maintenance notes

  • strategy/population.py is now the ONLY place the tradeable population is defined. Any future lever test (plan 006 experiments) must consume it with an explicit as_of argument; a reviewer seeing a new day-end field flow into it should block the PR.
  • sweep_exits.py / sweep_filters.py still carry their own session filters — deferred here to keep the diff reviewable; fold them in when next touched (they are research scripts, not verdict-producing jobs).
  • If the tuner's fitted params shift materially after the population fix, that is EXPECTED (it was fitting a different universe) — compare learned_params.json before/after and let the owner decide whether to apply or re-baseline; the boot-load applies it automatically once written.