Thales
← research journal

An internal research document, published verbatim by the automated daily export — not written for an audience, and better for it. All performance discussed is simulated paper trading; nothing here is investment advice.

PLB-1 — the placebo's C5 beta companion reads the FROZEN research store, which by design ends before the live window begins, so it has never computed and never can; a working live-SPY loader already exists three modules away and documents this exact trap

status: open · raised 2026-09-10 (owner-side interactive session, correcting its own record from #157 after the first decision-grade placebo run) · class: truth divergence between layers · judgement: YES · effort ~0.1 pd · horizon: fired at the 2026-09-08 maturity; fires again at every future placebo read, including December's


Plain-language summary for an owner reading one paragraph. The placebo test asks: did our stock-picking beat 200 random 50-name portfolios? A registered companion (C5, 2026-08-01) was supposed to repeat that comparison with the market's own move subtracted, so a rising tide could not flatter either side. It has never once produced a number. The reason is not a stale file anybody forgot to refresh: it reads data/raw/SPY.parquet, which is part of the deliberately frozen owned research store, and that store ends before the live trading window starts. The two can never overlap — not now, not in December. Meanwhile the forward OOS monitor computes a perfectly good SPY comparison over the same window, because it calls a different loader that refreshes SPY when the on-disk copy is behind.

Mechanism — ground truth at HEAD 82e802e

  • src/thales/backtest/placebo.py:288-295 — load_spy_returns(parquet_dir) reads data/raw/SPY.parquet and returns {} if the file is absent or unreadable. There is no refresh path.
  • The owned store is frozen by design (CLAUDE.md, "acquire-once → freeze → own"): sampled at HEAD, 299 of the first 300 symbols end 2026-06-09, and SPY.parquet ends at a 2026-06-05 return. The live clean clock starts 2026-06-11. The overlap is empty by construction, so the companion's own guard fires and prints "unavailable" — correctly, and forever.
  • src/thales/execution/digest.py:351-354 — _load_spy_aligned does the same job and names this exact hazard in its docstring: "Fetches SPY alone if the on-disk parquet is stale (SPY is not in the universe, so nothing else refreshes it). Degrades to {} on any failure." SPY is not a universe member, so the daily fetch never touches it — the trap was understood, written down, and handled in one caller only.
  • src/thales/cli.py:2979 routes the oos-monitor through that working loader; cli.py:792 routes the placebo through the frozen-store one. On 2026-09-09 the first produced excess-over-SPY Sharpe −2.720 (SPY +1.443, beta 0.41 / corr 0.49 over 60d) while the second reported 0 usable days — same session, same window, same benchmark, two different answers because of two different loaders.

Why it matters

The 2026-09-09 placebo is the first decision-grade read and it stands at the 2nd percentile of random stock-picking. The registration built C5 precisely so that number could be read net of the market's move — the check that separates "the strategy is bad" from "the tape was good and we lagged it." That check has been unavailable at the one reading that mattered, and December's gate reads the same evaluator.

Fix shape (propose-only)

Point the placebo at the loader that works — pass the digest's _load_spy_aligned behaviour (fetch-if-stale, degrade to {} on failure) rather than a bare parquet read, or lift it into one shared helper both callers use. The registration's rule that SPY may never be silently proxied by another symbol is preserved either way: the fix changes where SPY comes from, never what it is.

Test design + negative control

Fixture: a live window that starts after the on-disk SPY series ends → the companion must still compute (proving the refresh path is reached), and a fixture where the fetch fails → it must still degrade to the explicit "unavailable" note rather than a proxy or a crash. Negative control, shown in the PR: restore the bare parquet read and assert the first test fails.

Kill criterion (pre-registered)

If the fetch path cannot reach SPY from the environment the evaluator actually runs in (the owner's Mac, per the AUDIT.md register), the item dies and is replaced by a one-line change to the run instruction — fetch SPY before running the placebo — because a companion that needs an undocumented manual step is worse than one that says "unavailable" out loud.

Prior art / relitigation

None: grep -riE "beta.compan|c5|spy" research/queue/ research/RESEARCH_QUEUE.md returns only a commit hash (6c5da39). This row CORRECTS the record in #157, which attributed the failure to a stale file — the framing was wrong and the corrected finding is sharper. Sibling in shape, not in substance, to research/queue/open/fos-2-fleet-email-missing-streak-split.md: two callers, two sources, the divergent one silent.

Cap note: open/ was at its pre-registered 12-file cap before this row, so filing it puts the directory at 13. That is deliberate and disclosed, not an oversight: Monday's triage owns the mechanical overflow rule and should adjudicate this row against the incumbents rather than have the filer pre-empt it.


Triage annotation — 2026-09-28 (annotation only; the body above is untouched)

Still live at HEAD 864da4e, confirmed by reading both loaders side by side. src/thales/backtest/placebo.py:288-309 (load_spy_returns) resolves Path(parquet_dir) / "SPY.parquet" at :295, returns {} if absent at :296-297, and reads it at :299 — no refresh path anywhere in the function. cli.py:792 still routes the placebo's C5 beta companion through it. The working loader is untouched three modules away: digest.py:351-354 (_load_spy_aligned), whose docstring still says "Fetches SPY alone if the on-disk parquet is stale (SPY is not in the universe, so nothing else refreshes it)", and which oos-monitor uses at cli.py:2979/:2986. Two loaders, two answers, unchanged.

One number could not be re-grounded here and one structural claim replaces it. data/raw/ is gitignored and empty in this container, so the body's "SPY ends 2026-06-05" cannot be re-measured. The structural claim is verifiable and holds: the owned store is frozen by design and the live clock starts at config/settings.yaml:423 oos_monitor.since: "2026-06-11", so the overlap is empty by construction — the companion cannot compute over the live window whatever the exact end date is.

CORRECTION TO THE ROW'S TEMPO, and it lowers urgency without touching correctness. The body reads as though this fires on a schedule. It does not. The only automated invocation is .github/workflows/monthly-revalidation.yml:183, and that workflow's schedule is disabled (:8-19, workflow_dispatch only, disabled 2026-06-01); grep -n placebo scripts/monthly_revalidation.sh returns zero hits, so the local launchd monthly job does not run the placebo either. There is no cron that will re-fire this on 2026-10-01. It fires only on a manual owner run or a manual dispatch — which means the next read that matters is the December gate, and it will be a hand-run. "Fires at every future placebo read" is true; "every future read" is manual.

One tension to cite in the eventual PR. scripts/monthly_revalidation.sh:577-587 states the deliberate "OWNED, FROZEN DATA — NO re-fetch" doctrine. The fix as written (fetch-if-stale for SPY only) is compatible, but if the placebo is ever folded into that script a SPY fetch re-introduces the one network dependency the script exists to have removed. Name the doctrine in the PR so a reviewer does not have to rediscover it.

Blocked on: nothing. Still the cheapest row in the queue at ~0.1 pd — a one-call redirect plus two synthetic fixtures and a negative control — and it gates the reading of the one number (the 2026-09-09 placebo's 2nd percentile) that the December decision leans on. Its kill criterion (the fetch cannot reach SPY from the evaluator's environment) is unlikely to fire: that environment is the owner's networked Mac.


Triage annotation — 2026-10-05 (annotation only; the body above is untouched)

Unchanged at HEAD 502690b, and the diagnosis is confirmed STRUCTURAL rather than a stale file. src/thales/backtest/placebo.py:288-297 load_spy_returns still reads Path(parquet_dir) / "SPY.parquet", returns {} when absent, and has no refresh path; src/thales/cli.py:792 still routes the C5 companion through it, while the oos-monitor routes through digest._load_spy_aligned (cli.py:2979, :2986). The decisive new detail: digest.py:351-385 fetches SPY in memory only (:368 download_ohlcv(..., primary="yfinance"), concat :373-377, no write back to disk), so data/raw/SPY.parquet can never be advanced by any running code path. The frozen store cannot heal itself as a side effect of anything; the overlap stays empty forever. The live clean clock is still oos_monitor.since: "2026-06-11" (config/settings.yaml:423), so the stated non-overlap stands.

Its own registered kill criterion has NOT passed: "<15-point divergence across decision-grade renders by 2027-02-01 → inert, retired" (placebo.py:283-284).

One implementation nuance for whoever builds it — not an error in the row. The two loaders are not drop-in: load_spy_returns(parquet_dir) -> {date: return} versus _load_spy_aligned(equity_hist, config) -> {date: close}. A shared helper has to reconcile closes→returns and a different argument shape. Still ~0.1 pd, but it is 0.1 pd of real work, not a one-line import swap.

Rank this week: 11 of 12 — and it is the cheapest thing on the whole board. It survives the cap on a single argument: it is 0.1 pd, blocked on nothing, needs no owner ruling, no data and no credentials, and it re-fires at every future placebo read including December's gate, where the raw percentile is currently the only binding number because this companion has never computed. Nine weeks of an unused approval gate have cost this row nine weeks for want of one git mv.

Could not verify in this container: "SPY.parquet ends at a 2026-06-05 return" and "299 of the first 300 symbols end 2026-06-09" — data/raw/ is gitignored and empty here, and no placebo artifact is committed under results/diagnostics/, so the "0 usable days" render cannot be re-checked from committed state either. The structural argument above does not depend on them.


Triage annotation — 2026-10-19 (annotation only; the body above is untouched)

Unchanged, still the cheapest row on the board, still blocked on nothing. placebo.py:288-309's load_spy_returns reads Path(parquet_dir) / "SPY.parquet" at :295, returns {} if absent at :296-297, and has no refresh path; cli.py:792 still routes the C5 companion through it. The working loader is untouched three modules away — execution/digest.py:351 _load_spy_aligned, whose docstring at :352-354 names this exact hazard, fetching at :368 via download_ohlcv("SPY", …, primary="yfinance") — and it writes nothing back to disk, which is the structural confirmation the row needs: no running code path can ever advance data/raw/SPY.parquet, so the frozen store cannot grow into the live window by itself. oos_monitor uses the working loader (cli.py:2979-2986).

Not re-measurable here: data/raw/ is empty in this container, so the "SPY ends 2026-06-05" figure is unverifiable from this session, and find results -iname '*placebo*' finds nothing committed. The argument does not depend on either — the store is frozen by design and the clean clock starts 2026-06-11.

The registered kill criterion (placebo.py:283-284, a <15-point divergence by 2027-02-01) has not passed. The 10-05 caveat stands: the fix is not a drop-in, since the signatures differ (load_spy_returns(parquet_dir) -> {date: return} vs _load_spy_aligned(equity_hist, config) -> {date: close}).

Live state: ~0.1 pd · blocked on nothing · fires again at the December gate (43 days), which is the fourth consecutive read it will miss.