The loop healthcheck opened a fresh connection every 60s with schema
auto-apply enabled; the CREATE-IF-NOT-EXISTS lock bursts cancelled
minute_bars autovacuum every single cycle (visible as 'canceling
autovacuum task' each minute post-migration), so the 19M-row table
never got analyzed, snapshot queries crawled, browsers timed out, and
the dashboard log filled with double BrokenPipeError tracebacks.
- healthchecks now set PG_SKIP_SCHEMA=1 (both compose files)
- api/main.py _send_json swallows BrokenPipe/ConnectionReset (client
hung up; nothing to tell it)
- live DB: ALTER SYSTEM max_wal_size=4GB, shared_buffers=1GB,
effective_cache_size=3GB, checkpoint_completion_target=0.9 (the
checkpoint-every-24s warning under migration load on NAS storage)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New admin service — LAN-only, token-authed (ADMIN_TOKEN), card-based UI
matching the owner's preferred dashboard style. Capabilities:
- health: docker containers via /var/run/docker.sock (stdlib unix-socket
HTTP client), DB heartbeat, Alpaca account/clock, Ollama, feed
- config: whitelisted strategy knobs (TRADING_/TRIGGER_/WATCHER_/...)
edited via data/overrides.env, which every entrypoint now loads with
override=True at boot (runtime/env_overrides.py) — so changes apply on
container restart without touching the deployment YAML; secrets are
presence-only and rejected server-side if edited
- profiles: named knob presets (seeded: validated-core, conservative,
squeeze-hunter) applied in one click
- restart buttons per container; paper/live switch guarded behind
ALPACA_LIVE_* keys existing
Verified live in docker: auth 401s, status/config/profiles/write paths,
secret-edit rejection, page serves. Added to both compose files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two new nightly enrichments for the day's tracked names, both SHADOW
ONLY (float was refuted as a gate; a filings-based dilution veto needs
its own pre-registered bar):
- research/ingestion/float_cache.py: FMP stable shares-float ->
float_cache table (free-plan budget guards: 7-day refresh, 200
calls/run). First real float data in the system — LHSW's +346% ran
on a 1.1M float; BJDX is 0.4M.
- research/ingestion/sec_dilution.py: SEC EDGAR full-text search (free,
keyless, fair-use paced) -> populates the previously-empty
dilution_events table with S-1/S-3/424B filings; TVRD + BJDX flag
immediately. recent_dilution() helper for future veto work.
Wired into premarket_eval nightly. Not wired: Polygon/AlphaVantage/
TwelveData/Marketstack/EODHD (redundant vs paid Alpaca SIP) and the
generic news aggregators (slower than Alpaca/Benzinga, dedup noise).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
apscheduler + youtube-transcript-api (transcripts service crash-looped
on TrueNAS: ModuleNotFoundError), rich + typer (momentum_cli journal
job). Found via AST sweep of all third-party imports vs the image
contents. Image rebuilt, import-verified, pushed to both registries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI now pushes fez5587/momentum-app to Docker Hub alongside GHCR (creds
via repo secrets). First push done manually and verified anonymously
pullable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
truenas-compose.yml now uses /mnt/volume/Apps/momentum and publishes the
new postgres on 5433 so it can coexist with the existing instance during
migration. deploy/docker/migrate_db.sh: dump / restore / verify steps
(pg_dump -Fc read-only source, parallel pg_restore, ANALYZE, row-count
diff) with env-overridable URLs and PG18 client autodetection —
connectivity smoke-tested against the live DB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TrueNAS custom apps cannot build: the main compose's 'build: .' (plus
relative .env/bind mounts) fails app 'up'. Added a GitHub Actions
workflow publishing ghcr.io/fez5587/momentum-app on every master push,
and deploy/docker/truenas-compose.yml — a pull-only variant with
dataset host-paths, inline environment (secrets as <FILL-ME>
placeholders, current validated strategy knobs pre-filled), and the
same healthchecks. Validated with docker compose config.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
.env.example was stale (pre-migration keys, missing all current
TRADING_*/TRIGGER_* knobs). Regenerated from the live config with
secrets <REDACTED>; a leak check caught live Alpaca credentials
embedded in commented-out PowerShell lines and scrubbed them before
commit. README now documents the three config layers (protected
secrets vs promotable strategy knobs vs infra addresses) and the
external-Ollama networking expectations.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings the pre-migration compose file up to the current architecture:
python 3.13 image with ffmpeg + faster-whisper/yt-dlp (transcripts),
postgres 16 -> 18 (prod parity), and the real service set — loop
(--no-dashboard, heartbeat healthcheck), dashboard_api.py on :8765,
telegram qabot, youtube transcripts, and a 20:00-ET weekday scheduler
replacing the premarket-eval systemd timer. Jobs (journal/eod-replay/
nightly-tune) and Grafana keep their profiles. Self-contained by
default (in-stack DATABASE_URL); deploy/docker/README.md documents the
pg_dump migration, the transition mode against the LAN DB, and the
cutover checklist. Validated: image builds, compose config passes, and
a smoke container served the dashboard (HTTP 200) against the live
LAN Postgres.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tested the source trader's numeric selection playbook on the unbiased
(open-knowable) year universe, n=2,643 point-in-time ORB entries:
- Dead band RE-CONFIRMED on clean data (35-75% +0.006, 75-150% -0.066)
— CLRO/VTAK were anecdotes; band exclusion + squeeze exception stand.
- His $5-10 price sweet spot inverts for our system: edge RISES with
price ($10-20 best at +0.143); the $1-2 bucket is negative (-0.031,
n=609). SHIPPED: price floor $1->$2 (population gate band now
env-driven TRADING_PRICE_MIN/MAX; watcher floor raised to match).
- His rvol>=5 floor is backwards for us: rvol 2-5x at entry +0.346R vs
>=10x -0.047 (n=818) — crowded spotlight names hurt our entries.
NOT shipped (conflicts with the squeeze exception's rvol needs;
logged for forward validation via the new per-signal relative_volume).
- SHIPPED: buyout/merger headlines never rank-boost (price is pegged —
the CRNX class); keyword suppression in the catalyst multiplier.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Evening audit of 2026-07-08's post-fix entries (TVRD +4% true gap,
FRTT — both fired as "20% gappers"): the morning's static-gap fix was
defeated by a data hole. ingest_live_minute_bars' full-session first
pass was a GLOBAL one-shot, so symbols discovered mid-session only ever
received the 20-minute refresh window — their earliest known bar was a
mid-day bar, which became both their "session open" (fake overnight
gap) and the base of their "opening range" (fake ORB trigger). Exactly
the failure class the population gate exists to prevent.
Fixes: (1) step_ingest tracks per-symbol first sight — new names get
the full-session lookback, known names the incremental refresh;
(2) _session_open only trusts a first-RTH bar stamped <= 09:33 ET and
is the sole source for the static gap; (3) if the session is underway
and a candidate's true open is not yet ingested, it cannot arm (trigger
withheld until the backfill lands, typically the next pass).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2026-07-08 audit (owner: "figure out if there is some kind of logic
or audit issue") found BOTH entry paths feeding the population gate the
WRONG gap. The fast path used the trigger book's dynamic gap (latest
price vs prev close), so intraday rallies masqueraded as gappers: TVRD
(6.0% true gap), SKIN (2.2%, filled at $0.98), JLHL (4.3%) all traded
outside the validated population. The slow path used max(gap, intraday)
with "open" taken from the first lookback-window bar, and had no upper
band at all — dead-band names SEER (38.6%) and IOTR (40.7%) entered.
Fixes: candidates carry gap_open_pct (session open vs prev close);
trigger eligibility + submit_breakout_now use it exclusively once the
session is open; _population_reject now implements the full validated
band (10-35% OR volume-verified >=150% squeeze, $1-20 price band) for
both paths; evaluator gap uses the session open; signals carry
relative_volume for squeeze verification. Plus momentum-testpg.service
so the test-isolation Postgres survives reboots.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LHSW gapped +278% on 100M shares, was rank 2 in the trigger book at
4:49am, ran +346%, and was the source trader's entire day — the bot's
TRIGGER_GAP_MAX=35 capped it out, same as NXTS two weeks ago, while the
bot spent its entries on the shadow round-trips (SEER/ELAB/PLUG/YRD).
Year-scale replay (n=2,879 ORB entries, 14 months, reverse-split
artifacts excluded by requiring a real volume surge): the 35-150% band
is dead (+0.02R / +0.00R — the cap earns its keep there), but REAL
>150% squeezes (n=82) ran +0.63R with 8/14 months >= 0, 4x the core
band's edge. New gap_squeeze_min/gap_squeeze_rvol re-admit gaps >=150%
only when rvol >= 5 (split repricings show rvol ~0.01 and stay out).
Live: TRIGGER_GAP_SQUEEZE_MIN=150, TRIGGER_GAP_SQUEEZE_RVOL=5.0.
Existing guards (trigger/day extension, halt, BE@+5% ratchet, sizing)
still apply to these names.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live 2026-07-06: every fresh entry was market-exited seconds after
filling (ELAB round-tripped in 3s, SEER in 77s, PLUG in 85s) — all
logged as exit_trail. Root cause: seconds after a fill there is no
completed minute bar with timestamp >= the fill time, and _bars_since's
empty result fell back to the WHOLE DAY's bars, so manage_live's
high-water included the pre-entry HOD and the trail read as already
breached on any name trading off its high. The whole-day fallback is
removed: with no post-entry bars the manager now leaves the position
alone (the bracket's initial stop is the protection) until the first
post-entry bar lands. Regression test verified to fail on the old code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Everything hot already lives in Postgres — local data/ was 25 MB of
pre-migration relics and unbounded logs. Moved the two dead .duckdb
files (untouched since 2026-06-16) to archive/, added nightly log
rotation (copy-truncate >20 MB, safe with systemd append mode), and
updated the last vestigial "market.duckdb" docstrings to say Postgres,
closing the CONSOLIDATION_PLAN cleanup items.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The events table hit 3.5 GB / 1.8M rows, ~80% of it module_tick /
criteria_evaluated / signal_blocked / account_* snapshots that are only
useful for a few days of debugging. Trading-decision events are kept
forever. One-time purge (353k rows) + missing hot-path indexes
(raw_news_items payload_hash + fetched_at: 911k seq scans -> index-only)
and per-table autovacuum tuning applied directly to the DB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The owner runs this system fully autonomously — no approvals, no manual actions. The
control room is therefore a WATCHING instrument, not a steering one:
- Approvals tab REMOVED (vestigial: the loop auto-approves; interaction contradicts
the operating model). REST plumbing dropped.
- ACTIVITY tab added: merged live event feed (entries, fills, exits w/ realized P&L,
gate cuts, ready signals) refreshing every 4s — see the autonomous system act.
- TODAY summary strip on the Live tab: Day P&L (broker last_equity based, colored),
trades + win rate, submissions, gate cuts, and LOOP HEARTBEAT age (green <3min,
red otherwise) — at-a-glance trust that the unattended system is alive.
- Thread-safety fix: all fetchers run in io_bound THREADS but shared one psycopg2
connection — now serialized behind DB_LOCK (latent race, caught during redesign).
- Panic brake stays (emergency-only, typed confirmation) — the one deliberate
exception to zero-interaction.
Headless-verified: cards live (Day P&L, Loop 0s green), 10 ticker rows, 40-row
activity feed, zero JS errors. Service restarted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phases 1+2 of the staged control-room plan, adapted to this backend and shipped as a
PARALLEL service so the battle-tested :8765 dashboard keeps running until parity.
The owner's ask — live analysis with live tickers, realtime — is the centerpiece:
- LIVE tab: tracked/armed tickers grid with real-time SIP last-trade prices (1s,
flash-green/red on change), state, gap%, RVOL, trigger + distance, stop, quality
score, POPULATION-gate check (display mirror of the live gap/volume floors), VWAP,
catalyst — merged from the loop's trigger snapshots + ready-signal projections.
- Header: PAPER mode badge (chameleon pattern), market phase, live equity, heartbeat,
clock, and the PANIC brake (cancel ALL open orders via broker API, typed "STOP"
confirmation — the plan's conditional-friction flow).
- Positions: live broker positions w/ uP&L colors + naked-stop flag (2s).
- Gates & Attribution: today's gate-cut histogram (5s) + 7-day per-stage attribution.
- Approvals: pending queue w/ approve/reject via the existing dashboard REST + dialogs.
All DB/broker calls via run.io_bound (never block the event loop); dark theme +
semantic tokens per the plan. systemd momentum-controlroom (:8766), unit in
deploy/systemd/. Headless-verified: live header (equity, phase), 10 ticker rows
rendered, zero JS errors.
Remaining from the staged plan (next phases): manual orders, settings dials, audit
tab, connection-lost action guard, live-mode chameleon when a real-money broker lands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gapper-history reconstruction (n=3,008 symbol-days / 14 months) REFUTED the 15-min
confirmation gate (late crosses beat early 12/14 months incl June 2026 — the 19-day
"+0.42R early" was a definition artifact) and found the first at-scale +EV configuration:
top-20 ranked gappers (gap >= 10%, $1-20, day volume >= 300k) are +0.05..+0.15R under the
live BE@+5% ratchet exits in EVERY rank x timing segment — including rank 6-20 ("D tier",
owner-approved: +0.153R early, the best cell). The live book bled by trading OUTSIDE that
population (low-gap, thin names), not by mistiming.
- ExecutionSettings.min_entry_gap_pct / min_day_volume (TRADING_MIN_ENTRY_GAP_PCT=0.10,
TRADING_MIN_DAY_VOLUME=300000): POPULATION gate on BOTH entry paths — auto path skips +
logs rule_type=population_below; fast path returns skipped=population_below. Fail-open
on missing data (house rule: never block on unknowns).
- evaluate_setup now surfaces the true overnight gap_pct in signal_data; ready-signal
snapshot passes it through.
- fast path: submit_breakout_now takes gap_pct (trigger book stores % units; call site
normalizes to fraction); TRIGGER_GAP_MIN raised 3 -> 10 to align the arm floor.
- confirmation gate flipped OFF via env (code stays, 0=disabled).
- rank concentration stays OFF (owner call, data-consistent: rank 6-20 converts fine).
Tests: population skip+log, real gapper passes, fail-open on missing data. 52 pass.
Monday config: population gate + cap 10 fills + quality C+ + VWAP + BE@+5% ratchet + SIP.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every conclusion so far -- including the LIVE confirmation gate -- rests on 19-26 days of
history; the weakest link in the whole evidence base. The paid plan's 9+ years of SIP data
fixes it surgically: pull ~1yr of DAILY bars for the full listed universe (11,693 symbols
after junk filtering, via new client.get_assets), reconstruct each day's TOP-20 gappers
(gap>=10%, open $1-20, vol>=300k) into historical_gappers, pull SIP minute bars
(premarket included) for JUST those ~4-5k gapper symbol-days, then replay the ORB across
the year: conversion by CONFIRMATION-MINUTE bucket + month-by-month consistency.
PRE-REGISTERED BAR (locked before the data): the gate generalizes if the early(<=15min)
bucket beats late by >= +0.25R expectancy AND wins >= 70% of months. Steps are individually
resumable (minutes skips already-present symbol-days).
Side-effect handled: labeler._preload_daily now scoped to symbols with minute bars (the
whole-market daily universe would balloon the unscoped preload to ~3M dict entries).
Bonus: the historical gapper minute bars (incl premarket) massively grow the pm1 premarket
track's future sample.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The owner bought Alpaca Algo Trader Plus (real-time SIP; entitlement propagating on the
same keys — latest-trade already SIP-live, quotes/bars still 403). Before flipping
ALPACA_DATA_FEED=sip, two guards so the free-tier ratio estimator can never double-count:
- ingest_live_minute_bars resolves the EFFECTIVE feed and refuses volume_scale for
anything non-IEX (provider marked alpaca_<feed>).
- step_vratio self-disables when client.settings.feed != iex (saves the API calls too).
The estimator stays as the dormant free-tier fallback (plan lapse -> flip env back).
Test: SIP client + x25 ratios -> volumes NOT re-scaled, provider alpaca_sip. 4 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With the nightly SIP backfill making all BASELINES consolidated-scale, the still-IEX live
ingest would make Monday's RVOL read iex_intraday/sip_baseline ~= 25-50x too LOW and choke
the scanner. Free-key SIP is only 15-min DELAYED, so each morning the true per-name capture
ratio is directly measurable:
- research/ingestion/volume_ratio.py: compute_ratios fetches BOTH feeds for the identical
embargo-capped window (never vs our DB bars — those are scaled; that would double-correct);
guardrails (min samples, clamp [1,300]); persist_ratios records per-day ratios.
- ingest_live_minute_bars(volume_scale=...): scales bar volumes per symbol at ingest, marks
source_provider=alpaca_iex_scaled. Missing symbol -> 1.0 (conservative: reads LOW, blocks).
Constant per-symbol scale leaves cumulative VWAP unchanged (cancels in the weighted mean).
- run_live_paper: "vratio" step every 300s (VOLUME_RATIO_ENABLED/INTERVAL) merges fresh
ratios (a name gone thin keeps its last good ratio); step_ingest applies them.
- premarket_eval nightly audit: snapshots the day's scaled ESTIMATES before the SIP truth
overwrites, then logs median/worst error to data/volume_estimator_audit.tsv — the
estimator's accuracy is self-measured daily, which is exactly the evidence that decides
whether the $99/mo real-time SIP plan is ever needed.
Live check (2026-07-02 full session): ratios are hugely name-dependent — DSY x300(clamped),
CETX x286, SOFI x45, SNDQ x42, NOK x17 — validating per-name daily measurement over any
global constant. 3 pure unit tests (ratio math + thin-sample skip; ingest scaling + provider
marking; no-ratio unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MEASURED 2026-07-02: our entire bar history was IEX-only. IEX sees ~2-5% of consolidated
volume (SNDQ 7/01: IEX 188 bars/1.5M shares vs SIP 391 bars/60.1M — 39x) and misses whole
minutes on thin names. Every volume-based signal (RVOL, liquidity floors, volume bursts,
dollar-volume strata) and several research verdicts ("untradeable dust", "thin premarket")
were computed on that sample. Daily bars had the same defect. Historical SIP is FREE on our
keys (15-min embargo) — only real-time SIP needs the paid plan.
- client.get_daily_bars: feed param (None -> settings.feed; pass "sip" for consolidated).
- research/sip_backfill.py: re-fetch minute sessions (04:00-20:00 ET) + dailies from SIP
via the existing idempotent upserts; embargo-caps the end when refetching today;
refresh_rolling_volume rebuild after (this morning's 20d averages were IEX-scaled too).
CLI: full history < today, or --date D for one session.
- premarket_eval.py: nightly job now SIP-upgrades today's bars BEFORE the pm1 rebuild, so
every finished session lands on true volume automatically (live loop stays IEX real-time
by design — free-key SIP is embargoed 15 min).
Chain running: full backfill -> verification -> rebuild liquid/premarket/runner shadow
tracks + re-validate (their volume gates were IEX-calibrated; verdicts may change).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner's idea from the CETX day: unusually large resting order volume just above/below price
(the trader's "big seller at $6" read) may predict pop-vs-fade. Full L2 depth needs a paid
feed, but Alpaca NBBO quotes carry bid/ask SIZES — a workable free proxy. We stored no quotes,
so the idea was untestable; this makes it accumulate.
- quote_snapshots table (market schema): symbol, ts, bid/ask price + SIZE; (symbol, ts) index.
- step_quotes in the live loop (QUOTE_SNAPSHOT_INTERVAL_SECONDS=15, QUOTE_SNAPSHOT_ENABLED=1):
records tracked (trigger-book) + held names each pass, premarket included (where the edge
data lives). Recording ONLY — zero trading impact; fetch failures degrade to a log line.
- Caveat carried in the docstring: free IEX-feed NBBO is thinner than SIP (thin names often
print 100-share round lots), so persistent imbalance is the signal, not single snapshots.
Verified live: 10 names/pass recording every 15s with real sizes (e.g. RIVN 1300 bid / 3200 ask).
A shadow study tests ask/bid-size imbalance as a pop-vs-fade predictor once data accumulates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Forensic from the source trader's 2026-07-02 recap: his ENTIRE day (+$8.9k) was CX — a thin
reverse-split name that squeezed $3->$6 on a headline (his "wildcard effect": on dead pre-holiday
days the one mover gets everyone's attention). Our system NEVER SAW IT: zero bars, zero discovery
events. Root cause: discovery ranks by ABSOLUTE SHARE VOLUME (most-actives), and a thin %-squeezer
never cracks a volume-ranked top-N. Every other name he called out we saw and judged the same way
he did (DSY passed/backed-out, CWD too cheap, AHMA rejected) — the gap was pure discovery scope.
- alpaca_paper/client.py: get_movers(top) — /v1beta1/screener/stocks/movers top %-gainers
(verified working with our keys: CLRO +114%, CWD +73% live).
- discover_active_symbols: wildcard gainers are fetched FIRST (warrants/units filtered:
'.'-suffixed + 5-char W-endings), deduped with most-actives, same price-band filter.
Additive + fail-open: a movers failure never breaks the existing actives path.
Discovery/ingestion scope ONLY — every trading gate (confirmation window, quality, VWAP, cap)
still applies unchanged. The immediate value is that shadow tracks + the labeler finally get
BARS for wildcard movers, so the next CX is measurable instead of invisible.
Verified live: universe now leads with gainer-sourced names (CLRO, CWD, USDE, YRD, DSY). 16 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The owner's modularity/monitoring ask: rate each pipeline component so what's earning (and
what's cutting) is visible instead of inferred. Everything derives from the existing event
stream — no pipeline changes.
- research/attribution.py: compute_attribution(days) -> per-day per-stage metrics:
DISCOVERY (symbols found + precision = fraction that ever go ready), SIGNALS (ready split
EARLY-vs-LATE against the confirmation window — the only +EV ones), GATES (risk_rule
histogram: what each gate cut), EXECUTION (submissions vs cancels = fill efficiency),
OUTCOMES (realized P&L, win rate, per-close detail). CLI: python -m research.attribution.
All queries filter the native timestamp column first (payload casts on the 676k-row events
table time out).
- api/main.py: GET /api/attribution?days=N.
- dashboard.html: "Component Attribution" panel, lazy-loads on open, per-day table with
gate-histogram hover.
- momentum_cli board header: live pulse line — today's early/late signal split + top gate
cuts (two tiny date-scoped queries).
Verified live: CLI scorecard over 7 days, endpoint returns real ratings (e.g. 2026-07-02:
discovery 45%, early 38%, fill 39%, win 50%, P&L +$29), board renders the pulse line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The column was never populated by upsert_daily_bars, so every downstream consumer
(scan_gappers RVOL, the quality gate's relative_volume component, labeler baselines)
silently fell back to weak within-session proxies — degrading the very signals the
2026-07-02 backtest showed carry the edge.
refresh_rolling_volume(con, symbol=None): window-function UPDATE computing the average
of the PRIOR 20 sessions (excluding the row itself so it's knowable at that day's open,
>=5 prior sessions required). Called per-symbol after each upsert_daily_bars so new rows
stay populated; ran once with symbol=None to backfill.
VERIFIED against prod: 0 -> 79,778/89,899 non-null; spot-check matches an independent
pandas shift(1).rolling(20,min_periods=5).mean() exactly (0 mismatches).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Why the book is break-even (proven over 7 refuted levers): the ORB entry converts poorly and
the profitable trades are diluted by the negative majority. The 2026-07-02 quality-gate backtest
(6217 setups, adversarially verified across 3 seeds) isolated where the profit actually lives:
setups whose breakout CONFIRMS within ~10 bars of the open run +1R 71% / +0.42R expectancy —
even C-grade ones — while late-confirming setups are dead regardless of grade (bar-26+: 9% +1R,
-0.81R; time-of-day expectancy decays monotonically to -0.94R late-day). The edge is
confirmation TIMING, not the grade (the B-grade lift was mostly this confound).
ExecutionSettings.entry_confirm_by_minute (env TRADING_ENTRY_CONFIRM_BY_MINUTE, 0=off):
- auto path: request_approvals_for_ready_signals skips signals whose ready-timestamp landed
more than N minutes after the 09:30 open, shadow-logged rule_type=late_confirmation (visible,
measurable). Midday re-triggers are late by construction -> also cut (they were -0.75R).
- fast path: submit_breakout_now gates on now() (a live trigger fires in near-real-time, so
now() IS the confirmation time) -> skipped: late_confirmation. This also closes the fast
path's no-time-gate hole the all-day analysis flagged.
Deliberately KEEPS min_quality_score at 0.50 (C+): the gate retains the profitable early
C-band (+0.42R) that a B-grade gate would wrongly discard. Fail-open on unparseable timestamps.
4 new tests (late skip + logged, early passes, off-by-default, fast-path block); 50 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The behavior half of the cap fix. Root-caused 2026-07-01 and re-confirmed live 2026-07-02
(now visible via the auto_rejected logging): the cap incremented on any broker-ACCEPTED
submission incl. resting limits that never fill, and never decremented on back-out -- so
unfilled/canceled entries permanently burned slots. Both days the bot capped after ~3 real
trades because 2 phantom unfilled limits ate the budget, with good setups still on the board.
Fix: mark each counted entry's armed record ("counted"); when expire_stale_entries backs it
out UNFILLED (timeout, price-break, or broker-cancel), _release_cap_slot decrements the counter.
Fills keep their slot (their armed record is popped without releasing). Net: the cap now means
"N real filled entries/day", not "N submission attempts". New test drives 2 resting entries to
the cap then times them out and asserts both slots free. 46 tests pass.
The cap VALUE (currently 5) and the all-day question are separate (data-driven) decisions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two data-quality fixes surfaced by inspecting the first 94 claims:
- _clean_ticker: coerce the literal 'NULL'/'NONE'/'$AAPL' strings the LLM sometimes returns to
a real None, and collapse ASR space-mangling ('B VC' -> 'BVC').
- require_asset filter: drop claims with neither a ticker nor an asset_name -- generic trading
commentary ('buy the dip') from tutorial content is not an asset claim. On the backlog this
cut 94 -> 58 claims (36 generic), leaving clean per-asset signal.
Added the $2,000 Small Account Challenge recap playlist (the daily per-asset trade breakdowns) --
far higher claim density than the evergreen 'Anatomy of a Trade' tutorials. 7 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pm1 verdict is promising-but-underpowered (n=21): the forward moves are real (+11-14%) but the
sample is too small to conclude. The live loop ingests premarket bars every trading day, so this
automates the "accumulate more days then re-validate" step:
- research/premarket_eval.py: rebuild pm1 from the grown DB, append a timestamped metrics row
(n, days, +1R rate, Wilson-95 LB, median upside/adverse, big-gap +1R, pass/fail) to
data/premarket_eval_history.tsv, and print the current validate output. Watch n climb + the
Wilson bound tighten until the locked bar is decisively cleared or failed.
- deploy/systemd/momentum-premarket-eval.{service,timer}: weekday-evening (Mon-Fri 20:00) oneshot,
Persistent=true (catches reboots). Installed to ~/.config/systemd/user and enabled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every RTH selection/entry lever has now been refuted (6 of them separate a population but
don't convert through the entry). The one axis never touched is PREMARKET — the source
trader's actual edge (his words: news drops pre/after-hours, NO circuit-breaker halts pre-market
so moves run clean, and RTH after 9:30 is choppy/unpredictable — the exact window our bot trades).
strategy/evaluation/premarket_setup.py::detect_premarket_setup — pure, operates on PREMARKET bars:
gap vs prev_close + real premarket volume floor (the DXST-vs-CF tell: low pm volume = fail) +
price floor + fresh premarket-high breakout + premarket-VWAP hold. Calibrated to the thin pm tape
(cum pm volume p50 ~12k shares, so a 20k floor catches the genuine movers not 1%-gap noise).
Real-tape validated + SELECTIVE: 6/30 fires JEM(245%)/CELZ(143%); 7/1 fires LHAI(166%)/SOC/SNDQ —
2-3 names/day (~8% of pm names), vs the liquid detector's 50%. 6 unit tests green.
labeler pm1 track (build/report/validate-premarket): slides the detector over premarket bars,
scores forward on PREMARKET bars via the same _forward_labels (the trader enters+exits pre-9:30),
stratifies +1R by gap. Pre-registered bar locked 2026-07-01 (median pm upside >=+10%, adverse >-6%,
+1R >50%, big-gap subset clears). SHADOW-ONLY. Real caveats it sets aside: thin premarket fills, and
live premarket needs extended_hours execution the bot doesn't have. Full 26-day backtest building.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The systemd user service runs with a minimal PATH that excludes /usr/bin, so yt-dlp's audio
postprocessor reported 'ffprobe and ffmpeg not found' and every video silently fell back to
captions. Pass ffmpeg_location=config.FFMPEG_LOCATION (shutil.which with /usr/bin fallback) to
yt-dlp so the Whisper path works under systemd. Verified: yt-dlp download + ffmpeg extract now
produces a wav. Reset the 4 caption-fallback videos to pending so the backlog gets Whisper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extraction fix: the anti-hallucination check (a claim's verbatim_quote must appear in the
source window) was too brittle against auto-captions — fragmented segments joined with
newlines meant a valid quote spanning a join was dropped (0 claims on a 623-segment captions
transcript). Now whitespace-normalized matching (_norm + _quote_supported, ~40-char prefix):
the SAME transcript yields 6 real claims (WETG +160% reverse-split+news; 4 AURC trade claims
with prices; a rejection concern), each quote verifiably present. Whisper output is cleaner
still; this just makes the captions fallback usable too.
- tests/unit/test_youtube_extraction.py: 5 pure-helper tests (norm, quote-support across joins,
hallucination rejection, verbatim-quote-mandatory drop, chunk time-spans).
- youtube_claims/momentum-transcripts.service: reboot-surviving systemd user unit (Restart=always,
loads secrets from .env; Whisper option A/CPU, env-flippable to B/GPU).
END-TO-END VERIFIED (captions path, no ffmpeg): detection -> claim -> transcribe -> extract ->
6 claim rows in Postgres. Full Whisper path needs `sudo apt install ffmpeg`, then
`systemctl --user enable --now momentum-transcripts` to process the 11-video backlog at
Whisper quality + poll for new uploads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A descriptive ingestion layer feeding the downstream reasoning model: monitors YouTube
playlists, transcribes new uploads, and extracts structured, verifiable CLAIM records
(with provenance + a mandatory verbatim quote). It records WHAT was claimed; it never
judges correctness or tradeability -- all judgment stays downstream, in one place.
Built per the operator's spec. Two clocks coordinated through Postgres (§2): a 30-min
detection poll + a paced concurrency-1 transcription/extraction worker. Reuses the app's
infra: shared Postgres (isolated `transcripts` schema), the app's Ollama for extraction
(free, on-LAN, private -- transcripts never leave the network), the config.py host resolver.
youtube_claims/schema.sql playlists / videos / claims (§7-8); claims.verbatim_quote NOT NULL
youtube_claims/db.py psycopg2; claim-by-atomic-status-flip under FOR UPDATE SKIP LOCKED
youtube_claims/detection.py playlistItems.list over urllib; dedupe by video_id (§5)
youtube_claims/extraction.py local Ollama -> descriptive claims; drops any claim lacking an
exact verbatim quote present in the source window (anti-hallucination)
youtube_claims/transcription.py yt-dlp -> faster-whisper (medium/cpu/int8, option A;
initial_prompt=watchlist) with youtube-transcript-api captions fallback
youtube_claims/worker.py paced drain; per-video state machine + retries/backoff
youtube_claims/run.py APScheduler poll + worker loop in one process; CLI for setup
VERIFIED LIVE: schema applied; playlist "Anatomy of a Trade" (Ross Cameron) added; poll
ingested all 12 videos (1 API unit); Ollama extraction pulled clean descriptive claims
(TC/DXST/CF) with exact verbatim quotes from a real transcript. Whisper transcription pending
ffmpeg (`apt install ffmpeg`, needs sudo) + the yt-dlp/faster-whisper deps (installing via uv).
Secrets (YOUTUBE_API_KEY) live in gitignored .env only. Whisper is option A (CPU); env-flippable
to option B (LAN GPU large-v3) later.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the runner track: slides detect_liquid_intraday_breakout over RTH history, emits
non-tradeable liq1 setups scored by _forward_labels, stratifies +1R by rvol + cumulative
dollar-volume. Pre-registered bar locked 2026-07-01. Early in-sample read (5 days, 1511 fires):
+1R 51% beats ORB 35% on same days, but median 60m upside only +0.6% (tight-stop artifact);
rvol/liquidity strata flat. SHADOW-ONLY. Full 26-day out-of-sample build pending.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2026-07-01 forensic + the source trader's day-14 notes point at a thesis-scope gap:
the day's only clean, held, TRADEABLE +2R moves (MSTR, SSPC) were LIQUID names that ignited
intraday with ~zero premarket gap -- invisible to the premarket-gapper universe -- while the
cheap "held trenders" were untradeable dust and the liquid gappers the bot chased whipsawed.
strategy/evaluation/liquid_breakout.py::detect_liquid_intraday_breakout -- a pure detector that
makes LIQUIDITY (cumulative + per-bar dollar-volume) a first-class gate (the thing runner.py
lacked, which let it fire on dust), plus a price floor + fresh-session-high breakout + VWAP hold.
NO gap, NO blue-sky, NO float. Validated on the real tape: fires on MSTR (@bar15) and SSPC (@bar90),
correctly silent on the sub-$2 dust (DXF) and the <$1M single-print names.
Design finding baked in: RVOL is RECORDED, not a hard gate. MSTR/SSPC were NOT within-session
volume surges -- MSTR was liquid from the open (recent/early vol ratio ~0.5-1.0 the whole grind),
so a hard rvol>=3x "surge" gate structurally kills the steadily-liquid grinders this lane exists to
catch. rvol is stored as a stratification feature (the shadow track will measure whether high-rvol
fires convert better, like the runner GRADE); it only gates if rvol_min>0 is passed.
Selectivity is broad (~50% of the liquid universe fires at least once/day) -- this is a candidate
POPULATION definer, not a precise signal. The decisive question (does it convert to +1R better than
the ORB, and does any rvol/liquidity stratum beat it) is for the shadow track + a pre-registered
forward bar. SHADOW-ONLY: never routes to approve_order; 6th selection signal, must prove conversion
after cost before any live gating. See memory thesis-scope-liquid-intraday-momentum.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root-caused (workflow wjy4nzpbw, adversarially verified) why the live loop's per-cycle
log printed `execute: ... auto_executed=1` ~951 times after 09:45 ET on 2026-07-01 with
ZERO matching broker orders: reject_order() returns a truthy {"ok": True} dict that
process_auto_approvals() appends unconditionally, and step_execute logged len() of that
list as auto_executed. So every daily-entry-cap rejection masqueraded as an execution,
hiding the fact that the bot was hard-capped at 5 fresh entries for the rest of the day.
This is the log-VISIBILITY half only (safe, zero trading-behavior change):
- reject_order() tags its return rejected=True + reason, so a rejection is distinguishable
from a real execution (both carry ok=True).
- step_execute() counts auto_executed = results with a real broker_order_id, and appends
`auto_rejected=N (reason x N, ...)` so a tripped cap is VISIBLE instead of silent, e.g.
`auto_executed=0 auto_rejected=1 (daily_entry_capx1)`. ASCII-safe for the systemd log sink.
NOT included (deferred): the behavior fix (cap counts accepted submissions incl. unfilled
resting limits that get backed out, never decrements — so 4 phantom orders burned the whole
day's entry budget). That change lets the bot take more real shots, which given the live-
confirmed entry-conversion weakness likely means more churn — pair it with entry-quality
work, don't ship solo. See memory daily-entry-cap-counts-submissions.
Takes effect on the next loop boot; deliberately NOT restarting mid-session (the cap counter
is in-memory, so a restart would reset it to 0 and re-open entries into today's tape).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fidelity pass against the leading-gainer-runner workflow (wgcbvb3ce) synthesis:
- runner_quality.py: reorder calculate_runner_quality signature to the spec's
(gap_pct, vol_burst, velocity, above_vwap, ...) — behavior identical (all callers
use keywords), now matches the documented signature so a positional caller reading
the spec is correct.
- labeler.py: close build_plan step (3) "attach the runner grade". The grade is a pure
function of components already persisted on each fire (gap/velocity/burst/vwap), so
it is recomputed exactly at report/validate time (catalyst-OFF) — no 34-min rebuild.
report-runner now stratifies forward +1R and median 60m upside BY GRADE; validate-runner
adds the A/B-subset test (does concentrating on top grades salvage the locked bar?).
FINDING (385 fires, 25 session-days): the detector still FAILS all 5 pre-registered
bars (the workflow predicted this — SVRE/JEM put 100% of the move pre-market, RTH fires
confirm at/after the top). BUT the grade is MONOTONE with forward outcome:
grade A: +1R 45%, median max-upside 60m 17.7% (n=73)
grade B: +1R 41%, median 9.2% (n=186)
grade C: +1R 35%, median 7.8% (n=106)
grade F: +1R 30%, median 3.5% (n=20)
Grade A ALONE clears the +15% upside bar (17.7%) but not +1R>50% (45%): A-runners have
real continuation, the naive last-close entry still whipsaws. Confirms the grade earns
its keep as a RANKER/sizer (concentrate on A) while the bottleneck stays ENTRY-conversion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The default ORB grade (quality.py) pins catalyst RUNNERS at F/C: its two heaviest
components structurally break on a vertical spike -- STRUCTURE (30%) reads 0 (no
pullback to classify) and adv-RVOL (25%) reads 1.0x (NULL rolling-20d on fresh
reverse-split names). On the 2026-06-30 tape the source trader's winners (SVRE
+247%, JEM +348%, CELZ +472%) all graded C 0.565, indistinguishable from the bot's
F-grade chop. Two parallel, shadow-only artifacts fix this without touching the live
ORB path:
- strategy/evaluation/runner_quality.py::calculate_runner_quality -- a PARALLEL
grade that drops the two broken components and scores the traits that actually
separate runners from chop (gap saturating at +100% not +10%, price velocity,
own-name volume burst, above-VWAP, catalyst). Validated on the real tape:
CELZ/JEM/SVRE -> B/A, chop (SLS/RIVN/CCXI/BB/NVDX) -> F. quality.py untouched.
- strategy/evaluation/runner.py::detect_leading_gainer_runner -- catches the mover
detect_momentum_ignition is blind to (it requires blue-sky > prior ATH, so it
fires ZERO on beaten-down catalyst pops). Blue-sky deleted; gates = leading-gainer
run + verticality + own-name burst + holding session-cumulative VWAP; PM-inclusive;
tags (never gates) PM-exhaustion. Real-tape in-sample: 5/5 runners fire, 7/7 chop
silent, zero false-fires. Cumulative VWAP is a prefix function, so a precomputed
session_vwap makes a bar-by-bar slide O(n) not O(n^2) (identity verified exact).
- research/labeler.py -- build-runner/report-runner/validate-runner shadow track,
mirroring vwap_reclaim: emit a non-tradeable setup on each fresh fire, score with
the SAME _forward_labels. Pre-registered promotion bar LOCKED before forward data
(median fwd-max >= +15%, median adverse > -8%, +1R > 50%, beat vwap_reclaim,
PM-exhaustion tag must earn its keep). SHADOW-ONLY: never routes to approve_order,
never gates live until the bar clears out-of-sample. The honest open question the
track exists to answer: the detector confirms AT/AFTER the top for PM-driven names
(SVRE fires at +242% already run) -- does that late entry actually convert to +1R?
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ollama was unreachable — the enrichment/qabot pointed at the 127.0.0.1:30069 netsh tunnel,
which the WSL network no longer routes (same reboot-flip landmine as the DB), while the
direct 192.168.1.5:30068 answers. config.OllamaConfig.from_env now resolves the host: probe
the configured endpoint, else the first reachable known candidate (tunnel 30069 <-> direct
30068 — note the DIFFERENT ports). So enrichment self-heals on boot instead of silently
failing. .env OLLAMA_HOST also repointed at the direct host.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Forensic on 2026-06-30: of the bot's 8 traded names, SEVEN graded F (<0.50) and one B —
it sprayed low-quality chop (SLS F0.12 -$412, RIVN F0.19, BB F0.21, CCXI F0.26 +$374,
NVDX F0.33, NOK F0.48, RDW F0.49; GVH B0.71). The trader made +$616 on SVRE — which the
bot SAW but its 35% gap cap excluded; and even graded, SVRE is F (0.35): low computed
RVOL + no clean pullback structure. So the grade cuts chop but does NOT recognise a
vertical catalyst RUNNER.
Adds ExecutionSettings.min_quality_score (env TRADING_MIN_QUALITY_SCORE, default 0.0 =
off) and a gate in request_approvals_for_ready_signals mirroring the VWAP gate: a signal
scoring below the threshold is skipped and logged rule_type=quality_below; fail-open on a
missing score. 3 tests (skip-low-when-enforced / allow-when-off / allow-at-threshold);
44/44 trading-execution tests green.
Honest scope: this is the "trade less chop" lever (evidence: the bot's own labeled data
has q>=0.55 win ~71% vs q<0.55 ~36%). It will NOT catch SVRE-class leading gainers — the
grade rewards pullback structure the runners lack, and the gap cap excludes them upstream.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per the operator: a carried position sitting at breakeven shouldn't ladder off its stale
overnight entry. When a held position is at breakeven+ with no real R, the +0.25R trail
now RE-ANCHORS to today's opening range — reference = the session open, R = the first
5 RTH bars' high-low — so the stop tracks today's move from the open and locks in tighter
than the wide entry-based synthetic R. Until the opening range forms (first ~5 min) it
trails off the synthetic fallback R, then re-anchors once (one-shot, tracked in
_open_anchored; cleaned up when the position closes). The stop only ever ratchets UP and
never drops below the live breakeven. 1 test added (re-anchors + ladders tighter than the
synthetic); 11/11 exit-manager tests green.
For KEEL: at tomorrow's open its stop ladders up from the open's range instead of dangling
at the $5.56 breakeven.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Focused review pass on the session's safety-critical additions, applying the safe wins:
- exit_manager: drop the dead `_Tracked.last_stop` field (written, never read) and its
assignment; guard `_rest_protective_stop` against a degenerate qty<1 (a 0-qty submit
is rejected and would churn) + document the cur-falsy->breakeven fallback; reword the
synthetic-R comment (the branch is also hit on a healthy trade once breakeven ratchets
the stop to entry, not only the stripped-stop case).
- exits: surface default_trail_r_pct in ExitConfig.describe() (boot-log observability)
and clamp it to [0, 0.95] in from_env (a >=1 / negative synthetic R would put the stop
at/above entry).
- db_pg: comment the gate-vs-try-order distinction of _LAN_DB_HOSTS (superset incl.
localhost) vs _LAN_DB_FALLBACKS so a future edit can't break the non-LAN-DSN guard.
- momentum_cli: drop an unused `import sys`.
Behavior-preserving; 29/29 exit/exits tests green. Deliberately did NOT extract a shared
flatten-vs-rest helper for the catastrophe/naked blocks — the explicit blocks read
clearer for this safety code and the tests guard against drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KEEL was left at a frozen breakeven stop because its original R was lost — the bracket
stop leg was invalid (the limit filled below the ORB-low, so the stop sat ABOVE entry
and Alpaca cancelled it, which is what left it naked). The manager skipped any position
already at breakeven+, so it never trailed: a winner could only scratch or run
unprotected-above-breakeven.
Now a position first seen at breakeven+ with no real risk distance is tracked on a
SYNTHETIC R (ExitConfig.default_trail_r_pct, default 10% of entry, env
TRADING_EXIT_DEFAULT_TRAIL_R_PCT), so the SAME tested +0.25R step-trail / profit-lock
ratchet the broker stop UP as it rises (only ever up, never below the live stop).
default_trail_r_pct=0 keeps the old frozen behaviour; positions with a real sub-entry
stop are unchanged.
TRAILING is gated on the session being OPEN (one broker-clock check per pass): after
hours there are no new highs to trail under AND a resting GTC stop is often not
replaceable ("accepted" status -> Alpaca 422), so attempting it just churns. The stop
stays protective and ratchets up at the open. 4 tests added (synthetic-R trails up;
disabled=frozen; closed=no-attempt); 10/10 exit tests green.
Live now (after-hours): KEEL is protected at breakeven $5.56; the loop holds that stop
without churning and will trail it up once the session opens.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KEEL was carried into after-hours as a naked long (its bracket stop leg never
attached — 0 stop orders in its whole history). The enforcement tried to FLATTEN it
with a market order every pass, but a market sell can't fill when the market is
closed, so it churned submit->cancel ~every 30s (76 market-sells / 79 cancels, the
"naked-stop enforced exit KEEL xN" spam) and left the position genuinely unprotected.
Fix (runtime/exit_manager.py): both the naked-stop enforcement AND the catastrophe
backstop now branch on the broker clock. Market OPEN -> flatten (a market exit fills,
unchanged). Market CLOSED -> REST a protective sell-stop instead (GTC) — at breakeven
when in profit, a catastrophe-distance stop below market when underwater — so the
position survives to the open PROTECTED and the next pass sees the stop and stops
enforcing. Catastrophe with an existing (loose) stop while closed just defers to it
(no market churn, no double stop). New client bits: AlpacaPaperClient.get_clock() and
a standalone stop_price on submit_order. 3 exit-manager tests added (open->flatten,
closed->breakeven-rest, closed-underwater->catastrophe-rest); 7/7 green.
Operationally: KEEL was protected at breakeven ($5.56) immediately and the loop now
leaves that stop in place instead of churning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From a design pass (CLI spec + dashboard spec + an adversarial glance-safety review)
that read the real output of both surfaces. Principle: each safety/state number appears
ONCE on the first screen, every list has a bounded height capped by relevance (active/open
first), and load-bearing signals (the UNPROTECTED banner, heartbeat, day P&L, circuit
breaker) are never trimmed or hidden.
CLI board (momentum_cli.py _render_board): drop the static 2-line strategy subtitle (never
changed, redraws every 3s); trim the heartbeat to "Ns"; armed table shows actionable
(armed/fired/long) rows first then the top-4 scanning by rvol, dropping corrupt >1000%-gap /
0-volume rows with a "+N more scanning" footer; remove the redundant ready-signals panel
(restated the armed table) and the day-P&L echo in the trades title; trades float open lots
to the top (never capped off) and cap closed at 6; risk collapses consecutive identical
fires into one "×N" row (the 12 duplicate KEEL lines -> one) and is promoted ABOVE trades.
Dashboard (api/static/dashboard.html): cap the long "runs" — ready-signals to top 15 by
quality (also kills ~80 per-refresh chart fetches), activity feed 20, catalyst feed 20, order
lifecycle shows only actionable orders (raw 1000s incl rejected/cancelled read as an alarm),
watch-states hide the score-gate 'blocked' rows; drop redundant strip cells (Positions dup of
pnl Open/Closed, Gross) and the Account panel's Buying-Power/Synced; demote Account to a
collapsed half-width panel; drop the ~constant ready-signals "Setup" column. JS syntax
validated, no dangling refs, serves 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DB self-heal: the LAN Postgres is reached from WSL via EITHER the 127.0.0.1 netsh
portproxy OR a direct route to 192.168.1.5, and which one works FLIPS across WSL/host
reboots and network-mode changes — the recurring crash-loop landmine. storage/db_pg.py
_connect now tries the configured LAN host and then the alternate, so the loop boots
regardless of the current tunnel state ("connection refused" is instant, so it's cheap).
Dashboard UI: api/static/dashboard.html panels are now collapsible <details> (native,
no JS for the toggle; inner ids preserved so the live rendering JS is untouched). The
live/actionable panels (Armed Triggers, Open Positions, Approval Queue, Ready Signals,
Account) start OPEN and lead the layout; the big/historical ones (Catalyst Engine,
Daily Performance, Trades, Watch States, Activity Feed, Order Lifecycle, All-Time Score)
start COLLAPSED so the page is compact and scannable. Open/closed state persists per
panel in localStorage; an EXPAND/COLLAPSE-ALL header button toggles everything.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mechanics study found the no-chase entry (buy the VWAP reclaim, not the breakout high)
was the dominant lever. This codifies it as a permanent, pre-registered shadow-validation —
the disciplined close-out of the 4-lever investigation arc.
- strategy/evaluation/vwap_pullback_entry.py: pure find_vwap_pullback_entry — first forward
bar that dips to the CUMULATIVE session VWAP (calculate_vwap, NOT the per-bar minute_bars.vwap
midpoint, which would make 'reclaim' trivially true) and closes back above it; entry = its
close, stop = its low (the tight no-chase structure stop). 4 tests.
- research/labeler.py validate_no_chase_entry + `validate-entry`: simulates it vs the ORB-high
breakout via the live simulate_exit on real forward bars, judged in PERCENT net of a REALISTIC
per-name cost proxy (4% sub-$3 / 2% else — no NBBO in bars). PRE-REGISTERED bar locked: mean
net% must be STRICTLY POSITIVE, 'less negative than ORB' is NOT a pass.
IN-SAMPLE RESULT (v1 gappers, 19 autocorrelated days): the no-chase entry HALVES the loss
(ORB -7.94% -> no-chase -3.61% net, median -4.23%, offered on 75%), robustly (day-jackknife
[-3.83%,-3.48%]) -- confirming not-chasing the spike-top is the strongest mechanic -- but it
still does NOT cross zero under realistic cost: PRE-REGISTERED BAR = FAIL. Consistent with the
whole arc: no mechanizable entry/exit/selection lever beats cost on the full gapper book; the
path to positive is FEWER/cleaner setups (cut the ~70% fader population) COMBINED with this
no-chase entry. validate-entry re-runs forward; promote only if it clears the bar out-of-sample.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>