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>
16 KiB
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.ymlOn 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_pctis 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(envTRADING_MIN_ENTRY_GAP_PCT, code default "0.0" at trading_execution.py:291 — production sets 0.10; the ENTIRE gap logic is nested insideif min_entry_gap_pct > 0), dead band0.35 < g < 1.50and squeezeg >= 1.50 and rvol >= 5.0are HARDCODED literals in the function (trading_execution.py:~813-816) — theTRIGGER_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):and# 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"))_daily_gappers(52–76) filters ont_close(day-end price — look-ahead) withGAP_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.jsonas_of: 2026-06-17…(26 days before plan date). Boot loads it best-effort (_load_learnedinrun_live_paper.pyreturns{}on any exception — silent). - Scheduler: root
docker-compose.yml:119-140anddeploy/docker/truenas-compose.yml(~line 190) — shell loop firingpython -m research.premarket_evalat 20:00 ET Mon–Fri (pluseod_reconcileif plan 003 landed; plan 001 adds a separate db-backup service — expected drift, not a mismatch). eod_replay.shpoints 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_rejectinternals 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_learnedstaleness 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 thegap >= 0.03 or day_rvol >= 2.0admit withpopulation_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.gapat learn_params.py:60 is already a FRACTION — pass it as-is, do NOT multiply by 100. Delete theday_rvolfull-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 thePRICE_*/GAP_*/RVOL_MINconstants (lines 43–48 only — KEEPRISK_PCTat line 49, it is position sizing used at line 132, not population) and rewrite_daily_gappersto (a) filter on open price nott_close, (b) callpopulation_reject— NOTE its localgapat 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→ writepath + ".tmp",json.dump,f.flush()+os.fsync,os.replace(tmp, path).run_live_paper.py_load_learned: after successful load, ifas_ofis older thanLEARNED_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_envdefaults match production keys;as_ofcontract 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.pyexists;grep -n "population_reject" trading_execution.py learn_params.py eod_replay.py→ all three consume itgrep -n "volume.*sum()" learn_params.py→ not in the admission pathgrep -n "WATCHER_PRICE_MIN\|TRIGGER_RVOL_MIN" eod_replay.py→ gonegrep -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.mdupdated
STOP conditions
_population_rejectat 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.pyis now the ONLY place the tradeable population is defined. Any future lever test (plan 006 experiments) must consume it with an explicitas_ofargument; 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.jsonbefore/after and let the owner decide whether to apply or re-baseline; the boot-load applies it automatically once written.