At the first exit-thread pass >= TRADING_EXIT_FAIL_FAST_SECONDS after the fill,
flatten if price has already traded TRADING_EXIT_FAIL_FAST_R x R below entry.
It is checked once per position. A position first seen after its window (a
restart or a carry) is never judged late. 0 seconds = off (the default).
Evidence, from tick data on 1,093 live-band v1 ORB breaks (Jun-Sep 2026) with
outcomes under the current exit rules:
- 60 s / 0.3R: +0.060R per trade Jun-Jul (90% CI +0.015..+0.102) and +0.062R
Aug-Sep (+0.029..+0.094). It fired on 29% of trades and cut 22 runners.
- Every threshold from -0.15R to -0.4R at 60 s was positive in both halves,
a plateau rather than one fitted cell.
- On our own 194 real fills (10-s bars): +0.015R. Same sign, not yet
significant.
It is deliberately narrow. The same study found that later "bail out below
+1R" rules kill runners: the LLM trade-manager pilot, the hybrid bailout, and
orderflow H1 (Jul 17). The first minute is the one window where failure tells
you something. Tape-flow features (aggressor buy share, print rate, big
prints, quote imbalance, spread) separated nothing (AUC 0.44-0.59, not stable
across halves).
Live-only: it needs sub-minute prices, so simulate_exit (minute bars) does not
model it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3S5EFKThFVrjmN4HHsHMN
Why: the pilot in data/ross showed that Ross Cameron's daily recaps, joined to
the SIP tape and to what our bot saw, answer "which of his trades could we have
taken and why didn't we". It was a set of one-off scripts run by hand; the
server-side youtube_claims pipeline has been dead since ~08-24 (YouTube blocks
the TrueNAS IP) and its captions fallback hid that. This makes the pilot a
nightly, local, cumulative loop without a paid LLM API.
youtube_claims/recap/ (python -m youtube_claims.recap.nightly):
- channel: yt-dlp flat listing of @DaytradeWarrior, m4a audio, faster-whisper
small.en on CPU, pilot-format bundles (meta + our scanner list + transcript).
- extract: Ollama structured output (JSON schema) per EXTRACT_SPEC, chunked
with overlap for long transcripts and merged. The verbatim rule is enforced in
code: every quote must be found (token-level, one-word tolerance) and is
snapped to the transcript's words; unquoted items, trades with no surviving
quote, prices never spoken, and passed-on names never said are dropped.
- llm: endpoint pool over RECAP_OLLAMA_HOSTS (round-robin + failover, per-host
model/ctx/think pins) since the owner runs several Ollama boxes.
- tape/join/ourside: ported join.py/ourside.py -- tickers are price-verified on
the Alpaca SIP tape (plus an order-free check for his own named ticker), then
paper (:5433) and live (:5434) event views are attached. Bars cached on disk.
- store + schema.sql: idempotent recap_* tables in the transcripts schema,
upserted per video_id. report: the pilot's sections + "what changed since last
run" against the previous day's metrics JSON.
- evaluate: scores any model/prompt against the 36 gold extractions.
Model choice (36 gold videos, final code path, P/R):
gpt-oss:20b on the workstation GPU, two focused calls, think=low:
trades P .84 R .70; tickers P .82 R .77; entry +-2% P .54 R .50;
passed-on P .66 R .76; temperature 16/31; kind 30/36; ~49 s/video.
tools:latest (4B, server) one call: trades P .81 R .72, tickers P .83 R .85,
passed-on P .80 R .75, temperature 20/31, but it invents prices
(entry P .42) and occasionally runs away (5-9 min). Prices drive the tape
verification, so the more precise local model is the default.
Iterations: prompt v2 + spoken-price rescale (699 -> 6.99) + split calls took
gpt-oss trades F1 .74 -> .76-.78 and tickers .78 -> .79-.80; think=medium
blew the token budget. qwen3-30b-a3b on mason only finished 5 videos (host
saturated) -- inconclusive.
Also: youtube_claims/transcription.py::_captions_fallback now picks the
youtube-transcript-api shape by feature detection instead of retrying the
removed get_transcript on any error, and transcribe() reports both the whisper
and captions causes -- the IpBlocked error was being masked as AttributeError.
No trading code touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Discovery finds premarket squeezes far too late. Across 53 trades by a
professional small-cap momentum trader, our scanner had found only 4 of his
names before his entry, and 27 of his 28 entries were premarket (07:00-09:30
ET). KITT on 2026-09-25 ran $3.50 -> $8.39 at 09:00-09:01 ET (prior close
$0.49); the loop first "Discovered KITT via manual" at 09:35. The existing
sources (Alpaca movers / most-actives screeners) only reflect the regular
session, so a premarket squeeze is invisible until the open.
research/ingestion/snapshot_scan.py sweeps every active, tradable, listed US
equity (~11.9k names; OTC, warrants/units/rights and DISCOVER_EXCLUDE dropped;
asset list cached per ET day) through GET /v2/stocks/snapshots in 2,000-symbol
batches. The endpoint is bound by URL length rather than symbol count (8,000
worked; ~12.7k returned 414). A name qualifies when:
- the LATEST TRADE price is inside WATCHER_PRICE_MIN/MAX (never the prior
close, which is routinely < $1 on these names),
- % change vs the prior close >= DISCOVER_SNAPSHOT_MIN_PCT (20),
- it is trading now: last trade <= 180s old, latest minute bar <= 300s old
with >= 5,000 shares, and >= 100,000 shares since 04:00 ET (summed from
minute bars for the short list, one extra request).
Qualifiers are ranked by % change, capped per sweep, and announced once per
day. dailyBar OHLC ignores extended-hours prints (KITT's dailyBar high was
$3.24 against a premarket high of $8.39), so in premarket it can still be
yesterday's bar. The prior close is prevDailyBar.c only when dailyBar is dated
today, otherwise dailyBar.c.
run_live_paper.py runs it on its own daemon thread (04:00-16:00 ET weekdays,
every 45s), using the plan-019 pattern: its own DB connection, the thread-safe
Watchlist and the generic interval driver. Each new name emits
symbol_discovered with source="snapshot_scan" and price/prev_close/pct/minute
and cumulative volume in symbol_data, so first-sighting latency can be
measured. It gets a daily-history backfill and joins the watchlist, so the
normal new-symbol ingest path gives it its first full-session pull. This is
discovery only; no trading gate changes.
Gated by DISCOVER_SNAPSHOT_SCAN_ENABLED (default 1); knobs in .env.example.
Live read-only sweep at 13:10 ET: 11,871 names in 8 requests / 6.3s cold
(7 requests / 3.5s warm); top hits KITT +376%, MSGY +262%, INLF +60%.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KITT on 2026-09-25 (+557%, 9.3M shares, the day's biggest mover) was benched
as "gap 557% squeeze unverified (rvol 1.54)". Two separate faults:
1. The rvol baseline was the 20-day MEAN, and two 11-14M-share days on
09-01/09-02 put it at 1.66M against a 333k median. Squeeze verification
(gap >= 150% needs rvol >= 5) now takes the stronger of the mean-baseline
reading and the median-baseline one. Both are cumulative volume over a
full day's baseline, not prorated, so they share the units the rvol >= 5
boundary was calibrated in. A reverse-split fake still reads ~0.01 on
both. This applies in the watcher pre-filter, the trigger book's squeeze
exception and the fast-path population gate. The ranking and the tod-rvol
arm signal are unchanged.
2. A name's first ingest reached back only to ~09:15 (09:30 + 15 min, or
the 20-minute refresh floor). KITT was found at 09:35, so its stored day
began at 09:13 and missed the 09:00 squeeze and ~6M premarket shares.
The first pass now covers the session from 04:00 ET.
With both fixed, KITT's volume at 09:35 is ~20x its median day.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With BROKER=tradier the loop's `client` is a TradierClient that delegates
market data to the Alpaca client in `data_client`. Two places read the feed
as getattr(client.settings, "feed", "iex"). TradierSettings has no feed, so
both read "iex" even though the container runs ALPACA_DATA_FEED=sip (verified
in the running loop and in config_overrides):
- step_vratio kept computing IEX->SIP ratios as if on the free feed
- ingest_live_minute_bars multiplied the real SIP volume by those ratios
Checked against the paper stack's SIP bars for the same minutes: prices are
identical and unscaled volume matches 1.00x, but scaled rows run x83 (GLND),
x117-126 (GRML) and x163 (SKYQ). The 2026-09-25 GRML signal logged a
cum_volume of 1.39 billion shares and an rvol of 64.6. For any name with a
ratio, every volume gate — rvol, the 300k day-volume floor, min dollar
volume, the 2.5%-of-volume liquidity cap, and the gap x rvol ranking — has
been reading inflated numbers since the stack went live on 2026-07-31.
data_feed() reads through data_client when present. The regression test
wraps a SIP client in a TradierClient and fails without the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pre-registers the real-money test that starts 2026-09-24, the first session
without commission drag. PASS needs at least 80 round trips with a positive
mean R and positive P&L. FAIL is any session the equity floor halts. Changing
any strategy setting in live_test_freeze.json restarts the count, and the
scorecard reports that drift.
The settings were frozen, not tuned, because a study of 239 real fills
re-simulated under the current exit found nothing worth changing:
- Capping how far above the trigger an entry may fill lost R at every cap
from 0.25% to 10%, with either skip or resting-limit handling. Fills more
than 5% above the trigger were the best bucket (+0.66R).
- No break-time factor (spread, price, VWAP distance, stop width, L1
bid/ask size, premarket range) kept its sign across both date halves and
both accounts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TRADING_MIN_QUALITY_SCORE 0.60 -> 0.20.
setup_evaluator.py:164 emits "quality_score": quality.score, where quality comes
from calculate_data_quality_score(bars). That module's own docstring reads "Data
quality scoring for minute-bar coverage and integrity" -- it measures whether the
bars are complete, not whether the setup is good. The name collision meant a
coverage metric has been gating live entries.
Tested against realised outcomes over 62 trades: r = 0.0845. Grade A averaged
+0.13%, grade B -0.71%, grade C -0.17% with an 80% win rate -- grade C beat grade
A. There is no signal in it. On 2026-09-14 it blocked SNDQ ("ready below quality
gate (score 0.56 < 0.60)") purely for gappy bars.
0.20 keeps it as a guard against genuinely broken data while removing it as a
setup filter. Renaming the field so the collision cannot recur is the proper fix
and is left for a follow-up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
The 2026-09-13 exit retune is $7.50 behind the config it replaced across its
first 8 live trades. Its thesis -- surrender small frequent gains to keep a rare
large tail -- predicts exactly this early shape, and the backtest behind it was
234 trades across two stacks with train/test splits. But 8 losing live trades are
also what a wrong call looks like, and nothing in the data separates the two yet.
So pre-commit the exit rather than deciding it mid-drawdown. TRADING_EQUITY_FLOOR
halts the session and flattens the book at $215, about -11% from the 241.75 the
change started at. The check reads broker equity directly and returns early on a
0.0 reading, so it cannot trip on the overnight-maintenance nulls.
Owner decision 2026-09-16: keep the retune, on the view that the same day's
risk-equalisation fix (0.05 -> 0.015 per trade) removes most of what was actually
doing the damage -- the four widest-stop entries produced -$8.57 of the -$8.84
week, and those are the positions the sizing change shrinks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
TRADING_RISK_PER_TRADE_PCT 0.05 -> 0.015.
On a ~$240 account the 5% risk budget ($12) is unreachable: a 4.5% stop would
need $267 of notional against a $38 position cap (max_position_pct=0.16). So
calculate_position_size never bound -- the POSITION CAP did, on every trade.
The consequence is that every position was the same NOTIONAL (~$34 average) and
the dollar risk floated with stop width instead of being held constant:
measured over 64 live trades: risk ranged $0.52 to $8.79 -- a 17x spread
That is the opposite of what risk-based sizing is for, and it concentrated the
damage exactly where the stops were widest. On 2026-09-15/16 the four widest-stop
entries (CANG 13.5%, FTFT 21.6%, ZTG 13.2%, VNCE 19.8%) produced -$8.57 of an
-$8.84 week. It also interacts badly with the -10% catastrophe floor: any stop
wider than 10% is effectively a 10% stop, so a wide-stop trade cannot be saved by
its own stop but still realises a full 10%+ loss on the same notional.
At 1.5% the risk calc binds for wide stops while the position cap still binds for
narrow ones, compressing the spread to 6.8x. Replaying the same 64 trades:
-$4.09 -> -$0.32.
Checked that this is risk EQUALISATION and not merely trading smaller: against a
plain proportional shrink to the same average notional, equalising is worth
+$3.27 at 1.25%, +$2.30 at 1.0%, +$2.45 at 0.75%. The benefit holds at every
matched size.
Below 1% trades start being skipped outright as shares round to zero (1 of 64
lost at 1.0%, 13 of 64 at 0.5%) -- a hidden cost that looks like improved P&L.
1.5% is the tightest setting that drops no trades. Expressed as a percentage
rather than TRADING_MAX_RISK_DOLLARS so it scales with the account.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
_population_reject computed the static open gap from bars["open"].iloc[0].
Premarket that is a PREMARKET bar, so the gap is fabricated. Live on
2026-09-14: FTFT opened 3.30 against a 2.88 previous close (+14.6%, well
inside the tradeable 10-35% band) but its premarket bars started near 2.52,
so the filter read -12.5% and benched it at 09:12 -- before the session had
even opened.
It never self-corrected. The frame keeps its premarket bars all session, so
every later pass recomputed the same wrong number, and _transition only emits
on change, so the symbol stayed out_of_population for the day. Several of the
day's rejections show negative gaps (-12.5%, -17.4%, -1.9%) that look like the
same artifact.
Trading was unaffected -- the trigger path does not consult watcher state, and
FTFT was still evaluated and rejected on its own merits (over_extended_day, it
was already +59% off the open). The damage is diagnostic: valid candidates lose
their criteria trail, which is the blank-reasoning-panel problem a previous
commit set out to fix.
Read the open off the first is_regular_hours bar; return None (fail open) when
the session has not opened, since the static gap is not yet knowable. A frame
with no session flag is treated as already regular-hours-only, which is what
tests and replay hand it.
Also drop the pm-range=N counter from step_arm's return string. The arm step
never emits a module_tick -- verified against both 2026-09-11 and 2026-09-14,
where only discovery, ingestion, enrichment and triggers do -- so it reached
container stdout and nothing else. Adding a tick purely to carry it would put
~4k events/day back after the population filter had just removed 88% of them.
pm_range_pct is already on the armed candidate rows for the board; persist it
on the approval payload if it needs to be measurable per trade.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
Three independent tests over 2026-08-01+, all negative:
premarket news, any window 7.9% vs 8.6% ran >=50%
...controlling for PM range 11.3% vs 21.0% ran >=50%
intraday arrival, hour-matched 16.8% vs 24.6% ran >=10% forward
The feed explains it. Of 1,621 headlines delivered for screened names during
sessions: 61.7% reactive roundups ("Why X Shares Are Trading Higher", "20
Stocks Moving In..."), 2.7% law-firm spam, and 0.9% genuine catalyst PRs. The
wire reports the move after it happens, so a boost on "has a headline" buys the
late stage of a run. 11 of the 14 biggest runners (up to +912%) had no coverage
at all.
Set NEWS_RANK_BOOST to 1.0 (neutral). This also neutralises the binary fallback
restored in 9b4c8e5 -- correct in intent (a dead enrichment must not silently
disable news ranking) but it handed a flat 2x to any unclassified headline, and
62% of them are lagging roundups.
Deliberately unchanged:
- the LLM path. catalyst_score = conviction * max(0, sentiment), so a roundup
(type none, sentiment <= 0) scores 0 and earns nothing, while a real bullish
catalyst still lifts. It targets the 0.9% correctly.
- the dilution veto, which sinks confirmed offerings and is a separate,
sound mechanism.
- the enrichment-down alarm from 9b4c8e5.
Also evaluated and NOT shipped: halt-resumption as a catalyst feature. It is by
far the best signal in this universe -- a >=3min bar gap resuming >=2% up ran
>=25% afterwards 40-47% of the time vs ~11-20% baseline, holding in every hour
of the session. But a time-aware A/B of the armed set moved mean forward run
only 27.09% -> 27.84% and 165 -> 173 runners per 725 slots, because gap x rvol
already encodes it (a halting stock has high relative volume by definition).
Same collapse premarket range showed: strong univariate, near-zero marginal.
The ranking is saturated; selection is not where the remaining edge is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
Over 1,040 screened symbol-days (2026-08-01 on), asking which premarket
property predicts the session running >=50% open-to-high:
premarket range n avg run ran >=50%
>= 30% 201 34.7% 15.9%
15-30% 136 28.3% 14.7%
5-15% 303 15.2% 5.9%
< 5% 400 16.8% 4.3%
A name that already travelled >=15% premarket is 3.1x more likely to run
>=50% (15.4% vs 5.0%) -- the largest single lift in this dataset, and free:
it comes off minute bars the arm pass has already fetched.
Two findings constrain the shape:
- It is a STEP at 15%, not a gradient (15-30% and >=30% are 14.7 vs 15.9;
5-15% and <5% are 5.9 vs 4.3). So the multiplier is a threshold. A linear
scale would invent precision the data does not support.
- Premarket VOLUME carries NO signal (10.0/4.2/7.9/9.0% across participation
buckets, non-monotonic) and raw premarket volume runs inversely to the
outcome. scanner_snapshots.premarket_volume being NULL costs nothing;
range is the field that mattered all along.
Do not expect 3x. Replaying armed-set selection over the same 29 sessions,
top-5 by mean/median run and >=50% runners captured out of 145 slots:
gap x rvol (baseline) 39.77% / 10.97% 27
x3.0 if PM range >= 15% 42.00% / 13.16% 32
HARD FILTER >= 15% 37.27% / 12.67% 24 <- worse
PM range alone 32.86% / 12.65% 20 <- much worse
gap x rvol already encodes much of the same information, so the marginal gain
is ~+2 points of mean run and 4-5 extra big runners per 145 slots. It is
BOOST-only for a reason: gating on this feature scored below baseline, and it
must never replace gap/rvol. Every threshold in 10-20% and gain in 1.5-5.0 beat
baseline -- a flat plateau, so not fitted. The shipped 15%/3.0x are the measured
step and the measured lift, not the sweep's peak cell.
Fail-open: an unknown range yields a neutral 1.0, never a penalty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
Five fixes from the 2026-09-13 live-account investigation:
aae7269 exits stopped amputating winners (9.3% of the move captured on
trades that ran >=20%; winners held 10 min vs 19 for losers)
9b4c8e5 news ranking had been off since 2026-08-03, two days before the
first real-money fill -- and could not be seen to be off
0910e29 track the live overrides seed that LIVE-CUTOVER.md references
9034839 a zero equity reading sized off the $100k default (latent)
97a3a1b watcher stops scoring names the entry path can never accept
(3.1 GB of a 7.6 GB events table)
Config changes are already applied to config_overrides on the live stack.
This merge is what ships the CODE half to the image.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
criteria_evaluated + signal_blocked held 3.1 GB of a 7.6 GB events table.
On 2026-09-10 alone the watcher wrote 191,458 criteria receipts across 57
symbols -- ~3,359 per symbol per day -- and 154,260 of them (80.6%) were
for names whose static open gap was outside the tradeable band. NOK, NIO,
PATH, PLUG, SOFI, AAL and F all sit inside the $2-20 price filter, so
WATCHER_PRICE_MIN/MAX admits them; only the gap floor excludes them, and
that was checked per-tick instead of once up front.
Apply the SHARED population gate (strategy/population.population_reject --
the same function, same config the entry path already enforces) before the
evaluation and its receipts. This is behaviour-preserving for trading: those
names were being scored, emitted, and then rejected downstream anyway --
429 "outside tradeable population" risk events across 221 symbols, led by
exactly the names that top the receipt counts. The decision just moves
upstream of the write.
Details that matter:
- The verdict is recomputed each tick, never cached. A >150% gapper reads
"squeeze unverified" on thin volume and is admitted once rvol confirms;
a cached rejection would bench it for the session.
- _transition only emits on CHANGE, so a rejected symbol costs one
state event per session and is then silent.
- Fail-open throughout: an unreadable open, a missing previous close or
absent volume returns None, so a data gap can never bench a symbol.
- Gap is the STATIC OPEN gap (session open vs previous close), fixed for
the day, not the live price -- the verdict must not wander intraday.
Off by default (population=None) so every existing caller and test keeps
its current behaviour; the live loop opts in, and WATCHER_POPULATION_GATE=0
restores the old path.
Note for diagnostics: momentum_cli's criteria views now show only names
inside the population. That is the intended trade -- the excluded rows
described symbols the bot could never have taken.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
Tradier returns total_equity 0.0 during its overnight maintenance window --
sixteen times in the last fourteen days, all around 01:06. Three defects
compounded so that a zero could size a position off default_equity:
account_equity = equity or config.default_equity
`or` cannot tell "not supplied" from "read as zero", so a real 0.0 became
100000. Then the position-value cap, derived from the same equity, was
itself 0 -- and the guard
if max_position_value is not None and max_position_value > 0:
SKIPPED the cap precisely when the cap said "no room". Finally the auto
path (~96% of approval requests) had no `pos_cap <= 0` early-out, unlike
the manual path at request_entry_approval. Net effect on a ~$250 account:
risk 5% of 100k = $5,000, capped only by liquidity. The broker would have
rejected it for buying power, which is luck rather than design.
Never fired in live hours -- every zero read landed with the market closed,
and _current_equity caches the last good value -- so this is latent, not a
realised loss. The reachable path is a boot that coincides with the
maintenance window, when no good read has been cached yet.
Distinguish None from zero, refuse to size on non-positive equity, treat a
zero cap as "no room" rather than "no cap", and mirror the manual path's
early-out. TRADING_DEFAULT_EQUITY is set to 300 on the live stack so even
the untested fallback cannot oversize.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
docs/LIVE-CUTOVER.md already points at this file, so the cutover procedure
referenced something that existed only on one machine. It carries no
secrets -- keys stay in .env (gitignored) and the LAN address it names is
already in .env.example, the compose YAMLs and the systemd unit.
Tracking it also pins the two settings this branch corrects, with the
reasoning inline: the exit retune, and the Ollama endpoint that had drifted
to a port with nothing listening. Untracked, a rebuild would have quietly
restored both faults.
Note the precedence: compose env < overrides.env < config_overrides (DB).
The database wins, so editing this file alone changes nothing on a running
stack -- it is the bootstrap seed and the record of intent, not the live
switch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
step_arm ranks gappers by catalyst, but the enrichment feeding it stopped
on 2026-08-03 -- two days before the first real-money fill. raw_news_items
kept ingesting ~45k items/day throughout; news_events and
news_catalyst_cache have no rows after 2026-08-03. So every live trade to
date was ranked with the news multiplier pinned at 1.0.
Three faults, the third of which hid the other two:
1. Ollama was configured at a host/port with nothing listening, running a
model that was never pulled.
2. _cat_mult returned a flat 1.0 for a symbol with no LLM advisory instead
of falling back to the binary headline boost. With the scorer enabled
and the cache empty that branch is reached for EVERY symbol, so turning
on the graded ranker silently turned off the simple one it was meant to
improve. Fall back properly, so news ranking degrades instead of
disappearing.
3. enrich_recent_news never raises -- it reports failures in a returned
counts dict, and enrich_loop discarded that return value. Every 120s
for 40 days it received {enriched: 0, errors: 50} and threw it away: no
log line, no event, nothing to distinguish a dead pipeline from an idle
one. Consume the counts and emit a module_tick naming host and model,
on the first failed pass and hourly thereafter.
Fault 3 is the one worth fixing; 1 and 2 are its first casualties.
No claim that news ranking pays: a binary "has a headline" flag tests
mildly NEGATIVE across 1,030 screened symbol-days (7.8% vs 10.4% ran
>=50%), and the graded classifier only overlaps bar data for five days,
too few to separate anything. Restoring the pipeline makes it measurable;
it does not make it trusted. Re-measure catalyst type and sentiment
against realised runs before giving the multiplier more authority.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
The live exit ran FOUR independent stop ratchets at once: a +5% breakeven
inherited silently from the env default, a 0.25R step-trail, a prior-low
trail, and profit-lock tiers. Stacked, they pinned the stop at or just
above entry within the first minute of any real move, so winners were
capped near +1R while losers still ran to the full stop.
Measured over 62 live Tradier + 172 Alpaca paper round trips:
- trades whose stock ran >=20% after entry (avg +63.7%) kept 9.3% of it
- median hold 10 min for winners vs 19 min for losers -- inverted
- PCLA 2026-09-11: entered 12.8233, stopped 12.82, a 0.03% stop
breakeven_at_pct was the only exit lever in ExitConfig that defaulted ON
from the env (0.05) while its dataclass default and every sibling lever
defaulted to off, so the live stack ran it without ever setting it. Turn
the env default off so "not configured" means "not applied".
Replayed through simulate_exit -- same entries, same initial stops, only
the management varying -- with each stack split 70/30 by date. A 10%
trail running from entry looked best in-sample and went negative out-of-
sample; what survives is a trail armed only after +1R. The 8-20% range
all beat the old config in all four train/test cells (mean R 0.20-0.25 vs
0.125), so the live value of 12% is a plateau pick rather than a fitted
one.
Live config_overrides updated separately (market closed, no open
positions); this commit is the code default and the regression guard.
Risk per trade is unchanged and both safety backstops -- the -10%
catastrophe exit and naked-stop enforcement -- are untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
Tests whether a PROSPECTIVE forecaster (owner or local LLM) carries signal
the machine levers never found. Picks land premarket via dashboard panel or
research/llm_picker.py (Ollama); research/pick_scoring.py judges them under
a pre-registered bar (n>=40, >=30 sessions, mean >=+0.5%/pick net, median>0,
both halves positive). Picks after 9:30 ET stored but flagged invalid.
Shadow-only: human_picks is never read by the live loop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A market sell that sits unfilled means the symbol is halted (or session
closed); cancelling and resubmitting it every exit-manager pass fills no
faster and spams the broker — YXT 2026-08-05: 87 market sells across a
10-minute LULD halt, 86 cancelled, plus one exit_naked_stop journal event
every ~4s. On a halt-down the resting sell is also the position's only
protection.
cancel_protective_and_close now returns False (nothing submitted) when a
market sell for the symbol is already working, and only cancels
non-market protective sells before closing. Exit-manager paths
(catastrophe / naked-stop / first-red / trail) journal the exit only when
a close was actually submitted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tradier stack emitted account/positions/orders events hardcoded as
broker_name=alpaca_paper while the executor filters snapshots by
client.broker_name (tradier) — position cap and re-entry block read zero
positions, and the dashboard showed 'Alpaca Paper' over real Tradier values.
Mode label now defaults from BROKER/TRADIER_ENV (tradier_live) instead of
alpaca_paper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Year-replay stop-width buckets: <8% +0.072R, 8-12% +0.120R, 12-16%
-0.054R, >16% -0.092R — the wide cohort holds every recent dollar
disaster (BIYA 16.7%, FCUV day-2 17.5%), where the exit ladder's blind
spot (BE/trail only arm on the way up) rides the full catastrophe
distance. New stopwidth_gate intervention (fail-open without levels) +
registered spec, same forward bar as the pm-high gate; the two cohorts
overlap so the adjudicator attributes which does the work. In-sample
counts for nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/api/snapshots ran 33-60s even after retention pruned the events table —
profiling each projection found query_gate_receipts (21s) and
query_loop_session (10s) fetching thousands of full payload rows to
Python to keep 100 (or ONE). Both now select newest-first with SQL
LIMIT / single-column reads: 0.12s and 0.07s against prod. Remaining
projections (watch_states 6s, daily_performance 5s, approval_queue 3.5s)
are the same class — later pass if the snapshot still feels slow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On the Tradier live cash account buying_power maps to cash_available —
SETTLED cash. Capping every entry's notional at it means the bot never
spends today's sale proceeds today, which is the entire good-faith
-violation rule. That replaces the blunt 1-trade/day cap: the live
account now trades like paper (cap back to 5/day), throughput
self-limits at ~settled/position-size sequential trades and refreshes
each morning at T+1. On margin accounts buying power is ~4x equity so
the cap never binds — one universal rule, no broker branches. Fail-open
when the field is absent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
H3 (enter the pm-high break at 9:30 instead of the ORB) FAILED its
pre-registered bar on the year replay: paired delta -0.069R (n=689) —
entering earlier on the same names does not pay; the 5-minute wait stays.
The decomposition found the real signal: ORB fires where RTH price also
reclaimed the premarket high ran +0.316R (n=689) vs -0.298R (n=477) where
it never did — 100th percentile of a 2000-draw random-gate null at the
same keep-rate (null p99 +0.127), sign-consistent 14/14 months. First
lever of 15 to survive both checks in-sample.
Because the split was found in this dataset, it counts for nothing until
fresh fires confirm: new 'pmhigh_gate' intervention kind (fail-open on a
missing premarket_structure row, RTH-only reclaim check at/before the
entry bar) + registered spec with expR_delta >= 0.05 at n >= 200 forward
bar. Cohort self-registers on the next nightly experiments run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pre-registered premarket hypothesis H3 (the source trader's actual
entry) made executable: both legs run per symbol-day over the year of
labeled gapper sessions, sharing the deployed exit engine (config parity)
and +1-tick adverse fills so the paired delta isolates ENTRY choice.
Comparison metric locked in the module docstring before the first run:
paired mean R delta (H3-ORB) where both fire, pass at >= +0.05R with
n >= 300 pairs, plus a stop-unit guard (median stop-distance % and
captured-move % printed for both legs — R parity from a tighter stop is
a fail, the vwap-reclaim lesson).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four of ten armed triggers had NO criteria events ever — the trigger book
arms from the live gapper scan while the watcher scores only the
discovery watchlist, so scanner-only names (TCX/ZTG class) showed an
empty reasoning panel with nothing behind it.
- arm pass injects every complete armed candidate into the watchlist
provider's extra_symbols (its standing manual-add hook)
- provider returns injected symbols FIRST: the watcher truncates at
max_symbols, so extras appended after a full research list were
silently dropped — 'always include manually injected symbols' was
untrue exactly when the list was full
60 watcher/watchlist/trigger tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every SSE snapshot re-render rebuilt the expanded panel's HTML back to
'loading…' and re-fetched — panels flickered per tick and never looked
settled. Rendered panels now cache per symbol: re-renders repaint from
cache instantly, background refresh at most once/min, single-flight
guard against fetch races. Watch panel and trigger panel share the
cache; toggleCriteria's duplicate direct fetch removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
api/static/** ships inside the image but wasn't in the docker workflow's
paths filter — dashboard-only commits (a6da02f trigger-row reasoning,
81bf154, da17be8) silently produced no image and only reached production
by riding along with later .py commits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The day's relevant names live in ARMED TRIGGERS — the reasoning panel now
opens right there: click a trigger row for the score/rvol timelines,
criteria lanes, and worded reasons, same renderer as the watch panel.
Caret + hover affordance; open panels survive snapshot re-renders.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clicking a watch-state row now shows HOW the verdict evolved, not just the
latest flags:
- setup score across the session vs the 60% gate (SVG line)
- relative volume vs its 2.5x floor
- per-criterion pass/fail lanes over the whole session (the variance of
each logical step at a glance)
- each criterion's latest reason in words (rvol 4.9 (min 2.5), gap 80.9%,
price vs breakout ...) instead of bare pass/fail marks
Backend: query_symbol_criteria gains history[] (server-side strided to
~240 samples) + detail[]. The generic LIKE scan took 12s/click over weeks
of criteria rows; now a partial expression index on the extracted symbol
(idx_events_criteria_symbol, built at boot) + 24h window + SQL-side
stride = <1s. Latest-verdict fallback keeps stale symbols and historical
views working (covered by existing projection tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every LLM catalyst read's headline now links to the raw article
(raw_news_items.raw_url joined by headline), styled as a distinct dotted
underline per NN/g citation guidance — any AI verdict traces to its
source in one click. Rows without a matching raw item degrade to plain
text. Join verified against prod: 0.29s for the full feed query.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gain/loss no longer rides on green/red alone — red-green color vision
deficiency affects ~8% of males; the minus sign already printed, now the
plus does too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Two-texture typography: labels move to the UI sans (11px, brighter muted
#93a0bd), values stay tabular mono — label/value pairs parse instead of
dissolving into uniform mono soup
- TOTAL P&L is the one dominant instrument: 34px + green/red state wash and
accent bar on the cell (CSS :has on the existing pos/neg classes, no JS)
- Equity tile: available funds as a small sub-line (fixes the two-line wrap
that broke the strip height)
- Armed triggers: single-line ellipsized meta/levels (was a 3-line crumb
wrap), 15px symbols, ARMED/WEAK state chips, subtle zebra, thinner bars,
wider levels cell so entry/stop/now never clip
- Verified with headless before/after renders against live data; zero JS
logic changes, node --check clean
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eod_replay's universe was today's top-100 by ABSOLUTE volume — small-cap
gappers never crack that list, so 2026-07-28 replay found 1 gapper on a
day the live loop signaled 8 names. Universe is now the union of (a) the
session's symbol_discovered events, (b) historical_gappers for that date,
(c) most-actives only on a same-day replay (past-date most-actives would
be look-ahead). Verified: 7/28 replay now screens 6 gappers / 4 trades,
matching the live day. Historical sessions also fetch that day's
04:00-20:00 ET SIP bars explicitly instead of a from-now lookback window
that pulled the wrong day.
backfill_gapper_days.py: for each of the ~260 recorded gapper sessions,
pull SIP minute bars (premarket incl.) + 40d of daily bars for that day's
gappers only, then build premarket_structure. Idempotent (skips sessions
already covered). Fattens n for premarket H1/H2, pyramid_1r_v1, and the
orderflow experiments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New 'pyramid' intervention kind in the replay engine: at +add_at_r on
initial risk, add add_frac x size (+adverse fill), whole-position stop to
blended breakeven; scratch there if a later bar trades through, else both
legs exit at the baseline exit. variant_r is per unit DEPLOYED
(P&L / ((1+add_frac) x risk)) so leverage alone cannot fake a pass.
Spec pyramid_1r_v1: add 0.5x at +1R, bar expR_delta >= +0.05 at n >= 300,
futility kill at n >= 500 below -0.02. Hypothesis notes the known failure
mode (median winners scratched at blended BE on first pullback). Shadow
only — no live-path changes; cohort self-registers on the next nightly
experiments run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Distills each gapper's premarket tape (first-hit price/time, pm high/low,
run %, open-vs-pm-high, VWAP distance, jackknife count) into a
premarket_structure table for entry-conversion analysis. Motivated by
FCUV 2026-07-31: our DB captured the full $1.98->$10.49 premarket ramp
while the ORB entered at $11.89 into the reversal.
Pre-registered hypotheses H1-H3 locked in the module docstring before any
data was examined; report command prints descriptive buckets only, with
n>=30/bucket floor and Phase-2 shadow bar required for promotion. Gates
nothing, trades nothing.
Wired into the nightly scheduler after premarket_eval. One session scan
per build (per-symbol queries were ~100x slower); partial session index
created post-close only (mid-market CONCURRENTLY build deadlocked against
live ingest).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shows total_equity and buying_power (settled available cash) from the
latest account snapshot. On the Tradier live cash account these diverge
for days after a deposit or sale (T+1) — the bot sizes off equity but
buys need settled cash, so the gap explains rejected entries at a glance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delta to LIVE-CUTOVER.md (Alpaca-era): 3-line env flip on the existing
tradier stack, cash-account GFV cap (1 entry/day), equity floor 270,
sandbox-record truncation before first live boot, day-1 naked-stop drill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- runtime/retention.py: prune 8 high-volume debug event types older than
RETENTION_DAYS (default 14) once/day at RETENTION_HOUR (default 03:00).
Audit events (orders, fills, closes, risk rules) are never pruned. The
events table hit 6 GB in 3 weeks at ~340k rows/day; this caps it.
- runtime/telemetry_push.py: POST a JSON snapshot (equity, day realized
P&L, trades/wins, open positions, last-event age) to TELEMETRY_PUSH_URL
every TELEMETRY_PUSH_INTERVAL_SECONDS (default 300). Unset URL = step
disabled. Push failures are reported in the step result, never raised —
an external outage can't touch the loop. Works as healthchecks.io
dead-man ping + payload log in one.
- wired both as scheduler steps in run_live_paper (retention gated by
RETENTION_ENABLED, telemetry by TELEMETRY_PUSH_URL presence)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dashboard realized = broker equity delta (equity - last_equity). Tradier
balances carry no prior-session-close field and translate_balances echoed
current equity, making the delta permanently zero (win rate populated from
closed events while dollars showed $0). Absent last_equity now routes the
projection to its closed-events fallback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
General translation must not echo leg fills onto the OTOCO parent —
flatten_fills adds parent AND legs, double-counting entries in journal
reconstruction. The cancel-vs-fill reconcile still sees executed shares:
get_order_by_client_order_id surfaces the entry leg's fill on its result.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Incident: entry-timeout cancels raced lazy sandbox fills -> untracked
positions; naked-stop guard then force-sold but every sell rejected
('more shares than your current long position') for hours.
1. translate_order: Tradier OTOCO/OTO parents carry no top-level
symbol/side/qty (legs only) — inherit from the entry leg so
symbol-scoped lookups can see brackets; get_orders symbols filter
also matches by leg symbols. Without this the stop-leg search read
a fully-protected position as naked.
2. close_position: cancel the symbol's resting sell orders/legs first —
Tradier reserves held shares against open sells, so selling without
releasing them always rejects.
3. cancel_entry (broker-agnostic): after cancelling, re-check the
broker's final word by client_order_id; journal a fill for any
executed shares instead of orphaning a real position (2026-07-06
class, same race exists on Alpaca).
49 tests pass incl. new coverage for all three.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Exit manager reads position current_price for breakeven/trail ratchets and
the stop-breach guard; Tradier sandbox quotes are 15-min delayed, so marks
lagged reality. Prefer the Alpaca data client's latest trades (real-time),
fall back to Tradier /markets/quotes per missing symbol.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eod_reconcile, backfill_journal and the admin health block were hardwired
to AlpacaPaperClient — on a BROKER=tradier stack they would silently
reconcile/report against the wrong broker. make_trading_client() honors
BROKER env with no silent cross-broker fallback (missing Tradier keys =>
skip with reason, never fall back to Alpaca). Admin UI shows the active
broker label; JSON key stays 'alpaca' for compatibility.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Badge text was hardcoded to ALPACA; _broker_block now reports the active
broker name and derives paper/live from the Tradier sandbox host when
BROKER=tradier, keeping the base-URL-truth rule from plan 007.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fresh DB has no config_overrides row, so without the compose-level pin the
parallel stack falls back to the thin IEX feed and its rvol/volume decisions
diverge from the main stack (which carries feed=sip in the DB).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Self-contained shakeout stack beside the paper stack: own postgres (host
5434, disposable pgdata-tradier volume), own dashboard (host 8768), loop
with BROKER=tradier (sandbox). Data plane stays Alpaca; Telegram/deadman
disabled so main-stack alerts stay unambiguous; main data dir mounted
read-only (inherits learned_params/overrides.env). Filled local copy is
git-excluded; bootstrap_live_db.sh playbook reused for DB parity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/api/backtest now annotates each replay trade with the live loop's realized
P&L for the same (session, symbol) and returns a live_split summary (taken vs
not-taken n/sumR/avgR). Page grows two summary cells, a Live P&L column with
an amber ≠ marker when sim and live disagree on a trade's sign, and a footer
caveat: minute-bar granularity + adverse fills flip individual rows — trust
aggregates, not rows. Read-only annotation; cached replay result unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Read-only nightly-scheduler job (Friday-gated): buckets realized P&L by entry
time (open/late-morning/midday) and, for each market_exit close, computes the
post-exit run-up capturable under a breakeven stop (0 if price undercuts entry
first; intrabar ties count as stopped). Emits a weekly_tally event + Telegram
summary. Evidence accumulator for the dead no_new_entries_after cutoff and the
weakness-exit winner-clipping question — no live trading path touched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The DB-config wiring was unnecessary there anyway — market_context reads
zero trading knobs; the guard test (plan 009) rightly rejected the import.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version: CI now tags images YYYY.MM.DD-<shortsha> alongside :latest/:sha
and passes GIT_SHA/BUILD_TAG build-args; the running app reports them via
dashboard /api/health and admin /api/status (TrueNAS custom apps only show
'custom/1.0.0', so the app self-reports what it actually runs).
Config parity: every scheduler-run script (nightly_tune, eod_replay,
premarket_eval, regime, market_context, experiments cli) now loads
config_overrides at startup like the live services do — a bare env would
replay under DEFAULT exits (the documented 4.5x-baseline landmine).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner preference: the bulk of TRADING_*/TRIGGER_* logic switches should live
in the DB, not the compose file (keys + connection info stay in compose —
the DB address cannot live inside the DB, and secrets don't belong in a
table the stack can SELECT). Precedence at boot: compose env < overrides.env
< config_overrides table; every entrypoint already routes through
env_overrides.load(), which now applies the table last. Admin /api/config
and profile-apply write the table (secrets whitelist unchanged); the config
page shows source=database. DB down = silent fall-through to file/compose,
boot never blocks on config. One-shot migration:
python -m runtime.env_overrides seed-from-env
run in any stack container to copy its current effective knobs into the
table, after which the compose lines are dead weight strippable at the next
redeploy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 07-22 'tuner NOT running' alert could not be diagnosed remotely: the
heartbeat is a file on the scheduler container's volume, invisible to
everything else, and indistinguishable from a stale scheduler image or a
DATA_DIR mismatch. _write_heartbeat now also inserts a telemetry_events
row (best-effort); the boot staleness check falls back to the DB row when
the file is absent, so 'tuner ran, no profitable config' stops looking
like 'tuner dead'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/LIVE-CUTOVER.md: full flip-day procedure — staged pieces, discovered
dependencies (second paper key pair, DB seeding, learned_params copy,
telegram token, ports/dirs), 15-min cutover with the paper stack running
throughout, verification list, rollback. bootstrap_live_db.sh seeds the
live stack's Postgres from the paper nightly dump and truncates the
money-record tables so research history carries over while live P&L
starts at zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TRADING_EQUITY_FLOOR (0=off): equity at/below an absolute dollar floor
permanently halts the session and flattens the book — the total-drawdown
backstop the per-day breaker can't provide (a slow bleed of -2.9% days
never trips it). Same flatten path, loud risk event, bounce does not
re-open.
TRADING_BOOT_RECONCILE (default on): at boot, diff the broker's open
orders against today's submitted events — an open BUY on an unheld symbol
this process never submitted is a crash-restart orphan (the 2026-07-06
duplicate-order incident) and is cancelled. Orders on held symbols are
never touched (cancelling a bracket parent strips its protective stop).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prices each closed round-trip against the nearest quote_snapshots NBBO:
per-trade spread cost, book-walk flag (order size > displayed ask), and a
cost-adjusted P&L total. Measured this week: 0.3-0.5% median spreads on
gate-passing names, ~12% profit drag — far below the static 2-4% microcap
proxy. Coverage gaps (quote recorder only follows tracked names) reported
per-trade, not silently dropped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stored minute-bar history is 273 sessions (~13 months); the 45-session
clamp blocked months-scale ranges now that the nightly labeler keeps
setup_events current. Requests stay serialized behind _backtest_lock; a
full-history run takes minutes by operator choice.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dashboard backtest sources sessions from setup_events v1, which only the
labeler writes — and the labeler was never in the nightly scheduler, so
setups (and therefore backtests) froze at the last manual run (07-17).
Root blocker was _insert crashing on duplicate setup_ids on any re-run;
now ON CONFLICT DO NOTHING, so incremental nightly builds are safe.
Scheduler gains 'labeler build' + 'build-tod1' after eod_replay in both
compose files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The prior commit imported query_loop_session but a botched idempotency
guard skipped inserting the snapshot field (the import string itself
matched the 'already present' check) — ruff unused-import failed CI and
the stale-session banner had no data. Field added; banner live.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 07-21 morning outage (loop booted post-EOD on the 20th, survived
midnight, silently blocked every entry while signals went READY) had zero
UI surface. New query_loop_session projection parses the live session id's
embedded date; a red banner fires when it isn't today's — the exact day-2
poison signature — telling the operator to restart the loop. Armed
Triggers rows now also show rvol_tod (the arming signal since 5597250)
next to legacy rvol.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 07-21 misses (CPHI +417%, DFNS +94%, GREE/WGRX +40%) were all static-gap
<10% names the validated gapper population excludes by design, but they share
a signature no refuted detector (liq1/run1) measured: rvol_tod (cum volume vs
median-20d prorated by elapsed session) at 50-1000x on a flat open. New
labeler setup tod1 fires on the rising edge of rvol_tod>=50 AND price>=
prev_close*1.20, restricted to static gap <10%, stop = 5-bar swing low,
scored via the shared _forward_labels. Shadow-only; pre-registered promotion
bar locked before forward data (median fwdmax60 >=+10%, median dd60 >-6%,
+1R>50%, top stratum rvol_tod>=200 clears alone).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SNXX 2026-07-21: 18s after a mid-morning restart the fast path bought a
15-minute-old break +1.6% above the trigger — fires() only checked
price >= trigger, so any blind window (boot, halt, feed outage) fired
instantly on already-extended moves. A trigger now fires only if the live
price was seen BELOW the trigger since arming (a real cross), with a
small at-the-break tolerance (TRIGGER_FRESH_CROSS_TOL, default 1.5%)
so a fast break between ticks still fires; beyond it the name must pull
back below the level and re-cross. Gap-through runners stay covered by
the slow marketable path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A process booted AFTER the day's EOD flatten never gets exit-after-flatten,
survives midnight, and every memory-only daily flag (_session_closed, entry
caps, benches) stays stale — the documented day-2 limitation, hit live at the
2026-07-21 open (booted 17:11 the day before; signals READY + book armed but
zero gate evaluations, both entry paths silently blocked by _session_closed).
Main loop now exits cleanly when the session date rolls over; compose restarts
a fresh process that rebuilds the new day's state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Day-22 forensic (BIYA session): 6 entry submissions, 0 fills — IREX missed
+1.7R, IRE +1.65R by one cent. Two root causes fixed:
1. Slow-path limit rested AT the trigger while price ran 2% past it, and
retries reused the same stale price. Marketable model now prices from
max(trigger, live last) via price_provider; the anti-chase extension
gate gets the live ref too (was an entry_ref==trigger no-op on this
path). TRADING_ENTRY_FILL_MODEL flipped to marketable.
2. Fast trigger path armed nothing all day: legacy rvol (cum-so-far /
full-day MEAN) is ~0.1-0.3 minutes after the open for a normal heavy
name and its mean is poisoned by reverse-split/prior-squeeze days
(BIYA legacy 1.27 vs real activity 481x median). New rvol_tod =
cum / (MEDIAN(20d) * elapsed-session fraction) drives arm eligibility
and the scanner keep-filter; legacy rvol untouched for validated
boundaries (gap-squeeze rvol>=5).
Also: stagnation time-stop (TRADING_EXIT_STAGNATION_MIN/_R, default OFF)
in simulate_exit + manage_live with sweep_exits.py variants for
validation before any live enable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner asks on the candlesticks:
- VOLUME: histogram series (bottom 20% overlay), coloured by candle direction.
- BOLLINGER BANDS: 20-period 2-sigma, computed client-side from closes; price
riding the upper band = strong, break back inside = fading (momentum context).
- EXIT markers were NEVER wired — the fills feed carries only entries
(order_submitted), and the loop fills brackets via position-diffing so no
order_filled fires. Exits live in position_closed; now surfaced as square
markers with reason + P&L (green win / red loss). Entry 'expected' (unfilled
submit) markers labelled distinctly. Fixed a self-inflicted bug: queried the
event store AFTER closing it — moved the position_closed fetch inside the
open-store block. 11 chart tests green incl. new exit-marker test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The self-heal commit (a00f967) inserted the _research method INSIDE __init__ at
method-body indent, truncating it so _change_signal/_backtest_lock/_backtest_
cache never got set -> AttributeError on backtest. Moved _research to a sibling
method; __init__ now sets all attrs. Two chart/sparkline tests updated: a None
research_con now REOPENS lazily, so 'no research db attached' is reached only
when the reopen also fails (they monkeypatch open to fail). 22 api tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The charts weren't broken — the data pipe was empty. dashboard_api opened the
research connection ONCE at boot; if the DB was unreachable then (container
started before Postgres, or a blip) it stayed None forever, so /api/chart and
/api/bars returned 'no research db attached' and every candlestick + sparkline
rendered blank. Live-verified: GET /api/chart returned bars:[] reason:'no
research db attached' on the deployed dashboard. Now DashboardState._research()
opens the connection LAZILY and reopens if None — same self-heal as the
event-store emit fix. 2 tests; chart/backtest endpoint tests still green.
No library change: Lightweight Charts v5.2 is fine, it just had nothing to draw.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test compared len(w) and list(w) as two SEPARATE locked reads, so the
churn thread could mutate between them (30 vs 31) — a bug in the test, not the
Watchlist (each read is individually consistent; there is no torn-read
guarantee ACROSS two calls). Now snapshots once and asserts that snapshot never
tears + concurrent contains/slice never raise. 5/5 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Telemetry: discovery took 6-30s ON the single scheduler, freezing ingest/arm
so the trigger read stale bars — the real 'late edge detection'. Now discovery
runs on its own daemon thread with a dedicated DB connection; the watchlist it
mutates is a lock-guarded Watchlist (list-like drop-in, no torn reads); the
scheduler discover step auto-disables (single-writer). Loop is now 3 fast
threads (entries/exits/discovery) + slow scheduler for tolerant work.
Also: nightly_tune writes a heartbeat every run, so the boot alert distinguishes
'tuner not running' (real) from 'ran, no profitable ORB config, kept params'
(expected — the strategy has no edge). Kills the misleading Telegram alarm.
543 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Exits move off the single-threaded scheduler onto their own daemon thread at
1s (mirrors the fast trigger thread). Dedicated research connection (shared
facade isn't thread-safe); scheduler exits step auto-disabled when the thread
runs (single-caller invariant — no racing instance state, no double-submit);
manage() already idempotent (stop ratchets >1c up only). 3 driver tests + 539
suite green. On fast small-caps this is the 'get out fast' the owner asked for.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner: 'make all decisions as fast as possible.' The real entry-detection
ceiling was ingest — bars refreshed every 20s, so the 4s trigger thread read
data up to 20s stale and detected breakouts late. Ingest is incremental+cheap,
safe at 5s. Fresher bars + faster arm (trigger levels) + 2s trigger check +
the 4s exits shipped earlier = the whole ingest->detect->decide->exit loop
tightened. One batched data call per cycle so Alpaca rate limits are fine.
Sub-second exit management on a dedicated thread is the next (code) step.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner's real complaint: late exits gave back gains. Exits ran on the slow
scheduler at 12s while entries run on the 4s fast thread — exits were 3x
slower than entries. A small-cap can spike and round-trip inside 12s. Drop
TRADING_EXIT_MANAGE_INTERVAL_SECONDS to 4s (config, reversible, both compose
files). Deeper fix (exit mgmt on the sub-second trigger thread) planned
separately. Entry chase-tail (24% fill >5% above break) is the next
investigation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sliced 2191 depth fires gap x overhang under real exit: lower-gap is WORST
(0-3% -0.831R), inverting the low-gap-edge hypothesis. Overhang helps a little
everywhere, nothing crosses zero. Exhaustive: 13 levers + regime + depth +
this grid = zero positive. No positive subpopulation in gap-ORB $2-20.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Definitive test, n=2191 real deployed exit: ask-overhang separates +0.083R
(high -0.676 vs low -0.759, direction held sniff->scale = genuinely real) but
fails the random-gate null — it shrinks losses, never creates profit. Live
gap>=10% band n=13/74 = no signal. Same shape as regime gate on a negative-EV
base. 13 conversion levers + regime + depth tested, zero make it positive.
Real-money rec: NO. Depth edge argues for a broader-population redesign as
future research, not a live fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 2026-07-17 20:23 postgres blip left the live loop writing zero events for
hours: emit() executed on a RAW cursor, bypassing PgConnection._run's
reconnect. cursor() doesn't round-trip so the dead conn went unnoticed until
the raw execute failed with no retry, and self._conn was never rebuilt.
Routing emit through the facade execute() wraps the round-trip in _run, so a
DB blip reconnects on the next write instead of running blind. 2 reconnect
tests + 34 storage tests green. (Deeper 'halt trading when persistently
unable to write' safety guard queued separately.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plan 017 Phase 0->3. Databento depth backfilled for 602 fires across 21
sessions -> depth_features table. Reweighted to true 23% population win rate,
gating on ask-overhang-above-break (keep>=0.034) lifts win rate 23->28% and
clears the random-gate null (+0.057 vs +0.05 on the conservative +1/-1
bracket). iv_depth_overhang_gate = keep-or-zero, skip-on-no-coverage (the
dilution-v1 lesson). This commit registers the REAL-exit harness run
(simulate_exit asymmetric trail) for the true live delta. Counter-intuitive
mechanism: more resting supply above the break = MORE likely to win
(absorption/fuel), invisible in bar data — the owner's 'size stacked above'
instinct, proven.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First genuine selection signal in the project. 40 real fires, Databento
XNAS.ITCH book at each trigger: naive imbalance = noise (AUC 0.45/0.50), but
ask-size resting within 1% ABOVE the break separates winners at AUC 0.80
(winner median 0.276 vs loser 0.005; median split 27% vs 67% win). Inverted
from supply-trap intuition = absorption/fuel mechanism, invisible in OHLCV.
n=23 single-day sniff -> GO to Phase 1 recorder, not to trading. Cost: pennies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gate is damage-control, not an edge source: it can only allocate the
strategy's existing (negative) edge onto least-bad days — best case flat,
never gain. Its value is CONDITIONAL: a holding pattern until an entry edge
(plan 017 L2 depth) is found, at which point it flips to force-multiplier
(good names x good days). Owner's 'it just turns the bot off' critique is
correct at the current no-edge base.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cost-checked (no data spent): DATABENTO_API_KEY reaches XNAS.ITCH TotalView
10-level depth; every real gapper (EHGO/ATPC/MUD/LASE/AEON) covered at
$0.0001-0.0066/fire, full 20-fire sniff <$1 inside free credits. Catalogs
all 13 provisioned-but-unwired keys with a wire-on-demand triage (Databento
= game-changer; 4 news APIs = skip; rest redundant).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The one untested selection axis (order-book depth = the owner's 'size stacked
above the break' instinct). Staged so $0 is spent until a free sniff test +
recorder prove the signal exists and covers the universe; paid feed is an
explicit owner gate; the signal faces the same pre-registered bar + random-gate
null as every refuted lever before it touches the live entry. Honest ceiling
stated: a PASS nudges odds, is not an autonomous oracle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner saw a blank /backtest — it was CORRECT (the regime gate sat out every
recent session) but unexplained. Now: ?regime=0|1 override on /api/backtest,
an 'apply regime gate (as deployed)' checkbox, and an empty-state message
saying WHY there are no trades and what to click. Gated view = what the bot
would do; ungated = the raw simple criteria.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
admin_api.py called load_dotenv() unconditionally at module level, so
merely importing it (e.g. pytest collecting
tests/unit/test_admin_effective_config.py) ran python-dotenv's upward
find_dotenv() search, loaded the real repo .env (secrets included),
and polluted os.environ for the rest of the test session.
Gate the load_dotenv() call behind `if __name__ == "__main__":` at the
same source location, so it still runs before the module-level
os.environ.get(...) constants when run as the entrypoint (`python
admin_api.py` is unchanged) but never fires on a plain import.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
research/attribution.py's _connect() read os.environ["DATABASE_URL"]
directly, bypassing the storage facade fallback chain (DATABASE_URL,
PG* vars, LAN host retries) every other module goes through — and
KeyError-ing (uncaught by name, though still 500'd by the endpoint's
broad except) whenever DATABASE_URL was unset.
Delegate to storage.db.get_connection().raw instead, so DB resolution
matches EventStore/research.multi_schema, and a genuinely unreachable
DB fails with a catchable psycopg2.OperationalError rather than a
KeyError.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OrderFilledEvent had no side field, forcing api/main.py's chart() to
backfill it from the sibling order_submitted row. Add an additive-only
`side: str | None = None` field (existing payloads without it still
parse) and populate it at the sole production emit site
(alpaca_paper/execution.py _finalize) from the ExecutionRequest already
in scope. The api/main.py backfill workaround is left in place as a
harmless fallback for pre-existing events.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>