Compare commits

...

50 Commits

Author SHA1 Message Date
dennisthiessen 66ef0564c1 Add local backtest snapshot runner
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m22s
Deploy / deploy (push) Successful in 43s
2026-07-03 18:35:07 +02:00
dennisthiessen 14327ab25a Require aligned action for qualified setups
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m7s
Deploy / deploy (push) Successful in 39s
2026-07-03 16:13:27 +02:00
dennisthiessen eaad935a2a Bundle signal alert notifications
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m4s
Deploy / deploy (push) Successful in 35s
2026-07-03 13:32:59 +02:00
dennisthiessen d4ccea2d69 Normalize persisted test timestamps
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m12s
Deploy / deploy (push) Successful in 37s
2026-07-03 13:01:45 +02:00
dennisthiessen 8c36cfcef1 Make live signal reads non-mutating
Deploy / lint (push) Successful in 6s
Deploy / test (push) Failing after 48s
Deploy / deploy (push) Has been skipped
2026-07-03 10:09:46 +02:00
dennisthiessen ac51e23949 Serve live recommendation context on trade setup APIs and alerts
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 1m0s
Deploy / deploy (push) Successful in 32s
Stored TradeSetup rows are point-in-time snapshots from the RR scan, so
the ticker page could show stale confidence/reasoning/composite (e.g.
sentiment=neutral in the setup card while the sentiment panel showed
bullish). Overlay current score/sentiment context onto the API payload
for GET /trades and GET /trades/{symbol}, gate and format Telegram
qualified-setup alerts on the same live values, and apply the
min_confidence/recommended_action filters after the overlay so they
judge what the caller actually sees. Stored setups stay frozen for
outcome analysis and backtests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:17:27 +02:00
dennisthiessen 2b0068ae08 Add volume pane to ticker chart
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 58s
Deploy / deploy (push) Successful in 32s
2026-07-03 08:09:27 +02:00
dennisthiessen 7fd34d6de8 removed old requirements md file
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 1m5s
Deploy / deploy (push) Successful in 33s
2026-07-02 21:36:17 +02:00
dennisthiessen 8d5863bac4 document production backtest baseline 2026-07-02 21:31:51 +02:00
dennisthiessen be4d6a05ca clarify documented production strategy
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 1m7s
Deploy / deploy (push) Successful in 35s
2026-07-02 21:23:05 +02:00
dennisthiessen aadec7d403 promote residual momentum ranking
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m8s
Deploy / deploy (push) Successful in 35s
2026-07-02 21:00:39 +02:00
dennisthiessen 849489a4b5 refine strategy variant lab
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m0s
Deploy / deploy (push) Successful in 33s
2026-07-02 16:47:58 +02:00
dennisthiessen 80b4113280 feat: add strategy variant lab and signal context snapshots
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m1s
Deploy / deploy (push) Successful in 33s
Backtest report now includes research-only hold-to-horizon portfolio variants comparing raw vs residual 12-1 momentum, cutoff 80 vs 90, max 10 vs 15 positions, and SPY-200 risk scaling. A dynamic research recommendation panel flags residual momentum, cutoff 90, or regime scaling only when transparent promotion rules pass.

Adds signal_context_snapshots with migration 016 and captures one point-in-time context row per newly generated TradeSetup: setup fields, composite/dimensions, latest sentiment, latest fundamentals, and strategy_version=momentum_12_1_rr_time_v1. This is forward-only; no historical sentiment/fundamental backfill is attempted.

No live gate, paper-trade exit, or production ranking behavior changes.

Verification: 458 backend tests pass, ruff check app/ clean, frontend npm run build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:25:04 +02:00
dennisthiessen 13374087db feat: add residual momentum to signal-edge backtest
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 55s
Deploy / deploy (push) Successful in 33s
Adds a research-only 12-1 residual momentum signal to the cross-sectional signal-evaluation harness. The signal estimates benchmark beta over the 12-1 formation window and ranks cumulative stock return minus beta-adjusted benchmark return; it only appears when benchmark closes are available.

No production qualification behavior changes. The Backtest signal table labels the new row as 12-1 residual momentum. Tests cover benchmark-gated emission and beta removal while keeping stock-specific drift.

Verification: 453 backend tests pass, ruff check app/ clean, frontend npm run build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:46:54 +02:00
dennisthiessen 1e82dfad7f feat: adopt Phase 3 gate and paper-trade exit policy
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 56s
Deploy / deploy (push) Successful in 33s
Production strategy change based on the July 2026 backtest: paper trades now default to a 30-trading-day hold with the initial stop (classic momentum hold-and-rerank), while target and trailing exits remain available in Admin. The exit policy API/UI now carries hold_days and close_reason can be 'time'.

The activation confidence floor default is now 0/off because the gate ablation showed it added no per-trade edge while filtering out usable setups. Migration 015 clears stored activation_min_confidence and paper_exit_mode so the new defaults take effect; this intentionally resets Track Record comparability from this deploy.

Verification: 451 backend tests pass, ruff check app/ clean, frontend npm run build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:20:34 +02:00
dennisthiessen 29a61cb2ca fix: judge robustness under the recommended exit, not the abandoned one
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 57s
Deploy / deploy (push) Successful in 32s
The robustness warning was computed on the target-model distribution
while the same panel recommends the hold exit — internally inconsistent.
_robustness_stats (median, profit factor, ex-top-5% expectancy) is now
shared by _bucket_stats and _time_exit_bucket, the time-exit table shows
Median Net R and Ex-Top-5% per hold length, and _build_recommendation
reads the trimmed expectancy from the recommended exit's bucket (falling
back to the target model when no hold is recommended).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:50:13 +02:00
dennisthiessen 243e369e9a feat: robustness stats + dynamic recommendation; retire settled report sections
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 54s
Deploy / deploy (push) Successful in 32s
Robustness (answers 'is the edge just outliers?'):
- _bucket_stats gains median_net_r, profit_factor, and net_avg_r_ex_top5
  (expectancy with the top 5% of winners removed); shown as stat tiles.
- Portfolio sim gains per-calendar-year returns, shown in the sim table.

Dynamic recommendation ('What this backtest recommends' panel):
- _build_recommendation derives advice from the report's own numbers on
  every run — exit policy (target vs best hold, with sim CAGRs), which
  gate floors earn their keep (ablation Hold column), best momentum
  cutoff, book-vs-SPY verdict, and an outlier-dependence warning when
  the trimmed expectancy goes non-positive.

Retired (conclusions reached, tables removed from report + UI):
- Take-profit sweep (no interior optimum — fixed TP is the wrong tool
  for momentum), trailing sweep (converged to the hold-to-horizon exit),
  probability calibration (model is display-only by decision).
- _tp_primitives slimmed to _risk_and_stop_day; trailing machinery gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:33:22 +02:00
dennisthiessen 0f43e755f4 feat: portfolio simulation + per-trade stats (gaps, hold time, best/worst)
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 55s
Deploy / deploy (push) Successful in 38s
Per-trade additions to the report:
- Gap-through-stop fills: stops now fill at the worse of the stop or the
  bar's open across every exit model (target, TP, trailing, time), so a
  loss can exceed -1R; targets never fill better than their level.
- best_r / worst_r, avg holding days, and net R per day of capital
  deployed on the summary buckets and the time-exit sweep.

Portfolio simulation (the stats a per-setup replay cannot give):
- One capital-constrained book over the qualified setups: 10k start, max
  10 concurrent positions (one per ticker, best momentum first), 1%
  fixed-fractional risk with a 20% no-leverage notional cap, entries at
  the detection close, 0.1%/side costs, daily mark-to-market.
- Two exit policies compared: S/R target race vs hold-to-horizon.
- Equity-curve stats: final equity, total return, CAGR, max drawdown,
  annualized daily Sharpe, win rate, avg P&L, best/worst trade, avg
  hold, entries skipped on a full book, and SPY price return over the
  same window (benchmark history refreshed to cover the replay span).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:56:29 +02:00
dennisthiessen 942a22ce65 feat: grade gate-ablation variants under the hold-to-horizon exit too
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 55s
Deploy / deploy (push) Successful in 33s
The ablation judged floors under the target/stop model, but the exit
sweeps point at replacing that exit with a fixed hold — under which the
R:R floor's rationale (bigger payoff at the target) may not apply. Each
ablation row now also carries hold_avg_r / hold_net_avg_r / hold_total_r
(30d hold, initial stop only), so the Phase 3 gate decision can be read
under the exit policy that would actually be used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:34:41 +02:00
dennisthiessen 8750aac6d9 fix: carry action/risk_level onto backtest candidates for the gate ablation
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 57s
Deploy / deploy (push) Successful in 2m24s
_window_setups computed them but _replay_ticker dropped them, so the
ablation's NEUTRAL/tightener checks saw None for every candidate and the
'without confidence floor' / 'without R:R floor' rows collapsed to 0
setups (impossible — removing a floor can only add setups). Regression
test now goes through the real _replay_ticker path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:07:27 +02:00
dennisthiessen 29b1a9a28c feat: net-of-cost backtest, gate ablation + time-exit sweeps, longer tails
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 57s
Deploy / deploy (push) Successful in 32s
Phase 1 of the strategy-measurement plan — report-only, no production
trading behavior changes:

- Cost haircut: every bucket/sweep now reports net_avg_r/net_total_r
  alongside gross (COST_PER_SIDE=0.1% of notional, converted to R via
  each setup's stop distance); params carry cost_per_side_pct.
- Gate ablation table: re-qualifies candidates at the current momentum
  cutoff with one floor removed per row (confidence / R:R / NEUTRAL /
  momentum-only) to show which floors earn their keep.
- Time-based exit sweep: hold 5/10/21/30 days with the initial ATR stop,
  exit at the day-N close — the classic momentum implementation, to
  disambiguate the wide-trailing result.
- TP sweep extended to +40/+50%, trailing to 25/30% so the optima are
  interior instead of starred at the sweep edge.
- BacktestPanel: Net Avg R columns everywhere, gate-ablation and
  time-exit tables, stars now mark best net avg R; stale cached reports
  still render (all new fields optional/guarded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:50:37 +02:00
dennisthiessen 84ce7c5c26 docs: strategy status + maintainer guide in README; document CI/CD deploy
Adds the validated-vs-not verdict table, the iron rule for strategy
changes, ranked next experiments, a maintainer guide (invariants, file
map, verification, roadmap), and corrects the deploy docs: deploys are
automated by Gitea Actions (push to main = deploy), service is
signalplatform.service at /opt/signalplatform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:50:21 +02:00
dennisthiessen da0bb3367e feat: company names for tickers (Alpaca backfill + subtle display)
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 55s
Deploy / deploy (push) Successful in 32s
Store an optional company name on Ticker (migration 014) and backfill it from
Alpaca's asset list in a single Trading-API call for the whole universe — no
per-ticker fetch. Runs automatically at the end of universe bootstrap and via a
manual "Backfill Names" button (admin) / POST /admin/tickers/backfill-names.

The name ships on /tickers; a shared symbol→name map (useTickerNames) lets any view
show it without its own request. Displayed subtly next to the symbol — in the global
search, the ticker header, and as a small muted line under the symbol in Top Setups
and Open Trades (no extra column, truncated so it never widens the table).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:50:40 +02:00
dennisthiessen a9f4686157 fix: forward sentiment-adjustment fields in the scores API response
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 54s
Deploy / deploy (push) Successful in 31s
get_score computed base_score / sentiment_score / sentiment_adjustment /
max_sentiment_adjustment, but the router's _map_composite_breakdown built the
response model from only the five original keys and silently dropped the rest — so
the API always returned null for them. That's why the ticker page showed neither the
"Composite = Base + Sentiment" caption nor the ± marker on the sentiment row despite
the frontend and scoring service both supporting it. Pass the fields through, with a
guard test so they can't be dropped again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:04:06 +02:00
dennisthiessen 94ed3207d7 feat: show composite = base + sentiment caption under the Standing matrix
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 52s
Deploy / deploy (push) Successful in 30s
The ticker page renders the composite via the Standing matrix (ScoreCard runs with
showComposite=false), so the "Base X · sentiment +Y" line in the ScoreCard header
was never visible there. Add a compact caption beneath the matrix — "Composite 83 =
Base 78 + Sentiment 5.0" — shown only when sentiment actually moves the score, so
the composition of the number has a visible home where the number lives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:55:42 +02:00
dennisthiessen 5442b62495 fix: decouple the sentiment weight from the base mix in the weights form
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 50s
Deploy / deploy (push) Successful in 31s
Sentiment is now a signed adjustment (± points on top of the base), not part of
the averaged dimensions — but the weights form still squeezed all five sliders to
sum to 100%, so dragging sentiment rebalanced the base and you couldn't set a clean
±N. Now the four base dimensions normalize among themselves (share shown as %),
and sentiment is its own "influence (± points)" control passed through raw
(slider 10 → weight 0.10 → ±10), independent of the base.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:43:43 +02:00
dennisthiessen f61e11adea feat: sentiment as a signed adjustment to the composite, not averaged in
Deploy / lint (push) Successful in 23s
Deploy / test (push) Successful in 54s
Deploy / deploy (push) Successful in 31s
Going from no sentiment to a bullish read used to be able to *lower* the composite:
sentiment was blended into the weighted average as an absolute level, so a bullish
75 diluted a ticker already scoring 78. That's backwards for a directional signal.

Now the non-sentiment dimensions form a re-normalized weighted-average base, and
sentiment is applied as a signed adjustment around neutral (50):

    composite = clamp(base + MAX_ADJ * (sentiment - 50) / 50)
    MAX_ADJ   = sentiment weight * 100   (default weight 0.10 → ±10)

Neutral leaves the base unchanged, bullish adds and bearish subtracts (scaled by
confidence, since a 50%-confidence call maps to 50 → no effect), and no sentiment
never penalises. Default sentiment weight 0.15 → 0.10; the weight now means "max ±
points." Composite breakdown exposes base_score/sentiment_score/sentiment_adjustment,
and the ScoreCard shows "Base 78 · sentiment +5.0" plus the per-dimension adjustment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:34:37 +02:00
dennisthiessen 1566b84379 feat: trailing-stop auto-exit for paper trades + close/digest alerts
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 54s
Deploy / deploy (push) Successful in 33s
Applies the backtest-validated trailing stop to live paper trading, and surfaces
it transparently.

Exit (A):
- New paper-trade exit policy (paper_exit_mode=trailing, paper_trailing_pct=12),
  tunable in Admin → Paper-Trade Exit. resolve_open_trades runs a trailing stop
  (initial stop as floor, ratchets up from the peak; target ignored — the
  validated rule) and records close_reason (trailing|stop|target|manual; +migration
  013).
- list_trades enriches open trades with the live trailing-stop level + distance %.
  Open Trades panel shows the active tactic and a Trail Stop column.

Alerts (B):
- Daily digest now lists open trades with unrealized gain, trailing stop, and how
  far away it is.
- New "trade closed" alert: one summary per auto-close (trailing/target/stop, not
  manual) — direction, reason, days held, P&L abs+%/R — covering wins AND
  stop-loss losses. Deduped by trade id; toggle in Admin alerts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:48:05 +02:00
dennisthiessen ab9ce18809 feat: trailing-stop exit sweep in the backtest
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 55s
Deploy / deploy (push) Successful in 32s
Third exit model alongside target-vs-stop and the fixed take-profit. The TP sweep
showed the edge lives in the fat tail (avg R keeps rising as you let winners run),
but a fixed wide target is win-rate-brutal and gives everything back on a reversal.
A trailing stop harvests the tail while protecting gains.

Per setup the replay computes the realized R for several trail widths (3/5/7/10/
15/20%) in a single conservative pass — stop ratchets up via max(initial_stop,
peak*(1-trail)), exit on the pullback or at the horizon close, R vs the initial
risk. Aggregated into a trailing sweep (win rate = share closed in profit, avg R,
total R) over the qualified set and shown as a new table in the Backtest panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:33:17 +02:00
dennisthiessen c5f6b07a3e feat: extend take-profit sweep into the tail + clarify it ignores the target
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 55s
Deploy / deploy (push) Successful in 33s
Avg R was still rising at the previous top level (+15%), so the optimum was off
the table. Extend TP_LEVELS to 20/25/30% to reveal where letting winners run
stops paying (it plateaus toward "just hold to the horizon close").

Also clarify in the panel that the take-profit model deliberately does NOT use
the setup's S/R target — it's a standalone fixed-% exit; exiting at the target is
the target-vs-stop model above. The two are complementary ends, not in conflict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:14:54 +02:00
dennisthiessen c63951ca02 feat: take-profit exit sweep in the backtest (alongside target-vs-stop)
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 59s
Deploy / deploy (push) Successful in 34s
The target-vs-stop model counts a near-miss of a far S/R target as a full loss
and ignores the partial gains you actually bank — so it measures a different
strategy than "scalp the early pop, take +8%". Add a realistic take-profit exit
model next to it (original untouched).

Per setup the replay now also records risk%, whether the stop was hit, the
favourable excursion reachable before the stop (MFE), and the horizon-close move.
From those a fixed-take-profit sweep (4/6/8/10/12/15%) is scored in R: bank +X%
if reached before the stop, else -1R, else the horizon close. Hit rate = how
often +X% was banked (the MFE CDF), so you can pick the EV-optimal TP without
top-ticking fantasy. Shown as a new table in the Backtest panel; the IC,
calibration and momentum sweep are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:56:32 +02:00
dennisthiessen 6511a1020b feat: exclude NEUTRAL setups from the activation gate (default on)
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 56s
Deploy / deploy (push) Successful in 34s
A NEUTRAL ("No Clear Setup") recommendation means the engine found no clear
directional trade, yet such setups could still qualify and even be crowned the
top pick purely on momentum rank (e.g. an extended momentum leader with a far,
5%-probability target). A NEUTRAL signal isn't actionable, so it shouldn't
qualify.

New `exclude_neutral` activation flag (default on): setup_qualifies drops setups
whose recommended_action is NEUTRAL. It lives in the shared gate, so it flows
through the dashboard's qualified/top-pick selection, the track record's
qualified stats, and the backtest (which computes recommended_action and gates on
meets_core). Toggleable in Admin → Settings → Activation; the frontend mirror and
activationSummary ("directional") match.

Re-run the backtest after enabling to confirm it holds/improves expectancy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:19:07 +02:00
dennisthiessen 20a1c143f3 fix: surface empty OHLCV fetch as a warning, not success
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m25s
Deploy / deploy (push) Successful in 46s
Fetching a symbol the provider doesn't cover (e.g. RHM/Rheinmetall — Alpaca
serves US listings only) returned 0 bars but reported "complete · Successfully
ingested 0 records", which the UI showed as green success.

fetch_and_ingest now returns a distinct `no_data` status when the provider
returns nothing AND the ticker has no history (vs. "already up to date" when bars
exist). The fetch endpoint maps it to a `warning` source status, and the fetch
toast renders it as ⚠ with the provider message instead of success.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:27:41 +02:00
dennisthiessen 6c2e45377c feat: collapse track record into a live-vs-backtest check
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 57s
Deploy / deploy (push) Successful in 34s
The outcome section measures the same thing as the backtest with the same code
and data — its only unique value is catching when the live system drifts from
the backtest (a bug, config/data drift, or look-ahead). So reframe it as exactly
that: a one-line "Live X R vs Backtest Y R · n matured · tracking ✓ / drift ⚠"
indicator (like-for-like with the qualified toggle), with the stat cards and
By-Action/By-Confidence tables moved into a collapsed "Outcome details"
disclosure. Drop the always-empty By-Direction table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:58:15 +02:00
dennisthiessen 7e9a6cd7ec fix: only count matured setups in the live track record
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m1s
Deploy / deploy (push) Successful in 36s
The outcome stats were dominated by quick stop-outs: near stops resolve as losses
within days while far targets take weeks, so a young sample (mostly pending,
0 expired) skewed sharply negative (e.g. 13.8% hit / -0.46R vs the backtest's
35.8% / +0.18R) — a maturation artifact, not a real result.

get_performance_stats now counts only setups whose full ~30-day window has
elapsed (_MATURITY_DAYS), so winners had as long as losers (unbiased, and
comparable to the backtest). A new `maturing` count reports the younger setups
held back. The Track Record UI relabels "Evaluated" -> "Matured", shows the
maturing count, and explains the window in the empty state + methodology note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:41:48 +02:00
dennisthiessen 8bcbbfcfd0 fix: show benchmark job in admin; harden + split deploy workflow
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 48s
Deploy / deploy (push) Successful in 28s
- admin_service: register benchmark_collector in VALID_JOB_NAMES, JOB_LABELS and
  PIPELINE_MEMBERS. The Admin → Jobs list is built from these hardcoded sets, not
  the scheduler, so the job was registered but invisible/untriggerable.

- deploy.yml:
  - SSH: verify the host key (StrictHostKeyChecking=yes) now that known_hosts is
    supplied; move private-key cleanup to an `if: always()` step.
  - Add a concurrency guard so deploys serialize.
  - Health-check the service after restart (127.0.0.1:8998/api/v1/health).
  - Align CI Python to 3.12 (matches prod); pip + npm caching.
  - Clarify the Postgres service only validates migrations (tests use SQLite);
    drop the redundant DATABASE_URL from the pytest step.
  - Split the monolithic "Deploy to server" step into named steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:01:09 +02:00
dennisthiessen 0627787bfc fix(alembic): renumber benchmark migration 011 -> 012
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 46s
Deploy / deploy (push) Successful in 27s
011 collided with the existing 011_add_regime_snapshots (duplicate revision id
and a second head branching off 010), which broke `alembic upgrade head`. Chain
the benchmark_prices migration after regime_snapshots so the history is linear
again (010 -> 011 regime_snapshots -> 012 benchmark_prices, single head).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 08:47:36 +02:00
dennisthiessen 30effa89b7 feat: ticker search, watchlist momentum column, alpha vs S&P 500
Deploy / lint (push) Successful in 6s
Deploy / test (push) Failing after 12s
Deploy / deploy (push) Has been skipped
Three usability fixes:

1. Global ticker search in the sidebar (TickerSearch) — typeahead over the
   tracked universe that opens a ticker's detail page without adding it to the
   watchlist. Also wired into the mobile nav.

2. Watchlist table shows the ticker's 12-1 momentum percentile (the top-pick
   selector) instead of the noisy full S/R-level list. Enriched from the setup
   already loaded in watchlist_service._enrich_entry — no extra query.

3. Alpha vs the S&P 500 on paper trades (open + closed). New benchmark_prices
   table + benchmark_service store SPY daily closes (a standalone series, not a
   Ticker, so it never enters the scanner / momentum ranking / rankings) via a
   new daily-pipeline step. paper_trade_service computes per-trade
   benchmark_return / alpha_pct / alpha_usd over each holding period; the open-
   trades table, dashboard, and closed-trades panel surface per-trade and total
   alpha. The list read path never makes a provider call.

Deploy: alembic upgrade head, then run the benchmark/daily job once to populate
SPY closes (alpha shows "—" until then).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 08:44:40 +02:00
dennisthiessen 4a96f85cd9 feat: Standing matrix on the ticker page (quality x momentum verdict)
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 42s
Deploy / deploy (push) Successful in 26s
Replace the scattered score readouts with one hero: a quality (composite) x
momentum-percentile scatter that plots this ticker against the whole field and
reads out a verdict by quadrant — Strong Buy / Momentum / Accumulate / Pass. The
dashed divider is the activation gate's momentum percentile, so "above the line =
qualifies" is visible at a glance; peers are clickable. Reuses the regime-quadrant
visual language and is lazy-loaded so recharts stays out of the main ticker chunk.

- New StandingMatrix component (composite x momentum, field cloud, verdict).
- ScoreCard gains showComposite (default true); the ticker page now renders it
  without the composite ring (composite lives in the matrix) under "Dimensions".
- Confidence + target probability stay in the recommendation panel (the trade).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:00:47 +02:00
dennisthiessen 146dadf06f docs: refresh README and document how it works
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 43s
Deploy / deploy (push) Successful in 25s
- Add "How It Works": daily load (ordered pipeline steps), intraday flow,
  other jobs, and the score -> activation gate -> top pick chain.
- Add "Key Use Cases" (find today's best long setup; track a paper trade).
- Fix stale facts: user-curated (not auto) watchlist, actual routes/pages,
  scheduler description, wrong env defaults (RR 3.0->1.5, fundamentals
  daily->weekly).
- Add missing surface: paper trading, activation gate, market regime, Telegram
  alerts, backtest; API groups (paper-trades, market/regime, jobs); FRED +
  Telegram env vars; note that pipeline timing is admin-cron, not env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:12:59 +02:00
dennisthiessen d15acb8741 feat: top-pick and open-trade status labels on the ticker page
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 42s
Deploy / deploy (push) Successful in 25s
Two read-only pills in the ticker header, beside the watchlist toggle:
- "Top Pick" when the ticker is the current #1 — the same ranking the dashboard
  highlights, via a shared topPickSymbol() helper so the two stay in sync.
- "Open Trade" when an open paper trade exists on the ticker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:04:55 +02:00
dennisthiessen 2f21c685e8 feat: always-fresh sentiment for top picks, watchlist & open trades
Tiered, uncapped sentiment scope so the names that matter are never shown
without sentiment.

- Priority (always fully refreshed): top-pick feeders — momentum leaders with a
  tradeable long setup over the R:R floor (the tickers that are, or could become
  with positive sentiment, the dashboard top pick) — plus the curated watchlist
  and open paper trades.
- Filler: top-N by composite, a discovery net, fetched after the priority set so
  a mid-run rate limit lands the important names first.
- Removed the per-run cap (sentiment_max_per_run): the relevant set is naturally
  bounded (watchlist <= 20, composite <= top_composite), so a full refresh stays
  inside the free tier. extra="ignore" keeps a stale env var from breaking startup.
- Refresh window 72h -> 120h (5 days): sentiment shifts slowly, score window is 7d.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:59:58 +02:00
dennisthiessen 65dd53baa3 feat: Telegram alert on regime quadrant change (hysteresis + cooldown)
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 27s
Fires once when the regime monitor shifts quadrant (regime index x early
warning), so you don't have to watch the tab. Two guards against spam:

- Hysteresis: each axis only flips once the value crosses its divider by a
  margin, so a point parked on a boundary keeps its quadrant instead of
  flip-flopping day to day.
- Cooldown: a genuine change stays quiet for a few days after the last alert.

Seeds the baseline silently on first run; reuses the existing Telegram dispatch
+ AlertLog. New per-trigger toggle in Admin → Alerts (on by default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:05:01 +02:00
dennisthiessen e683513857 fix: smooth the quadrant trail and fade it by recency
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 42s
Deploy / deploy (push) Successful in 25s
The single solid trail line read like a tangle. Make older→newer legible: the
path now fades from muted slate (older) to bright blue (newer) via per-point
colors, the connecting line is faint, and the points are de-noised with a
centered moving average (today kept exact). Easier to see the direction of travel
through the quadrants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:22:30 +02:00
dennisthiessen a07bfee6e6 feat: regime quadrant plot in place of the combined gauge
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 42s
Deploy / deploy (push) Successful in 25s
The combined score collapsed two distinct signals into one not-very-meaningful
number. Replace its gauge with a quadrant scatter that shows both axes directly:
x = regime index (coincident), y = early warning (breadth divergence), with a
trail of the last 60 sessions and today highlighted.

The four quadrants make the readings legible — ① hot & brittle (narrow melt-up,
shakeout risk), ② transition, ③ healthy & broad, ④ real downturn — and the trail
surfaces the actual tell: the ①→④ move (early warning rolling over as the regime
index climbs = divergence resolving downward). Combined still shows as a line in
the score-history chart. Frontend-only; reuses the history endpoint. Lazy-loaded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:14:32 +02:00
dennisthiessen 66444af65c feat: score-history chart on the regime tab
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 25s
Plots the index, early-warning, and combined scores over time beneath the live
gauges, with a 1M/3M/6M/All range toggle and band reference lines — so the trend
and any divergence between the scores is visible, not just today's snapshot.

- Backend: GET /regime/history + get_regime_history (the three scores per
  snapshot date from regime_snapshots).
- Frontend: recharts line chart, lazy-loaded so recharts ships in its own
  regime-tab chunk instead of nearly doubling the main bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:48:42 +02:00
dennisthiessen 60def1155b fix: coverage-aware event-study headline instead of misleading median delta
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 39s
Deploy / deploy (push) Successful in 24s
The "warned a median -18 days later" line was the median-over-1-event trap: the
coincident baseline's 60d median is a single lucky event, while breadth warned on
7. Replace it with the honest coverage framing (7/11 vs 1/11) and flag that the
median-lead comparison is unreliable when coverage differs this much.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:36:39 +02:00
dennisthiessen 02b8df58f0 fix: populate early-warning/combined on the latest snapshot + recent history
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 24s
The early-warning score showed n/a because it required an exact date match
between the live benchmark (Alpaca, may have today's bar) and the stored
universe breadth (DB, often a day behind), which blanked the newest snapshot —
the one the UI displays.

- Look up the divergence as-of the snapshot date (newest value within a 7-day
  lag) instead of requiring an exact match.
- Backfill early_warning + combined onto recent existing snapshots (the index
  history predates this signal) so the 7/30-day trends populate on the first run
  rather than only filling in over the coming weeks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:31:02 +02:00
dennisthiessen 613fc756ec feat: separate live early-warning + combined score on the regime tab
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 40s
Deploy / deploy (push) Successful in 24s
The event study showed the breadth-divergence signal genuinely leads (warned
before 7/11 drawdowns, ~6 weeks median, where the coincident baseline almost
never did). Surface it live to observe before deciding how to embed it — kept
separate from the index, not folded into its weights.

- regime_monitor daily job now computes breadth-divergence live and attaches a
  separate early_warning score plus a combined blend (weighted mean, default
  0.6/0.4, configurable via combined_weights) to each snapshot, including the
  backfill so the 7/30-day trends populate immediately. Stored in breakdown_json
  — no schema change. Best-effort: a breadth failure can't break the index.
- get_regime_monitor returns the index, early_warning, and combined scores each
  with 7/30-day deltas.
- Regime tab shows three gauges (generalized ScoreGauge): coincident index,
  early warning, and a compact combined blend. Stale snapshots render "—".

Note: the daily regime job now also does a universe-wide breadth scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:23:37 +02:00
dennisthiessen 7c5fb1138d feat: sharpen the event study — more events, fair baseline, per-event view
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 26s
The first run gave only 2 events (N=2 is anecdote, not evidence) and an unfairly
weak coincident baseline, so the +42d lead couldn't be trusted. This makes the
measurement meaningful:

- More, cleaner events: default drawdown threshold 15%→10%, and dedup switched
  from "recover to the high" to a rising-edge + cooldown (40d), so distinct
  drawdowns each register instead of merging.
- Fair comparison: each indicator now warns at its OWN 80th percentile instead of
  a shared absolute 60, removing the artifact that muted the coincident baseline.
- Per-event breakdown (date · depth · breadth lead · coincident lead) so a median
  over a tiny sample can't hide an apples-to-oranges comparison — you see whether
  both warned on the same drawdown.
- Surface precision/recall (best row) + base rate per indicator — the honest edge
  read, not just lead time.

Re-run the Event Study job to regenerate the cached report in the new shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:54:29 +02:00
102 changed files with 8137 additions and 1086 deletions
+54 -29
View File
@@ -22,6 +22,12 @@ on:
type: boolean
default: false
# Serialize deploys so two quick pushes to main can't rsync/restart on top of
# each other. Don't cancel an in-flight deploy mid-restart.
concurrency:
group: deploy-main
cancel-in-progress: false
jobs:
lint:
runs-on: ubuntu-latest
@@ -29,7 +35,8 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.12"
cache: "pip"
- run: pip install ruff
- run: ruff check app/
@@ -52,17 +59,21 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.12"
cache: "pip"
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- run: pip install -e ".[dev]"
# The Postgres service exists only to validate the migrations against real
# Postgres (what prod runs). The test suite itself uses an in-memory SQLite
# engine (tests/conftest.py), so pytest doesn't touch this service.
- run: alembic upgrade head
env:
DATABASE_URL: postgresql+asyncpg://test_user:test_pass@postgres:5432/test_db
- run: pytest --tb=short
env:
DATABASE_URL: postgresql+asyncpg://test_user:test_pass@postgres:5432/test_db
- run: |
cd frontend
npm ci
@@ -76,37 +87,43 @@ jobs:
deploy:
needs: test
runs-on: ubuntu-latest
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ vars.SSH_KNOWN_HOSTS }}
SSH_PORT: ${{ vars.SSH_PORT || '22' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Build frontend
run: |
cd frontend
npm ci
npm run build
- name: Deploy to server
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ vars.SSH_KNOWN_HOSTS }}
SSH_PORT: ${{ vars.SSH_PORT || '22' }}
run: |
# Install tools missing from runner image
sudo apt-get update -qq && sudo apt-get install -y -qq rsync openssh-client > /dev/null 2>&1 || true
# Write SSH credentials
- name: Install deploy tools
run: sudo apt-get update -qq && sudo apt-get install -y -qq rsync openssh-client > /dev/null 2>&1 || true
- name: Set up SSH
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
# known_hosts is supplied, so verify the host key instead of blindly
# trusting it (StrictHostKeyChecking=no would defeat the fingerprint).
echo "SSH_OPTS=-i $HOME/.ssh/deploy_key -o StrictHostKeyChecking=yes -p ${SSH_PORT}" >> "$GITHUB_ENV"
SSH_OPTS="-i ~/.ssh/deploy_key -o StrictHostKeyChecking=no -p $SSH_PORT"
# Sync application files
- name: Sync files to server
run: |
rsync -avz --delete \
--exclude '.git/' \
--exclude '.gitea/' \
@@ -118,10 +135,11 @@ jobs:
--exclude '*.pyc' \
--exclude 'frontend/node_modules/' \
-e "ssh $SSH_OPTS" \
./ ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/
./ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/"
# Install deps & restart on server
ssh $SSH_OPTS ${DEPLOY_USER}@${DEPLOY_HOST} << REMOTE_SCRIPT
- name: Install deps & run migrations
run: |
ssh $SSH_OPTS "${DEPLOY_USER}@${DEPLOY_HOST}" << REMOTE_SCRIPT
set -e
cd ${DEPLOY_PATH}
@@ -141,12 +159,19 @@ jobs:
else
alembic upgrade head
fi
# Restart service
sudo systemctl restart signalplatform.service
echo "✓ signalplatform deployed"
REMOTE_SCRIPT
# Cleanup
rm -f ~/.ssh/deploy_key
- name: Restart service & health check
run: |
ssh $SSH_OPTS "${DEPLOY_USER}@${DEPLOY_HOST}" << REMOTE_SCRIPT
set -e
sudo systemctl restart signalplatform.service
sleep 3
curl -fsS http://127.0.0.1:8998/api/v1/health > /dev/null \
|| { echo "✗ health check failed after restart"; exit 1; }
echo "✓ signalplatform deployed"
REMOTE_SCRIPT
- name: Clean up SSH key
if: always()
run: rm -f ~/.ssh/deploy_key
+4
View File
@@ -33,3 +33,7 @@ alembic/versions/__pycache__/
# Generated SSL bundle
combined-ca-bundle.pem
# Local research artifacts
backtest_snapshots/
reports/backtest-*.json
+258 -48
View File
@@ -4,20 +4,110 @@ Investing-signal platform for NASDAQ stocks. Surfaces the best trading opportuni
**Philosophy:** Don't predict price. Find the path of least resistance, key S/R zones, and asymmetric R:R setups.
## How It Works
Scheduled pipelines turn raw prices into a ranked, gated list of tradeable setups. Everything downstream of OHLCV is recomputed from stored data, so each refresh is cheap and idempotent. Job timing is cron-based and configurable in **Admin → Jobs** (default timezone Europe/Berlin).
### Daily Load — the full refresh
Once a day (default 07:00). Steps run **in dependency order**, each consuming the previous step's fresh output:
1. **OHLCV** — fetch the latest daily bars for every tracked ticker (Alpaca); new tickers backfill ~5 years.
2. **Sentiment** — fetch sentiment for the names that matter and are stale (> 5 days): top-pick feeders (residual-momentum leaders with a tradeable long setup), the watchlist, and open paper trades, plus a top-N-by-composite discovery net. Runs *before* the scan so the scan sees fresh sentiment.
3. **R:R Scan** — recompute S/R zones, the 5-dimension scores and long/short setups (ATR stops, S/R targets) for every ticker, and attach each ticker's residual 121 momentum activation percentile.
4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and auto-close paper trades per the exit policy (default: hold 30 trading days with the initial stop — the backtest-validated exit).
5. **Market Regime** — recompute the regime index (breadth/trend).
6. **Regime Monitor** — observational early-warning snapshot (VIX, credit spreads via FRED); feeds nothing else.
A failing step is logged; the pipeline continues with the next.
### Intraday — light refresh
Hourly across the US session (MonFri): only **OHLCV → Outcome Eval**, to keep prices current and close paper trades intraday. No scan/sentiment — the dashboard recomputes live R:R from the latest price, so fresh prices are enough.
### Other jobs
Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (weekly) · Ticker-universe sync (daily). Deep history backfill and event study are manual-only (Admin → Jobs).
### From score to "top pick"
1. **Composite score** — technical, S/R-quality, sentiment, fundamental and momentum sub-scores (0100) combine into a weighted composite (weights configurable; missing dimensions re-normalize).
2. **Setups** — the scanner builds long/short setups with ATR stops and S/R targets, then adds a confidence score, conflict flags and a target reach-probability.
3. **Activation gate** — a setup *qualifies* only if it clears the R:R floor **and** ranks in the top residual-momentum percentile of the universe (the validated edge is long-only; the confidence floor was ablated to zero effect and defaults off).
4. **Top pick** — the highest residual-momentum qualified setup; highlighted on the Dashboard and labelled on the ticker page.
## Strategy Status — What's Validated and What Isn't
**Read this before touching scoring, gating, or setup logic.** The platform measures itself — a weekly-replay backtest plus a factor rank-IC harness (`app/services/backtest_service.py`) — and the verdicts below come from those reports (June 2026, ~5 years of OHLCV), not from opinion.
| Component | Verdict | Evidence |
|---|---|---|
| **Residual 12-1 cross-sectional momentum** (the activation gate, long-only) | **Production ranking — in-sample edge** | Promoted July 2026 after the portfolio variant beat raw 80 on CAGR, Sharpe and drawdown. Raw 12-1 remains a fallback only when benchmark data is unavailable |
| S/R setup engine (ATR stops, S/R targets, reach-probability) | **Filter/execution context, not the exit** | R:R/room-to-run still earns its keep as a filter, but S/R targets underperform the time exit. The probability model is display-only |
| Composite score + 5 dimensions | **Display/ranking only** | Sub-scores are hand-built heuristics; none has a measured IC. Note: the "momentum" *dimension* is 5/20-day ROC — NOT the validated 12-1 factor (that lives in `momentum_service`) |
| LLM sentiment | Display + a bounded composite adjustment (± weight × 100 pts around neutral 50) | Deliberately kept out of the setup engine; no point-in-time history to validate against yet |
| Fundamentals | Feeds composite + confidence only | Latest values only, no history — same limitation |
| Short setups | **Excluded while the momentum gate is active** | Backtest showed shorts fight the trend and drag expectancy |
| Expected-value gate (removed June 2026) | Degenerate — do not resurrect | Structurally favored distant lottery targets; selected *worse*-than-random setups |
Caveats on the momentum result: in-sample, roughly one market regime, costs/slippage approximated at 0.1% per side, and residual momentum still needs SPY benchmark history to compute. The **out-of-sample proof is the forward paper-trade record**: Signals → Track Record compares live qualified expectancy against the backtest.
### Current production baseline
Use this as a regression guardrail for future strategy changes, not as a return promise. Backtest run: 506 tickers, weekly cadence, 30-trading-day horizon, 2022-06-28 → 2026-07-01, 0.1% per-side costs, price-only SPY benchmark.
| Item | Current baseline |
|---|---|
| Strategy version | `residual_momentum_12_1_rr_time_v2` |
| Production gate | Long-only, residual 12-1 momentum percentile >= 80, R:R floor on, NEUTRAL excluded, confidence floor effectively off |
| Exit | Hold 30 trading days with the initial ATR stop |
| Qualified setups | 1,810 |
| Qualified net expectancy | +0.16R per setup |
| Profit factor | 1.27 |
| Portfolio CAGR | +40.4% |
| Portfolio total return | +289.4% vs SPY +95.9% |
| Max drawdown | -26.1% |
| Sharpe | 1.52 daily, annualized |
| Robustness | 30d hold remains +0.16R net/trade after removing the top 5% winners |
Nearest challengers from the same run: legacy raw 80 was weaker (+33.8% CAGR, -28.8% max drawdown, Sharpe 1.32); raw 90 was close but had lower Sharpe and worse drawdown (+40.4% CAGR, -27.6% max drawdown, Sharpe 1.49); residual 80 / max 15 removed book-full skips but did not improve CAGR, drawdown, Sharpe or closed trades.
### The iron rule for strategy changes
A signal earns its way into selection **only** through the factor harness:
1. Add it as a point-in-time function of past bars in `_signal_values()` (`backtest_service.py`).
2. Run the backtest (Admin → Jobs, or the weekly run) and read the **Signal edge** table (Signals → Track Record).
3. Wire it into the gate or ranking **only if** |mean IC| ≳ 0.03 with a consistent sign and `reliable: true` (≥ 12 non-overlapping windows).
Corollaries: never let an unvalidated score gate setups; the outcome evaluator must keep scoring **all** setups (unqualified ones are the control group); LLM output stays display-only in the quant path.
### Highest-value next experiments (in order)
1. **Raw 90 challenger** — keep comparing raw 12-1 momentum at cutoff 90 against production residual 80; promote only if it beats residual production on Sharpe and drawdown without a meaningful CAGR hit.
2. **Capacity check** — keep only the residual 80 / max 15 portfolio row as a guardrail; max 20 and raw max 15 added no information in the July 2026 run.
3. **Signal context snapshots** — accumulate point-in-time composite/sentiment/fundamental context for every new setup so the discretionary overlay can be tested forward-only.
4. **More breadth, not more history** — widening the ranked universe (e.g. `nasdaq_all`) strengthens each week's cross-section and the IC t-stat, even if only the top slice is traded. (Deeper history was considered and declined.)
## Key Use Cases
- **Find today's best long setup.** On the **Dashboard**, the *Top Setups* table lists qualified setups ranked by residual momentum with the #1 flagged "Top pick". Each row opens the ticker page for the chart, scores, S/R targets and entry/stop.
- **Track a trade you took.** Mark a setup as a **paper trade**: it's marked-to-market against the latest close, auto-closed by the active exit policy (default: 30 trading days with the initial stop), and its sentiment stays fresh while open. *Signals → Track Record* shows the realized edge.
## Stack
| Layer | Tech |
|---|---|
| Backend | Python 3.12+, FastAPI, Uvicorn, async SQLAlchemy, Alembic |
| Database | PostgreSQL (asyncpg) |
| Scheduler | APScheduler — OHLCV, sentiment, fundamentals, R:R scan |
| Scheduler | APScheduler — daily & intraday pipelines, fundamentals, alerts, regime, backtest |
| Frontend | React 18, TypeScript, Vite 5 |
| Styling | Tailwind CSS 3 with custom glassmorphism design system |
| State | TanStack React Query v5 (server), Zustand (client/auth) |
| Charts | Canvas 2D candlestick chart with S/R overlays |
| Routing | React Router v6 (SPA) |
| HTTP | Axios with JWT interceptor |
| Data providers | Alpaca (OHLCV), OpenAI (sentiment, optional micro-batch), Fundamentals chain: FMP → Finnhub → Alpha Vantage |
| Data providers | Alpaca (OHLCV); OpenAI / Gemini / DeepSeek / xAI (sentiment, pluggable); Fundamentals chain: FMP → Finnhub → Alpha Vantage; FRED (regime); Telegram (alerts) |
## Features
@@ -30,10 +120,15 @@ Investing-signal platform for NASDAQ stocks. Surfaces the best trading opportuni
- Sentiment analysis with time-decay weighted scoring
- Fundamental data tracking (P/E, revenue growth, earnings surprise, market cap)
- 5-dimension scoring engine (technical, S/R quality, sentiment, fundamental, momentum) with configurable weights
- Risk:Reward scanner — long and short setups, ATR-based stops, configurable R:R threshold (default 1.5:1)
- Auto-populated watchlist (top-10 by composite score) + manual entries (cap: 20)
- Risk:Reward scanner — long and short setups, ATR-based stops, S/R-based targets, configurable R:R threshold (default 1.5:1)
- Activation gate — qualifies setups on a residual-momentum percentile floor plus an R:R floor (validated long-only edge)
- Recommendation layer — directional confidence, conflict detection, per-target reach-probability
- Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: hold 30 trading days with the initial stop; trailing / target-stop selectable), realized track record + outcome evaluation
- Market-regime index + FRED early-warning monitor (VIX, credit spreads); weekly backtest + manual event study
- Telegram alerts (e.g. regime-quadrant changes)
- User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary
- JWT auth with admin role, configurable registration, user access control
- Scheduled jobs with enable/disable control and status monitoring
- Cron-scheduled pipelines (admin-configurable) with per-job enable/disable and live status monitoring
- Admin panel: user management, data cleanup, job control, system settings
### Frontend
@@ -56,12 +151,15 @@ Investing-signal platform for NASDAQ stocks. Surfaces the best trading opportuni
|---|---|---|
| `/login` | Login | Public |
| `/register` | Register | Public (when enabled) |
| `/watchlist` | Watchlist (default) | Authenticated |
| `/` | Dashboard — top setups, open trades, regime (default) | Authenticated |
| `/market` | Market — watchlist + rankings tabs | Authenticated |
| `/signals` | Signals — scanner + track record tabs | Authenticated |
| `/regime` | Market Regime | Authenticated |
| `/ticker/:symbol` | Ticker Detail | Authenticated |
| `/scanner` | Trade Scanner | Authenticated |
| `/rankings` | Rankings | Authenticated |
| `/admin` | Admin Panel | Admin only |
Legacy routes redirect: `/watchlist``/market`, `/rankings``/market?tab=rankings`, `/scanner``/signals`, `/performance``/signals?tab=track`.
## API Endpoints
All under `/api/v1/`. Interactive docs at `/docs` (Swagger) and `/redoc`.
@@ -78,7 +176,10 @@ All under `/api/v1/`. Interactive docs at `/docs` (Swagger) and `/redoc`.
| Sentiment | `GET /sentiment/{symbol}` |
| Fundamentals | `GET /fundamentals/{symbol}` |
| Scores | `GET /scores/{symbol}`, `GET /rankings`, `PUT /scores/weights` |
| Trades | `GET /trades` |
| Trades | `GET /trades`, `GET /trades/{symbol}`, `GET /trades/{symbol}/history`, `GET /trades/activation`, `GET /trades/performance` |
| Paper Trades | `GET /paper-trades`, `POST /paper-trades`, `POST /paper-trades/{id}/close` |
| Market / Regime | `GET /market/regime`, `GET /regime/monitor`, `GET/PUT /regime/config`, `GET /regime/history`, `GET /regime/event-study`, `GET/PUT /regime/fundamentals`, `GET /backtest/report` |
| Jobs | `GET /jobs/running` |
| Watchlist | `GET /watchlist`, `POST /watchlist/{symbol}`, `DELETE /watchlist/{symbol}` |
| Admin | `GET /admin/users`, `POST /admin/users`, `PUT /admin/users/{id}/access`, `PUT /admin/users/{id}/password`, `PUT /admin/settings/registration`, `GET /admin/settings`, `PUT /admin/settings/{key}`, `GET/PUT /admin/settings/recommendations`, `GET/PUT /admin/settings/ticker-universe`, `POST /admin/tickers/bootstrap`, `POST /admin/data/cleanup`, `GET /admin/jobs`, `POST /admin/jobs/{name}/trigger`, `PUT /admin/jobs/{name}/toggle`, `GET /admin/pipeline/readiness` |
@@ -140,11 +241,56 @@ npm run preview # Preview the production build locally
# Backend tests (in-memory SQLite — no PostgreSQL needed)
pytest tests/ -v
# Frontend tests
# Frontend: there is no test suite — `npm test` calls vitest, which is not
# installed. The frontend check is the full TypeScript build:
cd frontend
npm test
npm run build
```
### Local Backtest Snapshots
For research loops, run the production backtest locally from a SQLite snapshot
instead of deploying and clicking the Admin job. The snapshot contains only the
tables needed by `run_backtest`: tickers, OHLCV bars, SPY benchmark closes, and
activation/recommendation settings. Secrets and cached reports are not copied.
1. Open an SSH tunnel to the production Postgres instance:
```bash
ssh -N -L 15432:127.0.0.1:5432 deploy@your-server
```
2. In another terminal, create or refresh the snapshot:
```bash
# macOS/Linux
python scripts/create_backtest_snapshot.py \
--database-url "postgresql+asyncpg://stock_backend:PASSWORD@127.0.0.1:15432/stock_data_backend" \
--output backtest_snapshots/prod.sqlite \
--force
# Windows PowerShell
.venv\Scripts\python.exe scripts\create_backtest_snapshot.py `
--database-url "postgresql+asyncpg://stock_backend:PASSWORD@127.0.0.1:15432/stock_data_backend" `
--output backtest_snapshots\prod.sqlite `
--force
```
3. Run the backtest fully offline from the snapshot:
```bash
# macOS/Linux
python scripts/run_backtest_snapshot.py backtest_snapshots/prod.sqlite --workers 8
# Windows PowerShell
.venv\Scripts\python.exe scripts\run_backtest_snapshot.py backtest_snapshots\prod.sqlite --workers 12 --allow-spawn
```
The runner writes `reports/backtest-<timestamp>.json` and prints the headline
metrics. Keep the SSH tunnel open only while creating the snapshot; the backtest
run itself is local/offline. `backtest_snapshots/` and generated backtest reports
are git-ignored.
## Environment Variables
Configure in `.env` (copy from `.env.example`):
@@ -164,83 +310,86 @@ Configure in `.env` (copy from `.env.example`):
| `FMP_API_KEY` | Optional (fundamentals) | — | Financial Modeling Prep API key (first provider in chain) |
| `FINNHUB_API_KEY` | Optional (fundamentals) | — | Finnhub API key (fallback provider) |
| `ALPHA_VANTAGE_API_KEY` | Optional (fundamentals) | — | Alpha Vantage API key (fallback provider) |
| `DATA_COLLECTOR_FREQUENCY` | No | `daily` | OHLCV collection schedule |
| `FRED_API_KEY` | Optional (regime) | — | FRED key for the regime monitor (VIX, credit spreads) |
| `TELEGRAM_BOT_TOKEN` | Optional (alerts) | — | Telegram bot token for alerts (can also be set in Admin) |
| `TELEGRAM_CHAT_ID` | Optional (alerts) | — | Telegram chat id for alerts |
| `DATA_COLLECTOR_FREQUENCY` | No | `daily` | OHLCV collection schedule (legacy — see note below) |
| `SENTIMENT_POLL_INTERVAL_MINUTES` | No | `30` | Sentiment polling interval |
| `FUNDAMENTAL_FETCH_FREQUENCY` | No | `daily` | Fundamentals fetch schedule |
| `FUNDAMENTAL_FETCH_FREQUENCY` | No | `weekly` | Fundamentals fetch cadence |
| `RR_SCAN_FREQUENCY` | No | `daily` | R:R scanner schedule |
| `FUNDAMENTAL_RATE_LIMIT_RETRIES` | No | `3` | Retries per ticker on fundamentals rate-limit |
| `FUNDAMENTAL_RATE_LIMIT_BACKOFF_SECONDS` | No | `15` | Base backoff seconds for fundamentals retry (exponential) |
| `DEFAULT_WATCHLIST_AUTO_SIZE` | No | `10` | Auto-watchlist size |
| `DEFAULT_RR_THRESHOLD` | No | `3.0` | Minimum R:R ratio for setups |
| `DEFAULT_RR_THRESHOLD` | No | `1.5` | Minimum R:R ratio for setups |
| `DB_POOL_SIZE` | No | `5` | Database connection pool size |
| `LOG_LEVEL` | No | `INFO` | Logging level |
> **Note:** Pipeline timing (daily / intraday / fundamentals cron, timezone) is configured at runtime in **Admin → Jobs** and stored in the DB — the `*_FREQUENCY` env vars are legacy fallbacks for the few jobs still on interval triggers (alerts, universe sync).
## Production Deployment (Debian 12)
**Ongoing deploys are automated.** Every push to `main` triggers the Gitea Actions pipeline (`.gitea/workflows/deploy.yml`): lint → test → rsync to the server → `pip install``alembic upgrade head` → restart `signalplatform.service` → health check. There is no manual deploy step; the steps below are only for provisioning a new server.
### 1. Install dependencies
```bash
sudo apt update && sudo apt install -y python3.12 python3.12-venv postgresql nginx nodejs npm
sudo apt update && sudo apt install -y python3.12 python3.12-venv postgresql nginx rsync
```
### 2. Create service user
### 2. Create the deploy user
The pipeline connects over SSH as this user; it owns the app directory and needs passwordless permission to restart the service:
```bash
sudo useradd -r -s /usr/sbin/nologin stockdata
sudo useradd -m deploy
sudo mkdir -p /opt/signalplatform
sudo chown deploy:deploy /opt/signalplatform
echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart signalplatform.service' | sudo tee /etc/sudoers.d/deploy-restart
```
### 3. Deploy application
### 3. Configure the pipeline (Gitea repo settings)
Variables: `DEPLOY_HOST`, `DEPLOY_USER` (`deploy`), `DEPLOY_PATH` (`/opt/signalplatform`), `SSH_KNOWN_HOSTS` (host fingerprint), `SSH_PORT`. Secret: `SSH_PRIVATE_KEY` (matching the deploy user's authorized key).
### 4. Configure the app
```bash
sudo mkdir -p /opt/stock-data-backend
# Copy project files to /opt/stock-data-backend
cd /opt/stock-data-backend
python3.12 -m venv .venv
source .venv/bin/activate
pip install .
```
### 4. Configure
```bash
sudo cp .env.example /opt/stock-data-backend/.env
sudo chown stockdata:stockdata /opt/stock-data-backend/.env
# After the first pipeline run has synced the files:
cp /opt/signalplatform/.env.example /opt/signalplatform/.env
# Edit .env with production values (strong JWT_SECRET, real API keys, etc.)
```
`.env` is excluded from the rsync, so it survives every deploy.
### 5. Database
Either trigger the workflow manually (workflow_dispatch) with `run_setup_db: true` — the deploy then runs `deploy/setup_db.sh` instead of plain migrations — or run it once by hand:
```bash
DB_NAME=stock_data_backend DB_USER=stock_backend DB_PASS=strong_password ./deploy/setup_db.sh
```
### 6. Build frontend
### 6. Systemd service
```bash
cd frontend
npm ci
npm run build
```
### 7. Systemd service
```bash
sudo cp deploy/stock-data-backend.service /etc/systemd/system/
sudo cp deploy/signalplatform.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now stock-data-backend
sudo systemctl enable --now signalplatform
```
### 8. Nginx reverse proxy
The unit runs uvicorn on `127.0.0.1:8998` as the `deploy` user, with `WorkingDirectory=/opt/signalplatform`.
### 7. Nginx reverse proxy
```bash
sudo cp deploy/nginx.conf /etc/nginx/sites-available/stock-data-backend
sudo ln -s /etc/nginx/sites-available/stock-data-backend /etc/nginx/sites-enabled/
sudo cp deploy/nginx.conf /etc/nginx/sites-available/signalplatform
sudo ln -s /etc/nginx/sites-available/signalplatform /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
Nginx serves the frontend static files from `frontend/dist/` and proxies `/api/v1/` to the backend.
Nginx serves the frontend static files from `frontend/dist/` (built on the CI runner and rsynced) and proxies `/api/v1/` to the backend.
### 9. SSL (recommended)
### 8. SSL (recommended)
```bash
sudo apt install certbot python3-certbot-nginx
@@ -292,7 +441,7 @@ frontend/
│ └── watchlist/ # Watchlist table, add ticker form
├── hooks/ # React Query hooks (one per resource)
├── lib/ # Types, formatting utilities
├── pages/ # Page components (7 pages)
├── pages/ # Page components (Login, Register, Dashboard, Market, Signals, Regime, Ticker, Admin)
├── stores/ # Zustand auth store
└── styles/ # Global CSS with glassmorphism classes
@@ -306,3 +455,64 @@ tests/
├── unit/ # Unit tests
└── property/ # Property-based tests (Hypothesis)
```
## Maintainer Guide
Context for whoever — human or AI — continues this work. The owner pushes straight to `main` on a self-hosted Gitea remote (no PRs); deployment is automated by the Gitea Actions workflow at `.gitea/workflows/deploy.yml`.
### Invariants — do not break these
- **`app/services/qualification.py` is mirrored in `frontend/src/lib/qualification.ts`.** Any gate change must land in both, or the UI's "qualified" flags silently disagree with the server.
- **Live scan and backtest share the same pure functions.** The backtest replays production logic through DB-free functions (`compute_technical_from_arrays`, `compute_momentum_from_closes`, `detect_sr_levels`, the recommendation helpers). New strategy logic must stay in pure functions consumed by both paths, or the backtest stops measuring what production actually does.
- **One S/R model app-wide:** `sr_service.detect_sr_levels` + `cluster_sr_zones` (2% tolerance) feed the chart, alerts, and target generation identically.
- **The outcome evaluator evaluates ALL setups**, not just qualified ones — unqualified setups are the control group that makes the Track Record meaningful.
- **`SystemSetting` access goes through `app/services/settings_store.py`** — don't query the model directly.
- **Time-series data gets a real table** (see `benchmark_prices`, `regime_snapshots`); `SystemSetting` JSON is only for config and cached reports.
- **Discretionary overlay data is forward-only.** `signal_context_snapshots` captures composite/dimension/sentiment/fundamental context for new setups. Do not approximate historical sentiment/fundamental snapshots from today's data.
- Style: surgical changes, minimal new files; extend existing services rather than adding parallel ones.
### Where the strategy lives
| Concern | File |
|---|---|
| Composite + 5 dimension scores, weights | `app/services/scoring_service.py` |
| Residual 12-1 momentum ranking (the validated activation factor) | `app/services/momentum_service.py` |
| Setup construction (ATR stop, S/R targets) | `app/services/rr_scanner_service.py` |
| Confidence, targets, reach-probability, action | `app/services/recommendation_service.py` |
| Activation gate predicate (mirrored in TS) | `app/services/qualification.py` |
| Gate defaults / admin config | `app/services/admin_service.py` (`ACTIVATION_DEFAULTS`) |
| Backtest + factor rank-IC harness ("Signal edge") | `app/services/backtest_service.py` |
| Outcome resolution (target/stop/expired/ambiguous) | `app/services/outcome_service.py` |
| Paper trades + time/trailing/target auto-exit | `app/services/paper_trade_service.py` |
| Point-in-time setup context snapshots | `app/models/signal_context_snapshot.py` + `app/services/rr_scanner_service.py` |
| S/R detection & zone clustering | `app/services/sr_service.py` |
| SPY benchmark for residual momentum + paper-trade alpha | `app/services/benchmark_service.py` |
| Pipelines & job registration | `app/scheduler.py` |
### Verifying changes
```bash
pytest tests/ -q # backend; in-memory SQLite, no Postgres needed
cd frontend && npm run build # full tsc check — this IS the frontend "test"
```
- `npm test` in `frontend/` is dead (vitest isn't installed; there are no frontend test files). Use `npm run build`.
- Backend tests that exercise services which `commit()` need a plain session fixture, not the rolling-back `db_session` — copy the pattern in `tests/unit/test_rr_scanner_integration.py`.
- `ruff` reports ~11 pre-existing errors in old test files; those are not regressions.
### Deploying
Automated by Gitea Actions (`.gitea/workflows/deploy.yml`) on every push to `main`: **lint** (`ruff check app/`) → **test** (pytest; `alembic upgrade head` validated against a real Postgres 16 service; frontend `npm ci && npm run build`) → **deploy** (frontend built on the runner, rsync to the server excluding `.env`, `pip install -e .`, `alembic upgrade head`, restart `signalplatform.service`, health check on `127.0.0.1:8998`). Deploys are serialized by a concurrency group so overlapping pushes can't race.
Practical consequences:
- **A `ruff` error in `app/` or any failing backend test blocks the deploy.** (CI lints only `app/`, so the pre-existing ruff noise in old test files doesn't.)
- **Migrations run automatically on deploy** — no manual `alembic` step. A migration that only works on SQLite will fail CI against Postgres, by design.
- Pushing to `main` **is** deploying to production — there is no separate release step.
- After a gate or scanner change ships, trigger an R:R scan (Admin → Jobs) so live setups pick up new fields.
### Roadmap (agreed June 2026)
1. **Forward paper-test the momentum book** — the out-of-sample proof the backtest can't give. Watch Signals → Track Record (live vs backtest).
2. **Full IBKR integration** — read real positions, overlay entries/stops on charts, alert on holdings' score deterioration. (Paper trading, the lighter alternative, is done.)
3. Strategy experiments in the order listed under **Strategy Status** above — each one goes through the factor harness first.
@@ -0,0 +1,41 @@
"""add benchmark_prices
Stores daily closes for a benchmark index (SPY) so paper-trade alpha — trade
return minus the benchmark's return over the same holding period — can be
computed. Kept separate from the tradeable universe: the benchmark is not a
Ticker, so it never enters the scanner, momentum ranking, or rankings.
Revision ID: 012
Revises: 011
Create Date: 2026-06-28 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "012"
down_revision: Union[str, None] = "011"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"benchmark_prices",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("symbol", sa.String(length=20), nullable=False),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("close", sa.Float(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("symbol", "date", name="uq_benchmark_symbol_date"),
)
op.create_index("ix_benchmark_prices_symbol", "benchmark_prices", ["symbol"])
def downgrade() -> None:
op.drop_index("ix_benchmark_prices_symbol", table_name="benchmark_prices")
op.drop_table("benchmark_prices")
@@ -0,0 +1,29 @@
"""add close_reason to paper_trades
Records how an open paper trade was closed (trailing | stop | target | manual) so
the close alert can summarise it and the UI can show why a position exited.
Revision ID: 013
Revises: 012
Create Date: 2026-06-30 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "013"
down_revision: Union[str, None] = "012"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("paper_trades", sa.Column("close_reason", sa.String(length=10), nullable=True))
def downgrade() -> None:
op.drop_column("paper_trades", "close_reason")
+29
View File
@@ -0,0 +1,29 @@
"""add name to tickers
Company name (e.g. "Biogen Inc."), backfilled from Alpaca so the UI can show which
company is behind a symbol. Nullable — symbols Alpaca doesn't cover stay name-less.
Revision ID: 014
Revises: 013
Create Date: 2026-07-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "014"
down_revision: Union[str, None] = "013"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("tickers", sa.Column("name", sa.String(length=120), nullable=True))
def downgrade() -> None:
op.drop_column("tickers", "name")
@@ -0,0 +1,50 @@
"""Phase 3 strategy adoption: time-based exit + confidence floor removed.
The July 2026 backtest (gate ablation graded under both exit models, plus the
capital-constrained portfolio simulation) concluded:
- The best exit is hold-to-horizon: keep the initial ATR stop and exit at the
30th trading day's close (+0.50R net/trade vs +0.13R for the S/R target
exit; simulated book +31.9% vs +23.7% CAGR at the same drawdown). The paper
trade exit-policy default is now ``time`` (30 trading days).
- The confidence floor adds nothing (identical net/trade with it removed,
under both exit models) while cutting ~25% of qualified trades. Its default
is now 0 (off).
Stored rows for these two settings were written under the old semantics, so
they are cleared here and the new code defaults take effect. Re-tune in
Admin -> Activation / Paper-Trade Exit if desired. Note: this changes which
setups qualify and how paper trades close, so Track Record comparability
resets from this deploy.
Revision ID: 015
Revises: 014
Create Date: 2026-07-02 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "015"
down_revision: Union[str, None] = "014"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
sa.text(
"DELETE FROM system_settings "
"WHERE key IN ('activation_min_confidence', 'paper_exit_mode')"
)
)
def downgrade() -> None:
# One-way data reset: the old per-key values aren't recoverable. Code
# defaults apply until re-tuned, so there is nothing to restore.
pass
@@ -0,0 +1,55 @@
"""add signal context snapshots
Revision ID: 016
Revises: 015
Create Date: 2026-07-02 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "016"
down_revision: Union[str, None] = "015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"signal_context_snapshots",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("trade_setup_id", sa.Integer(), nullable=False),
sa.Column("ticker_id", sa.Integer(), nullable=False),
sa.Column("detected_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("strategy_version", sa.String(length=80), nullable=False),
sa.Column("direction", sa.String(length=10), nullable=False),
sa.Column("entry_price", sa.Float(), nullable=False),
sa.Column("stop_loss", sa.Float(), nullable=False),
sa.Column("target", sa.Float(), nullable=False),
sa.Column("rr_ratio", sa.Float(), nullable=False),
sa.Column("confidence_score", sa.Float(), nullable=True),
sa.Column("recommended_action", sa.String(length=20), nullable=True),
sa.Column("risk_level", sa.String(length=10), nullable=True),
sa.Column("momentum_percentile", sa.Float(), nullable=True),
sa.Column("score_context_json", sa.Text(), nullable=False),
sa.Column("sentiment_context_json", sa.Text(), nullable=False),
sa.Column("fundamental_context_json", sa.Text(), nullable=False),
sa.ForeignKeyConstraint(["ticker_id"], ["tickers.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["trade_setup_id"], ["trade_setups.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("trade_setup_id", name="uq_signal_context_trade_setup"),
)
op.create_index(
"ix_signal_context_ticker_detected",
"signal_context_snapshots",
["ticker_id", "detected_at"],
)
def downgrade() -> None:
op.drop_index("ix_signal_context_ticker_detected", table_name="signal_context_snapshots")
op.drop_table("signal_context_snapshots")
+9 -5
View File
@@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
# Database
database_url: str = "postgresql+asyncpg://stock_backend:changeme@localhost:5432/stock_data_backend"
@@ -49,10 +49,14 @@ class Settings(BaseSettings):
data_collector_frequency: str = "daily"
sentiment_poll_interval_minutes: int = 30
# Sentiment search-budget controls (Gemini grounding free tier = 5000/month).
# Only fetch sentiment for relevant tickers (watchlist + open trades + top-N by
# composite), skip ones refreshed within fresh_hours, and cap per run.
sentiment_fresh_hours: int = 72
sentiment_max_per_run: int = 25
# Scope (see _get_sentiment_priority_tickers): everything that matters is always
# refreshed in full — open paper trades + the curated watchlist + top-pick
# feeders (residual-momentum leaders with a tradeable long setup) — plus a top-N composite
# discovery net. No per-run cap: the set is naturally bounded (watchlist <= 20,
# composite <= top_composite), so a full refresh stays well inside the free tier.
# Skip anything refreshed within fresh_hours (5 days: sentiment shifts slowly and
# the score window is 7 days).
sentiment_fresh_hours: int = 120
sentiment_top_composite: int = 30
fundamental_fetch_frequency: str = "weekly" # quarterly-ish data; weekly conserves API quota
rr_scan_frequency: str = "daily"
+4
View File
@@ -11,6 +11,8 @@ from app.models.settings import SystemSetting, IngestionProgress
from app.models.alert import AlertLog
from app.models.paper_trade import PaperTrade
from app.models.regime_snapshot import RegimeSnapshot
from app.models.benchmark_price import BenchmarkPrice
from app.models.signal_context_snapshot import SignalContextSnapshot
__all__ = [
"Ticker",
@@ -28,4 +30,6 @@ __all__ = [
"AlertLog",
"PaperTrade",
"RegimeSnapshot",
"BenchmarkPrice",
"SignalContextSnapshot",
]
+25
View File
@@ -0,0 +1,25 @@
from datetime import date as date_type
from sqlalchemy import Date, Float, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class BenchmarkPrice(Base):
"""Daily close for a benchmark index (e.g. SPY), used to compute trade alpha.
A standalone price series, deliberately NOT a tracked ``Ticker`` — so the
benchmark never becomes a trade candidate or rankings-table row. Its closes
are used for residual momentum and trade alpha. One row per (symbol, date).
"""
__tablename__ = "benchmark_prices"
__table_args__ = (
UniqueConstraint("symbol", "date", name="uq_benchmark_symbol_date"),
)
id: Mapped[int] = mapped_column(primary_key=True)
symbol: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
date: Mapped[date_type] = mapped_column(Date, nullable=False)
close: Mapped[float] = mapped_column(Float, nullable=False)
+2
View File
@@ -34,3 +34,5 @@ class PaperTrade(Base):
)
close_price: Mapped[float | None] = mapped_column(Float, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# How the trade was closed: "trailing" | "stop" | "target" | "manual".
close_reason: Mapped[str | None] = mapped_column(String(10), nullable=True)
+45
View File
@@ -0,0 +1,45 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class SignalContextSnapshot(Base):
"""Point-in-time context captured when a trade setup is generated.
This stores the discretionary overlay inputs (scores, sentiment,
fundamentals) as they looked at detection time, so future analysis can test
whether human filtering improved or hurt the qualified-list strategy.
"""
__tablename__ = "signal_context_snapshots"
id: Mapped[int] = mapped_column(primary_key=True)
trade_setup_id: Mapped[int] = mapped_column(
ForeignKey("trade_setups.id", ondelete="CASCADE"), nullable=False, unique=True
)
ticker_id: Mapped[int] = mapped_column(
ForeignKey("tickers.id", ondelete="CASCADE"), nullable=False
)
detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
strategy_version: Mapped[str] = mapped_column(String(80), nullable=False)
direction: Mapped[str] = mapped_column(String(10), nullable=False)
entry_price: Mapped[float] = mapped_column(Float, nullable=False)
stop_loss: Mapped[float] = mapped_column(Float, nullable=False)
target: Mapped[float] = mapped_column(Float, nullable=False)
rr_ratio: Mapped[float] = mapped_column(Float, nullable=False)
confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True)
recommended_action: Mapped[str | None] = mapped_column(String(20), nullable=True)
risk_level: Mapped[str | None] = mapped_column(String(10), nullable=True)
momentum_percentile: Mapped[float | None] = mapped_column(Float, nullable=True)
score_context_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
sentiment_context_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
fundamental_context_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
trade_setup = relationship("TradeSetup")
ticker = relationship("Ticker")
+3
View File
@@ -11,6 +11,9 @@ class Ticker(Base):
id: Mapped[int] = mapped_column(primary_key=True)
symbol: Mapped[str] = mapped_column(String(10), unique=True, nullable=False)
# Company name (e.g. "Biogen Inc."); backfilled from Alpaca, nullable for
# symbols Alpaca doesn't know.
name: Mapped[str | None] = mapped_column(String(120), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
+3 -2
View File
@@ -26,8 +26,9 @@ class TradeSetup(Base):
)
confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True)
# Ticker's 12-1 momentum percentile across the universe at detection time
# (0100, 100 = strongest). Drives the activation gate's core selection.
# Ticker's activation momentum percentile across the universe at detection
# time. Since July 2026 this is residual 12-1 momentum when benchmark data is
# available, with raw 12-1 as a fallback.
momentum_percentile: Mapped[float | None] = mapped_column(Float, nullable=True)
targets_json: Mapped[str | None] = mapped_column(Text, nullable=True)
conflict_flags_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+10
View File
@@ -315,6 +315,16 @@ async def bootstrap_tickers(
return APIEnvelope(status="success", data=result)
@router.post("/admin/tickers/backfill-names", response_model=APIEnvelope)
async def backfill_ticker_names(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Fill in company names for tracked tickers (one Alpaca call)."""
result = await ticker_universe_service.backfill_ticker_names(db)
return APIEnvelope(status="success", data=result)
# ---------------------------------------------------------------------------
# Data cleanup
# ---------------------------------------------------------------------------
+2 -1
View File
@@ -114,8 +114,9 @@ async def fetch_symbol(
result = await ingestion_service.fetch_and_ingest(
db, provider, symbol_upper, start_date, end_date
)
status_map = {"complete": "ok", "partial": "ok", "no_data": "warning"}
sources_out["ohlcv"] = {
"status": "ok" if result.status in ("complete", "partial") else "error",
"status": status_map.get(result.status, "error"),
"records": result.records_ingested,
"message": result.message,
}
+12 -1
View File
@@ -1,6 +1,6 @@
"""Market-level endpoints (benchmark regime + AI/Tech regime-change monitor)."""
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -129,3 +129,14 @@ async def regime_event_study(
None until the manual "Event Study" job has run (Admin → Jobs)."""
data = await event_study_service.get_event_study_report(db)
return APIEnvelope(status="success", data=data)
@router.get("/regime/history", response_model=APIEnvelope)
async def regime_history(
days: int = Query(default=400, ge=7, le=2000),
_user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Daily history of the index / early-warning / combined scores (for the chart)."""
data = await regime_monitor_service.get_regime_history(db, days=days)
return APIEnvelope(status="success", data=data)
+29 -2
View File
@@ -3,10 +3,15 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access
from app.dependencies import get_db, require_access, require_admin
from app.models.user import User
from app.schemas.common import APIEnvelope
from app.schemas.paper_trade import PaperTradeClose, PaperTradeCreate, PaperTradeResponse
from app.schemas.paper_trade import (
ExitPolicyUpdate,
PaperTradeClose,
PaperTradeCreate,
PaperTradeResponse,
)
from app.services import paper_trade_service
router = APIRouter(tags=["paper-trades"])
@@ -40,6 +45,28 @@ async def list_paper_trades(
return APIEnvelope(status="success", data=data)
@router.get("/paper-trades/exit-policy", response_model=APIEnvelope)
async def read_exit_policy(
_user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""The active auto-exit policy for open paper trades (shown in the UI)."""
return APIEnvelope(status="success", data=await paper_trade_service.get_exit_policy(db))
@router.put("/paper-trades/exit-policy", response_model=APIEnvelope)
async def write_exit_policy(
body: ExitPolicyUpdate,
_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Change the auto-exit policy (admin)."""
data = await paper_trade_service.set_exit_policy(
db, mode=body.mode, trailing_pct=body.trailing_pct, hold_days=body.hold_days
)
return APIEnvelope(status="success", data=data)
@router.post("/paper-trades", response_model=APIEnvelope, status_code=201)
async def create_paper_trade(
body: PaperTradeCreate,
+6 -1
View File
@@ -39,6 +39,10 @@ def _map_composite_breakdown(raw: dict | None) -> CompositeBreakdownResponse | N
missing_dimensions=raw["missing_dimensions"],
renormalized_weights=raw["renormalized_weights"],
formula=raw["formula"],
base_score=raw.get("base_score"),
sentiment_score=raw.get("sentiment_score"),
sentiment_adjustment=raw.get("sentiment_adjustment"),
max_sentiment_adjustment=raw.get("max_sentiment_adjustment"),
)
router = APIRouter(tags=["scores"])
@@ -50,7 +54,7 @@ async def read_score(
_user=Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Get composite + dimension scores for a symbol. Recomputes stale scores."""
"""Get the latest persisted composite + dimension scores for a symbol."""
result = await get_score(db, symbol)
data = ScoreResponse(
@@ -90,6 +94,7 @@ async def read_rankings(
RankingEntry(
symbol=r["symbol"],
composite_score=r["composite_score"],
composite_stale=r.get("composite_stale", False),
dimensions=[
DimensionScoreResponse(**d) for d in r["dimensions"]
],
+6 -1
View File
@@ -34,6 +34,7 @@ async def list_trade_setups(
direction=direction,
min_confidence=min_confidence,
recommended_action=recommended_action,
live_recommendation=True,
)
data = []
@@ -92,7 +93,11 @@ async def get_ticker_trade_setups(
_user=Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
rows = await get_trade_setups(db, symbol=symbol)
rows = await get_trade_setups(
db,
symbol=symbol,
live_recommendation=True,
)
data = []
for row in rows:
summary = RecommendationSummaryResponse(
+147 -53
View File
@@ -20,7 +20,7 @@ from datetime import date, datetime, timedelta, timezone
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import case, func, or_, select
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -36,6 +36,7 @@ from app.providers.protocol import SentimentData
from app.services import fundamental_service, ingestion_service, sentiment_service, settings_store
from app.services.alert_service import dispatch_alerts
from app.services.backtest_service import run_and_store as run_backtest_and_store
from app.services.benchmark_service import refresh_benchmark_prices
from app.services.market_regime_service import update_market_regime
from app.services.regime_monitor_service import update_regime_monitor
from app.services.event_study_service import run_and_store as run_event_study_and_store
@@ -218,78 +219,141 @@ async def _get_ohlcv_priority_tickers(db: AsyncSession) -> list[str]:
return list(result.scalars().all())
async def _get_sentiment_priority_tickers(db: AsyncSession) -> list[str]:
"""Symbols to fetch sentiment for, budgeted to stay in the free search tier.
async def _get_top_pick_feeder_ids(db: AsyncSession) -> set[int]:
"""Ticker ids whose latest LONG setup makes them a top-pick feeder.
Scope: only tickers that matter — watchlist + open paper trades + top-N by
composite score + the momentum leaders the activation gate qualifies on. Skip
any refreshed within ``sentiment_fresh_hours``. Cap the run at
``sentiment_max_per_run``, oldest/missing first. Once the relevant set is
fresh, runs make zero grounded searches until it ages out.
A dashboard 'top pick' is the highest residual-momentum *qualified* setup.
Sentiment can never move a ticker's activation percentile (the gate's core
axis) — only its confidence and EV ranking. So the only tickers that are, or
could become with positive sentiment, a top pick are residual-momentum leaders
that already have a tradeable long setup clearing the R:R floor. That set is exactly:
latest long setup with momentum_percentile >= gate AND rr_ratio >= floor.
It contains both the currently-qualified setups and the near-miss ones held
back only by a neutral/missing sentiment — the cases the user saw surface as
top picks with no sentiment. Only meaningful with the momentum gate on
(min_momentum_percentile > 0); off, there is no leader axis to anchor on and we
defer to the filler set. Best-effort: a config failure must not stop collection.
"""
from app.models.trade_setup import TradeSetup
try:
from app.services.admin_service import get_activation_config
activation = await get_activation_config(db)
min_pct = float(activation.get("min_momentum_percentile", 0.0))
min_rr = float(activation.get("min_rr", 0.0))
except Exception:
logger.exception("Sentiment top-pick scoping failed; using filler set only")
return set()
if min_pct <= 0:
return set()
# Latest long setup per ticker, then keep those clearing the gate's momentum
# percentile and R:R floor. (Sentiment runs before the day's scan, so this
# reads the previous scan's setups — momentum is a slow, cross-sectional signal,
# so yesterday's leaders are the right anchor.)
latest_long = (
select(TradeSetup.ticker_id, func.max(TradeSetup.detected_at).label("md"))
.where(TradeSetup.direction == "long")
.group_by(TradeSetup.ticker_id)
.subquery()
)
rows = await db.execute(
select(TradeSetup.ticker_id)
.join(
latest_long,
and_(
TradeSetup.ticker_id == latest_long.c.ticker_id,
TradeSetup.detected_at == latest_long.c.md,
),
)
.where(
TradeSetup.direction == "long",
TradeSetup.rr_ratio >= min_rr,
TradeSetup.momentum_percentile.is_not(None),
TradeSetup.momentum_percentile >= min_pct,
)
)
return {r[0] for r in rows.all()}
async def _stale_sentiment_symbols(
db: AsyncSession, ticker_ids: set[int], cutoff: datetime
) -> list[str]:
"""Symbols among ``ticker_ids`` whose newest sentiment is missing or older than
``cutoff``, ordered missing-first → oldest → alphabetical."""
if not ticker_ids:
return []
latest_ts = func.max(SentimentScore.timestamp)
missing_first = case((latest_ts.is_(None), 0), else_=1)
stmt = (
select(Ticker.symbol)
.outerjoin(SentimentScore, SentimentScore.ticker_id == Ticker.id)
.where(Ticker.id.in_(ticker_ids))
.group_by(Ticker.id, Ticker.symbol)
.having(or_(latest_ts.is_(None), latest_ts < cutoff))
.order_by(missing_first.asc(), latest_ts.asc(), Ticker.symbol.asc())
)
result = await db.execute(stmt)
return list(result.scalars().all())
async def _get_sentiment_priority_tickers(db: AsyncSession) -> list[str]:
"""Symbols to fetch sentiment for, skipping anything refreshed within
``sentiment_fresh_hours``.
No per-run cap: the relevant set is naturally bounded (curated watchlist <= 20,
a handful of open trades and top-pick feeders, top-N composite), so refreshing
all of it stays well inside the free search tier — and everything that matters
is always fully covered. The two tiers only affect ORDER, so a mid-run provider
rate limit still lands the names we care about first:
Priority: top-pick feeders (residual-momentum leaders with a tradeable long setup, see
``_get_top_pick_feeder_ids``) + the curated watchlist + open paper trades —
the set we never want shown without sentiment.
Filler: top-N by composite — a cheap discovery net for names not yet covered.
Once the set is fresh, runs make zero grounded searches until it ages out.
"""
from app.models.paper_trade import PaperTrade
from app.models.score import CompositeScore
from app.models.watchlist import WatchlistEntry
relevant: set[int] = set()
cutoff = datetime.now(timezone.utc) - timedelta(hours=settings.sentiment_fresh_hours)
# Priority: the set we always want fresh — top-pick feeders, the curated
# watchlist, and open positions.
priority_ids = await _get_top_pick_feeder_ids(db)
wl = await db.execute(
select(WatchlistEntry.ticker_id)
.where(WatchlistEntry.entry_type != "dismissed")
.distinct()
)
relevant.update(r[0] for r in wl.all())
priority_ids.update(r[0] for r in wl.all())
pt = await db.execute(
select(PaperTrade.ticker_id).where(PaperTrade.status == "open").distinct()
)
relevant.update(r[0] for r in pt.all())
priority_ids.update(r[0] for r in pt.all())
# Filler: top-N by composite, a discovery net for names not already covered.
top = await db.execute(
select(CompositeScore.ticker_id)
.order_by(CompositeScore.score.desc())
.limit(settings.sentiment_top_composite)
)
relevant.update(r[0] for r in top.all())
filler_ids = {r[0] for r in top.all()} - priority_ids
# Momentum leaders: the tickers that can clear the activation gate, which
# selects the top ``min_momentum_percentile`` slice by 12-1 momentum — a
# different axis than composite score. The gate qualifies setups on this
# percentile, so without including them a freshly-qualifying ticker carries no
# sentiment and gets enhanced as neutral. Pre-fetching their sentiment here (in
# the daily pipeline, sentiment runs right after the OHLCV refresh) means the
# following R:R scan reads real sentiment for the setups it qualifies.
# Best-effort: a momentum/config failure must not stop sentiment collection.
try:
from app.services import momentum_service
from app.services.admin_service import get_activation_config
activation = await get_activation_config(db)
min_pct = float(activation.get("min_momentum_percentile", 0.0))
if min_pct > 0:
percentiles = await momentum_service.compute_momentum_percentiles(db)
leaders = [sym for sym, pct in percentiles.items() if pct >= min_pct]
if leaders:
rows = await db.execute(
select(Ticker.id).where(Ticker.symbol.in_(leaders))
)
relevant.update(r[0] for r in rows.all())
except Exception:
logger.exception("Sentiment momentum-leader scoping failed; using base relevant set")
if not relevant:
if not priority_ids and not filler_ids:
return []
cutoff = datetime.now(timezone.utc) - timedelta(hours=settings.sentiment_fresh_hours)
latest_ts = func.max(SentimentScore.timestamp)
missing_first = case((latest_ts.is_(None), 0), else_=1)
result = await db.execute(
select(Ticker.symbol)
.outerjoin(SentimentScore, SentimentScore.ticker_id == Ticker.id)
.where(Ticker.id.in_(relevant))
.group_by(Ticker.id, Ticker.symbol)
.having(or_(latest_ts.is_(None), latest_ts < cutoff))
.order_by(missing_first.asc(), latest_ts.asc(), Ticker.symbol.asc())
.limit(settings.sentiment_max_per_run)
)
return list(result.scalars().all())
# No cap — fetch every stale name. Priority first so a rate limit mid-run still
# covers the curated/at-risk set before the discovery net.
priority_syms = await _stale_sentiment_symbols(db, priority_ids, cutoff)
filler_syms = await _stale_sentiment_symbols(db, filler_ids, cutoff)
return priority_syms + filler_syms
async def _get_fundamental_priority_tickers(db: AsyncSession) -> list[str]:
@@ -803,6 +867,34 @@ async def compute_market_regime() -> None:
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
# ---------------------------------------------------------------------------
# Job: Benchmark Collector (SPY closes for paper-trade alpha)
# ---------------------------------------------------------------------------
async def collect_benchmark() -> None:
"""Refresh the stored benchmark (SPY) daily closes used for paper-trade alpha."""
job_name = "benchmark_collector"
_log_event(logging.INFO, "job_start", job=job_name)
_runtime_start(job_name, total=1)
try:
async with async_session_factory() as db:
if not await _is_job_enabled(db, job_name):
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
return
written = await refresh_benchmark_prices(db)
_runtime_progress(job_name, processed=1, total=1)
_runtime_finish(job_name, "completed", processed=1, total=1, message=f"{written} rows")
_log_event(logging.INFO, "job_complete", job=job_name, rows=written)
except Exception as exc:
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
# ---------------------------------------------------------------------------
# Job: Regime Monitor
# ---------------------------------------------------------------------------
@@ -953,6 +1045,7 @@ async def sync_ticker_universe() -> None:
# Daily (full): the complete data→signal refresh, once a day.
_DAILY_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv"),
("benchmark_collector", "collect_benchmark"),
("sentiment_collector", "collect_sentiment"),
("rr_scanner", "scan_rr"),
("outcome_evaluator", "evaluate_outcomes"),
@@ -1005,8 +1098,8 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
async def run_daily_pipeline() -> None:
"""Full daily flow: OHLCV → sentiment → R:R scan → outcome eval (+paper
close) → market regime."""
"""Full daily flow: OHLCV → benchmark → sentiment → R:R scan → outcome eval
(+paper close) → market regime."""
await _run_pipeline("daily_pipeline", _DAILY_PIPELINE_STEPS)
@@ -1113,6 +1206,7 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
# interval job). They stay manually triggerable from Admin → Jobs.
_members = [
(collect_ohlcv, "data_collector", "Data Collector (OHLCV)"),
(collect_benchmark, "benchmark_collector", "Benchmark Collector"),
(collect_sentiment, "sentiment_collector", "Sentiment Collector"),
(scan_rr, "rr_scanner", "R:R Scanner"),
(evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"),
+3
View File
@@ -64,6 +64,7 @@ class ActivationConfigUpdate(BaseModel):
min_confidence: float | None = Field(default=None, ge=0, le=100)
require_high_conviction: bool | None = None
exclude_conflicts: bool | None = None
exclude_neutral: bool | None = None
class ScheduleConfigUpdate(BaseModel):
@@ -98,3 +99,5 @@ class AlertConfigUpdate(BaseModel):
sr_proximity_enabled: bool | None = None
score_drop_enabled: bool | None = None
digest_enabled: bool | None = None
regime_quadrant_enabled: bool | None = None
trade_closed_enabled: bool | None = None
+17
View File
@@ -20,6 +20,13 @@ class PaperTradeClose(BaseModel):
close_price: float | None = Field(default=None, gt=0)
class ExitPolicyUpdate(BaseModel):
"""Auto-exit policy for open paper trades."""
mode: str | None = Field(default=None, pattern=r"^(time|trailing|target)$")
trailing_pct: float | None = Field(default=None, ge=0.5, le=90)
hold_days: int | None = Field(default=None, ge=2, le=250)
class PaperTradeResponse(BaseModel):
id: int
symbol: str
@@ -33,3 +40,13 @@ class PaperTradeResponse(BaseModel):
close_price: float | None = None
closed_at: datetime | None = None
current_price: float | None = None
# Alpha vs the S&P 500 (SPY) over the trade's holding period. None when the
# benchmark series doesn't cover the trade's open date yet.
benchmark_return_pct: float | None = None
alpha_pct: float | None = None
alpha_usd: float | None = None
close_reason: str | None = None
# Live trailing-stop level + how far price sits above it (% ), for open trades
# when the trailing exit policy is active.
trailing_stop: float | None = None
trailing_distance_pct: float | None = None
+7
View File
@@ -33,6 +33,12 @@ class CompositeBreakdownResponse(BaseModel):
missing_dimensions: list[str]
renormalized_weights: dict[str, float]
formula: str
# Sentiment is applied as a signed adjustment on top of the non-sentiment base
# rather than averaged in.
base_score: float | None = None
sentiment_score: float | None = None
sentiment_adjustment: float | None = None
max_sentiment_adjustment: float | None = None
class DimensionScoreResponse(BaseModel):
@@ -72,6 +78,7 @@ class RankingEntry(BaseModel):
symbol: str
composite_score: float
composite_stale: bool = False
dimensions: list[DimensionScoreResponse] = []
+1
View File
@@ -12,6 +12,7 @@ class TickerCreate(BaseModel):
class TickerResponse(BaseModel):
id: int
symbol: str
name: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
+9
View File
@@ -26,6 +26,14 @@ class RecommendationSummaryResponse(BaseModel):
composite_score: float
class TradeSetupContextAsOfResponse(BaseModel):
setup_detected_at: datetime
score_computed_at: datetime | None = None
sentiment_at: datetime | None = None
price_date: date | None = None
price_updated_at: datetime | None = None
class TradeSetupResponse(BaseModel):
"""A single trade setup detected by the R:R scanner."""
@@ -49,4 +57,5 @@ class TradeSetupResponse(BaseModel):
evaluated_at: datetime | None = None
current_price: float | None = None
momentum_percentile: float | None = None
context_as_of: TradeSetupContextAsOfResponse | None = None
recommendation_summary: RecommendationSummaryResponse | None = None
+1
View File
@@ -32,6 +32,7 @@ class WatchlistEntryResponse(BaseModel):
dimensions: list[DimensionScoreSummary] = []
rr_ratio: float | None = None
rr_direction: str | None = None
momentum_percentile: float | None = None
sr_levels: list[SRLevelSummary] = []
last_close: float | None = None
change_pct: float | None = None
+14 -5
View File
@@ -39,10 +39,9 @@ SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
# Track Record's qualified stats. The outcome evaluator deliberately ignores
# these — every setup is evaluated so the gate itself can be validated.
#
# The core test is expected value (in R): probability-weighted asymmetry, so a
# fat-but-improbable target and a likely-but-thin one are both rejected. R:R and
# confidence are floors; high-conviction / clean-read / target-probability are
# optional tighteners (off by default — turn on to be more selective).
# The core selection is residual cross-sectional 12-1 momentum (top percentile
# of the universe, long-only). R:R and confidence are floors; high-conviction /
# clean-read are optional tighteners (off by default).
_ACTIVATION_FLOAT_KEYS: dict[str, str] = {
"min_momentum_percentile": "activation_min_momentum_percentile",
"min_rr": "activation_min_rr",
@@ -51,13 +50,20 @@ _ACTIVATION_FLOAT_KEYS: dict[str, str] = {
_ACTIVATION_BOOL_KEYS: dict[str, str] = {
"require_high_conviction": "activation_require_high_conviction",
"exclude_conflicts": "activation_exclude_conflicts",
"exclude_neutral": "activation_exclude_neutral",
}
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
"min_momentum_percentile": 80.0,
"min_rr": 1.2,
"min_confidence": 55.0,
# 0 = off. The July 2026 gate ablation showed the confidence floor added
# nothing (identical net/trade with it removed, under both exit models)
# while cutting ~25% of qualified trades.
"min_confidence": 0.0,
"require_high_conviction": False,
"exclude_conflicts": False,
# On by default: a NEUTRAL ("no clear setup") recommendation isn't an
# actionable signal, so it shouldn't qualify or be crowned a top pick.
"exclude_neutral": True,
}
@@ -512,6 +518,7 @@ async def get_pipeline_readiness(db: AsyncSession) -> list[dict]:
VALID_JOB_NAMES = {
"data_collector",
"data_backfill",
"benchmark_collector",
"sentiment_collector",
"fundamental_collector",
"rr_scanner",
@@ -529,6 +536,7 @@ VALID_JOB_NAMES = {
JOB_LABELS = {
"data_collector": "Data Collector (OHLCV)",
"data_backfill": "Data Backfill (deep history)",
"benchmark_collector": "Benchmark Collector",
"sentiment_collector": "Sentiment Collector",
"fundamental_collector": "Fundamental Collector",
"rr_scanner": "R:R Scanner",
@@ -546,6 +554,7 @@ JOB_LABELS = {
# Jobs driven by the daily_pipeline (in order) rather than their own timer.
PIPELINE_MEMBERS = {
"data_collector",
"benchmark_collector",
"sentiment_collector",
"rr_scanner",
"outcome_evaluator",
+322 -23
View File
@@ -26,6 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.alert import AlertLog
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.score import CompositeScore
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
@@ -46,6 +47,8 @@ KEY_QUALIFIED = "alerts_qualified_enabled"
KEY_SR = "alerts_sr_proximity_enabled"
KEY_SCORE_DROP = "alerts_score_drop_enabled"
KEY_DIGEST = "alerts_digest_enabled"
KEY_REGIME_QUADRANT = "alerts_regime_quadrant_enabled"
KEY_TRADE_CLOSED = "alerts_trade_closed_enabled"
_BOOL_DEFAULTS = {
KEY_ENABLED: False,
@@ -53,8 +56,16 @@ _BOOL_DEFAULTS = {
KEY_SR: True,
KEY_SCORE_DROP: True,
KEY_DIGEST: True,
KEY_REGIME_QUADRANT: True,
KEY_TRADE_CLOSED: True,
}
# Paper-trade auto-close alert: catch every close at least once (the job runs
# hourly), then never re-send the same trade (a huge cooldown ≈ once-per-trade).
CLOSED_LOOKBACK_HOURS = 26
CLOSED_ALERT_COOLDOWN_HOURS = 24 * 365 * 5
TRADE_CLOSED_TYPE = "trade_closed"
# Tunables (kept as constants for now; promote to settings if needed)
SR_PROXIMITY_PCT = 2.0 # within this % of a strong zone → alert
SR_MIN_STRENGTH = 60 # only strong zones are alert-worthy
@@ -64,6 +75,31 @@ COOLDOWN_HOURS = 72 # don't re-send the same key within this window
DIGEST_HOUR_UTC = 22 # send the daily digest on the first run at/after this hour
WATERMARK_TYPE = "score_watermark"
SIGNAL_BUNDLE_ALERT_TYPES = ("qualified", "sr_proximity", "score_drop")
SIGNAL_BUNDLE_SECTIONS = (
("qualified", "Qualified setups"),
("sr_proximity", "Near support/resistance"),
("score_drop", "Score drops"),
)
SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML parsing
# Regime quadrant-change alert: (regime index x early-warning) quadrant.
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary
# from flip-flopping; the cooldown caps how often a genuine change can re-alert.
QUAD_TYPE = "regime_quadrant"
QUAD_X_DIV = 40.0 # regime index divider (matches the frontend quadrant)
QUAD_Y_DIV = 60.0 # early-warning divider
QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider
QUAD_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts
QUAD_LABELS = {
"1": "① Hot & brittle",
"2": "② Transition",
"3": "③ Healthy & broad",
"4": "④ Real downturn",
}
AlertItem = tuple[str, str, str] # alert_type, dedup_key, text
AlertLogRef = tuple[str, str] # alert_type, dedup_key
def _as_bool(value: str | None, default: bool) -> bool:
@@ -73,7 +109,7 @@ def _as_bool(value: str | None, default: bool) -> bool:
async def _resolve(db: AsyncSession) -> dict:
keys = [KEY_ENABLED, KEY_TOKEN, KEY_CHAT_ID, KEY_QUALIFIED, KEY_SR, KEY_SCORE_DROP, KEY_DIGEST]
keys = [KEY_ENABLED, KEY_TOKEN, KEY_CHAT_ID, KEY_QUALIFIED, KEY_SR, KEY_SCORE_DROP, KEY_DIGEST, KEY_REGIME_QUADRANT, KEY_TRADE_CLOSED]
stored = await settings_store.get_map(db, keys)
db_token = (stored.get(KEY_TOKEN) or "").strip()
@@ -95,6 +131,8 @@ async def _resolve(db: AsyncSession) -> dict:
"sr": _as_bool(stored.get(KEY_SR), _BOOL_DEFAULTS[KEY_SR]),
"score_drop": _as_bool(stored.get(KEY_SCORE_DROP), _BOOL_DEFAULTS[KEY_SCORE_DROP]),
"digest": _as_bool(stored.get(KEY_DIGEST), _BOOL_DEFAULTS[KEY_DIGEST]),
"regime_quadrant": _as_bool(stored.get(KEY_REGIME_QUADRANT), _BOOL_DEFAULTS[KEY_REGIME_QUADRANT]),
"trade_closed": _as_bool(stored.get(KEY_TRADE_CLOSED), _BOOL_DEFAULTS[KEY_TRADE_CLOSED]),
}
@@ -110,6 +148,8 @@ async def get_alert_config(db: AsyncSession) -> dict:
"sr_proximity_enabled": r["sr"],
"score_drop_enabled": r["score_drop"],
"digest_enabled": r["digest"],
"regime_quadrant_enabled": r["regime_quadrant"],
"trade_closed_enabled": r["trade_closed"],
}
@@ -123,6 +163,8 @@ async def update_alert_config(
sr_proximity_enabled: bool | None = None,
score_drop_enabled: bool | None = None,
digest_enabled: bool | None = None,
regime_quadrant_enabled: bool | None = None,
trade_closed_enabled: bool | None = None,
) -> dict:
"""Persist config. An empty/omitted bot_token leaves the stored token intact."""
bool_updates = {
@@ -131,6 +173,8 @@ async def update_alert_config(
KEY_SR: sr_proximity_enabled,
KEY_SCORE_DROP: score_drop_enabled,
KEY_DIGEST: digest_enabled,
KEY_REGIME_QUADRANT: regime_quadrant_enabled,
KEY_TRADE_CLOSED: trade_closed_enabled,
}
for key, val in bool_updates.items():
if val is not None:
@@ -220,19 +264,37 @@ async def _watchlist_tickers(db: AsyncSession) -> list[tuple[int, str]]:
async def _qualified_setups(db: AsyncSession) -> list[dict]:
setups = await get_trade_setups(db)
# live_recommendation: gate and format on current score/sentiment context,
# not the values frozen into the setup at scan time.
setups = await get_trade_setups(db, live_recommendation=True)
config = await get_activation_config(db)
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
def _fmt_price(value: float | int | None) -> str:
return "n/a" if value is None else f"{float(value):.2f}"
def _fmt_signed_move(from_price: float | int | None, to_price: float | int | None) -> str:
if from_price is None or to_price is None:
return "n/a"
from_float = float(from_price)
if from_float == 0:
return "n/a"
pct = (float(to_price) - from_float) / from_float * 100.0
return f"{pct:+.1f}%"
def _format_qualified(s: dict) -> str:
prob = best_target_probability(SimpleNamespace(**s))
arrow = "🟢" if s["direction"] == "long" else "🔴"
current = s.get("current_price") or s.get("entry_price")
return (
f"{arrow} <b>{s['symbol']} {s['direction'].upper()}</b> — qualified setup\n"
f"entry {s['entry_price']:.2f} → target {s['target']:.2f} "
f"(R:R {s['rr_ratio']:.1f}:1)\n"
f"confidence {(s.get('confidence_score') or 0):.0f}% · P(target) {prob:.0f}%"
f"{arrow} <b>{s['symbol']} {s['direction'].upper()}</b> | "
f"now {_fmt_price(current)} | entry {_fmt_price(s['entry_price'])} | "
f"target {_fmt_price(s['target'])} ({_fmt_signed_move(current, s['target'])}) | "
f"stop {_fmt_price(s['stop_loss'])} | R:R {s['rr_ratio']:.1f} | "
f"conf {(s.get('confidence_score') or 0):.0f}% | P(target) {prob:.0f}%"
)
@@ -255,6 +317,34 @@ async def _latest_close(db: AsyncSession, ticker_id: int) -> float | None:
return float(row[0]) if row else None
def _sr_zone_label(zone: dict) -> str:
return (
f"{zone['low']:.2f}{zone['high']:.2f}"
if zone["level_count"] > 1
else f"{zone['midpoint']:.2f}"
)
def _sr_touch_price(zone: dict, current_price: float) -> float:
low = float(zone["low"])
high = float(zone["high"])
if current_price < low:
return low
if current_price > high:
return high
return float(zone["midpoint"])
def _format_sr_proximity(symbol: str, zone: dict, current_price: float) -> str:
touch_price = _sr_touch_price(zone, current_price)
return (
f"📍 <b>{symbol}</b> {zone['type']} | "
f"now {_fmt_price(current_price)} -> {_sr_zone_label(zone)} "
f"({_fmt_signed_move(current_price, touch_price)}) | "
f"strength {float(zone['strength']):.0f}"
)
async def _collect_sr_proximity(db: AsyncSession) -> list[tuple[str, str]]:
"""One alert per watchlist ticker for the NEAREST strong S/R zone within range.
@@ -284,21 +374,12 @@ async def _collect_sr_proximity(db: AsyncSession) -> list[tuple[str, str]]:
# Nearest strong zone only.
nearest = min(strong, key=lambda z: abs(price - z["midpoint"]))
dist_pct = abs(price - nearest["midpoint"]) / price * 100
dist_pct = abs(price - _sr_touch_price(nearest, price)) / price * 100
if dist_pct > SR_PROXIMITY_PCT:
continue
label = (
f"{nearest['low']:.2f}{nearest['high']:.2f}"
if nearest["level_count"] > 1
else f"{nearest['midpoint']:.2f}"
)
key = f"sr:{symbol}:{nearest['type']}" # one per side per ticker per cooldown
out.append((
key,
f"📍 <b>{symbol}</b> approaching {nearest['type']} {label} "
f"(now {price:.2f}, {dist_pct:.1f}% away)",
))
out.append((key, _format_sr_proximity(symbol, nearest, price)))
return out
@@ -355,13 +436,210 @@ async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None:
)
else:
lines.append("No qualified setups today.")
# Open paper trades: unrealized gain + the live trailing stop and how far away.
from app.services import paper_trade_service
open_trades = await paper_trade_service.list_trades(db, status="open")
if open_trades:
lines.append("")
lines.append(f"💼 <b>{len(open_trades)} open trade(s):</b>")
for t in open_trades:
entry = t["entry_price"]
cur = t.get("current_price")
sign = 1.0 if t["direction"] == "long" else -1.0
if cur and entry:
gain_pct = (cur - entry) / entry * 100.0 * sign
gain_usd = (cur - entry) * t["shares"] * sign
gain = f"{gain_pct:+.1f}% ({'+' if gain_usd >= 0 else ''}${abs(gain_usd):.0f})"
else:
gain = "n/a"
ts = t.get("trailing_stop")
if ts is not None:
dist = t.get("trailing_distance_pct")
stop_txt = f"trail {ts:.2f}" + (f" ({dist:.1f}% away)" if dist is not None else "")
else:
stop_txt = f"stop {t['stop_loss']:.2f}"
lines.append(f"{t['symbol']} {t['direction'].upper()} {gain} · {stop_txt}")
return key, "\n".join(lines)
# ---------------------------------------------------------------------------
# Paper-trade close trigger (one summary per auto-closed trade)
# ---------------------------------------------------------------------------
def _format_closed_trade(trade: PaperTrade, symbol: str) -> str:
sign = 1.0 if trade.direction == "long" else -1.0
entry = trade.entry_price
exit_price = trade.close_price if trade.close_price is not None else entry
per_share = (exit_price - entry) * sign
pnl_pct = (per_share / entry * 100.0) if entry else 0.0
pnl_usd = per_share * trade.shares
risk = abs(entry - trade.stop_loss)
r_mult = (per_share / risk) if risk > 0 else None
win = per_share > 0
money = f"{'+' if pnl_usd >= 0 else ''}${abs(pnl_usd):.2f}"
r_txt = f" · {r_mult:+.2f}R" if r_mult is not None else ""
days = (trade.closed_at - trade.opened_at).days if (trade.closed_at and trade.opened_at) else None
held = f" · held {days}d" if days is not None else ""
reason = {"trailing": "trailing stop", "stop": "stop-loss", "target": "target"}.get(
trade.close_reason or "", trade.close_reason or "closed"
)
return (
f"{'' if win else '🔴'} <b>{symbol} {trade.direction.upper()} closed</b> ({reason})\n"
f"{pnl_pct:+.1f}% · {money}{r_txt}{held}\n"
f"{entry:.2f}{exit_price:.2f}"
)
async def _collect_closed_trades(db: AsyncSession) -> list[tuple[str, str]]:
"""One alert per auto-closed paper trade (trailing / stop / target). Manual
closes are skipped — you already know about those. Dedup is by trade id."""
cutoff = datetime.now(timezone.utc) - timedelta(hours=CLOSED_LOOKBACK_HOURS)
result = await db.execute(
select(PaperTrade, Ticker.symbol)
.join(Ticker, PaperTrade.ticker_id == Ticker.id)
.where(
PaperTrade.status == "closed",
PaperTrade.closed_at.is_not(None),
PaperTrade.closed_at > cutoff,
PaperTrade.close_reason.in_(("trailing", "stop", "target")),
)
.order_by(PaperTrade.closed_at.desc())
)
return [(str(trade.id), _format_closed_trade(trade, symbol)) for trade, symbol in result.all()]
# ---------------------------------------------------------------------------
# Regime quadrant-change trigger (hysteresis + cooldown)
# ---------------------------------------------------------------------------
def _bools_to_quadrant(x_high: bool, y_high: bool) -> str:
if y_high:
return "2" if x_high else "1" # ② Transition / ① Hot & brittle
return "4" if x_high else "3" # ④ Real downturn / ③ Healthy & broad
def _quadrant_to_bools(q: str) -> tuple[bool, bool]:
return {"1": (False, True), "2": (True, True), "3": (False, False), "4": (True, False)}[q]
def _classify_quadrant(x: float, y: float, prev: str | None, margin: float = QUAD_MARGIN) -> str:
"""Quadrant of (regime index x, early warning y), with per-axis hysteresis.
Each axis only flips once the value crosses its divider by ``margin`` in the
new direction, so a point parked on a divider keeps its current quadrant
instead of flip-flopping. ``prev`` None means a fresh (no-hysteresis) classify.
"""
if prev is None:
return _bools_to_quadrant(x >= QUAD_X_DIV, y >= QUAD_Y_DIV)
px, py = _quadrant_to_bools(prev)
x_high = (x >= QUAD_X_DIV - margin) if px else (x >= QUAD_X_DIV + margin)
y_high = (y >= QUAD_Y_DIV - margin) if py else (y >= QUAD_Y_DIV + margin)
return _bools_to_quadrant(x_high, y_high)
async def _last_quadrant(db: AsyncSession) -> tuple[str | None, datetime | None]:
"""Most recently logged quadrant (and when), our baseline for change + cooldown."""
result = await db.execute(
select(AlertLog.dedup_key, AlertLog.created_at)
.where(AlertLog.alert_type == QUAD_TYPE)
.order_by(AlertLog.created_at.desc())
.limit(1)
)
row = result.first()
return (row[0], row[1]) if row else (None, None)
async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
"""Alert once when the regime quadrant changes (hysteresis + cooldown).
Seeds silently on first run. Thereafter alerts only when the
hysteresis-confirmed quadrant differs from the last logged one AND the
cooldown has elapsed. The dispatch loop logs the new quadrant on send, which
becomes the next baseline and resets the cooldown clock.
"""
from app.services.regime_monitor_service import get_regime_monitor
data = await get_regime_monitor(db)
if not data.get("available"):
return []
x = data.get("total_score")
y = (data.get("early_warning") or {}).get("score")
if x is None or y is None:
return []
prev, prev_time = await _last_quadrant(db)
if prev is None:
_log_alert(db, QUAD_TYPE, _classify_quadrant(x, y, None)) # seed, no alert
return []
new_q = _classify_quadrant(x, y, prev)
if new_q == prev:
return []
if prev_time is not None:
if prev_time.tzinfo is None:
prev_time = prev_time.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - prev_time < timedelta(days=QUAD_COOLDOWN_DAYS):
return [] # genuine change, but inside the cooldown — stay quiet
text = (
f"🧭 <b>Regime quadrant change</b>\n"
f"{QUAD_LABELS.get(prev, prev)}{QUAD_LABELS.get(new_q, new_q)}\n"
f"regime {x:.0f} · early-warning {y:.0f}"
)
return [(new_q, text)]
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
def _signal_bundle_messages(items: list[AlertItem]) -> list[tuple[list[AlertLogRef], str]]:
if not items:
return []
by_type: dict[str, list[AlertItem]] = {key: [] for key in SIGNAL_BUNDLE_ALERT_TYPES}
for item in items:
by_type.setdefault(item[0], []).append(item)
total = sum(len(group) for group in by_type.values())
header = f"📣 <b>Signal run</b> — {total} new alert(s)"
bundles: list[tuple[list[AlertLogRef], str]] = []
lines = [header]
logs: list[AlertLogRef] = []
current_section: str | None = None
def flush() -> None:
nonlocal lines, logs, current_section
if logs:
bundles.append((logs.copy(), "\n".join(lines)))
lines = [f"{header} (continued)"]
logs = []
current_section = None
for alert_type, section_title in SIGNAL_BUNDLE_SECTIONS:
for item_type, key, text in by_type.get(alert_type, []):
block: list[str] = []
if current_section != alert_type:
block.extend(["", f"<b>{section_title}</b>"])
block.append(text)
if logs and len("\n".join(lines + block)) > SIGNAL_BUNDLE_MAX_CHARS:
flush()
block = ["", f"<b>{section_title}</b>", text]
lines.extend(block)
logs.append((item_type, key))
current_section = alert_type
if logs:
bundles.append((logs.copy(), "\n".join(lines)))
return bundles
async def dispatch_alerts(db: AsyncSession) -> dict:
"""Gather all enabled triggers, dedup, and push to Telegram. Job entrypoint."""
cfg = await _resolve(db)
@@ -370,31 +648,52 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
if not cfg["token"] or not cfg["chat_id"]:
return {"status": "no_credentials", "sent": 0}
outgoing: list[tuple[str, str, str]] = [] # (alert_type, key, text)
signal_outgoing: list[AlertItem] = []
outgoing: list[AlertItem] = []
if cfg["qualified"]:
for key, text in await _collect_qualified(db):
if not await _recently_alerted(db, "qualified", key):
outgoing.append(("qualified", key, text))
signal_outgoing.append(("qualified", key, text))
if cfg["sr"]:
for key, text in await _collect_sr_proximity(db):
if not await _recently_alerted(db, "sr_proximity", key):
outgoing.append(("sr_proximity", key, text))
signal_outgoing.append(("sr_proximity", key, text))
if cfg["score_drop"]:
# also seeds/advances watermarks as a side effect
for key, text in await _collect_score_drops(db):
outgoing.append(("score_drop", key, text))
signal_outgoing.append(("score_drop", key, text))
if cfg["digest"]:
digest = await _collect_digest(db)
if digest is not None:
outgoing.append(("digest", digest[0], digest[1]))
if cfg["regime_quadrant"]:
# cooldown/hysteresis handled in the collector (like score drops)
for key, text in await _collect_regime_quadrant(db):
outgoing.append((QUAD_TYPE, key, text))
if cfg["trade_closed"]:
for key, text in await _collect_closed_trades(db):
if not await _recently_alerted(db, TRADE_CLOSED_TYPE, key, cooldown_hours=CLOSED_ALERT_COOLDOWN_HOURS):
outgoing.append((TRADE_CLOSED_TYPE, key, text))
sent = 0
if outgoing:
candidates = len(signal_outgoing) + len(outgoing)
if signal_outgoing or outgoing:
async with httpx.AsyncClient(timeout=15) as client:
for log_refs, text in _signal_bundle_messages(signal_outgoing):
try:
await _send(client, cfg["token"], cfg["chat_id"], text)
for alert_type, key in log_refs:
_log_alert(db, alert_type, key)
sent += 1
except Exception:
logger.exception("Failed to send signal alert bundle")
for alert_type, key, text in outgoing:
try:
await _send(client, cfg["token"], cfg["chat_id"], text)
@@ -404,7 +703,7 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
logger.exception("Failed to send alert %s", key)
await db.commit() # persist watermark seeds/advances and sent-logs
return {"status": "ok", "sent": sent, "candidates": len(outgoing)}
return {"status": "ok", "sent": sent, "candidates": candidates}
async def send_test_alert(db: AsyncSession) -> dict:
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
"""Benchmark price store + alpha helpers.
Fetches the S&P 500 proxy (SPY) daily closes via Alpaca and persists them, so
paper-trade alpha — a trade's return minus the benchmark's return over the same
holding period — can be computed. The benchmark is a standalone series, NOT a
tracked ``Ticker``; its closes feed residual momentum and alpha, but it never
becomes a trade candidate or rankings-table row.
"""
from __future__ import annotations
import bisect
import logging
from datetime import date, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.benchmark_price import BenchmarkPrice
from app.providers.alpaca import AlpacaOHLCVProvider
logger = logging.getLogger(__name__)
BENCHMARK_SYMBOL = "SPY"
# ~800 calendar days ≈ 550 trading days — comfortably covers any realistic paper
# holding period plus a margin for the nearest-prior-trading-day lookup.
_HISTORY_DAYS = 800
async def refresh_benchmark_prices(
db: AsyncSession, symbol: str = BENCHMARK_SYMBOL, days: int = _HISTORY_DAYS
) -> int:
"""Fetch the benchmark's daily closes and upsert them. Returns rows written.
Idempotent: inserts new dates, updates a close only if it changed (e.g. after
a split adjustment). Best-effort — returns 0 when Alpaca keys are unset.
"""
if not settings.alpaca_api_key or not settings.alpaca_api_secret:
logger.warning("Benchmark refresh skipped: Alpaca keys not configured")
return 0
provider = AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret)
end = date.today()
start = end - timedelta(days=days)
bars = await provider.fetch_ohlcv(symbol, start, end)
existing = {
row.date: row
for row in (
await db.execute(select(BenchmarkPrice).where(BenchmarkPrice.symbol == symbol))
).scalars()
}
written = 0
for bar in bars:
current = existing.get(bar.date)
if current is None:
db.add(BenchmarkPrice(symbol=symbol, date=bar.date, close=float(bar.close)))
written += 1
elif abs(current.close - float(bar.close)) > 1e-9:
current.close = float(bar.close)
written += 1
if written:
await db.commit()
logger.info("Benchmark %s refreshed: %d rows written", symbol, written)
return written
async def load_benchmark_closes(
db: AsyncSession, symbol: str = BENCHMARK_SYMBOL
) -> dict[date, float]:
"""Return ``{date: close}`` for the benchmark (empty if none stored yet)."""
rows = await db.execute(
select(BenchmarkPrice.date, BenchmarkPrice.close).where(BenchmarkPrice.symbol == symbol)
)
return {d: float(c) for d, c in rows.all()}
def benchmark_return_pct(
closes: dict[date, float], open_date: date, as_of_date: date
) -> float | None:
"""Benchmark % return between two dates, using the nearest close on/before each.
Returns ``None`` when there's no benchmark data at or before either endpoint
(e.g. a trade opened before the stored history, or the table is empty).
"""
if not closes:
return None
dates = sorted(closes)
def _close_on_or_before(target: date) -> float | None:
idx = bisect.bisect_right(dates, target) - 1
return closes[dates[idx]] if idx >= 0 else None
start = _close_on_or_before(open_date)
end = _close_on_or_before(as_of_date)
if start is None or end is None or start == 0:
return None
return (end - start) / start * 100.0
+76 -33
View File
@@ -34,12 +34,15 @@ logger = logging.getLogger(__name__)
KEY_REPORT = "regime_event_study"
# Defaults — admin-tunable later if needed.
EVENT_THRESHOLD_PCT = 15.0 # drawdown from the 52w high that counts as a "break"
RECOVER_PCT = 5.0 # must recover to within this of the high before a new event
# Defaults. The 15% threshold gave only 2 events in 5y (statistically useless),
# so the default is lower with a cooldown-based dedup to surface more, cleaner
# events. Each indicator "warns" at its OWN 80th percentile rather than a shared
# absolute level, so the leading vs. coincident comparison is fair across scales.
EVENT_THRESHOLD_PCT = 10.0 # drawdown from the 52w high that counts as a "break"
COOLDOWN_DAYS = 40 # min trading days between event onsets (dedup)
DRAWDOWN_LOOKBACK = 252 # 52-week trailing high
HORIZON_DAYS = 20 # signal-centered prediction horizon
WARN_THRESHOLD = 60.0 # indicator level treated as "warning on"
WARN_PERCENTILE = 80.0 # each indicator warns at its own Nth percentile
PRE, POST = 60, 20 # event-centered window (trading days)
@@ -52,6 +55,17 @@ def _median(values: list[float]) -> float | None:
return float(s[mid]) if n % 2 else (s[mid - 1] + s[mid]) / 2.0
def _percentile(values: list[float], pct: float) -> float | None:
"""Linear-interpolated percentile of the non-None values."""
vals = sorted(v for v in values if v is not None)
if not vals:
return None
k = (len(vals) - 1) * (pct / 100.0)
lo = int(k)
hi = min(lo + 1, len(vals) - 1)
return vals[lo] + (vals[hi] - vals[lo]) * (k - lo)
# ---------------------------------------------------------------------------
# Event detection
# ---------------------------------------------------------------------------
@@ -61,22 +75,23 @@ def detect_events(
dates: list[date],
threshold_pct: float = EVENT_THRESHOLD_PCT,
lookback: int = DRAWDOWN_LOOKBACK,
recover_pct: float = RECOVER_PCT,
cooldown: int = COOLDOWN_DAYS,
) -> list[dict]:
"""Drawdown events: ``t0`` = first day the drawdown from the trailing 52w high
crosses ``threshold_pct``. De-duplicated — a new event needs a recovery back to
within ``recover_pct`` of the high first (so one decline = one event)."""
"""Drawdown events: ``t0`` = a day the drawdown from the trailing 52w high
crosses up through ``threshold_pct`` (rising edge). De-duplicated by a
``cooldown`` of trading days, so a continuous decline counts once but distinct
drawdowns separated by a recovery each register."""
events: list[dict] = []
in_event = False
prev_dd = 0.0
last_event = -10**9
for i in range(len(closes)):
window = closes[max(0, i - lookback + 1): i + 1]
hi = max(window)
dd = (hi - closes[i]) / hi * 100.0 if hi > 0 else 0.0
if not in_event and dd >= threshold_pct:
if dd >= threshold_pct and prev_dd < threshold_pct and (i - last_event) >= cooldown:
events.append({"date": dates[i].isoformat(), "index": i, "depth_pct": round(dd, 1)})
in_event = True
elif in_event and dd <= recover_pct:
in_event = False
last_event = i
prev_dd = dd
return events
@@ -84,31 +99,38 @@ def detect_events(
# Event-centered: lead time + mean path
# ---------------------------------------------------------------------------
def _lead(indicator: dict[date, float], t0: int, dates: list[date], pre: int, threshold: float) -> int | None:
"""Earliest day within ``[t0-pre, t0]`` at which the indicator crosses
``threshold`` — i.e. how many days of warning before the event, or None."""
lead: int | None = None
for k in range(0, pre + 1):
idx = t0 - k
if idx < 0:
break
v = indicator.get(dates[idx])
if v is not None and v >= threshold:
lead = k # keep going: the largest k = earliest warning in the window
return lead
def event_centered(
indicator: dict[date, float],
events_idx: list[int],
dates: list[date],
pre: int = PRE,
post: int = POST,
threshold: float = WARN_THRESHOLD,
threshold: float = 60.0,
) -> dict:
"""Align the indicator at each event's ``t0`` and measure how early it warned.
Lead = the earliest day within ``[t0-pre, t0]`` at which the indicator first
crosses ``threshold``. Also returns the cross-event mean path.
Lead time is measured against ``threshold`` (each indicator gets its own,
derived from its distribution). Also returns the cross-event mean path.
"""
leads: list[float] = []
sums: dict[int, float] = {}
counts: dict[int, int] = {}
for t0 in events_idx:
lead: int | None = None
for k in range(0, pre + 1):
idx = t0 - k
if idx < 0:
break
v = indicator.get(dates[idx])
if v is not None and v >= threshold:
lead = k # keep going: the largest k = earliest warning in the window
lead = _lead(indicator, t0, dates, pre, threshold)
if lead is not None:
leads.append(lead)
for rel in range(-pre, post + 1):
@@ -125,6 +147,7 @@ def event_centered(
"median_lead_days": _median(leads),
"events_with_signal": len(leads),
"events_total": len(events_idx),
"warn_threshold": round(threshold, 1),
"mean_path": mean_path,
}
@@ -211,7 +234,8 @@ async def run_event_study(
db: AsyncSession,
threshold_pct: float = EVENT_THRESHOLD_PCT,
horizon: int = HORIZON_DAYS,
warn_threshold: float = WARN_THRESHOLD,
cooldown: int = COOLDOWN_DAYS,
warn_percentile: float = WARN_PERCENTILE,
) -> dict:
"""Run the study: detect events on the benchmark, then measure breadth-divergence
vs. the coincident price composite. Best-effort; returns available=False on no data."""
@@ -227,23 +251,40 @@ async def run_event_study(
dates = [d for d, _ in bench]
closes = [c for _, c in bench]
events = detect_events(closes, dates, threshold_pct)
events = detect_events(closes, dates, threshold_pct, cooldown=cooldown)
events_idx = [e["index"] for e in events]
breadth = await breadth_service.compute_breadth_series(db)
divergence = breadth_service.compute_divergence_series(breadth, bench)
coincident = _coincident_series(prices, dates, config)
def _evaluate(series: dict[date, float]) -> dict:
# Each indicator warns at its OWN distribution's percentile, so a leading
# indicator isn't penalised for living on a different scale than the baseline.
warn = {
"breadth_divergence": _percentile(list(divergence.values()), warn_percentile) or 60.0,
"coincident_price": _percentile(list(coincident.values()), warn_percentile) or 60.0,
}
series_by_key = {"breadth_divergence": divergence, "coincident_price": coincident}
def _evaluate(series: dict[date, float], threshold: float) -> dict:
return {
**event_centered(series, events_idx, dates, threshold=warn_threshold),
**event_centered(series, events_idx, dates, threshold=threshold),
"signal": signal_centered(series, events_idx, dates, horizon),
}
indicators = {
"breadth_divergence": _evaluate(divergence),
"coincident_price": _evaluate(coincident),
}
indicators = {key: _evaluate(series_by_key[key], warn[key]) for key in series_by_key}
# Per-event comparison: which event, and each indicator's lead on THAT event —
# so a median over a tiny sample can't hide an apples-to-oranges comparison.
per_event = [
{
"date": e["date"],
"depth_pct": e["depth_pct"],
"breadth_lead": _lead(divergence, e["index"], dates, PRE, warn["breadth_divergence"]),
"coincident_lead": _lead(coincident, e["index"], dates, PRE, warn["coincident_price"]),
}
for e in events
]
bd = indicators["breadth_divergence"]["median_lead_days"]
cd = indicators["coincident_price"]["median_lead_days"]
@@ -261,11 +302,13 @@ async def run_event_study(
"params": {
"benchmark": leader,
"event_threshold_pct": threshold_pct,
"cooldown_days": cooldown,
"horizon_days": horizon,
"warn_threshold": warn_threshold,
"warn_percentile": warn_percentile,
},
"events": events,
"indicators": indicators,
"per_event": per_event,
"lead_delta_days": lead_delta,
"recent_breadth": recent_breadth,
}
+26 -1
View File
@@ -30,7 +30,7 @@ class IngestionResult:
symbol: str
records_ingested: int
last_date: date | None
status: str # "complete" | "partial" | "error"
status: str # "complete" | "partial" | "error" | "no_data"
message: str | None = None
@@ -143,6 +143,31 @@ async def fetch_and_ingest(
message=str(exc),
)
# Provider returned nothing. With no history at all this almost always means
# the provider doesn't cover this symbol (Alpaca = US listings only) — surface
# that instead of a misleading "success". With existing bars it just means
# there were no new bars in the requested window.
if not records:
existing = await _get_ohlcv_bar_count(db, ticker.id)
if existing == 0:
return IngestionResult(
symbol=ticker.symbol,
records_ingested=0,
last_date=None,
status="no_data",
message=(
"No data returned by the provider — it may not cover this symbol "
"(Alpaca serves US-listed securities only)."
),
)
return IngestionResult(
symbol=ticker.symbol,
records_ingested=0,
last_date=None,
status="complete",
message="Already up to date — no new bars.",
)
# Sort records by date to ensure ordered ingestion
records.sort(key=lambda r: r.date)
+88 -15
View File
@@ -1,17 +1,18 @@
"""Cross-sectional 12-1 momentum ranking for the universe.
"""Cross-sectional residual 12-1 momentum ranking for the universe.
The activation gate selects the top ``min_momentum_percentile`` of the universe
by 12-1 month momentum (return from ~12 months ago to ~1 month ago — the one
price signal the backtest showed sorts forward returns). The daily scan ranks
every ticker and stores each setup's percentile (see ``rr_scanner_service``), so
the live list, the Track Record's qualified stats, and outcome evaluation all gate
on the same value.
by residual 12-1 month momentum: the stock's 12-1 return after subtracting its
estimated benchmark beta contribution over the same formation window. The daily
scan ranks every ticker and stores each setup's percentile (see
``rr_scanner_service``), so the live list, the Track Record's qualified stats,
and outcome evaluation all gate on the same value.
"""
from __future__ import annotations
import json
import logging
from datetime import date
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -35,29 +36,101 @@ def compute_12_1_momentum(closes: list[float]) -> float | None:
return None
def compute_residual_12_1_momentum(
dates: list[date],
closes: list[float],
benchmark_closes: dict[date, float],
) -> float | None:
"""12-1 momentum after removing linear benchmark exposure.
Estimate beta from daily stock/benchmark returns over the standard 12-1
formation window, then sum stock return minus beta * benchmark return. No
intercept is subtracted: fitting an intercept over the same window would make
residuals sum to roughly zero and destroy the ranking signal.
"""
i = len(closes) - 1
if not benchmark_closes or len(dates) != len(closes) or i - _MOM_LOOKBACK < 0:
return None
stock_rets: list[float] = []
market_rets: list[float] = []
for k in range(i - _MOM_LOOKBACK + 1, i - _MOM_SKIP + 1):
prev_close = closes[k - 1]
bench_prev = benchmark_closes.get(dates[k - 1])
bench_cur = benchmark_closes.get(dates[k])
if prev_close <= 0 or bench_prev is None or bench_cur is None or bench_prev <= 0:
continue
stock_rets.append(closes[k] / prev_close - 1.0)
market_rets.append(bench_cur / bench_prev - 1.0)
if len(stock_rets) < 100:
return None
mean_market = sum(market_rets) / len(market_rets)
mean_stock = sum(stock_rets) / len(stock_rets)
var_market = sum((x - mean_market) ** 2 for x in market_rets)
if var_market <= 0:
return None
cov = sum(
(stock_rets[k] - mean_stock) * (market_rets[k] - mean_market)
for k in range(len(stock_rets))
)
beta = cov / var_market
return sum(stock_rets[k] - beta * market_rets[k] for k in range(len(stock_rets)))
async def _load_activation_benchmark(db: AsyncSession) -> dict[date, float]:
"""Load SPY closes for residual momentum; refresh once if the table is empty."""
try:
from app.services.benchmark_service import load_benchmark_closes, refresh_benchmark_prices
closes = await load_benchmark_closes(db)
if closes:
return closes
await refresh_benchmark_prices(db)
return await load_benchmark_closes(db)
except Exception:
logger.exception("Residual momentum benchmark load failed; falling back to raw momentum")
return {}
async def compute_momentum_percentiles(db: AsyncSession) -> dict[str, float]:
"""Compute each ticker's 12-1 momentum and rank the universe into a
``{symbol: percentile}`` map (0100, 100 = strongest momentum). Tickers
without a full year of history are absent (can't be ranked)."""
"""Compute each ticker's activation momentum rank.
Production uses residual 12-1 momentum when benchmark data is available. If
SPY data is absent, fall back to raw 12-1 momentum rather than disabling the
scanner. Tickers without enough stock/benchmark history are absent.
"""
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
tickers = list(result.scalars().all())
momentum: dict[str, float] = {}
benchmark_closes = await _load_activation_benchmark(db)
using_residual = len(benchmark_closes) >= _MOM_LOOKBACK
values: dict[str, float] = {}
for ticker in tickers:
try:
records = await query_ohlcv(db, ticker.symbol)
except Exception:
logger.exception("Momentum fetch failed for %s", ticker.symbol)
continue
m = compute_12_1_momentum([float(r.close) for r in records])
if m is not None:
momentum[ticker.symbol] = m
closes = [float(r.close) for r in records]
value = (
compute_residual_12_1_momentum([r.date for r in records], closes, benchmark_closes)
if using_residual
else compute_12_1_momentum(closes)
)
if value is not None:
values[ticker.symbol] = value
ranked = sorted(momentum, key=lambda s: momentum[s])
ranked = sorted(values, key=lambda s: values[s])
n = len(ranked)
percentiles = {
sym: round((rank / (n - 1) * 100.0) if n > 1 else 100.0, 2)
for rank, sym in enumerate(ranked)
}
logger.info(json.dumps({"event": "momentum_ranked", "tickers": n}))
logger.info(json.dumps({
"event": "momentum_ranked",
"signal": "residual_12_1" if using_residual else "raw_12_1_fallback",
"tickers": n,
}))
return percentiles
+26 -3
View File
@@ -16,7 +16,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -34,6 +34,13 @@ OUTCOME_EXPIRED = "expired"
DEFAULT_MAX_BARS = 30
# A setup's outcome is only unbiased once its full evaluation window has elapsed:
# until then, near stops resolve as losses within days while far targets are still
# pending, so a young sample skews sharply negative. Only count setups detected at
# least this many CALENDAR days ago (~max_bars trading days, ×1.5 to cover
# weekends/holidays). Younger setups are reported separately as "maturing".
_MATURITY_DAYS = int(DEFAULT_MAX_BARS * 1.5)
# Confidence buckets for the performance breakdown
_CONFIDENCE_BUCKETS = [
("<50%", 0.0, 50.0),
@@ -183,7 +190,12 @@ async def get_performance_stats(
db: AsyncSession,
config: dict | None = None,
) -> dict:
"""Aggregate outcome statistics over all evaluated trade setups.
"""Aggregate outcome statistics over the *matured* evaluated trade setups.
Only setups whose full evaluation window has elapsed (see ``_MATURITY_DAYS``)
are counted, so the headline isn't dominated by quick stop-outs while slower
winners are still in flight. ``maturing`` reports how many are excluded for
being too young.
avg_r is the expectancy per trade in R-multiples (win = +rr_ratio,
loss = -1R, expired = 0R). A positive avg_r means the signals have
@@ -197,13 +209,23 @@ async def get_performance_stats(
result = await db.execute(
select(TradeSetup).where(TradeSetup.actual_outcome.is_not(None))
)
evaluated = list(result.scalars().all())
evaluated_all = list(result.scalars().all())
# Matured cohort only — see _MATURITY_DAYS. Setups whose window hasn't fully
# elapsed are excluded so quick stop-outs can't drag the headline negative
# while their slower-to-resolve winners are still in flight.
cutoff_date = (datetime.now(timezone.utc) - timedelta(days=_MATURITY_DAYS)).date()
evaluated = [s for s in evaluated_all if s.detected_at.date() <= cutoff_date]
pending_result = await db.execute(
select(TradeSetup.id).where(TradeSetup.actual_outcome.is_(None))
)
pending_count = len(pending_result.scalars().all())
# Still inside their measurement window (excluded above so they can't bias the
# stats): young setups that already resolved + everything still pending.
maturing_count = (len(evaluated_all) - len(evaluated)) + pending_count
if config is not None:
qualified = [s for s in evaluated if setup_qualifies(s, config)]
else:
@@ -229,6 +251,7 @@ async def get_performance_stats(
return {
"overall": _bucket_stats(qualified),
"pending": pending_count,
"maturing": maturing_count,
"by_direction": {k: _bucket_stats(v) for k, v in sorted(by_direction.items())},
"by_action": {k: _bucket_stats(v) for k, v in sorted(by_action.items())},
"by_confidence": {
+225 -26
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +11,7 @@ from app.exceptions import NotFoundError, ValidationError
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker
from app.services import benchmark_service, settings_store
from app.services.outcome_service import (
OUTCOME_AMBIGUOUS,
OUTCOME_STOP_HIT,
@@ -19,6 +20,66 @@ from app.services.outcome_service import (
evaluate_setup_against_bars,
)
# Exit policy for OPEN paper trades (auto-close). "time" holds a fixed number of
# trading days with the initial stop and exits at that day's close — the exit the
# July 2026 backtest validated (the classic momentum hold-and-re-rank); "trailing"
# rides a trailing stop; "target" closes at the setup's stop/target. Stored in
# SystemSetting so it's tunable + transparent in the UI.
KEY_EXIT_MODE = "paper_exit_mode"
KEY_TRAILING_PCT = "paper_trailing_pct"
KEY_HOLD_DAYS = "paper_hold_days"
DEFAULT_EXIT_MODE = "time"
DEFAULT_TRAILING_PCT = 12.0
DEFAULT_HOLD_DAYS = 30
_VALID_EXIT_MODES = ("time", "trailing", "target")
async def get_exit_policy(db: AsyncSession) -> dict:
"""Active auto-exit policy:
{'mode': 'time'|'trailing'|'target', 'trailing_pct': float, 'hold_days': int}."""
mode = (await settings_store.get_value(db, KEY_EXIT_MODE, DEFAULT_EXIT_MODE)).strip().lower()
if mode not in _VALID_EXIT_MODES:
mode = DEFAULT_EXIT_MODE
raw = await settings_store.get_value(db, KEY_TRAILING_PCT, str(DEFAULT_TRAILING_PCT))
try:
pct = float(raw)
except (TypeError, ValueError):
pct = DEFAULT_TRAILING_PCT
pct = max(0.5, min(90.0, pct))
raw_days = await settings_store.get_value(db, KEY_HOLD_DAYS, str(DEFAULT_HOLD_DAYS))
try:
hold_days = int(float(raw_days))
except (TypeError, ValueError):
hold_days = DEFAULT_HOLD_DAYS
hold_days = max(2, min(250, hold_days))
return {"mode": mode, "trailing_pct": pct, "hold_days": hold_days}
async def set_exit_policy(
db: AsyncSession,
*,
mode: str | None = None,
trailing_pct: float | None = None,
hold_days: int | None = None,
) -> dict:
"""Persist the auto-exit policy (admin). Validates inputs."""
if mode is not None:
mode = mode.strip().lower()
if mode not in _VALID_EXIT_MODES:
raise ValidationError("mode must be 'time', 'trailing' or 'target'")
await settings_store.upsert_setting(db, KEY_EXIT_MODE, mode)
if trailing_pct is not None:
if not 0.5 <= float(trailing_pct) <= 90.0:
raise ValidationError("trailing_pct must be between 0.5 and 90")
await settings_store.upsert_setting(db, KEY_TRAILING_PCT, str(float(trailing_pct)))
if hold_days is not None:
if not 2 <= int(hold_days) <= 250:
raise ValidationError("hold_days must be between 2 and 250")
await settings_store.upsert_setting(db, KEY_HOLD_DAYS, str(int(hold_days)))
await db.commit()
return await get_exit_policy(db)
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper()
@@ -50,6 +111,58 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
return {tid: float(close) for tid, close in result.all()}
async def _max_high_after(db: AsyncSession, ticker_id: int, since: date) -> float | None:
"""Highest high strictly after ``since`` — the running peak for a trailing stop."""
result = await db.execute(
select(func.max(OHLCVRecord.high)).where(
OHLCVRecord.ticker_id == ticker_id, OHLCVRecord.date > since
)
)
v = result.scalar()
return float(v) if v is not None else None
def _time_close(
direction: str, init_stop: float, hold_days: int, rows: list[tuple]
) -> tuple[float, date, str] | None:
"""Walk post-entry ``rows`` of (date, open, high, low, close); close at the
initial stop if hit (a gap through it fills at the open, matching the
backtest's fill model), else at the ``hold_days``-th bar's close ('time').
None while neither has happened."""
long = direction == "long"
for i, (d, open_, high, low, close) in enumerate(rows):
if (low <= init_stop) if long else (high >= init_stop):
fill = min(init_stop, open_) if long else max(init_stop, open_)
return float(fill), d, "stop"
if i + 1 >= hold_days:
return float(close), d, "time"
return None
def _trailing_close(
direction: str, entry: float, init_stop: float, trail_frac: float, bars: list[Bar]
) -> tuple[float, date, str] | None:
"""Walk post-entry bars; return (price, date, reason) when the trailing or initial
stop is hit, else None. The stop only ratchets up: max(init_stop, peak*(1-trail))
for a long. reason = 'trailing' once it's above the initial stop, else 'stop'."""
long = direction == "long"
peak = entry
for b in bars:
if long:
level = max(init_stop, peak * (1 - trail_frac))
if b.low <= level:
return level, b.date, ("trailing" if level > init_stop else "stop")
if b.high > peak:
peak = b.high
else:
level = min(init_stop, peak * (1 + trail_frac))
if b.high >= level:
return level, b.date, ("trailing" if level < init_stop else "stop")
if b.low < peak:
peak = b.low
return None
async def create_trade(
db: AsyncSession,
user_id: int,
@@ -85,7 +198,35 @@ async def create_trade(
return trade
def _to_dict(trade: PaperTrade, symbol: str, current_price: float | None) -> dict:
def _to_dict(
trade: PaperTrade,
symbol: str,
current_price: float | None,
benchmark_closes: dict[date, float] | None = None,
trailing: tuple[float, float | None] | None = None,
) -> dict:
# For open trades, mark to market; for closed, the realized exit price.
ref = current_price if trade.status == "open" else trade.close_price
# Alpha = trade return benchmark (SPY) return over the same holding period.
benchmark_return = None
alpha_pct = None
alpha_usd = None
if ref is not None and trade.entry_price and benchmark_closes:
sign = 1.0 if trade.direction == "long" else -1.0
trade_return = (ref - trade.entry_price) / trade.entry_price * 100.0 * sign
as_of = (
trade.closed_at.date()
if trade.status == "closed" and trade.closed_at is not None
else date.today()
)
benchmark_return = benchmark_service.benchmark_return_pct(
benchmark_closes, trade.opened_at.date(), as_of
)
if benchmark_return is not None:
alpha_pct = trade_return - benchmark_return
alpha_usd = alpha_pct / 100.0 * trade.entry_price * trade.shares
return {
"id": trade.id,
"symbol": symbol,
@@ -98,21 +239,27 @@ def _to_dict(trade: PaperTrade, symbol: str, current_price: float | None) -> dic
"opened_at": trade.opened_at,
"close_price": trade.close_price,
"closed_at": trade.closed_at,
# For open trades, mark to market; for closed, the realized exit price.
"current_price": current_price if trade.status == "open" else trade.close_price,
"current_price": ref,
"benchmark_return_pct": benchmark_return,
"alpha_pct": alpha_pct,
"alpha_usd": alpha_usd,
"close_reason": trade.close_reason,
"trailing_stop": trailing[0] if trailing else None,
"trailing_distance_pct": trailing[1] if trailing else None,
}
async def list_trades(
db: AsyncSession,
user_id: int,
user_id: int | None = None,
status: str | None = None,
) -> list[dict]:
stmt = (
select(PaperTrade, Ticker.symbol)
.join(Ticker, PaperTrade.ticker_id == Ticker.id)
.where(PaperTrade.user_id == user_id)
)
if user_id is not None: # None → all users (single-user app; used by the digest)
stmt = stmt.where(PaperTrade.user_id == user_id)
if status is not None:
stmt = stmt.where(PaperTrade.status == status)
stmt = stmt.order_by(PaperTrade.opened_at.desc())
@@ -120,7 +267,38 @@ async def list_trades(
rows = (await db.execute(stmt)).all()
open_ids = {t.ticker_id for t, _ in rows if t.status == "open"}
prices = await _latest_closes(db, open_ids)
return [_to_dict(t, sym, prices.get(t.ticker_id)) for t, sym in rows]
# Benchmark closes for alpha — populated by the daily/benchmark job. Empty until
# that runs once, in which case alpha is simply left unset (a read path never
# makes a provider call).
benchmark_closes = await benchmark_service.load_benchmark_closes(db)
# Current trailing-stop level + distance for open trades (when trailing is active).
policy = await get_exit_policy(db)
trailing_info: dict[int, tuple[float, float | None]] = {}
if policy["mode"] == "trailing":
trail_frac = policy["trailing_pct"] / 100.0
for t, _ in rows:
if t.status != "open":
continue
max_high = await _max_high_after(db, t.ticker_id, t.opened_at.date())
peak = max(t.entry_price, max_high) if max_high is not None else t.entry_price
long = t.direction == "long"
level = (
max(t.stop_loss, peak * (1 - trail_frac))
if long
else min(t.stop_loss, peak * (1 + trail_frac))
)
cur = prices.get(t.ticker_id)
dist = None
if cur:
dist = ((cur - level) / cur * 100.0) if long else ((level - cur) / cur * 100.0)
trailing_info[t.id] = (level, dist)
return [
_to_dict(t, sym, prices.get(t.ticker_id), benchmark_closes, trailing_info.get(t.id))
for t, sym in rows
]
async def close_trade(
@@ -149,6 +327,7 @@ async def close_trade(
trade.status = "closed"
trade.close_price = float(close_price)
trade.close_reason = "manual"
trade.closed_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(trade)
@@ -156,47 +335,67 @@ async def close_trade(
async def resolve_open_trades(db: AsyncSession) -> int:
"""Auto-close open trades whose stop or target was hit in the daily bars.
"""Auto-close open trades per the active exit policy, from the daily bars.
Walks the bars after each trade's open (same logic as the outcome evaluator).
Target hit → close at the target; stop (or an ambiguous same-bar touch) →
close at the stop. Trades that have hit neither stay open. Returns the count
closed.
Walks the bars after each trade's open. 'time' closes at the initial stop or
the hold_days-th close; 'trailing' at the trailing/initial stop; 'target' at
the setup's target or stop (same logic as the outcome evaluator). Trades that
have hit nothing stay open. Returns the count closed.
"""
result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open"))
open_trades = list(result.scalars().all())
if not open_trades:
return 0
policy = await get_exit_policy(db)
mode = policy["mode"]
trail_frac = policy["trailing_pct"] / 100.0
hold_days = policy["hold_days"]
closed = 0
for trade in open_trades:
bars_result = await db.execute(
select(OHLCVRecord.date, OHLCVRecord.high, OHLCVRecord.low)
select(
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
OHLCVRecord.low, OHLCVRecord.close,
)
.where(
OHLCVRecord.ticker_id == trade.ticker_id,
OHLCVRecord.date > trade.opened_at.date(),
)
.order_by(OHLCVRecord.date.asc())
)
bars = [Bar(date=d, high=h, low=lo) for d, h, lo in bars_result.all()]
rows = bars_result.all()
bars = [Bar(date=d, high=h, low=lo) for d, _, h, lo, _ in rows]
if not bars:
continue
# max_bars beyond the data so a still-open trade returns undecided (not "expired").
outcome, outcome_date = evaluate_setup_against_bars(
trade.direction, trade.stop_loss, trade.target, bars, max_bars=len(bars) + 1
)
if outcome == OUTCOME_TARGET_HIT:
trade.close_price = trade.target
elif outcome in (OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS):
trade.close_price = trade.stop_loss
if mode == "time":
hit = _time_close(trade.direction, trade.stop_loss, hold_days, rows)
if hit is None:
continue # neither the stop nor the hold horizon reached yet
close_price, close_date, reason = hit
elif mode == "trailing":
hit = _trailing_close(trade.direction, trade.entry_price, trade.stop_loss, trail_frac, bars)
if hit is None:
continue # neither the trailing nor the initial stop reached yet
close_price, close_date, reason = hit
else:
continue
# max_bars beyond the data so a still-open trade returns undecided (not "expired").
outcome, outcome_date = evaluate_setup_against_bars(
trade.direction, trade.stop_loss, trade.target, bars, max_bars=len(bars) + 1
)
if outcome == OUTCOME_TARGET_HIT:
close_price, close_date, reason = trade.target, outcome_date, "target"
elif outcome in (OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS):
close_price, close_date, reason = trade.stop_loss, outcome_date, "stop"
else:
continue
trade.status = "closed"
trade.closed_at = datetime.combine(
outcome_date, datetime.min.time(), tzinfo=timezone.utc
)
trade.close_price = float(close_price)
trade.close_reason = reason
trade.closed_at = datetime.combine(close_date, datetime.min.time(), tzinfo=timezone.utc)
closed += 1
if closed:
+31 -12
View File
@@ -2,12 +2,12 @@
A single predicate, driven by the admin activation config, used by the
performance stats (server) and mirrored on the frontend. The core selection is
cross-sectional momentum: a setup's ticker must rank in the top
``min_momentum_percentile`` of the universe by 12-1 month momentum — the one
signal the backtest showed actually sorts forward returns. R:R and confidence
remain as floors, and conviction/conflict survive as optional tighteners (off by
default). The momentum percentile is computed across the universe and attached to
each setup upstream; when it's absent the gate falls back to the floors.
residual cross-sectional momentum: a setup's ticker must rank in the top
``min_momentum_percentile`` of the universe by beta-adjusted 12-1 month momentum.
R:R and confidence remain as floors, and conviction/conflict survive as optional
tighteners (off by default). The activation percentile is computed across the
universe and attached to each setup upstream; when it's absent the gate falls
back to the floors.
"""
from __future__ import annotations
@@ -17,6 +17,16 @@ from typing import Any
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
def _action_direction(action: str | None) -> str:
if not action or action == "NEUTRAL":
return "neutral"
if action.startswith("LONG"):
return "long"
if action.startswith("SHORT"):
return "short"
return "neutral"
def best_target_probability(setup: Any) -> float:
"""Highest probability among a setup's targets, 0 if none."""
targets = getattr(setup, "targets", None) or []
@@ -65,12 +75,12 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
return False
if (setup.confidence_score or 0.0) < config["min_confidence"]:
return False
# Cross-sectional momentum: the core selection. A setup's ticker must rank in
# the top ``min_momentum_percentile`` of the universe by 12-1 momentum. The
# validated edge is long-only, so while the gate is active shorts (which fight
# the trend) never qualify. The percentile floor is only enforced when a
# percentile is attached (live setups / backtest); callers that don't attach
# it defer to the floors above.
# Residual cross-sectional momentum: the core selection. A setup's ticker
# must rank in the top ``min_momentum_percentile`` of the universe by
# beta-adjusted 12-1 momentum. The validated edge is long-only, so while the
# gate is active shorts (which fight the trend) never qualify. The percentile
# floor is only enforced when a percentile is attached (live setups /
# backtest); callers that don't attach it defer to the floors above.
min_pct = float(config.get("min_momentum_percentile", 0.0))
if min_pct > 0:
if (getattr(setup, "direction", "long") or "long") == "short":
@@ -78,6 +88,15 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
momentum_percentile = getattr(setup, "momentum_percentile", None)
if momentum_percentile is not None and momentum_percentile < min_pct:
return False
# A setup is actionable only when the live ticker action points in the same
# direction. NEUTRAL means no clear signal; an opposite action means the
# setup is counter-bias. ``exclude_neutral`` defaults on; callers that omit
# it keep legacy floor-only behavior.
if config.get("exclude_neutral"):
action_direction = _action_direction(getattr(setup, "recommended_action", None))
setup_direction = (getattr(setup, "direction", "long") or "long").lower()
if action_direction == "neutral" or action_direction != setup_direction:
return False
if config.get("require_high_conviction"):
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
return False
+57 -27
View File
@@ -524,6 +524,56 @@ def _build_reasoning(
)
def build_recommendation_snapshot(
dimension_scores: dict[str, float],
sentiment_classification: str | None,
config: dict[str, float],
available_directions: set[str] | None = None,
) -> dict[str, Any]:
"""Build the ticker-level recommendation from the supplied live context."""
conflicts = signal_conflict_detector.detect_conflicts(
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
config=config,
)
long_confidence = direction_analyzer.calculate_confidence(
direction="long",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
short_confidence = direction_analyzer.calculate_confidence(
direction="short",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
action = _choose_recommended_action(
long_confidence, short_confidence, config, available_directions
)
reasoning = _build_reasoning(
action=action,
long_confidence=long_confidence,
short_confidence=short_confidence,
conflicts=conflicts,
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
config=config,
available_directions=available_directions,
)
return {
"action": action,
"reasoning": reasoning,
"risk_level": _risk_level_from_conflicts(conflicts),
"long_confidence": long_confidence,
"short_confidence": short_confidence,
"conflicts": conflicts,
}
PRIMARY_TARGET_MIN_RR = 1.5
@@ -559,24 +609,15 @@ async def enhance_trade_setup(
) -> TradeSetup:
config = await get_recommendation_config(db)
conflicts = signal_conflict_detector.detect_conflicts(
snapshot = build_recommendation_snapshot(
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
config=config,
available_directions=available_directions,
)
long_confidence = direction_analyzer.calculate_confidence(
direction="long",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
short_confidence = direction_analyzer.calculate_confidence(
direction="short",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
conflicts = list(snapshot["conflicts"])
long_confidence = float(snapshot["long_confidence"])
short_confidence = float(snapshot["short_confidence"])
direction = setup.direction.lower()
confidence = long_confidence if direction == "long" else short_confidence
@@ -622,19 +663,8 @@ async def enhance_trade_setup(
# Action and reasoning are ticker-level: they consider both directions and
# which directions are actually tradeable, and are identical on every setup.
action = _choose_recommended_action(
long_confidence, short_confidence, config, available_directions
)
reasoning = _build_reasoning(
action=action,
long_confidence=long_confidence,
short_confidence=short_confidence,
conflicts=conflicts,
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
config=config,
available_directions=available_directions,
)
action = str(snapshot["action"])
reasoning = str(snapshot["reasoning"])
setup.confidence_score = round(confidence, 2)
setup.targets_json = json.dumps(targets)
+128 -10
View File
@@ -38,7 +38,7 @@ from app.config import settings
from app.exceptions import ProviderError
from app.models.regime_snapshot import RegimeSnapshot
from app.providers.alpaca import AlpacaOHLCVProvider
from app.services import settings_store
from app.services import breadth_service, settings_store
from app.services.admin_service import update_setting
from app.services.sentiment_provider_service import _resolve as resolve_llm_config
@@ -65,6 +65,11 @@ DEFAULT_CONFIG: dict = {
"F1": 25, "F2": 15, "F3": 8, "F4": 7,
},
"alert_threshold": 65,
# Observational early-warning blend: a small Combined score = weighted mean of
# the coincident index and the breadth-divergence early-warning score. Kept
# separate from the index weights above so the early-warning side stays
# decoupled until proven. Tunable; need not sum to 1 (normalised).
"combined_weights": {"coincident": 0.6, "early_warning": 0.4},
"leader_weight": 2.0, # SMH counts 2x vs QQQ where both feed a signal
"rs_lookback": 60, # trading days for relative-strength / breadth trend
"fundamental_staleness_days": 80,
@@ -530,6 +535,21 @@ async def update_regime_monitor(db: AsyncSession, backfill_days: int = 90) -> di
leader_series = prices.get(leader or "", [])
latest_date = leader_series[-1][0] if leader_series else end
# Early-warning signal: breadth-divergence over the stored universe (leads but
# noisy). Computed once here so the daily job carries it live, as a SEPARATE
# score next to the coincident index — not folded into the index weights.
# Best-effort: a breadth failure must not stop the index update.
try:
breadth = await breadth_service.compute_breadth_series(db)
divergence = breadth_service.compute_divergence_series(breadth, sorted(leader_series))
except Exception as exc:
logger.warning("Regime monitor: breadth/divergence skipped: %s", exc)
divergence = {}
# As-of lookup: the stored universe (breadth) can lag the live benchmark date
# by a day or two, so an exact-date match would blank the newest snapshot.
div_items = sorted(divergence.items())
cw = config.get("combined_weights") or {"coincident": 0.6, "early_warning": 0.4}
dates = {latest_date}
if await _snapshot_count(db) < 5 and leader_series:
cutoff = end - timedelta(days=backfill_days)
@@ -538,8 +558,26 @@ async def update_regime_monitor(db: AsyncSession, backfill_days: int = 90) -> di
latest_result: dict | None = None
for d in sorted(dates):
result = _compute_index(prices, vix_series, oas_series, overrides, config, d)
_attach_early_warning(result, _divergence_asof(div_items, d), cw)
await _upsert_snapshot(db, result)
latest_result = result
# Backfill early-warning + combined onto recent existing snapshots (e.g. the
# index history written before this signal existed) so their 7/30-day trends
# populate immediately rather than only filling in over the coming weeks.
if div_items:
recent = await db.execute(
select(RegimeSnapshot).where(RegimeSnapshot.date >= end - timedelta(days=120))
)
for row in recent.scalars().all():
try:
res = json.loads(row.breakdown_json)
except (TypeError, ValueError):
continue
if (res.get("early_warning") or {}).get("score") is not None:
continue
_attach_early_warning(res, _divergence_asof(div_items, row.date), cw)
row.breakdown_json = json.dumps(res)
await db.commit()
logger.info(json.dumps({
@@ -551,19 +589,65 @@ async def update_regime_monitor(db: AsyncSession, backfill_days: int = 90) -> di
return latest_result or {"available": False, "reason": "no data"}
async def _score_at_or_before(db: AsyncSession, target: date) -> float | None:
def _divergence_asof(div_items: list[tuple[date, float]], as_of: date, max_lag_days: int = 7) -> float | None:
"""Latest divergence value on/before ``as_of``, tolerating a small data lag
between the live benchmark and the stored universe. None if too stale/absent."""
chosen: tuple[date, float] | None = None
for d, v in div_items:
if d <= as_of:
chosen = (d, v)
else:
break
if chosen is None or (as_of - chosen[0]).days > max_lag_days:
return None
return chosen[1]
def _attach_early_warning(result: dict, ew: float | None, weights: dict) -> None:
"""Attach the separate early-warning score and a combined blend to a snapshot.
``ew`` is the breadth-divergence value as-of this date (or None). The combined
score is a normalised weighted mean of the coincident index and the early
warning — observational, kept apart from the index itself.
"""
result["early_warning"] = {
"score": round(ew, 1) if ew is not None else None,
"band": band_for(ew) if ew is not None else None,
}
if ew is None:
combined = result["total_score"]
else:
wc = float(weights.get("coincident", 0.6))
we = float(weights.get("early_warning", 0.4))
wsum = (wc + we) or 1.0
combined = (result["total_score"] * wc + ew * we) / wsum
result["combined"] = {"score": round(combined, 1), "band": band_for(combined)}
async def _result_at_or_before(db: AsyncSession, target: date) -> dict | None:
"""Parsed snapshot result for the latest date on/before ``target``."""
res = await db.execute(
select(RegimeSnapshot.total_score)
select(RegimeSnapshot.breakdown_json)
.where(RegimeSnapshot.date <= target)
.order_by(RegimeSnapshot.date.desc())
.limit(1)
)
val = res.scalar_one_or_none()
return float(val) if val is not None else None
raw = res.scalar_one_or_none()
if raw is None:
return None
try:
return json.loads(raw)
except (TypeError, ValueError):
return None
def _delta(curr: float | None, prev: float | None) -> float | None:
return round(curr - prev, 1) if (curr is not None and prev is not None) else None
async def get_regime_monitor(db: AsyncSession) -> dict:
"""Latest snapshot result + 7/30-day trend deltas. Cheap (one+ row reads)."""
"""Latest snapshot + 7/30-day trend deltas for the index, early-warning, and
combined scores. Cheap (a few row reads)."""
res = await db.execute(
select(RegimeSnapshot).order_by(RegimeSnapshot.date.desc()).limit(1)
)
@@ -577,16 +661,50 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
result = {"date": latest.date.isoformat(), "total_score": latest.total_score,
"band": latest.band, "breakdown": []}
score_7 = await _score_at_or_before(db, latest.date - timedelta(days=7))
score_30 = await _score_at_or_before(db, latest.date - timedelta(days=30))
r7 = await _result_at_or_before(db, latest.date - timedelta(days=7))
r30 = await _result_at_or_before(db, latest.date - timedelta(days=30))
def _nested(r: dict | None, key: str) -> float | None:
return (r.get(key) or {}).get("score") if r else None
result["available"] = True
cur_total = result.get("total_score")
result["trend"] = {
"delta_7": round(latest.total_score - score_7, 1) if score_7 is not None else None,
"delta_30": round(latest.total_score - score_30, 1) if score_30 is not None else None,
"delta_7": _delta(cur_total, (r7 or {}).get("total_score")),
"delta_30": _delta(cur_total, (r30 or {}).get("total_score")),
}
for key in ("early_warning", "combined"):
block = result.get(key) or {"score": None, "band": None}
block["delta_7"] = _delta(block.get("score"), _nested(r7, key))
block["delta_30"] = _delta(block.get("score"), _nested(r30, key))
result[key] = block
return result
async def get_regime_history(db: AsyncSession, days: int = 400) -> list[dict]:
"""Daily history of the index, early-warning, and combined scores for the
score-over-time chart. One point per snapshot date, ascending."""
cutoff = date.today() - timedelta(days=days)
res = await db.execute(
select(RegimeSnapshot)
.where(RegimeSnapshot.date >= cutoff)
.order_by(RegimeSnapshot.date.asc())
)
out: list[dict] = []
for row in res.scalars().all():
try:
data = json.loads(row.breakdown_json)
except (TypeError, ValueError):
data = {}
out.append({
"date": row.date.isoformat(),
"index": row.total_score,
"early_warning": (data.get("early_warning") or {}).get("score"),
"combined": (data.get("combined") or {}).get("score"),
})
return out
# ---------------------------------------------------------------------------
# F1/F3 via grounded LLM (reuses the configured sentiment provider)
# ---------------------------------------------------------------------------
+346 -17
View File
@@ -11,24 +11,33 @@ from __future__ import annotations
import json
import logging
from collections.abc import Callable
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import NotFoundError
from app.models.fundamental import FundamentalData
from app.models.ohlcv import OHLCVRecord
from app.models.score import CompositeScore, DimensionScore
from app.models.sentiment import SentimentScore
from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.services.indicator_service import _extract_ohlcv, compute_atr
from app.services.price_service import query_ohlcv
from app.services.recommendation_service import enhance_trade_setup
from app.services.recommendation_service import (
_risk_level_from_conflicts,
build_recommendation_snapshot,
enhance_trade_setup,
get_recommendation_config,
)
logger = logging.getLogger(__name__)
STRATEGY_VERSION = "residual_momentum_12_1_rr_time_v2"
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper()
@@ -76,6 +85,263 @@ async def _get_latest_sentiment(db: AsyncSession, ticker_id: int) -> str | None:
return row.classification if row else None
async def _apply_live_recommendation_context(
db: AsyncSession,
setup_rows: list[tuple[TradeSetup, str]],
rows: list[dict],
) -> list[dict]:
"""Decorate latest setup rows with current score/sentiment recommendation data.
This intentionally updates only the API payload. Stored trade setups and
history remain point-in-time records for outcome analysis.
"""
if not rows or not setup_rows:
return rows
ticker_ids = {setup.ticker_id for setup, _ in setup_rows}
setups_by_id = {setup.id: setup for setup, _ in setup_rows}
directions_by_ticker = await _latest_available_directions_by_ticker(db, ticker_ids)
dim_result = await db.execute(
select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids))
)
dims_by_ticker: dict[int, dict[str, float]] = {}
for ds in dim_result.scalars().all():
dims_by_ticker.setdefault(ds.ticker_id, {})[ds.dimension] = float(ds.score)
comp_result = await db.execute(
select(CompositeScore)
.where(CompositeScore.ticker_id.in_(ticker_ids))
.order_by(CompositeScore.ticker_id, CompositeScore.computed_at.desc())
)
composites: dict[int, CompositeScore] = {}
for comp in comp_result.scalars().all():
composites.setdefault(comp.ticker_id, comp)
sent_result = await db.execute(
select(SentimentScore)
.where(SentimentScore.ticker_id.in_(ticker_ids))
.order_by(SentimentScore.ticker_id, SentimentScore.timestamp.desc())
)
sentiments: dict[int, SentimentScore] = {}
for sent in sent_result.scalars().all():
sentiments.setdefault(sent.ticker_id, sent)
config = await get_recommendation_config(db)
live_rows: list[dict] = []
for row in rows:
setup = setups_by_id.get(row["id"])
if setup is None:
live_rows.append(row)
continue
ticker_id = setup.ticker_id
live_row = dict(row)
comp = composites.get(ticker_id)
if comp is not None:
live_row["composite_score"] = float(comp.score)
live_row["context_as_of"]["score_computed_at"] = comp.computed_at
dimension_scores = dims_by_ticker.get(ticker_id)
sentiment = sentiments.get(ticker_id)
if sentiment is not None:
live_row["context_as_of"]["sentiment_at"] = sentiment.timestamp
if dimension_scores:
snapshot = build_recommendation_snapshot(
dimension_scores=dimension_scores,
sentiment_classification=sentiment.classification if sentiment else None,
config=config,
available_directions=directions_by_ticker.get(ticker_id),
)
direction = setup.direction.lower()
confidence_key = "long_confidence" if direction == "long" else "short_confidence"
live_row["confidence_score"] = round(float(snapshot[confidence_key]), 2)
live_row["recommended_action"] = snapshot["action"]
live_row["reasoning"] = snapshot["reasoning"]
setup_conflicts = _setup_specific_conflicts(live_row.get("conflict_flags", []))
live_conflicts = [str(item) for item in snapshot["conflicts"]]
live_row["conflict_flags"] = live_conflicts + setup_conflicts
live_row["risk_level"] = _risk_level_from_conflicts(live_row["conflict_flags"])
live_rows.append(live_row)
return live_rows
def _setup_specific_conflicts(conflicts: list[str]) -> list[str]:
signal_prefixes = (
"sentiment-technical:",
"sentiment-momentum:",
"momentum-technical:",
"fundamental-technical:",
)
return [
str(conflict)
for conflict in conflicts
if not str(conflict).startswith(signal_prefixes)
]
async def _latest_available_directions_by_ticker(
db: AsyncSession,
ticker_ids: set[int],
) -> dict[int, set[str]]:
if not ticker_ids:
return {}
result = await db.execute(
select(TradeSetup)
.where(TradeSetup.ticker_id.in_(ticker_ids))
.order_by(
TradeSetup.ticker_id,
TradeSetup.direction,
TradeSetup.detected_at.desc(),
TradeSetup.id.desc(),
)
)
latest_by_key: set[tuple[int, str]] = set()
directions: dict[int, set[str]] = {}
for setup in result.scalars().all():
direction = setup.direction.lower()
key = (setup.ticker_id, direction)
if key in latest_by_key:
continue
latest_by_key.add(key)
directions.setdefault(setup.ticker_id, set()).add(direction)
return directions
def _json_default(value):
if isinstance(value, (datetime, date)):
return value.isoformat()
return str(value)
async def _create_signal_context_snapshots(
db: AsyncSession,
setups: list[TradeSetup],
*,
strategy_version: str = STRATEGY_VERSION,
) -> None:
"""Capture point-in-time discretionary context for freshly generated setups.
The scanner stores the setup itself first so each snapshot can be keyed by
``trade_setup_id``. This is intentionally forward-only: old sentiment,
fundamentals and composite scores are not reconstructed from today's data.
"""
if not setups:
return
ticker_ids = {s.ticker_id for s in setups}
dims: dict[int, dict[str, dict]] = {}
dim_rows = (
await db.execute(select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids)))
).scalars().all()
for row in dim_rows:
dims.setdefault(row.ticker_id, {})[row.dimension] = {
"score": float(row.score),
"is_stale": bool(row.is_stale),
"computed_at": row.computed_at,
}
composites: dict[int, CompositeScore] = {}
comp_rows = (
await db.execute(
select(CompositeScore)
.where(CompositeScore.ticker_id.in_(ticker_ids))
.order_by(CompositeScore.ticker_id, CompositeScore.computed_at.desc())
)
).scalars().all()
for row in comp_rows:
composites.setdefault(row.ticker_id, row)
sentiments: dict[int, SentimentScore] = {}
sent_rows = (
await db.execute(
select(SentimentScore)
.where(SentimentScore.ticker_id.in_(ticker_ids))
.order_by(SentimentScore.ticker_id, SentimentScore.timestamp.desc())
)
).scalars().all()
for row in sent_rows:
sentiments.setdefault(row.ticker_id, row)
fundamentals: dict[int, FundamentalData] = {}
fund_rows = (
await db.execute(
select(FundamentalData)
.where(FundamentalData.ticker_id.in_(ticker_ids))
.order_by(FundamentalData.ticker_id, FundamentalData.fetched_at.desc())
)
).scalars().all()
for row in fund_rows:
fundamentals.setdefault(row.ticker_id, row)
now = datetime.now(timezone.utc)
for setup in setups:
comp = composites.get(setup.ticker_id)
sent = sentiments.get(setup.ticker_id)
fund = fundamentals.get(setup.ticker_id)
score_context = {
"composite_score": float(comp.score) if comp else float(setup.composite_score),
"composite_is_stale": bool(comp.is_stale) if comp else None,
"composite_computed_at": comp.computed_at if comp else None,
"dimensions": dims.get(setup.ticker_id, {}),
}
sentiment_context = (
{
"classification": sent.classification,
"confidence": int(sent.confidence),
"recommendation": sent.recommendation,
"timestamp": sent.timestamp,
"source": sent.source,
}
if sent
else {}
)
fundamental_context = (
{
"pe_ratio": fund.pe_ratio,
"revenue_growth": fund.revenue_growth,
"earnings_surprise": fund.earnings_surprise,
"market_cap": fund.market_cap,
"next_earnings_date": fund.next_earnings_date,
"fetched_at": fund.fetched_at,
}
if fund
else {}
)
db.add(
SignalContextSnapshot(
trade_setup_id=setup.id,
ticker_id=setup.ticker_id,
detected_at=setup.detected_at,
created_at=now,
strategy_version=strategy_version,
direction=setup.direction,
entry_price=float(setup.entry_price),
stop_loss=float(setup.stop_loss),
target=float(setup.target),
rr_ratio=float(setup.rr_ratio),
confidence_score=(
float(setup.confidence_score) if setup.confidence_score is not None else None
),
recommended_action=setup.recommended_action,
risk_level=setup.risk_level,
momentum_percentile=(
float(setup.momentum_percentile)
if setup.momentum_percentile is not None
else None
),
score_context_json=json.dumps(score_context, default=_json_default),
sentiment_context_json=json.dumps(sentiment_context, default=_json_default),
fundamental_context_json=json.dumps(fundamental_context, default=_json_default),
)
)
async def scan_ticker(
db: AsyncSession,
symbol: str,
@@ -85,9 +351,9 @@ async def scan_ticker(
) -> list[TradeSetup]:
"""Scan a single ticker for trade setups meeting the R:R threshold.
``momentum_percentile`` is the ticker's 12-1 momentum rank across the universe
(computed by the caller), stored on each setup so the activation gate can
select the top slice."""
``momentum_percentile`` is the ticker's residual 12-1 momentum activation
rank across the universe (computed by the caller), stored on each setup so
the activation gate can select the top slice."""
ticker = await _get_ticker(db, symbol)
records = await query_ohlcv(db, symbol)
@@ -238,6 +504,9 @@ async def scan_ticker(
for s in enhanced_setups:
await db.refresh(s)
await _create_signal_context_snapshots(db, enhanced_setups)
await db.commit()
return enhanced_setups
@@ -256,8 +525,9 @@ async def scan_all_tickers(
tickers = list(result.scalars().all())
total = len(tickers)
# Rank the universe by 12-1 momentum up front so each new setup carries its
# ticker's percentile (used by the activation gate). Best-effort.
# Rank the universe by residual 12-1 momentum up front so each new setup
# carries its activation percentile. Best-effort; the ranker falls back to
# raw 12-1 momentum only if benchmark data is unavailable.
try:
from app.services import momentum_service
@@ -303,6 +573,7 @@ async def get_trade_setups(
min_confidence: float | None = None,
recommended_action: str | None = None,
symbol: str | None = None,
live_recommendation: bool = False,
) -> list[dict]:
"""Get latest stored trade setups, optionally filtered."""
stmt = (
@@ -313,9 +584,11 @@ async def get_trade_setups(
stmt = stmt.where(TradeSetup.direction == direction.lower())
if symbol is not None:
stmt = stmt.where(Ticker.symbol == symbol.strip().upper())
if min_confidence is not None:
# With live_recommendation these fields are overlaid with current values
# below, so filtering happens there instead of against the stored columns.
if min_confidence is not None and not live_recommendation:
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
if recommended_action is not None:
if recommended_action is not None and not live_recommendation:
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
@@ -339,15 +612,37 @@ async def get_trade_setups(
reverse=True,
)
prices = await _latest_closes(db, {s.ticker_id for s, _ in latest_rows})
return [
prices = await _latest_price_context(db, {s.ticker_id for s, _ in latest_rows})
rows_out = [
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
for setup, ticker_symbol in latest_rows
]
if live_recommendation:
rows_out = await _apply_live_recommendation_context(db, latest_rows, rows_out)
if min_confidence is not None:
rows_out = [
row for row in rows_out
if row["confidence_score"] is not None
and row["confidence_score"] >= min_confidence
]
if recommended_action is not None:
rows_out = [
row for row in rows_out
if row["recommended_action"] == recommended_action
]
rows_out.sort(
key=lambda row: (
row["confidence_score"] if row["confidence_score"] is not None else -1.0,
row["rr_ratio"],
row["composite_score"],
),
reverse=True,
)
return rows_out
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
"""Most recent close per ticker — used to judge a setup's current relevance."""
async def _latest_price_context(db: AsyncSession, ticker_ids: set[int]) -> dict[int, dict]:
"""Most recent daily OHLCV row per ticker for live price context."""
if not ticker_ids:
return {}
latest = (
@@ -356,7 +651,12 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
.group_by(OHLCVRecord.ticker_id)
.subquery()
)
stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join(
stmt = select(
OHLCVRecord.ticker_id,
OHLCVRecord.close,
OHLCVRecord.date,
OHLCVRecord.created_at,
).join(
latest,
and_(
OHLCVRecord.ticker_id == latest.c.ticker_id,
@@ -364,7 +664,23 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
),
)
result = await db.execute(stmt)
return {tid: float(close) for tid, close in result.all()}
return {
tid: {
"current_price": float(close),
"price_date": price_date,
"price_updated_at": created_at,
}
for tid, close, price_date, created_at in result.all()
}
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
"""Most recent close per ticker, kept for callers that only need price."""
price_context = await _latest_price_context(db, ticker_ids)
return {
ticker_id: context["current_price"]
for ticker_id, context in price_context.items()
}
async def get_trade_setup_history(
@@ -381,16 +697,28 @@ async def get_trade_setup_history(
result = await db.execute(stmt)
rows = result.all()
prices = await _latest_closes(db, {s.ticker_id for s, _ in rows})
prices = await _latest_price_context(db, {s.ticker_id for s, _ in rows})
return [
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
for setup, ticker_symbol in rows
]
def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float | None = None) -> dict:
def _trade_setup_to_dict(setup: TradeSetup, symbol: str, price_context: dict | None = None) -> dict:
targets: list[dict] = []
conflicts: list[str] = []
current_price = (
float(price_context["current_price"])
if price_context and price_context.get("current_price") is not None
else None
)
context_as_of = {
"setup_detected_at": setup.detected_at,
"score_computed_at": None,
"sentiment_at": None,
"price_date": price_context.get("price_date") if price_context else None,
"price_updated_at": price_context.get("price_updated_at") if price_context else None,
}
if setup.targets_json:
try:
@@ -429,4 +757,5 @@ def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float |
"evaluated_at": setup.evaluated_at,
"current_price": current_price,
"momentum_percentile": setup.momentum_percentile,
"context_as_of": context_as_of,
}
+88 -91
View File
@@ -2,8 +2,8 @@
Computes dimension scores (technical, sr_quality, sentiment, fundamental,
momentum) each 0-100, composite score as weighted average of available
dimensions with re-normalized weights, staleness marking/recomputation
on demand, and weight update triggers full recomputation.
dimensions with re-normalized weights, staleness marking, explicit refresh
paths, and weight update triggers full recomputation.
"""
from __future__ import annotations
@@ -28,13 +28,31 @@ DIMENSIONS = ["technical", "sr_quality", "sentiment", "fundamental", "momentum"]
DEFAULT_WEIGHTS: dict[str, float] = {
"technical": 0.25,
"sr_quality": 0.20,
"sentiment": 0.15,
"sentiment": 0.10,
"fundamental": 0.20,
"momentum": 0.20,
}
SCORING_WEIGHTS_KEY = "scoring_weights"
# Sentiment enters the composite as a signed adjustment around this neutral point,
# not as an averaged-in level (see _sentiment_adjustment / compute_composite_score).
NEUTRAL_SENTIMENT = 50.0
def _sentiment_adjustment(sentiment_score: float | None, sentiment_weight: float) -> float:
"""Signed points sentiment contributes to the base composite.
+MAX_ADJ at max-confidence bullish (score 100), 0 at neutral (50), -MAX_ADJ at
max-confidence bearish (score 0), where MAX_ADJ = sentiment weight * 100. A
50%-confidence call maps to score 50 → no effect (a coin flip carries no info),
so going from no sentiment to bullish can only ever help.
"""
if sentiment_score is None:
return 0.0
max_adj = sentiment_weight * 100.0
return max_adj * (sentiment_score - NEUTRAL_SENTIMENT) / 50.0
# ---------------------------------------------------------------------------
# Helpers
@@ -670,10 +688,15 @@ async def compute_composite_score(
symbol: str,
weights: dict[str, float] | None = None,
) -> tuple[float | None, list[str]]:
"""Compute composite score from available dimension scores.
"""Compute the composite score.
The non-sentiment dimensions form a re-normalized weighted-average *base*.
Sentiment is then applied as a signed adjustment around neutral (50), not
averaged in: neutral leaves the base unchanged, bullish adds and bearish
subtracts (scaled by confidence), so going from no sentiment to bullish can
only help. See _sentiment_adjustment.
Returns (composite_score, missing_dimensions).
Missing dimensions are excluded and weights re-normalized.
"""
ticker = await _get_ticker(db, symbol)
@@ -686,29 +709,32 @@ async def compute_composite_score(
)
dim_scores = {ds.dimension: ds for ds in result.scalars().all()}
available: list[tuple[str, float, float]] = [] # (dim, weight, score)
missing: list[str] = []
for dim in DIMENSIONS:
w = weights.get(dim, 0.0)
if w <= 0:
continue
def _live(dim: str) -> float | None:
ds = dim_scores.get(dim)
if ds is not None and not ds.is_stale and ds.score is not None:
available.append((dim, w, ds.score))
else:
missing.append(dim)
return ds.score
return None
if not available:
return None, missing
missing = [dim for dim in DIMENSIONS if _live(dim) is None]
# Re-normalize weights
total_weight = sum(w for _, w, _ in available)
if total_weight == 0:
return None, missing
# Base: re-normalized weighted average of the non-sentiment dimensions.
base_available = [
(dim, weights.get(dim, 0.0), _live(dim))
for dim in DIMENSIONS
if dim != "sentiment" and weights.get(dim, 0.0) > 0 and _live(dim) is not None
]
sentiment_score = _live("sentiment")
composite = sum(w * s for _, w, s in available) / total_weight
composite = max(0.0, min(100.0, composite))
if base_available:
total_weight = sum(w for _, w, _ in base_available)
base = sum(w * s for _, w, s in base_available) / total_weight
elif sentiment_score is not None:
base = NEUTRAL_SENTIMENT # only sentiment present → neutral baseline
else:
return None, missing # nothing to score
delta = _sentiment_adjustment(sentiment_score, weights.get("sentiment", 0.0))
composite = max(0.0, min(100.0, base + delta))
# Persist composite score
now = datetime.now(timezone.utc)
@@ -739,73 +765,37 @@ async def compute_composite_score(
async def get_score(
db: AsyncSession, symbol: str
) -> dict:
"""Get composite + all dimension scores for a ticker.
"""Read composite + dimension scores for a ticker without recomputing.
Recomputes stale dimensions on demand, then recomputes composite.
Returns a dict suitable for ScoreResponse, including dimension breakdowns
and composite breakdown with re-normalization info.
GET endpoints use this path, so it must not mutate persisted score context.
Scheduled/manual write paths are responsible for refreshing scores.
"""
ticker = await _get_ticker(db, symbol)
weights = await _get_weights(db)
# Check for stale dimension scores and recompute them
result = await db.execute(
select(DimensionScore).where(DimensionScore.ticker_id == ticker.id)
)
dim_scores = {ds.dimension: ds for ds in result.scalars().all()}
for dim in DIMENSIONS:
ds = dim_scores.get(dim)
if ds is None or ds.is_stale:
await compute_dimension_score(db, symbol, dim)
# Check composite staleness
comp_result = await db.execute(
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
)
comp = comp_result.scalar_one_or_none()
if comp is None or comp.is_stale:
await compute_composite_score(db, symbol, weights)
await db.commit()
# Re-fetch everything fresh
result = await db.execute(
select(DimensionScore).where(DimensionScore.ticker_id == ticker.id)
)
dim_scores_list = list(result.scalars().all())
dim_scores = {ds.dimension: ds for ds in dim_scores_list}
comp_result = await db.execute(
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
)
comp = comp_result.scalar_one_or_none()
# Compute breakdowns for each dimension by calling the dimension computers
breakdowns: dict[str, dict | None] = {}
for dim in DIMENSIONS:
try:
raw_result = await _DIMENSION_COMPUTERS[dim](db, symbol)
if isinstance(raw_result, tuple) and len(raw_result) == 2:
breakdowns[dim] = raw_result[1]
else:
breakdowns[dim] = None
except Exception:
breakdowns[dim] = None
# Build dimension entries with breakdowns
dimensions = []
missing = []
available_dims: list[str] = []
for dim in DIMENSIONS:
found = next((ds for ds in dim_scores_list if ds.dimension == dim), None)
found = dim_scores.get(dim)
if found is not None and not found.is_stale and found.score is not None:
dimensions.append({
"dimension": found.dimension,
"score": found.score,
"is_stale": found.is_stale,
"computed_at": found.computed_at,
"breakdown": breakdowns.get(dim),
"breakdown": None,
})
w = weights.get(dim, 0.0)
if w > 0:
@@ -819,25 +809,50 @@ async def get_score(
"score": found.score,
"is_stale": found.is_stale,
"computed_at": found.computed_at,
"breakdown": breakdowns.get(dim),
"breakdown": None,
})
# Build composite breakdown with re-normalization info
composite_breakdown = None
available_weight_sum = sum(weights.get(d, 0.0) for d in available_dims)
# Build composite breakdown: the non-sentiment base (re-normalized weighted
# average) plus sentiment as a signed adjustment around neutral.
base_dims = [d for d in available_dims if d != "sentiment"]
available_weight_sum = sum(weights.get(d, 0.0) for d in base_dims)
if available_weight_sum > 0:
renormalized_weights = {
d: weights.get(d, 0.0) / available_weight_sum for d in available_dims
d: weights.get(d, 0.0) / available_weight_sum for d in base_dims
}
else:
renormalized_weights = {}
fresh = {
ds.dimension: ds.score
for ds in dim_scores_list
if not ds.is_stale and ds.score is not None
}
if renormalized_weights:
base_score = sum(renormalized_weights[d] * fresh[d] for d in base_dims)
elif "sentiment" in fresh:
base_score = NEUTRAL_SENTIMENT
else:
base_score = None
sentiment_val = fresh.get("sentiment")
sentiment_weight = weights.get("sentiment", 0.0)
sentiment_adjustment = _sentiment_adjustment(sentiment_val, sentiment_weight)
composite_breakdown = {
"weights": weights,
"available_dimensions": available_dims,
"available_dimensions": base_dims,
"missing_dimensions": missing,
"renormalized_weights": renormalized_weights,
"formula": "Weighted average of available dimensions with re-normalized weights: sum(weight_i * score_i) / sum(weight_i)",
"base_score": base_score,
"sentiment_score": sentiment_val,
"sentiment_adjustment": sentiment_adjustment,
"max_sentiment_adjustment": sentiment_weight * 100.0,
"formula": (
"Base = re-normalized weighted average of the non-sentiment dimensions. "
"Composite = base + sentiment adjustment, where adjustment = "
"MAX_ADJ * (sentiment - 50) / 50 and MAX_ADJ = sentiment weight * 100."
),
}
return {
@@ -874,31 +889,13 @@ async def get_rankings(db: AsyncSession) -> dict:
dims[ds.ticker_id][ds.dimension] = ds
return comps, dims
# Two bulk reads instead of ~4 queries per ticker.
comps, dims_by_ticker = await _load_scores()
# Lazily recompute any stale/missing scores (kept fresh by the daily scan;
# this self-heals tickers that aged out between scans), committing once.
recomputed = False
for ticker in tickers:
comp = comps.get(ticker.id)
if comp is None or comp.is_stale:
dim_scores = dims_by_ticker.get(ticker.id, {})
for dim in DIMENSIONS:
ds = dim_scores.get(dim)
if ds is None or ds.is_stale:
await compute_dimension_score(db, ticker.symbol, dim)
await compute_composite_score(db, ticker.symbol, weights)
recomputed = True
if recomputed:
await db.commit()
comps, dims_by_ticker = await _load_scores()
rankings = [
{
"symbol": ticker.symbol,
"composite_score": comp.score,
"composite_stale": comp.is_stale,
"dimensions": [
{
"dimension": ds.dimension,
+57
View File
@@ -6,6 +6,7 @@ well-known universes (S&P 500, NASDAQ-100, NASDAQ All).
from __future__ import annotations
import asyncio
import json
import logging
import os
@@ -357,6 +358,55 @@ async def fetch_universe_symbols(db: AsyncSession, universe: str) -> list[str]:
raise ProviderError(f"Universe '{normalised_universe}' returned no valid symbols. Attempts: {reason}")
async def _fetch_alpaca_asset_names() -> dict[str, str]:
"""One Alpaca Trading-API call → {internal_symbol: company_name} for all US
equities. Tries paper and live endpoints so it works with either key type."""
if not settings.alpaca_api_key or not settings.alpaca_api_secret:
raise ValidationError("Alpaca API credentials are required to backfill names")
from alpaca.trading.client import TradingClient
from alpaca.trading.enums import AssetClass, AssetStatus
from alpaca.trading.requests import GetAssetsRequest
req = GetAssetsRequest(status=AssetStatus.ACTIVE, asset_class=AssetClass.US_EQUITY)
last_err: Exception | None = None
for paper in (True, False):
try:
client = TradingClient(settings.alpaca_api_key, settings.alpaca_api_secret, paper=paper)
assets = await asyncio.to_thread(client.get_all_assets, req)
names: dict[str, str] = {}
for asset in assets:
sym = getattr(asset, "symbol", None)
nm = getattr(asset, "name", None)
if sym and nm:
names[sym.replace(".", "-").upper()] = nm # BRK.B → BRK-B
if names:
return names
except Exception as exc: # noqa: BLE001 — try the other endpoint
last_err = exc
raise ProviderError(f"Failed to fetch asset names from Alpaca: {last_err}")
async def backfill_ticker_names(db: AsyncSession, *, only_missing: bool = True) -> dict[str, int]:
"""Fill Ticker.name from Alpaca in a single request for the whole universe."""
result = await db.execute(select(Ticker))
tickers = list(result.scalars().all())
targets = [t for t in tickers if not t.name] if only_missing else tickers
if not targets:
return {"updated": 0, "checked": 0, "unmatched": 0}
names = await _fetch_alpaca_asset_names()
updated = 0
for ticker in targets:
nm = names.get(ticker.symbol.upper())
if nm and nm != ticker.name:
ticker.name = nm[:120]
updated += 1
await db.commit()
return {"updated": updated, "checked": len(targets), "unmatched": len(targets) - updated}
async def bootstrap_universe(
db: AsyncSession,
universe: str,
@@ -387,6 +437,13 @@ async def bootstrap_universe(
await db.commit()
# Best-effort: fill company names for any tickers still missing one. Never let
# a name-fetch hiccup fail the bootstrap itself.
try:
await backfill_ticker_names(db, only_missing=True)
except Exception: # noqa: BLE001
logger.warning("Ticker name backfill failed during bootstrap", exc_info=True)
return {
"universe": normalised_universe,
"total_universe_symbols": len(symbols),
+3
View File
@@ -173,6 +173,9 @@ async def _enrich_entry(
"dimensions": dims,
"rr_ratio": setup.rr_ratio if setup else None,
"rr_direction": setup.direction if setup else None,
# Residual 12-1 activation percentile (the top-pick selector); ticker-level,
# so any of the ticker's setups carries the same value.
"momentum_percentile": setup.momentum_percentile if setup else None,
"sr_levels": sr_levels,
"last_close": last_close,
"change_pct": change_pct,
+8
View File
@@ -134,6 +134,8 @@ export function updateAlertSettings(payload: {
sr_proximity_enabled?: boolean;
score_drop_enabled?: boolean;
digest_enabled?: boolean;
regime_quadrant_enabled?: boolean;
trade_closed_enabled?: boolean;
}) {
return apiClient
.put<AlertConfig>('admin/settings/alerts', payload)
@@ -169,6 +171,12 @@ export function bootstrapTickers(universe: TickerUniverse, pruneMissing: boolean
.then((r) => r.data);
}
export function backfillTickerNames() {
return apiClient
.post<{ updated: number; checked: number; unmatched: number }>('admin/tickers/backfill-names')
.then((r) => r.data);
}
// Jobs
export interface JobStatus {
name: string;
+1 -1
View File
@@ -1,7 +1,7 @@
import apiClient from './client';
export interface IngestionSourceResult {
status: 'ok' | 'error' | 'skipped';
status: 'ok' | 'error' | 'skipped' | 'warning';
message?: string | null;
records?: number;
classification?: string;
+9 -1
View File
@@ -1,5 +1,5 @@
import apiClient from './client';
import type { PaperTrade } from '../lib/types';
import type { ExitPolicy, PaperTrade } from '../lib/types';
export function listPaperTrades(status?: 'open' | 'closed') {
return apiClient
@@ -7,6 +7,14 @@ export function listPaperTrades(status?: 'open' | 'closed') {
.then((r) => r.data);
}
export function getExitPolicy() {
return apiClient.get<ExitPolicy>('paper-trades/exit-policy').then((r) => r.data);
}
export function updateExitPolicy(payload: Partial<ExitPolicy>) {
return apiClient.put<ExitPolicy>('paper-trades/exit-policy', payload).then((r) => r.data);
}
export interface CreatePaperTradeBody {
symbol: string;
direction: 'long' | 'short';
+7
View File
@@ -4,12 +4,19 @@ import type {
RegimeConfig,
RegimeFundamentals,
EventStudyReport,
RegimeHistoryPoint,
} from '../lib/types';
export function getRegimeMonitor() {
return apiClient.get<RegimeMonitor>('regime/monitor').then((r) => r.data);
}
export function getRegimeHistory(days = 400) {
return apiClient
.get<RegimeHistoryPoint[]>('regime/history', { params: { days } })
.then((r) => r.data);
}
export function getEventStudy() {
return apiClient.get<EventStudyReport | null>('regime/event-study').then((r) => r.data);
}
@@ -9,6 +9,7 @@ const DEFAULTS: ActivationConfig = {
min_confidence: 55,
require_high_conviction: false,
exclude_conflicts: false,
exclude_neutral: true,
};
export function ActivationSettings() {
@@ -40,16 +41,16 @@ export function ActivationSettings() {
<p className="mt-1 text-xs text-gray-500">
What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the
Signals "Qualified only" view, and the Track Record's qualified stats. The core selection is
<span className="text-gray-300"> cross-sectional momentum</span> the ticker must rank in the
top slice of the universe by 12-1 month momentum, the one signal the backtest showed predicts
forward returns. R:R and confidence stay as floors. Tune the cutoff against the Track Record's
<span className="text-gray-300"> residual cross-sectional momentum</span> the ticker must rank in the
top slice of the universe by beta-adjusted 12-1 month momentum, the production signal promoted
from the backtest. R:R and confidence stay as floors. Tune the cutoff against the Track Record's
momentum sweep to see what actually wins.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Momentum Percentile</span>
<span className="text-xs text-gray-400">Min Residual Momentum Percentile</span>
<input
type="number"
min={0}
@@ -59,7 +60,7 @@ export function ActivationSettings() {
onChange={(e) => setForm((prev) => ({ ...prev, min_momentum_percentile: Number(e.target.value) }))}
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-600">Ticker's 12-1 momentum rank. 80 = top 20% of the universe. 0 disables. The core gate.</span>
<span className="text-[11px] text-gray-600">Ticker's residual 12-1 momentum rank. 80 = top 20% of the universe. 0 disables. The core gate.</span>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
@@ -87,6 +88,24 @@ export function ActivationSettings() {
</label>
</div>
<div className="border-t border-white/[0.06] pt-4">
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-gray-300">
<input
type="checkbox"
checked={form.exclude_neutral}
onChange={(e) => setForm((prev) => ({ ...prev, exclude_neutral: e.target.checked }))}
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
/>
<span>
Require a directional call (exclude NEUTRAL)
<span className="mt-0.5 block text-[11px] text-gray-500">
On by default. A NEUTRAL ("No Clear Setup") recommendation isn't a tradeable signal, so it
never qualifies or becomes a top pick. Turn off to also count no-clear-direction residual momentum leaders.
</span>
</span>
</label>
</div>
<div className="border-t border-white/[0.06] pt-4">
<p className="text-xs font-medium uppercase tracking-widest text-gray-500">Optional tighteners</p>
<p className="mt-1 text-[11px] text-gray-600">Off by default turn on to be more selective on top of the momentum gate.</p>
@@ -12,13 +12,17 @@ type TriggerKey =
| 'qualified_enabled'
| 'sr_proximity_enabled'
| 'score_drop_enabled'
| 'digest_enabled';
| 'digest_enabled'
| 'regime_quadrant_enabled'
| 'trade_closed_enabled';
const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [
{ key: 'qualified_enabled', label: 'Qualified setups', hint: 'a setup newly clears the activation gate' },
{ key: 'sr_proximity_enabled', label: 'Watchlist S/R proximity', hint: 'a watched ticker nears a strong support/resistance' },
{ key: 'score_drop_enabled', label: 'Score deterioration', hint: 'a watched tickers composite drops sharply' },
{ key: 'digest_enabled', label: 'Daily digest', hint: 'one end-of-day summary of qualified setups' },
{ key: 'digest_enabled', label: 'Daily digest', hint: 'end-of-day summary incl. open trades + trailing stops' },
{ key: 'regime_quadrant_enabled', label: 'Regime quadrant change', hint: 'the regime monitor shifts quadrant (hysteresis + cooldown)' },
{ key: 'trade_closed_enabled', label: 'Trade closed', hint: 'a paper trade auto-closes (trailing/target/stop) — incl. losses' },
];
function Toggle({ checked, onChange, label, hint }: {
@@ -56,6 +60,8 @@ export function AlertSettings() {
sr_proximity_enabled: true,
score_drop_enabled: true,
digest_enabled: true,
regime_quadrant_enabled: true,
trade_closed_enabled: true,
});
useEffect(() => {
@@ -67,6 +73,8 @@ export function AlertSettings() {
sr_proximity_enabled: data.sr_proximity_enabled,
score_drop_enabled: data.score_drop_enabled,
digest_enabled: data.digest_enabled,
regime_quadrant_enabled: data.regime_quadrant_enabled,
trade_closed_enabled: data.trade_closed_enabled,
});
}
}, [data]);
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import type { ExitPolicy } from '../../lib/types';
import { useExitPolicy, useUpdateExitPolicy } from '../../hooks/usePaperTrades';
import { SkeletonCard } from '../ui/Skeleton';
export function ExitPolicySettings() {
const { data, isLoading } = useExitPolicy();
const update = useUpdateExitPolicy();
const [mode, setMode] = useState<ExitPolicy['mode']>('time');
const [pct, setPct] = useState(12);
const [holdDays, setHoldDays] = useState(30);
useEffect(() => {
if (data) {
setMode(data.mode);
setPct(data.trailing_pct);
setHoldDays(data.hold_days ?? 30);
}
}, [data]);
if (isLoading) return <SkeletonCard />;
return (
<div className="glass p-5 space-y-4">
<div>
<h3 className="text-sm font-semibold text-gray-200">Paper-Trade Exit</h3>
<p className="mt-1 text-xs text-gray-500">
How open paper trades auto-close (in the nightly/intraday outcome job).{' '}
<span className="text-gray-300">Hold</span> keeps the initial stop and exits at the Nth trading
day's close — the backtest-validated exit (classic momentum: hold ~a month, re-rank);{' '}
<span className="text-gray-300">Trailing</span> rides a trailing stop;{' '}
<span className="text-gray-300">Target / stop</span> closes at the setup's target or stop.
The setup's initial stop is always the floor.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Exit mode</span>
<select
value={mode}
onChange={(e) => setMode(e.target.value as ExitPolicy['mode'])}
className="w-full input-glass px-3 py-2 text-sm"
>
<option value="time">Hold N days + stop</option>
<option value="trailing">Trailing stop</option>
<option value="target">Target / stop</option>
</select>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Hold (trading days)</span>
<input
type="number"
min={2}
max={250}
step={1}
value={holdDays}
onChange={(e) => setHoldDays(Number(e.target.value))}
disabled={mode !== 'time'}
className="w-full input-glass px-3 py-2 text-sm disabled:opacity-50"
/>
<span className="text-[11px] text-gray-600">Backtest optimum: 30 (its evaluation horizon).</span>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Trailing width (%)</span>
<input
type="number"
min={0.5}
max={90}
step={0.5}
value={pct}
onChange={(e) => setPct(Number(e.target.value))}
disabled={mode !== 'trailing'}
className="w-full input-glass px-3 py-2 text-sm disabled:opacity-50"
/>
<span className="text-[11px] text-gray-600">Give-back from the peak. 15% the hold exit.</span>
</label>
</div>
<button
className="btn-primary px-4 py-2 text-sm disabled:opacity-50"
disabled={update.isPending}
onClick={() => update.mutate({ mode, trailing_pct: pct, hold_days: holdDays })}
>
{update.isPending ? 'Saving…' : 'Save Exit Policy'}
</button>
</div>
);
}
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import {
useBackfillTickerNames,
useBootstrapTickers,
useTickerUniverseSetting,
useUpdateTickerUniverseSetting,
@@ -17,6 +18,7 @@ export function TickerUniverseBootstrap() {
const { data, isLoading, isError, error } = useTickerUniverseSetting();
const updateDefault = useUpdateTickerUniverseSetting();
const bootstrap = useBootstrapTickers();
const backfillNames = useBackfillTickerNames();
const [universe, setUniverse] = useState<TickerUniverse>('sp500');
const [pruneMissing, setPruneMissing] = useState(false);
@@ -85,6 +87,14 @@ export function TickerUniverseBootstrap() {
>
{bootstrap.isPending ? 'Bootstrapping…' : 'Bootstrap Now'}
</button>
<button
className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white disabled:opacity-50"
onClick={() => backfillNames.mutate()}
disabled={backfillNames.isPending}
title="Fill in company names from Alpaca (one request for all tickers)"
>
{backfillNames.isPending ? 'Backfilling…' : 'Backfill Names'}
</button>
</div>
</div>
);
@@ -1,6 +1,6 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types';
import { formatPrice, formatDate } from '../../lib/format';
import { formatPrice, formatDate, formatLargeNumber } from '../../lib/format';
interface CandlestickChartProps {
data: OHLCVBar[];
@@ -50,6 +50,9 @@ interface TooltipState {
}
const MIN_VISIBLE_BARS = 10;
const CHART_HEIGHT = 440;
const VOLUME_PANE_HEIGHT = 72;
const PANE_GAP = 18;
type RangePreset = '1M' | '3M' | '6M' | 'YTD' | '1Y' | '3Y' | '5Y' | 'All';
const RANGE_PRESETS: RangePreset[] = ['1M', '3M', '6M', 'YTD', '1Y', '3Y', '5Y', 'All'];
@@ -109,7 +112,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
const W = rect.width;
const H = 400;
const H = CHART_HEIGHT;
canvas.width = W * dpr;
canvas.height = H * dpr;
@@ -124,7 +127,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
// Margins
const ml = 12, mr = 70, mt = 12, mb = 32;
const cw = W - ml - mr;
const ch = H - mt - mb;
const volumeH = VOLUME_PANE_HEIGHT;
const ch = H - mt - mb - volumeH - PANE_GAP;
const priceBottom = mt + ch;
const volumeTop = priceBottom + PANE_GAP;
const volumeBottom = volumeTop + volumeH;
// Current price = explicit prop, else latest close
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
@@ -145,6 +152,9 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
const barW = cw / visibleData.length;
const candleW = Math.max(barW * 0.65, 1);
const volumeW = Math.max(barW * 0.65, 1);
const maxVolume = Math.max(...visibleData.map((b) => Math.max(0, b.volume)), 1);
const volumeScale = (v: number) => volumeTop + volumeH - (Math.max(0, v) / maxVolume) * volumeH;
// Grid lines (horizontal)
const nTicks = 6;
@@ -172,6 +182,34 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
ctx.fillText(formatDate(visibleData[i].date), x, H - 6);
}
// Volume pane
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ml, volumeTop - 9);
ctx.lineTo(ml + cw, volumeTop - 9);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(ml, volumeBottom);
ctx.lineTo(ml + cw, volumeBottom);
ctx.stroke();
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
ctx.fillStyle = '#6b7280';
ctx.textAlign = 'left';
ctx.fillText('Volume', ml, volumeTop - 13);
ctx.textAlign = 'right';
ctx.fillText(formatLargeNumber(maxVolume), W - 8, volumeTop + 4);
visibleData.forEach((bar, i) => {
const x = ml + i * barW + barW / 2;
const bullish = bar.close >= bar.open;
const yVolume = volumeScale(bar.volume);
const hVolume = Math.max(volumeBottom - yVolume, bar.volume > 0 ? 1 : 0);
ctx.fillStyle = bullish ? 'rgba(16, 185, 129, 0.32)' : 'rgba(239, 68, 68, 0.28)';
ctx.fillRect(x - volumeW / 2, yVolume, volumeW, hVolume);
});
// Nearest support/resistance only (band if it came from a zone)
markers.forEach((m) => {
const isSupport = m.role === 'support';
@@ -312,7 +350,22 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
});
// Store geometry for hit testing (includes visibleRange offset)
(canvas as any).__chartMeta = { ml, mr, mt, mb, cw, ch, barW, lo, hi, yScale, visibleStart: start };
(canvas as any).__chartMeta = {
ml,
mr,
mt,
mb,
cw,
ch,
barW,
lo,
hi,
yScale,
visibleStart: start,
volumeTop,
volumeH,
volumeBottom,
};
// Size the overlay canvas to match
const overlay = overlayCanvasRef.current;
@@ -342,12 +395,14 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
const meta = (canvas as any).__chartMeta;
if (!meta) return;
const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart } = meta;
const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart, volumeBottom } = meta;
const H = overlay.height / dpr;
const priceBottom = mt + ch;
const chartBottom = volumeBottom ?? priceBottom;
// Clamp crosshair to chart area
const cx = Math.max(ml, Math.min(ml + cw, pos.x));
const cy = Math.max(mt, Math.min(mt + ch, pos.y));
const cy = Math.max(mt, Math.min(chartBottom, pos.y));
// Dashed crosshair lines
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
@@ -357,37 +412,44 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
// Vertical line
ctx.beginPath();
ctx.moveTo(cx, mt);
ctx.lineTo(cx, mt + ch);
ctx.stroke();
// Horizontal line
ctx.beginPath();
ctx.moveTo(ml, cy);
ctx.lineTo(ml + cw, cy);
ctx.lineTo(cx, chartBottom);
ctx.stroke();
ctx.setLineDash([]);
// Price label on y-axis (right side)
const price = hi - ((cy - mt) / ch) * (hi - lo);
const priceText = formatPrice(price);
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
const priceMetrics = ctx.measureText(priceText);
const labelPadX = 5;
const labelPadY = 3;
const labelW = priceMetrics.width + labelPadX * 2;
const labelH = 16 + labelPadY * 2;
const labelX = ml + cw + 2;
const labelY = cy - labelH / 2;
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
ctx.beginPath();
ctx.roundRect(labelX, labelY, labelW, labelH, 3);
ctx.fill();
ctx.fillStyle = '#e5e7eb';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(priceText, labelX + labelPadX, cy);
if (cy <= priceBottom) {
// Horizontal price crosshair only belongs in the price pane.
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
ctx.lineWidth = 0.75;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(ml, cy);
ctx.lineTo(ml + cw, cy);
ctx.stroke();
ctx.setLineDash([]);
// Price label on y-axis (right side)
const price = hi - ((cy - mt) / ch) * (hi - lo);
const priceText = formatPrice(price);
const priceMetrics = ctx.measureText(priceText);
const labelW = priceMetrics.width + labelPadX * 2;
const labelH = 16 + labelPadY * 2;
const labelX = ml + cw + 2;
const labelY = cy - labelH / 2;
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
ctx.beginPath();
ctx.roundRect(labelX, labelY, labelW, labelH, 3);
ctx.fill();
ctx.fillStyle = '#e5e7eb';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(priceText, labelX + labelPadX, cy);
}
// Date label on x-axis (bottom)
const localIdx = Math.floor((cx - ml) / barW);
@@ -619,7 +681,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
<span>High</span><span class="text-right text-gray-200">${formatPrice(bar.high)}</span>
<span>Low</span><span class="text-right text-gray-200">${formatPrice(bar.low)}</span>
<span>Close</span><span class="text-right text-gray-200">${formatPrice(bar.close)}</span>
<span>Vol</span><span class="text-right text-gray-200">${bar.volume.toLocaleString()}</span>
<span>Vol</span><span class="text-right text-gray-200" title="${bar.volume.toLocaleString()}">${formatLargeNumber(bar.volume)}</span>
</div>${tradeTooltipHtml}`;
} else {
tip.style.display = 'none';
@@ -670,16 +732,16 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
))}
<span className="ml-1 text-[10px] text-gray-600">scroll to zoom · drag to pan</span>
</div>
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}>
<div ref={containerRef} className="relative w-full" style={{ height: CHART_HEIGHT }}>
<canvas
ref={canvasRef}
className="w-full"
style={{ height: 400 }}
style={{ height: CHART_HEIGHT }}
/>
<canvas
ref={overlayCanvasRef}
className="absolute top-0 left-0 w-full cursor-crosshair"
style={{ height: 400 }}
style={{ height: CHART_HEIGHT }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { usePaperTrades, useClosePaperTrade } from '../../hooks/usePaperTrades';
import { usePaperTrades, useClosePaperTrade, useExitPolicy } from '../../hooks/usePaperTrades';
import { useTickerNames } from '../../hooks/useTickers';
import { tradePnl } from '../../lib/paperTrade';
import { formatPrice } from '../../lib/format';
import { Section } from '../ui/Section';
@@ -18,19 +19,32 @@ function pnlColor(v: number): string {
export function OpenTradesPanel() {
const { data: trades, isLoading } = usePaperTrades('open');
const { data: policy } = useExitPolicy();
const tickerNames = useTickerNames();
const close = useClosePaperTrade();
const exitLabel = policy
? policy.mode === 'trailing'
? `auto-exit: trailing ${Math.round(policy.trailing_pct)}%`
: 'auto-exit: target/stop'
: null;
const totals = useMemo(() => {
let pnl = 0, winners = 0, losers = 0, priced = 0;
let pnl = 0, winners = 0, losers = 0, priced = 0, alphaUsd = 0, alphaPriced = 0;
for (const t of trades ?? []) {
const p = tradePnl(t);
if (!p) continue;
priced += 1;
pnl += p.pnl;
if (p.pnl > 0) winners += 1;
else if (p.pnl < 0) losers += 1;
if (p) {
priced += 1;
pnl += p.pnl;
if (p.pnl > 0) winners += 1;
else if (p.pnl < 0) losers += 1;
}
if (t.alpha_usd != null) {
alphaUsd += t.alpha_usd;
alphaPriced += 1;
}
}
return { pnl, winners, losers, priced };
return { pnl, winners, losers, priced, alphaUsd, alphaPriced };
}, [trades]);
if (isLoading) return null;
@@ -39,7 +53,7 @@ export function OpenTradesPanel() {
return (
<Section
title="Open Trades"
hint={rows.length > 0 ? `${rows.length} open · ${totals.winners}${totals.losers}` : 'paper trading'}
hint={rows.length > 0 ? `${rows.length} open · ${totals.winners}${totals.losers}${exitLabel ? ` · ${exitLabel}` : ''}` : 'paper trading'}
>
{rows.length === 0 ? (
<Callout variant="empty">
@@ -58,6 +72,8 @@ export function OpenTradesPanel() {
<th className="px-4 py-3 text-right">P&L</th>
<th className="px-4 py-3 text-right">%</th>
<th className="px-4 py-3 text-right">R</th>
<th className="px-4 py-3 text-right">Alpha</th>
<th className="px-4 py-3 text-right">Trail Stop</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
@@ -70,6 +86,11 @@ export function OpenTradesPanel() {
<Link to={`/ticker/${t.symbol}`} className="font-medium text-blue-300 hover:text-blue-200">
{t.symbol}
</Link>
{tickerNames.get(t.symbol.toUpperCase()) && (
<div className="max-w-[150px] truncate text-[11px] text-gray-500">
{tickerNames.get(t.symbol.toUpperCase())}
</div>
)}
</td>
<td className="px-4 py-3">
<span className={`num text-[10px] font-semibold uppercase ${t.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
@@ -90,6 +111,23 @@ export function OpenTradesPanel() {
<td className={`num px-4 py-3 text-right ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
{p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
</td>
<td className={`num px-4 py-3 text-right ${t.alpha_pct != null ? pnlColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">
{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}
</td>
<td className="num px-4 py-3 text-right text-gray-300" title="Current trailing-stop level · how far below the price">
{t.trailing_stop != null ? (
<>
{formatPrice(t.trailing_stop)}
{t.trailing_distance_pct != null && (
<span className="ml-1 text-[10px] text-gray-500">
{Math.abs(t.trailing_distance_pct).toFixed(1)}%
</span>
)}
</>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => {
@@ -110,12 +148,16 @@ export function OpenTradesPanel() {
<tfoot>
<tr className="border-t border-white/[0.08]">
<td className="px-4 py-2.5 text-xs text-gray-500" colSpan={5}>
Total unrealized P&L
Total unrealized P&L · alpha vs S&P 500
</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${pnlColor(totals.pnl)}`}>
{money(totals.pnl)}
</td>
<td colSpan={3} />
<td colSpan={2} />
<td className={`num px-4 py-2.5 text-right font-semibold ${totals.alphaPriced > 0 ? pnlColor(totals.alphaUsd) : 'text-gray-500'}`}>
{totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'}
</td>
<td colSpan={2} />
</tr>
</tfoot>
</table>
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import TickerSearch from './TickerSearch';
const navItems = [
{ to: '/', label: 'Overview', end: true },
@@ -46,6 +47,9 @@ export default function MobileNav() {
}`}
>
<nav className="px-3 py-2 space-y-1">
<div className="pb-2">
<TickerSearch onNavigate={() => setOpen(false)} />
</div>
{navItems.map(({ to, label, end }) => (
<NavLink
key={to}
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore';
import { check as healthCheck } from '../../api/health';
import { getRunningJobs } from '../../api/jobs';
import TickerSearch from './TickerSearch';
const navItems = [
{ to: '/', label: 'Overview', index: '01', end: true },
@@ -54,6 +55,10 @@ export default function Sidebar() {
<p className="text-[10px] text-gray-500 mt-1.5 font-mono uppercase tracking-[0.22em]">Trading Intelligence</p>
</div>
<div className="px-3 pt-4">
<TickerSearch />
</div>
<nav className="flex-1 px-3 py-5 space-y-1">
{navItems.map(({ to, label, index, end }) => (
<NavLink key={to} to={to} end={end} className={({ isActive }) => linkClasses(isActive)}>
@@ -0,0 +1,97 @@
import { useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTickers } from '../../hooks/useTickers';
import { Input } from '../ui/Field';
const MAX_RESULTS = 8;
/** Jump-to-ticker search over the tracked universe. Selecting a match opens its
* detail page it does NOT add the ticker to the watchlist. */
export default function TickerSearch({ onNavigate }: { onNavigate?: () => void }) {
const tickers = useTickers();
const navigate = useNavigate();
const [q, setQ] = useState('');
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const blurTimer = useRef<number | null>(null);
const matches = useMemo(() => {
const query = q.trim().toUpperCase();
if (!query) return [];
const all = tickers.data ?? [];
const starts = all.filter((t) => t.symbol.toUpperCase().startsWith(query));
const contains = all.filter(
(t) => !t.symbol.toUpperCase().startsWith(query) && t.symbol.toUpperCase().includes(query),
);
return [...starts, ...contains].slice(0, MAX_RESULTS);
}, [q, tickers.data]);
const go = (symbol: string) => {
navigate(`/ticker/${symbol}`);
setQ('');
setOpen(false);
setActive(0);
onNavigate?.();
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive((a) => Math.min(a + 1, matches.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((a) => Math.max(a - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const m = matches[active];
if (m) go(m.symbol);
} else if (e.key === 'Escape') {
setQ('');
setOpen(false);
}
};
const showList = open && q.trim().length > 0 && matches.length > 0;
return (
<div className="relative">
<Input
type="text"
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
setActive(0);
}}
onFocus={() => setOpen(true)}
onBlur={() => {
blurTimer.current = window.setTimeout(() => setOpen(false), 120);
}}
onKeyDown={onKeyDown}
placeholder="Search ticker…"
aria-label="Search ticker"
autoComplete="off"
className="w-full"
/>
{showList && (
<ul className="absolute z-20 mt-1 max-h-72 w-full overflow-y-auto rounded-lg glass py-1 shadow-xl">
{matches.map((t, i) => (
<li key={t.symbol}>
<button
type="button"
onMouseEnter={() => setActive(i)}
onClick={() => go(t.symbol)}
className={`flex w-full items-baseline gap-2 px-3 py-1.5 text-left text-sm transition-colors ${
i === active ? 'bg-blue-400/[0.12] text-blue-200' : 'text-gray-300 hover:bg-white/[0.04]'
}`}
>
<span className="font-medium">{t.symbol}</span>
{t.name && <span className="truncate text-xs text-gray-500">{t.name}</span>}
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -1,12 +1,16 @@
import { useState, useMemo, type FormEvent } from 'react';
import { useState, type FormEvent } from 'react';
import { useUpdateWeights } from '../../hooks/useScores';
interface WeightsFormProps {
weights: Record<string, number>;
}
const SENTIMENT = 'sentiment';
export function WeightsForm({ weights }: WeightsFormProps) {
// Convert API decimal weights (0-1) to 0-100 integer scale on mount
// API decimal weights (0-1) 0-100 integer sliders. For the base dimensions
// that's their share of the weighted average; for sentiment it's the ± points
// it can move the composite (MAX_ADJ), decoupled from the base mix.
const [sliderValues, setSliderValues] = useState<Record<string, number>>(() =>
Object.fromEntries(
Object.entries(weights).map(([key, w]) => [key, Math.round(w * 100)])
@@ -14,10 +18,10 @@ export function WeightsForm({ weights }: WeightsFormProps) {
);
const updateWeights = useUpdateWeights();
const allZero = useMemo(
() => Object.values(sliderValues).every((v) => v === 0),
[sliderValues]
);
const baseKeys = Object.keys(weights).filter((k) => k !== SENTIMENT);
const hasSentiment = SENTIMENT in weights;
const baseTotal = baseKeys.reduce((sum, k) => sum + (sliderValues[k] ?? 0), 0);
const sentimentPts = sliderValues[SENTIMENT] ?? 0;
const handleChange = (key: string, value: string) => {
const num = parseInt(value, 10);
@@ -26,24 +30,35 @@ export function WeightsForm({ weights }: WeightsFormProps) {
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (allZero) return;
if (baseTotal === 0) return;
const total = Object.values(sliderValues).reduce((sum, v) => sum + v, 0);
const normalized = Object.fromEntries(
Object.entries(sliderValues).map(([key, v]) => [key, v / total])
// Base dimensions normalize among themselves; sentiment passes through raw
// (slider value / 100) so it stays independent of the base.
const payload: Record<string, number> = Object.fromEntries(
baseKeys.map((key) => [key, (sliderValues[key] ?? 0) / baseTotal])
);
updateWeights.mutate(normalized);
if (hasSentiment) payload[SENTIMENT] = sentimentPts / 100;
updateWeights.mutate(payload);
};
return (
<form onSubmit={handleSubmit} className="glass p-5">
<h3 className="mb-4 text-xs font-semibold uppercase tracking-widest text-gray-500">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-widest text-gray-500">
Scoring Weights
</h3>
<p className="mb-4 text-[11px] text-gray-500">
The base dimensions are a weighted average (shares normalize to 100%). Sentiment is applied
separately as a signed adjustment on top.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Object.keys(weights).map((key) => (
{baseKeys.map((key) => (
<label key={key} className="flex flex-col gap-1.5">
<span className="text-xs text-gray-400 capitalize">{key.replace(/_/g, ' ')}</span>
<span className="text-xs text-gray-400 capitalize">
{key.replace(/_/g, ' ')}
<span className="ml-1 text-gray-600">
· {baseTotal > 0 ? Math.round(((sliderValues[key] ?? 0) / baseTotal) * 100) : 0}%
</span>
</span>
<div className="flex items-center gap-2">
<input
type="range"
@@ -61,14 +76,39 @@ export function WeightsForm({ weights }: WeightsFormProps) {
</label>
))}
</div>
{allZero && (
<p className="mt-3 text-xs text-red-400">
At least one weight must be greater than zero
</p>
{hasSentiment && (
<div className="mt-4 border-t border-white/[0.06] pt-4">
<label className="flex flex-col gap-1.5">
<span className="text-xs text-gray-400">Sentiment influence (± points)</span>
<div className="flex items-center gap-2 sm:max-w-sm">
<input
type="range"
min={0}
max={30}
step={1}
value={sentimentPts}
onChange={(e) => handleChange(SENTIMENT, e.target.value)}
className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-700 accent-blue-500"
/>
<span className="min-w-[3ch] text-right text-sm font-medium text-gray-300">
±{sentimentPts}
</span>
</div>
<span className="text-[11px] text-gray-600">
Max points a bullish (or bearish) read moves the composite, scaled by confidence.
Doesn&rsquo;t change the base mix. 0 = ignore sentiment.
</span>
</label>
</div>
)}
{baseTotal === 0 && (
<p className="mt-3 text-xs text-red-400">At least one base weight must be greater than zero</p>
)}
<button
type="submit"
disabled={updateWeights.isPending || allZero}
disabled={updateWeights.isPending || baseTotal === 0}
className="mt-4 btn-primary px-4 py-2 text-sm disabled:opacity-50"
>
<span>{updateWeights.isPending ? 'Updating…' : 'Update Weights'}</span>
@@ -0,0 +1,183 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
ScatterChart,
Scatter,
Cell,
XAxis,
YAxis,
ZAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
ReferenceArea,
} from 'recharts';
import { getRegimeHistory } from '../../api/regime';
import { Callout } from '../ui/Callout';
import { SkeletonCard } from '../ui/Skeleton';
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
// Quadrant dividers. Regime < 40 ≈ intact; early-warning > 60 ≈ elevated.
const X_DIV = 40; // regime index
const Y_DIV = 60; // early warning
const TRAIL = 60; // sessions shown
interface QPoint {
x: number;
y: number;
date: string;
}
/** Centered moving average to de-noise the path; today (last) kept exact. */
function smoothTrail(points: QPoint[], half = 2): QPoint[] {
const n = points.length;
return points.map((p, i) => {
if (i === n - 1) return { ...p };
let sx = 0;
let sy = 0;
let c = 0;
for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
sx += points[j].x;
sy += points[j].y;
c += 1;
}
return { x: sx / c, y: sy / c, date: p.date };
});
}
/** Recency gradient: 0 = oldest (muted slate), 1 = newest (bright blue). */
function recencyColor(t: number): string {
const lerp = (a: number, b: number) => Math.round(a + (b - a) * t);
const r = lerp(71, 96);
const g = lerp(85, 165);
const b = lerp(105, 250);
const alpha = (0.3 + 0.7 * t).toFixed(2);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payload: QPoint }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="glass px-2.5 py-1.5 text-[11px]">
<div className="text-gray-300">{p.date}</div>
<div className="text-gray-400">
Regime <span className="text-blue-300">{Math.round(p.x)}</span> · Early warning{' '}
<span className="text-orange-300">{Math.round(p.y)}</span>
</div>
</div>
);
}
export default function RegimeQuadrant() {
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(400) });
const points = useMemo<QPoint[]>(() => {
const data = history.data ?? [];
return data
.filter((p) => p.early_warning != null)
.slice(-TRAIL)
.map((p) => ({ x: p.index, y: p.early_warning as number, date: p.date }));
}, [history.data]);
const trail = useMemo(() => smoothTrail(points), [points]);
const latest = points.length ? points[points.length - 1] : null;
return (
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">
Regime quadrant last {TRAIL} sessions
</div>
{latest && (
<div className="text-[11px] text-gray-500">
now: regime <span className="text-blue-300">{Math.round(latest.x)}</span> · warning{' '}
<span className="text-orange-300">{Math.round(latest.y)}</span>
</div>
)}
</div>
{history.isLoading ? (
<SkeletonCard className="mt-3 h-72" />
) : !points.length ? (
<Callout variant="empty">
Not enough history yet the early-warning fills in as the daily job runs.
</Callout>
) : (
<>
<div className="mt-3 h-80">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
{/* Quadrant shading (drawn first, behind everything) */}
<ReferenceArea x1={0} x2={X_DIV} y1={Y_DIV} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={X_DIV} x2={100} y1={Y_DIV} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={X_DIV} y1={0} y2={Y_DIV} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={X_DIV} x2={100} y1={0} y2={Y_DIV} fill="#ef4444" fillOpacity={0.08} stroke="none" />
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
<ReferenceLine x={X_DIV} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={Y_DIV} stroke="rgba(255,255,255,0.12)" />
<XAxis
type="number"
dataKey="x"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
label={{ value: 'Regime index →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
/>
<YAxis
type="number"
dataKey="y"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={30}
tickLine={false}
axisLine={false}
label={{ value: 'Early warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
/>
<ZAxis range={[13, 13]} />
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<QuadrantTip />} />
{/* Smoothed trail with a recency gradient (old → new) */}
<Scatter
data={trail}
line={{ stroke: 'rgba(96,165,250,0.18)', strokeWidth: 1.5 }}
isAnimationActive={false}
>
{trail.map((_, i) => (
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
))}
</Scatter>
{/* Today */}
{latest && (
<Scatter
data={[latest]}
isAnimationActive={false}
shape={(props: { cx?: number; cy?: number }) => (
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke="#60a5fa" strokeWidth={2} />
)}
/>
)}
</ScatterChart>
</ResponsiveContainer>
</div>
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
<span><span className="text-amber-400"> Hot &amp; brittle</span> narrow melt-up, shakeout risk</span>
<span><span className="text-orange-400"> Transition</span> break may be starting</span>
<span><span className="text-emerald-400"> Healthy &amp; broad</span> calm uptrend</span>
<span><span className="text-red-400"> Real downturn</span> regime breaking, broad</span>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
White dot = today; the trail fades from muted (older) to bright blue (newer) over the last {TRAIL}{' '}
sessions, smoothed. The tell isn&apos;t a single spot but the move (early warning rolling over while
the regime index climbs = divergence resolving downward). Observational not wired into trades.
</p>
</>
)}
</div>
);
}
@@ -0,0 +1,135 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import { getRegimeHistory } from '../../api/regime';
import { Callout } from '../ui/Callout';
import { SkeletonCard } from '../ui/Skeleton';
import { formatDate } from '../../lib/format';
// Lazy-loaded (see RegimePage) so recharts only ships in the regime-tab chunk.
const HISTORY_RANGES = [
{ key: '1M', days: 30 },
{ key: '3M', days: 90 },
{ key: '6M', days: 182 },
{ key: 'All', days: 100000 },
] as const;
type HistoryRange = (typeof HISTORY_RANGES)[number]['key'];
const HISTORY_SERIES = [
{ key: 'index', label: 'Index', color: '#60a5fa' },
{ key: 'early_warning', label: 'Early warning', color: '#fb923c' },
{ key: 'combined', label: 'Combined', color: '#a78bfa' },
] as const;
export default function ScoreHistoryChart() {
const [range, setRange] = useState<HistoryRange>('3M');
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(400) });
const filtered = useMemo(() => {
const data = history.data ?? [];
const days = HISTORY_RANGES.find((r) => r.key === range)!.days;
if (range === 'All') return data;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
return data.filter((p) => new Date(p.date) >= cutoff);
}, [history.data, range]);
return (
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">Score history</div>
<div className="flex gap-1">
{HISTORY_RANGES.map((r) => (
<button
key={r.key}
type="button"
onClick={() => setRange(r.key)}
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
range === r.key ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
}`}
>
{r.key}
</button>
))}
</div>
</div>
{history.isLoading ? (
<SkeletonCard className="mt-3 h-56" />
) : filtered.length < 2 ? (
<Callout variant="empty">Not enough history yet it accumulates as the daily job runs.</Callout>
) : (
<>
<div className="mt-3 h-60">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={filtered} margin={{ top: 6, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: '#6b7280', fontSize: 10 }}
tickFormatter={(d) => formatDate(String(d))}
minTickGap={28}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
/>
<YAxis
domain={[0, 100]}
ticks={[0, 30, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={28}
tickLine={false}
axisLine={false}
/>
<ReferenceLine y={30} stroke="rgba(255,255,255,0.06)" />
<ReferenceLine y={60} stroke="rgba(255,255,255,0.06)" />
<ReferenceLine y={80} stroke="rgba(255,255,255,0.06)" />
<Tooltip
contentStyle={{
background: 'rgba(17,24,39,0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: '#9ca3af' }}
labelFormatter={(l) => formatDate(String(l))}
formatter={(value) => (value == null ? '—' : Math.round(Number(value)))}
/>
{HISTORY_SERIES.map((s) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={s.color}
dot={false}
strokeWidth={1.5}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex flex-wrap gap-4">
{HISTORY_SERIES.map((s) => (
<span key={s.key} className="flex items-center gap-1.5 text-[11px] text-gray-400">
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: s.color }} />
{s.label}
</span>
))}
</div>
</>
)}
</div>
);
}
+344 -31
View File
@@ -6,15 +6,30 @@ import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure';
import { Section } from '../ui/Section';
import { useToast } from '../ui/Toast';
import type { BacktestBucket } from '../../lib/types';
import type { BacktestBucket, BacktestPortfolioPolicy, BacktestStrategyVariant } from '../../lib/types';
function fmtR(v: number | null): string {
if (v === null) return '—';
function fmtR(v: number | null | undefined): string {
if (v === null || v === undefined) return '—';
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
}
function fmtPct(v: number | null): string {
return v === null ? '—' : `${v.toFixed(1)}%`;
}
function fmtMoney(v: number | null | undefined): string {
if (v === null || v === undefined) return '—';
return v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function fmtSignedPct(v: number | null | undefined): string {
if (v === null || v === undefined) return '—';
return `${v > 0 ? '+' : ''}${v.toFixed(1)}%`;
}
function fmtDays(v: number | null | undefined): string {
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
}
function fmtRPerDay(v: number | null | undefined): string {
if (v === null || v === undefined) return '—';
return `${v > 0 ? '+' : ''}${v.toFixed(3)}R`;
}
function rColor(v: number | null): string {
if (v === null) return 'text-gray-400';
if (v > 0) return 'text-emerald-400';
@@ -24,6 +39,7 @@ function rColor(v: number | null): string {
const SIGNAL_LABELS: Record<string, string> = {
mom_12_1: '121 month momentum',
mom_12_1_resid: '121 residual momentum',
mom_6_1: '61 month momentum',
mom_3_1: '31 month momentum',
reversal_1m: '1-month reversal',
@@ -32,6 +48,25 @@ const SIGNAL_LABELS: Record<string, string> = {
vol_6m: '6-month realized volatility',
};
const ABLATION_LABELS: Record<string, string> = {
all_floors: 'All floors (current gate)',
no_confidence_floor: 'Without confidence floor',
no_rr_floor: 'Without R:R floor',
no_neutral_exclusion: 'Without NEUTRAL exclusion',
momentum_only: 'Momentum only (no floors)',
};
const POLICY_LABELS: Record<string, string> = {
target: 'S/R target exit',
hold: 'Hold to horizon',
};
// Prefer the net-of-costs number when the report carries it; older cached
// reports (pre-cost model) fall back to gross.
function netOrGross(r: { avg_r: number | null; net_avg_r?: number | null }): number | null {
return r.net_avg_r ?? r.avg_r;
}
// An |IC| this large, with a consistent sign, is a real (if small) edge worth
// building on; below it, ranking on the signal sorts essentially nothing.
const IC_EDGE_THRESHOLD = 0.03;
@@ -76,6 +111,11 @@ function BucketRow({ label, b }: { label: string; b: BacktestBucket }) {
<td className="num px-4 py-2.5 text-right text-gray-400">{b.expired}</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(b.hit_rate)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(b.avg_r)}`}>{fmtR(b.avg_r)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(b.net_avg_r ?? null)}`}>{fmtR(b.net_avg_r ?? null)}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{fmtR(b.best_r)}</td>
<td className="num px-4 py-2.5 text-right text-red-400">{fmtR(b.worst_r)}</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{fmtDays(b.avg_hold_days)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(b.net_r_per_day ?? null)}`}>{fmtRPerDay(b.net_r_per_day)}</td>
</tr>
);
}
@@ -85,6 +125,12 @@ export function BacktestPanel() {
const queryClient = useQueryClient();
const toast = useToast();
const bestTimeAvgR =
report?.time_exit_sweep && report.time_exit_sweep.length > 0
? Math.max(...report.time_exit_sweep.map((r) => netOrGross(r) ?? -Infinity))
: null;
const sim = report?.portfolio_sim ?? null;
const run = useMutation({
mutationFn: () => triggerJob('backtest'),
onSuccess: (res) => {
@@ -131,8 +177,54 @@ export function BacktestPanel() {
<p className="text-[11px] text-gray-500">
Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups
({report.qualified} qualified) · weekly cadence, {report.params.horizon_days}-day horizon
{report.params.cost_per_side_pct != null && (
<> · net assumes {report.params.cost_per_side_pct}%/side costs</>
)}
</p>
{report.recommendation && report.recommendation.items.length > 0 && (
<div className="glass border border-blue-400/20 p-4">
<p className="section-index">What this backtest recommends</p>
{report.recommendation.headline && (
<p className="mt-1.5 text-sm font-semibold text-gray-100">
{report.recommendation.headline}
</p>
)}
<ul className="mt-2 space-y-1">
{report.recommendation.items.map((item) => (
<li
key={item.topic + item.text}
className={`text-xs ${item.text.includes('WARNING') || item.text.includes('LAGS') ? 'text-amber-400' : 'text-gray-400'}`}
>
{item.text}
</li>
))}
</ul>
{report.recommendation.note && (
<p className="mt-2 text-[11px] text-gray-600">{report.recommendation.note}</p>
)}
</div>
)}
{report.research_recommendation && report.research_recommendation.items.length > 0 && (
<div className="glass border border-emerald-400/15 p-4">
<p className="section-index">Research candidates</p>
<ul className="mt-2 space-y-1">
{report.research_recommendation.items.map((item) => (
<li
key={item.topic + item.text}
className={`text-xs ${item.candidate ? 'text-emerald-400' : 'text-gray-400'}`}
>
{item.text}
</li>
))}
</ul>
{report.research_recommendation.note && (
<p className="mt-2 text-[11px] text-gray-600">{report.research_recommendation.note}</p>
)}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat
label="Qualified Hit Rate"
@@ -157,6 +249,30 @@ export function BacktestPanel() {
valueClass={rColor(report.overall_qualified.total_r)}
sub="cumulative, risk-adjusted"
/>
{report.overall_qualified.median_net_r != null && (
<Stat
label="Median Net R"
value={fmtR(report.overall_qualified.median_net_r)}
valueClass={rColor(report.overall_qualified.median_net_r)}
sub="qualified · the typical trade"
/>
)}
{report.overall_qualified.profit_factor != null && (
<Stat
label="Profit Factor"
value={report.overall_qualified.profit_factor.toFixed(2)}
valueClass={report.overall_qualified.profit_factor > 1 ? 'text-emerald-400' : 'text-red-400'}
sub="qualified · net wins / net losses"
/>
)}
{report.overall_qualified.net_avg_r_ex_top5 != null && (
<Stat
label="Ex-Top-5% Net R"
value={fmtR(report.overall_qualified.net_avg_r_ex_top5)}
valueClass={rColor(report.overall_qualified.net_avg_r_ex_top5)}
sub="expectancy without the biggest winners"
/>
)}
</div>
<div className="glass overflow-x-auto">
@@ -170,6 +286,11 @@ export function BacktestPanel() {
<th className="px-4 py-2.5 text-right">Expired</th>
<th className="px-4 py-2.5 text-right">Hit Rate</th>
<th className="px-4 py-2.5 text-right">Avg R</th>
<th className="px-4 py-2.5 text-right">Net Avg R</th>
<th className="px-4 py-2.5 text-right">Best R</th>
<th className="px-4 py-2.5 text-right">Worst R</th>
<th className="px-4 py-2.5 text-right">Avg Hold</th>
<th className="px-4 py-2.5 text-right">Net R/d</th>
</tr>
</thead>
<tbody>
@@ -187,11 +308,11 @@ export function BacktestPanel() {
{report.sweep && report.sweep.length > 0 && report.sweep[0].min_momentum_percentile != null && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Momentum-percentile sweep
Residual-momentum percentile sweep
</p>
<p className="mb-2 text-[11px] text-gray-500">
How many setups qualify and how they perform at each momentum-rank cutoff (floors
held fixed). 80 = only the top 20% of the universe by 12-1 momentum each week; 0 =
How many setups qualify and how they perform at each production-rank cutoff (floors
held fixed). 80 = only the top 20% of the universe by residual 12-1 momentum each week; 0 =
floors only. Lower = more trades, watch that expectancy holds. Your current setting is
highlighted; set it in Admin Settings Activation.
</p>
@@ -199,12 +320,13 @@ export function BacktestPanel() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Min momentum %ile</th>
<th className="px-4 py-2.5">Min residual %ile</th>
<th className="px-4 py-2.5 text-right">Qualified</th>
<th className="px-4 py-2.5 text-right">Wins</th>
<th className="px-4 py-2.5 text-right">Losses</th>
<th className="px-4 py-2.5 text-right">Hit Rate</th>
<th className="px-4 py-2.5 text-right">Avg R</th>
<th className="px-4 py-2.5 text-right">Net Avg R</th>
<th className="px-4 py-2.5 text-right">Total R</th>
</tr>
</thead>
@@ -222,6 +344,7 @@ export function BacktestPanel() {
<td className="num px-4 py-2.5 text-right text-red-400">{row.losses}</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.hit_rate)}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_avg_r ?? null)}`}>{fmtR(row.net_avg_r ?? null)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
</tr>
);
@@ -232,46 +355,236 @@ export function BacktestPanel() {
</div>
)}
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Probability calibration
</p>
<p className="mb-2 text-[11px] text-gray-500">
Do targets we call X% likely actually hit that often? Realized below predicted =
the model is over-confident.
</p>
{report.calibration.length === 0 ? (
<Callout variant="empty">Not enough resolved setups to calibrate.</Callout>
) : (
{report.gate_ablation && report.gate_ablation.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Gate ablation which floors earn their keep
</p>
<p className="mb-2 text-[11px] text-gray-500">
{report.gate_ablation_note ??
'Each row re-qualifies the same candidates at the current momentum cutoff with one floor removed (long-only throughout).'}
</p>
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Predicted Bucket</th>
<th className="px-4 py-2.5">Variant</th>
<th className="px-4 py-2.5 text-right">Setups</th>
<th className="px-4 py-2.5 text-right">Avg Predicted</th>
<th className="px-4 py-2.5 text-right">Realized Hit Rate</th>
<th className="px-4 py-2.5 text-right">Hit Rate</th>
<th className="px-4 py-2.5 text-right">Avg R</th>
<th className="px-4 py-2.5 text-right">Net Avg R</th>
<th className="px-4 py-2.5 text-right">Total R</th>
<th className="px-4 py-2.5 text-right">Hold Net Avg R</th>
<th className="px-4 py-2.5 text-right">Hold Total R</th>
</tr>
</thead>
<tbody>
{report.calibration.map((row) => {
const over = row.realized_hit_rate < row.predicted_avg;
{report.gate_ablation.map((row) => (
<tr
key={row.variant}
className={`border-b border-white/[0.04] ${row.variant === 'all_floors' ? 'bg-blue-400/10' : ''}`}
>
<td className="px-4 py-2.5 font-medium text-gray-200">
{ABLATION_LABELS[row.variant] ?? row.variant}
</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.hit_rate)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.net_avg_r ?? null)}`}>
{fmtR(row.net_avg_r ?? null)}
</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.hold_net_avg_r ?? null)}`}>
{fmtR(row.hold_net_avg_r ?? null)}
</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.hold_total_r ?? null)}`}>
{fmtR(row.hold_total_r ?? null)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{report.time_exit_sweep && report.time_exit_sweep.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Time-based exit
</p>
<p className="mb-2 text-[11px] text-gray-500">
Buy at detection, keep the initial ATR stop, and exit at the{' '}
<span className="text-gray-300">day-N close</span> no target, no trailing. This is the
classic cross-sectional momentum implementation (hold ~a month, re-rank).{' '}
<span className="text-gray-300">Win Rate = share closed in profit.</span> = best net avg R.
</p>
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Hold</th>
<th className="px-4 py-2.5 text-right">Setups</th>
<th className="px-4 py-2.5 text-right">Profitable</th>
<th className="px-4 py-2.5 text-right">Win Rate</th>
<th className="px-4 py-2.5 text-right">Avg R</th>
<th className="px-4 py-2.5 text-right">Net Avg R</th>
<th className="px-4 py-2.5 text-right">Total R</th>
<th className="px-4 py-2.5 text-right">Best R</th>
<th className="px-4 py-2.5 text-right">Worst R</th>
<th className="px-4 py-2.5 text-right">Avg Hold</th>
<th className="px-4 py-2.5 text-right">Net R/d</th>
<th className="px-4 py-2.5 text-right">Median Net R</th>
<th className="px-4 py-2.5 text-right">Ex-Top-5%</th>
</tr>
</thead>
<tbody>
{report.time_exit_sweep.map((row) => {
const best = netOrGross(row) != null && netOrGross(row) === bestTimeAvgR;
return (
<tr key={row.bucket} className="border-b border-white/[0.04]">
<td className="px-4 py-2.5 text-gray-200">{row.bucket}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.n}</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{row.predicted_avg.toFixed(0)}%</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${over ? 'text-amber-400' : 'text-emerald-400'}`}>
{row.realized_hit_rate.toFixed(0)}%
<tr key={row.hold_days} className={`border-b border-white/[0.04] ${best ? 'bg-emerald-400/[0.06]' : ''}`}>
<td className="num px-4 py-2.5 text-gray-200">
{best && <span className="mr-1 text-emerald-300"></span>}
{row.hold_days}d
</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.win_rate)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.net_avg_r ?? null)}`}>{fmtR(row.net_avg_r ?? null)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{fmtR(row.best_r)}</td>
<td className="num px-4 py-2.5 text-right text-red-400">{fmtR(row.worst_r)}</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{fmtDays(row.avg_hold_days)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_r_per_day ?? null)}`}>{fmtRPerDay(row.net_r_per_day)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.median_net_r ?? null)}`}>{fmtR(row.median_net_r)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_avg_r_ex_top5 ?? null)}`}>{fmtR(row.net_avg_r_ex_top5)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{sim && sim.policies.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Portfolio simulation
</p>
<p className="mb-2 text-[11px] text-gray-500">
{sim.note ?? 'One capital-constrained book over the qualified setups.'}{' '}
<span className="text-gray-300">
Start {fmtMoney(sim.params.starting_capital)} · max {sim.params.max_positions} positions ·{' '}
{sim.params.risk_per_trade_pct}% risk/trade · {sim.params.notional_cap_pct}% notional cap ·{' '}
{sim.params.cost_per_side_pct}%/side costs · {sim.policies[0].start_date} {sim.policies[0].end_date}
</span>
</p>
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Metric</th>
{sim.policies.map((p) => (
<th key={p.policy} className="px-4 py-2.5 text-right">
{POLICY_LABELS[p.policy] ?? p.policy}
</th>
))}
</tr>
</thead>
<tbody>
{(
[
['Final equity', (p) => fmtMoney(p.final_equity), (p) => rColor(p.final_equity - p.starting_capital)],
['Total return', (p) => fmtSignedPct(p.total_return_pct), (p) => rColor(p.total_return_pct)],
['SPY return (same window)', (p) => fmtSignedPct(p.spy_return_pct), () => 'text-gray-300'],
['CAGR', (p) => fmtSignedPct(p.cagr_pct), (p) => rColor(p.cagr_pct)],
['Max drawdown', (p) => `${p.max_drawdown_pct.toFixed(1)}%`, () => 'text-amber-400'],
['Sharpe (daily, annualized)', (p) => (p.sharpe === null ? '—' : p.sharpe.toFixed(2)), () => 'text-gray-200'],
['Trades', (p) => String(p.trades), () => 'text-gray-300'],
['Win rate', (p) => fmtPct(p.win_rate), () => 'text-gray-200'],
['Avg P&L / trade', (p) => fmtMoney(p.avg_trade_pnl), (p) => rColor(p.avg_trade_pnl)],
['Best / worst trade', (p) => `${fmtR(p.best_trade_r)} / ${fmtR(p.worst_trade_r)}`, () => 'text-gray-300'],
['Avg holding time', (p) => fmtDays(p.avg_hold_days), () => 'text-gray-300'],
[
'Per-year returns',
(p) =>
p.yearly_returns && p.yearly_returns.length > 0
? p.yearly_returns
.map((y) => `${y.year} ${fmtSignedPct(y.return_pct)}`)
.join(' · ')
: '—',
() => 'text-gray-300',
],
['Entries skipped (book full)', (p) => String(p.skipped_book_full), () => 'text-gray-500'],
] as [string, (p: BacktestPortfolioPolicy) => string, (p: BacktestPortfolioPolicy) => string][]
).map(([label, fmt, color]) => (
<tr key={label} className="border-b border-white/[0.04]">
<td className="px-4 py-2.5 font-medium text-gray-200">{label}</td>
{sim.policies.map((p) => (
<td key={p.policy} className={`num px-4 py-2.5 text-right ${color(p)}`}>
{fmt(p)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{report.strategy_variants && report.strategy_variants.variants.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Strategy variants
</p>
<p className="mb-2 text-[11px] text-gray-500">
{report.strategy_variants.note ?? 'Research-only portfolio variants.'}
</p>
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Variant</th>
<th className="px-4 py-2.5 text-right">Rank</th>
<th className="px-4 py-2.5 text-right">Cutoff</th>
<th className="px-4 py-2.5 text-right">Max Pos</th>
<th className="px-4 py-2.5 text-right">Risk</th>
<th className="px-4 py-2.5 text-right">CAGR</th>
<th className="px-4 py-2.5 text-right">Max DD</th>
<th className="px-4 py-2.5 text-right">Sharpe</th>
<th className="px-4 py-2.5 text-right">Total Ret</th>
<th className="px-4 py-2.5 text-right">Trades</th>
<th className="px-4 py-2.5 text-right">Skipped</th>
</tr>
</thead>
<tbody>
{report.strategy_variants.variants.map((row: BacktestStrategyVariant) => (
<tr key={row.variant} className="border-b border-white/[0.04]">
<td className="px-4 py-2.5 font-medium text-gray-200">{row.label}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.ranking}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.cutoff.toFixed(0)}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.max_positions}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">
{`${row.risk_per_trade_pct.toFixed(1)}%`}
</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.cagr_pct)}`}>{fmtSignedPct(row.cagr_pct)}</td>
<td className="num px-4 py-2.5 text-right text-amber-400">{row.max_drawdown_pct.toFixed(1)}%</td>
<td className="num px-4 py-2.5 text-right text-gray-200">
{row.sharpe === null ? '—' : row.sharpe.toFixed(2)}
</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_return_pct)}`}>{fmtSignedPct(row.total_return_pct)}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.trades}</td>
<td className="num px-4 py-2.5 text-right text-gray-500">{row.skipped_book_full}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{report.signal_eval && report.signal_eval.length > 0 && (
<div>
@@ -38,6 +38,7 @@ export function MyTradesPanel() {
const rows = (closed ?? []).map((t) => ({ t, p: tradePnl(t) }));
const rs = rows.map((r) => r.p?.r).filter((r): r is number => r != null);
const pnls = rows.map((r) => r.p?.pnl ?? 0);
const alphas = rows.map((r) => r.t.alpha_usd).filter((a): a is number => a != null);
const wins = pnls.filter((p) => p > 0).length;
const losses = pnls.filter((p) => p < 0).length;
const decided = wins + losses;
@@ -49,6 +50,7 @@ export function MyTradesPanel() {
avgR: rs.length ? rs.reduce((a, b) => a + b, 0) / rs.length : null,
totalR: rs.length ? rs.reduce((a, b) => a + b, 0) : null,
totalPnl: pnls.reduce((a, b) => a + b, 0),
totalAlpha: alphas.length ? alphas.reduce((a, b) => a + b, 0) : null,
rows,
};
}, [closed]);
@@ -64,11 +66,12 @@ export function MyTradesPanel() {
</Callout>
) : (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<Stat label="Hit Rate" value={stats.hitRate != null ? `${stats.hitRate.toFixed(1)}%` : '—'} sub={`${stats.wins}W / ${stats.losses}L`} />
<Stat label="Expectancy" value={fmtR(stats.avgR)} valueClass={color(stats.avgR)} sub="avg R per closed trade" />
<Stat label="Total R" value={fmtR(stats.totalR)} valueClass={color(stats.totalR)} sub={`${stats.total} closed`} />
<Stat label="Total P&L" value={money(stats.totalPnl)} valueClass={color(stats.totalPnl)} sub="realized, all closed" />
<Stat label="Alpha vs S&P 500" value={stats.totalAlpha != null ? money(stats.totalAlpha) : '—'} valueClass={color(stats.totalAlpha)} sub="realized vs buy-and-hold SPY" />
</div>
<div className="glass overflow-x-auto">
@@ -81,6 +84,7 @@ export function MyTradesPanel() {
<th className="px-4 py-2.5 text-right">Exit</th>
<th className="px-4 py-2.5 text-right">P&L</th>
<th className="px-4 py-2.5 text-right">R</th>
<th className="px-4 py-2.5 text-right">Alpha</th>
<th className="px-4 py-2.5 text-right">Closed</th>
</tr>
</thead>
@@ -97,6 +101,7 @@ export function MyTradesPanel() {
<td className="num px-4 py-2.5 text-right text-gray-300">{t.close_price != null ? formatPrice(t.close_price) : '—'}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${p ? color(p.pnl) : 'text-gray-500'}`}>{p ? money(p.pnl) : '—'}</td>
<td className={`num px-4 py-2.5 text-right ${p?.r != null ? color(p.r) : 'text-gray-500'}`}>{p?.r != null ? fmtR(p.r) : '—'}</td>
<td className={`num px-4 py-2.5 text-right ${t.alpha_pct != null ? color(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}</td>
<td className="num px-4 py-2.5 text-right text-gray-500">{t.closed_at ? new Date(t.closed_at).toLocaleDateString() : '—'}</td>
</tr>
))}
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useActivation } from '../../hooks/useActivation';
import { activationSummary } from '../../lib/qualification';
import { usePerformance } from '../../hooks/usePerformance';
import { useBacktestReport } from '../../hooks/useMarketRegime';
import { triggerJob, resetTrackRecord } from '../../api/admin';
import { Button } from '../ui/Button';
import { Callout } from '../ui/Callout';
@@ -15,6 +16,14 @@ import { BacktestPanel } from './BacktestPanel';
import { MyTradesPanel } from './MyTradesPanel';
import type { OutcomeBucketStats } from '../../lib/types';
// Need at least this many matured setups before a live-vs-backtest verdict means
// anything; below it the live sample is too noisy to compare.
const MIN_MATURED = 20;
// Live expectancy this far (in R) below the backtest counts as drift, not noise.
const DRIFT_TOLERANCE_R = 0.2;
type TrackingStatus = 'building' | 'tracking' | 'drift' | 'no-backtest';
function fmtR(value: number | null): string {
if (value === null) return '—';
return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`;
@@ -31,6 +40,17 @@ function rColor(value: number | null): string {
return 'text-gray-300';
}
function VerdictChip({ status }: { status: TrackingStatus }) {
const styles: Record<TrackingStatus, { cls: string; label: string }> = {
tracking: { cls: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', label: '✓ tracking' },
drift: { cls: 'border-amber-500/30 bg-amber-500/15 text-amber-300', label: '⚠ drift' },
building: { cls: 'border-white/10 bg-white/[0.05] text-gray-400', label: 'building' },
'no-backtest': { cls: 'border-white/10 bg-white/[0.05] text-gray-400', label: 'no backtest' },
};
const s = styles[status];
return <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${s.cls}`}>{s.label}</span>;
}
function StatCard({ label, value, valueClass = 'text-gray-100', sub }: {
label: string;
value: string;
@@ -57,7 +77,7 @@ function BreakdownTable({ rows, labelHeader, mapLabel }: {
}) {
const entries = Object.entries(rows);
if (entries.length === 0) {
return <Callout variant="empty">No evaluated setups in this breakdown yet.</Callout>;
return <Callout variant="empty">No matured setups in this breakdown yet.</Callout>;
}
return (
<div className="glass overflow-x-auto">
@@ -100,6 +120,7 @@ export function TrackRecordPanel() {
const { data, isLoading, isError, error } = usePerformance(
qualifiedOnly ? { qualified_only: true } : undefined,
);
const backtest = useBacktestReport();
const queryClient = useQueryClient();
const toast = useToast();
@@ -137,114 +158,145 @@ export function TrackRecordPanel() {
}
};
// Live (matured cohort) vs the backtest, like-for-like with the qualified toggle.
const live = data?.overall ?? null;
const btBucket = qualifiedOnly ? backtest.data?.overall_qualified : backtest.data?.overall_all;
const liveAvgR = live?.avg_r ?? null;
const liveN = live?.total ?? 0;
const btAvgR = btBucket?.avg_r ?? null;
let status: TrackingStatus = 'building';
if (liveAvgR != null && liveN >= MIN_MATURED) {
status = btAvgR == null ? 'no-backtest' : liveAvgR >= btAvgR - DRIFT_TOLERANCE_R ? 'tracking' : 'drift';
}
const verdictNote: Record<TrackingStatus, string> = {
building: `Not enough matured setups yet (need ~${MIN_MATURED}). Only setups whose full ~30-day window has elapsed are counted — the rest are still maturing. Until then, the backtest is your edge estimate; this becomes a live check as setups age past ~6 weeks.`,
'no-backtest': 'Run the backtest below to get a baseline to compare the live record against.',
tracking: 'Live setups are resolving in line with the backtest — the running system is faithfully implementing it (no look-ahead, config or data drift).',
drift: 'Live expectancy is running materially below the backtest. Could be small-sample noise, a regime shift, or a config/data/look-ahead gap between live and the backtest — worth a look.',
};
return (
<div className="space-y-6">
{/* Your real, realized results come first; the signal/theoretical record follows. */}
{/* Your real, realized results come first; the live-vs-backtest check follows. */}
<MyTradesPanel />
<div className="border-t border-white/[0.06]" />
<div className="glass-sm flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-gray-300">
<input
type="checkbox"
checked={qualifiedOnly}
onChange={(e) => setQualifiedOnly(e.target.checked)}
className="h-4 w-4 cursor-pointer accent-blue-400"
/>
<span>
Qualified signals only
{activation.data && (
<span className="num ml-2 text-xs text-gray-500">{activationSummary(activation.data)}</span>
)}
</span>
</label>
<p className="text-xs text-gray-500">Confidence breakdown always covers all setups.</p>
</div>
<div className="flex items-start justify-between gap-4">
<Disclosure summary="How outcomes are measured">
<p className="text-xs text-gray-400">
Each setup is replayed against the daily bars after its detection: a{' '}
<span className="text-emerald-400">win</span> means the target was reached before the
stop, a <span className="text-red-400">loss</span> means the stop was hit first (bars
where both levels fall inside the same day count conservatively as losses). Setups with
neither level hit within 30 trading days <span className="text-gray-300">expire</span> at
0R. Avg R is the expectancy per trade: wins earn their R:R ratio, losses cost 1R a
positive value means the signals have been profitable on a risk-adjusted basis. The
evaluator runs nightly after OHLCV collection.
</p>
</Disclosure>
<div className="flex shrink-0 items-center gap-2">
<Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending}>
{evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
</Button>
<Button variant="danger" onClick={onReset} loading={resetMutation.isPending}>
{resetMutation.isPending ? 'Resetting…' : 'Reset'}
</Button>
</div>
</div>
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
</div>
)}
{isError && (
<Callout variant="error">
{error instanceof Error ? error.message : 'Failed to load performance stats'}
</Callout>
)}
{data && data.overall.total === 0 && (
<Callout variant="empty">
{qualifiedOnly
? 'No evaluated setups meet the activation thresholds yet. Untick "Qualified signals only" to see all evaluated setups, or wait for more outcomes.'
: 'No evaluated setups yet. Outcomes appear once setups are old enough for their stop or target to be hit — the evaluator runs nightly, or click Evaluate Now.'}
{data.pending > 0 && ` ${data.pending} setup${data.pending === 1 ? '' : 's'} pending evaluation.`}
</Callout>
)}
{data && data.overall.total > 0 && (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="Hit Rate"
value={fmtPct(data.overall.hit_rate)}
sub={`${data.overall.wins} wins / ${data.overall.losses} losses`}
/>
<StatCard
label="Expectancy"
value={fmtR(data.overall.avg_r)}
valueClass={rColor(data.overall.avg_r)}
sub="average R per trade"
/>
<StatCard
label="Total R"
value={fmtR(data.overall.total_r)}
valueClass={rColor(data.overall.total_r)}
sub="cumulative risk-adjusted result"
/>
<StatCard
label="Evaluated"
value={String(data.overall.total)}
sub={`${data.pending} pending · ${data.overall.expired} expired`}
/>
<Section title="Live vs Backtest" hint="is the live system tracking the backtest?">
{isError ? (
<Callout variant="error">
{error instanceof Error ? error.message : 'Failed to load performance stats'}
</Callout>
) : (
<div className="glass-sm space-y-2.5 p-4">
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
<div className="flex flex-wrap items-baseline gap-x-5 gap-y-1">
<span className="text-sm text-gray-400">
Live <span className={`num font-semibold ${rColor(liveAvgR)}`}>{fmtR(liveAvgR)}</span>
</span>
<span className="text-sm text-gray-400">
Backtest <span className={`num font-semibold ${rColor(btAvgR)}`}>{fmtR(btAvgR)}</span>
</span>
<span className="text-xs text-gray-500">
{liveN} matured{data ? ` · ${data.maturing} maturing` : ''} · {qualifiedOnly ? 'qualified' : 'all setups'}
</span>
</div>
<VerdictChip status={status} />
</div>
<p className="text-[11px] leading-relaxed text-gray-500">{verdictNote[status]}</p>
</div>
)}
</Section>
<Section title="By Direction">
<BreakdownTable rows={data.by_direction} labelHeader="Direction" />
</Section>
<Disclosure summary="Outcome details (matured cohort)">
<div className="space-y-4 pt-1">
<label className="flex w-fit cursor-pointer items-center gap-2.5 text-sm text-gray-300">
<input
type="checkbox"
checked={qualifiedOnly}
onChange={(e) => setQualifiedOnly(e.target.checked)}
className="h-4 w-4 cursor-pointer accent-blue-400"
/>
<span>
Qualified signals only
{activation.data && (
<span className="num ml-2 text-xs text-gray-500">{activationSummary(activation.data)}</span>
)}
</span>
</label>
<Section title="By Recommended Action">
<BreakdownTable rows={data.by_action} labelHeader="Action" mapLabel={actionLabel} />
</Section>
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
</div>
)}
<Section title="By Confidence" hint="at detection time">
<BreakdownTable rows={data.by_confidence} labelHeader="Confidence" />
</Section>
</>
)}
{data && data.overall.total === 0 && (
<Callout variant="empty">
{data.maturing > 0
? `No setups have completed their ~30-day window yet — ${data.maturing} still maturing. ` +
'Counting them earlier would skew toward quick stop-outs.'
: 'No matured setups yet. Outcomes appear once setups complete their evaluation window — the evaluator runs nightly, or click Evaluate Now.'}
</Callout>
)}
{data && data.overall.total > 0 && (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="Hit Rate"
value={fmtPct(data.overall.hit_rate)}
sub={`${data.overall.wins} wins / ${data.overall.losses} losses`}
/>
<StatCard
label="Expectancy"
value={fmtR(data.overall.avg_r)}
valueClass={rColor(data.overall.avg_r)}
sub="average R per trade"
/>
<StatCard
label="Total R"
value={fmtR(data.overall.total_r)}
valueClass={rColor(data.overall.total_r)}
sub="cumulative risk-adjusted result"
/>
<StatCard
label="Matured"
value={String(data.overall.total)}
sub={`${data.maturing} maturing · ${data.overall.expired} expired`}
/>
</div>
<Section title="By Recommended Action">
<BreakdownTable rows={data.by_action} labelHeader="Action" mapLabel={actionLabel} />
</Section>
<Section title="By Confidence" hint="at detection time · all setups">
<BreakdownTable rows={data.by_confidence} labelHeader="Confidence" />
</Section>
</>
)}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-white/[0.06] pt-3">
<p className="max-w-2xl text-xs text-gray-500">
Each setup is replayed against the daily bars after detection: target before stop = win,
stop first = loss (both in one bar counts conservatively as a loss), neither within 30
trading days = expired at 0R. Only setups whose full window has elapsed are counted; younger
ones are still <span className="text-gray-300">maturing</span> (near stops resolve fast, far
targets need time, so early numbers would skew negative). The evaluator runs nightly.
</p>
<div className="flex shrink-0 items-center gap-2">
<Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending}>
{evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
</Button>
<Button variant="danger" onClick={onReset} loading={resetMutation.isPending}>
{resetMutation.isPending ? 'Resetting…' : 'Reset'}
</Button>
</div>
</div>
</div>
</Disclosure>
<div className="border-t border-white/[0.06] pt-2" />
<BacktestPanel />
@@ -0,0 +1,213 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ScatterChart,
Scatter,
XAxis,
YAxis,
ZAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
ReferenceArea,
} from 'recharts';
// Lazy-loaded by TickerDetailPage so recharts stays out of the main ticker chunk.
export interface FieldPoint {
symbol: string;
composite: number;
momentum: number;
}
interface StandingMatrixProps {
symbol: string;
composite: number | null; // X for the highlighted dot (authoritative, from the scores endpoint)
momentum: number | null; // Y for the highlighted dot (residual 12-1 momentum percentile)
field: FieldPoint[]; // every tracked ticker, for the background cloud
gateMomentum: number; // Y divider = the activation gate's momentum percentile
status: 'top-pick' | 'qualified' | 'none';
confidence?: number | null; // long confidence, for the verdict sidebar
}
// X divider: composite midpoint between "amber" (4070) and clearly good (>70).
const QUALITY_DIV = 60;
type Tone = 'emerald' | 'amber' | 'sky' | 'slate';
const TONE: Record<Tone, { text: string; dot: string }> = {
emerald: { text: 'text-emerald-300', dot: '#10b981' },
amber: { text: 'text-amber-300', dot: '#f59e0b' },
sky: { text: 'text-sky-300', dot: '#38bdf8' },
slate: { text: 'text-gray-300', dot: '#94a3b8' },
};
function verdict(composite: number, momentum: number, gate: number): { label: string; tone: Tone; note: string } {
const q = composite >= QUALITY_DIV;
const m = momentum >= gate;
if (m && q) return { label: 'Strong Buy', tone: 'emerald', note: 'Solid quality and top-tier momentum — clears the gate.' };
if (m && !q) return { label: 'Momentum', tone: 'amber', note: 'Trending hard, but quality is thin — speculative.' };
if (!m && q) return { label: 'Accumulate', tone: 'sky', note: 'Good quality; momentum not yet in the top tier.' };
return { label: 'Pass', tone: 'slate', note: 'Neither quality nor momentum stands out yet.' };
}
function MatrixTip({ active, payload }: { active?: boolean; payload?: { payload: FieldPoint }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="glass px-2.5 py-1.5 text-[11px]">
<div className="text-gray-200">{p.symbol}</div>
<div className="text-gray-400">
quality <span className="text-gray-200">{Math.round(p.composite)}</span> · momentum{' '}
<span className="text-gray-200">{Math.round(p.momentum)}</span>
</div>
</div>
);
}
function StatRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline justify-between">
<span>{label}</span>
<span className="num text-gray-300">{value}</span>
</div>
);
}
export default function StandingMatrix({
symbol,
composite,
momentum,
field,
gateMomentum,
status,
confidence,
}: StandingMatrixProps) {
const navigate = useNavigate();
const gate = gateMomentum > 0 ? gateMomentum : 80;
const sym = symbol.toUpperCase();
const here = useMemo<FieldPoint | null>(
() => (composite != null && momentum != null ? { symbol: sym, composite, momentum } : null),
[sym, composite, momentum],
);
// Background cloud excludes this ticker — it's drawn separately, highlighted.
const others = useMemo(() => field.filter((p) => p.symbol.toUpperCase() !== sym), [field, sym]);
const v = here ? verdict(here.composite, here.momentum, gate) : null;
return (
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">
Standing quality × momentum vs. the field
</div>
{status === 'top-pick' && (
<span className="rounded-full border border-blue-500/30 bg-blue-500/15 px-2.5 py-0.5 text-[11px] font-medium text-blue-300">
Top Pick
</span>
)}
{status === 'qualified' && (
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/15 px-2.5 py-0.5 text-[11px] font-medium text-emerald-300">
Qualified
</span>
)}
</div>
<div className="mt-3 grid gap-4 lg:grid-cols-5">
<div className="h-72 lg:col-span-3">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
{/* Quadrant shading (behind everything) */}
<ReferenceArea x1={QUALITY_DIV} x2={100} y1={gate} y2={100} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={QUALITY_DIV} y1={gate} y2={100} fill="#f59e0b" fillOpacity={0.06} stroke="none" />
<ReferenceArea x1={QUALITY_DIV} x2={100} y1={0} y2={gate} fill="#38bdf8" fillOpacity={0.06} stroke="none" />
<ReferenceArea x1={0} x2={QUALITY_DIV} y1={0} y2={gate} fill="#94a3b8" fillOpacity={0.05} stroke="none" />
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
<ReferenceLine x={QUALITY_DIV} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={gate} stroke="rgba(255,255,255,0.12)" strokeDasharray="4 4" />
<XAxis
type="number"
dataKey="composite"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
label={{ value: 'Quality (composite) →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
/>
<YAxis
type="number"
dataKey="momentum"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={30}
tickLine={false}
axisLine={false}
label={{ value: 'Momentum pct', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
/>
<ZAxis range={[20, 20]} />
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<MatrixTip />} />
<Scatter
data={others}
isAnimationActive={false}
onClick={(p: any) => p?.symbol && navigate(`/ticker/${p.symbol}`)}
shape={(props: { cx?: number; cy?: number }) => (
<circle cx={props.cx} cy={props.cy} r={3} fill="rgba(148,163,184,0.35)" className="cursor-pointer" />
)}
/>
{here && v && (
<Scatter
data={[here]}
isAnimationActive={false}
shape={(props: { cx?: number; cy?: number }) => (
<circle
cx={props.cx}
cy={props.cy}
r={7}
fill="#ffffff"
stroke={TONE[v.tone].dot}
strokeWidth={3}
style={{ filter: `drop-shadow(0 0 6px ${TONE[v.tone].dot}66)` }}
/>
)}
/>
)}
</ScatterChart>
</ResponsiveContainer>
</div>
<div className="flex flex-col justify-center lg:col-span-2">
{v && here ? (
<>
<div className={`text-2xl font-semibold ${TONE[v.tone].text}`}>{v.label}</div>
<p className="mt-1 text-sm leading-snug text-gray-400">{v.note}</p>
<div className="mt-3 space-y-1 text-xs text-gray-500">
<StatRow label="Quality (composite)" value={`${Math.round(here.composite)}`} />
<StatRow label="Residual momentum percentile" value={`${Math.round(here.momentum)}`} />
{confidence != null && <StatRow label="Long confidence" value={`${Math.round(confidence)}%`} />}
</div>
</>
) : (
<p className="text-sm leading-relaxed text-gray-500">
No active setup, so this ticker isnt ranked on the momentum axis yet. Run the scanner to place it.
</p>
)}
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
<span><span className="text-emerald-400">Strong Buy</span> quality + momentum (top-right)</span>
<span><span className="text-amber-400">Momentum</span> trend without the quality</span>
<span><span className="text-sky-400">Accumulate</span> quality, awaiting momentum</span>
<span><span className="text-gray-400">Pass</span> neither stands out</span>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
Each dot is a tracked ticker; <span className="text-gray-300">this one is highlighted</span>. The dashed line is the
activation gate ({Math.round(gate)}th-pct residual momentum) above it qualifies for a top pick. Click any peer to open it.
</p>
</div>
);
}
+47 -21
View File
@@ -5,7 +5,10 @@ import type { DimensionScoreDetail, CompositeBreakdown } from '../../lib/types';
interface ScoreCardProps {
compositeScore: number | null;
dimensions: DimensionScoreDetail[];
compositeBreakdown?: CompositeBreakdown;
compositeBreakdown?: CompositeBreakdown | null;
/** Hide the composite ring/header when the composite is shown elsewhere
* (e.g. the Standing matrix) and this card only carries the dimension detail. */
showComposite?: boolean;
}
function scoreColor(score: number): string {
@@ -51,7 +54,7 @@ function ScoreRing({ score }: { score: number }) {
);
}
export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: ScoreCardProps) {
export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, showComposite = true }: ScoreCardProps) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const toggleExpand = (dimension: string) => {
@@ -60,27 +63,35 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: Sc
return (
<div className="glass p-5">
<div className="flex items-center gap-4">
{compositeScore !== null ? (
<ScoreRing score={compositeScore} />
) : (
<div className="flex h-[88px] w-[88px] items-center justify-center text-sm text-gray-500">N/A</div>
)}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Composite Score</p>
<p className={`text-2xl font-bold ${compositeScore !== null ? scoreColor(compositeScore) : 'text-gray-500'}`}>
{compositeScore !== null ? Math.round(compositeScore) : '—'}
</p>
{compositeBreakdown && (
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[200px]" data-testid="renorm-explanation">
Weighted average of available dimensions with re-normalized weights.
</p>
{showComposite && (
<div className="flex items-center gap-4">
{compositeScore !== null ? (
<ScoreRing score={compositeScore} />
) : (
<div className="flex h-[88px] w-[88px] items-center justify-center text-sm text-gray-500">N/A</div>
)}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Composite Score</p>
<p className={`text-2xl font-bold ${compositeScore !== null ? scoreColor(compositeScore) : 'text-gray-500'}`}>
{compositeScore !== null ? Math.round(compositeScore) : '—'}
</p>
{compositeBreakdown && (
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[220px]" data-testid="renorm-explanation">
{compositeBreakdown.sentiment_adjustment != null &&
compositeBreakdown.base_score != null &&
Math.abs(compositeBreakdown.sentiment_adjustment) >= 0.05
? `Base ${Math.round(compositeBreakdown.base_score)} · sentiment ${
compositeBreakdown.sentiment_adjustment >= 0 ? '+' : ''
}${Math.abs(compositeBreakdown.sentiment_adjustment).toFixed(1)}`
: 'Weighted base of the other dimensions; sentiment adjusts it up or down.'}
</p>
)}
</div>
</div>
</div>
)}
{dimensions.length > 0 && (
<div className="mt-5 space-y-1">
<div className={`${showComposite ? 'mt-5' : ''} space-y-1`}>
<p className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Dimensions</p>
{dimensions.map((d) => {
const isExpanded = expanded[d.dimension] ?? false;
@@ -102,11 +113,26 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: Sc
{d.dimension}
</span>
<div className="flex items-center gap-2">
{weight != null && (
{d.dimension === 'sentiment' && compositeBreakdown?.sentiment_adjustment != null ? (
<span
className={`text-[10px] tabular-nums ${
compositeBreakdown.sentiment_adjustment > 0.05
? 'text-emerald-400/80'
: compositeBreakdown.sentiment_adjustment < -0.05
? 'text-red-400/80'
: 'text-gray-500'
}`}
data-testid="weight-sentiment"
title="Points sentiment adds to or subtracts from the base composite"
>
{compositeBreakdown.sentiment_adjustment >= 0 ? '+' : ''}
{Math.abs(compositeBreakdown.sentiment_adjustment).toFixed(1)}
</span>
) : weight != null ? (
<span className="text-[10px] text-gray-500 tabular-nums" data-testid={`weight-${d.dimension}`}>
{Math.round(weight * 100)}%
</span>
)}
) : null}
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
<div
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`}
@@ -42,7 +42,7 @@ export function WatchlistTable({ entries }: WatchlistTableProps) {
<th className="px-4 py-3">Dimensions</th>
<th className="px-4 py-3">R:R</th>
<th className="px-4 py-3">Direction</th>
<th className="px-4 py-3">S/R Levels</th>
<th className="px-4 py-3">Momentum</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
@@ -114,15 +114,9 @@ export function WatchlistTable({ entries }: WatchlistTableProps) {
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3.5">
{entry.sr_levels.length > 0 ? (
<div className="flex flex-wrap gap-1">
{entry.sr_levels.map((level, i) => (
<span key={i} className={`text-xs ${level.type === 'support' ? 'text-emerald-400' : 'text-red-400'}`}>
{formatPrice(level.price_level)}
</span>
))}
</div>
<td className="px-4 py-3.5 num text-gray-200">
{entry.momentum_percentile !== null ? (
`${Math.round(entry.momentum_percentile)}%ile`
) : (
<span className="text-gray-500"></span>
)}
+16
View File
@@ -283,6 +283,22 @@ export function useBootstrapTickers() {
});
}
export function useBackfillTickerNames() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: () => adminApi.backfillTickerNames(),
onSuccess: (result) => {
qc.invalidateQueries({ queryKey: ['tickers'] });
addToast('success', `Company names: +${result.updated} filled (${result.unmatched} unmatched)`);
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to backfill company names');
},
});
}
// ── Jobs ──
export function useJobs() {
+22
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as api from '../api/paperTrades';
import type { ExitPolicy } from '../lib/types';
import { useToast } from '../components/ui/Toast';
export function usePaperTrades(status?: 'open' | 'closed') {
@@ -10,6 +11,27 @@ export function usePaperTrades(status?: 'open' | 'closed') {
});
}
export function useExitPolicy() {
return useQuery({
queryKey: ['paper-trades', 'exit-policy'],
queryFn: () => api.getExitPolicy(),
staleTime: 5 * 60 * 1000,
});
}
export function useUpdateExitPolicy() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (body: Partial<ExitPolicy>) => api.updateExitPolicy(body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['paper-trades'] });
addToast('success', 'Exit policy updated.');
},
onError: (e: Error) => addToast('error', e.message || 'Failed to update exit policy'),
});
}
export function useCreatePaperTrade() {
const qc = useQueryClient();
const { addToast } = useToast();
+14
View File
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as tickersApi from '../api/tickers';
import { useToast } from '../components/ui/Toast';
@@ -9,6 +10,19 @@ export function useTickers() {
});
}
/** symbol (upper) company name, from the tracked-ticker list. Shared lookup so
* any view can show the company behind a symbol without its own request. */
export function useTickerNames(): Map<string, string> {
const { data } = useTickers();
return useMemo(() => {
const map = new Map<string, string>();
for (const t of data ?? []) {
if (t.name) map.set(t.symbol.toUpperCase(), t.name);
}
return map;
}, [data]);
}
export function useAddTicker() {
const qc = useQueryClient();
const { addToast } = useToast();
+6 -1
View File
@@ -25,6 +25,9 @@ export function summarizeIngestionResult(
if (info.status === 'ok') {
return `${label}`;
}
if (info.status === 'warning') {
return `${label}${info.message ? `: ${info.message}` : ': no data'}`;
}
if (info.status === 'skipped') {
return `${label}: skipped${info.message ? ` (${info.message})` : ''}`;
}
@@ -32,8 +35,10 @@ export function summarizeIngestionResult(
});
const hasError = entries.some(([, source]) => source.status === 'error');
const hasWarning = entries.some(([, source]) => source.status === 'warning');
const hasSkip = entries.some(([, source]) => source.status === 'skipped');
const toastType: IngestionToastType = hasError ? 'error' : hasSkip ? 'info' : 'success';
// A warning (e.g. 0 bars returned) must not read as success.
const toastType: IngestionToastType = hasError ? 'error' : hasWarning || hasSkip ? 'info' : 'success';
return {
toastType,
+36 -4
View File
@@ -2,6 +2,13 @@ import type { ActivationConfig, TradeSetup } from './types';
const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'short' | 'neutral' {
if (!action || action === 'NEUTRAL') return 'neutral';
if (action.startsWith('LONG')) return 'long';
if (action.startsWith('SHORT')) return 'short';
return 'neutral';
}
export function bestTargetProbability(setup: TradeSetup): number {
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0;
}
@@ -33,15 +40,20 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
return false;
}
if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
// Cross-sectional momentum is the core selection (long-only). While the gate is
// active, shorts never qualify; the percentile floor is enforced only when a
// percentile is attached, otherwise defer to the floors.
// Residual cross-sectional momentum is the core selection (long-only). While
// the gate is active, shorts never qualify; the percentile floor is enforced
// only when a percentile is attached, otherwise defer to the floors.
if (config.min_momentum_percentile > 0) {
if (setup.direction === 'short') return false;
if (setup.momentum_percentile != null && setup.momentum_percentile < config.min_momentum_percentile) {
return false;
}
}
// NEUTRAL = "no clear setup"; an opposite action means this setup is counter-bias.
if (config.exclude_neutral) {
const actionDir = actionDirection(setup.recommended_action);
if (actionDir === 'neutral' || actionDir !== setup.direction) return false;
}
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
return false;
}
@@ -49,11 +61,31 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
return true;
}
/**
* Symbol of the current single 'top pick' the #1 row the dashboard highlights:
* the highest residual 12-1 momentum percentile among qualified setups. Returns
* null when there are no actionable setups. Keep in step with the Top Setups
* ranking in DashboardPage.
*/
export function topPickSymbol(
trades: TradeSetup[] | undefined,
activation: ActivationConfig | undefined,
): string | null {
const all = trades ?? [];
if (all.length === 0) return null;
const qualified = activation ? all.filter((t) => qualifiesSetup(t, activation)) : [];
const top = [...qualified].sort(
(a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity),
)[0];
return top?.symbol ?? null;
}
/** Short human summary of the active gate, e.g. for tooltips/labels. */
export function activationSummary(config: ActivationConfig): string {
const parts = [];
if (config.min_momentum_percentile > 0) parts.push(`top ${(100 - config.min_momentum_percentile).toFixed(0)}% momentum`);
if (config.min_momentum_percentile > 0) parts.push(`top ${(100 - config.min_momentum_percentile).toFixed(0)}% residual momentum`);
parts.push(`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`);
if (config.exclude_neutral) parts.push('directional');
if (config.require_high_conviction) parts.push('high-conviction');
if (config.exclude_conflicts) parts.push('clean');
return parts.join(' · ');
+176 -12
View File
@@ -19,6 +19,7 @@ export interface WatchlistEntry {
dimensions: DimensionScore[];
rr_ratio: number | null;
rr_direction: string | null;
momentum_percentile: number | null;
sr_levels: SRLevelSummary[];
last_close: number | null;
change_pct: number | null;
@@ -81,6 +82,10 @@ export interface CompositeBreakdown {
missing_dimensions: string[];
renormalized_weights: Record<string, number>;
formula: string;
base_score?: number | null;
sentiment_score?: number | null;
sentiment_adjustment?: number | null;
max_sentiment_adjustment?: number | null;
}
export interface ScoreResponse {
@@ -91,7 +96,7 @@ export interface ScoreResponse {
dimensions: DimensionScoreDetail[];
missing_dimensions: string[];
computed_at: string | null;
composite_breakdown?: CompositeBreakdown;
composite_breakdown?: CompositeBreakdown | null;
}
export interface DimensionScoreDetail {
@@ -99,12 +104,13 @@ export interface DimensionScoreDetail {
score: number;
is_stale: boolean;
computed_at: string | null;
breakdown?: ScoreBreakdown;
breakdown?: ScoreBreakdown | null;
}
export interface RankingEntry {
symbol: string;
composite_score: number;
composite_stale: boolean;
dimensions: DimensionScoreDetail[];
}
@@ -135,9 +141,18 @@ export interface TradeSetup {
evaluated_at: string | null;
current_price: number | null;
momentum_percentile?: number | null;
context_as_of?: TradeSetupContextAsOf | null;
recommendation_summary?: RecommendationSummary;
}
export interface TradeSetupContextAsOf {
setup_detected_at: string;
score_computed_at: string | null;
sentiment_at: string | null;
price_date: string | null;
price_updated_at: string | null;
}
// Performance / outcome statistics
export interface OutcomeBucketStats {
total: number;
@@ -152,6 +167,7 @@ export interface OutcomeBucketStats {
export interface PerformanceStats {
overall: OutcomeBucketStats;
pending: number;
maturing: number;
by_direction: Record<string, OutcomeBucketStats>;
by_action: Record<string, OutcomeBucketStats>;
by_confidence: Record<string, OutcomeBucketStats>;
@@ -164,6 +180,7 @@ export interface ActivationConfig {
min_confidence: number;
require_high_conviction: boolean;
exclude_conflicts: boolean;
exclude_neutral: boolean;
}
// Cron schedule for the daily/intraday pipelines + fundamentals
@@ -201,6 +218,18 @@ export interface PaperTrade {
close_price: number | null;
closed_at: string | null;
current_price: number | null;
benchmark_return_pct: number | null;
alpha_pct: number | null;
alpha_usd: number | null;
close_reason: 'time' | 'trailing' | 'stop' | 'target' | 'manual' | null;
trailing_stop: number | null;
trailing_distance_pct: number | null;
}
export interface ExitPolicy {
mode: 'time' | 'trailing' | 'target';
trailing_pct: number;
hold_days: number;
}
export interface BacktestBucket {
@@ -211,19 +240,112 @@ export interface BacktestBucket {
hit_rate: number | null;
avg_r: number | null;
total_r: number | null;
}
export interface BacktestCalibrationRow {
bucket: string;
n: number;
predicted_avg: number;
realized_hit_rate: number;
// Net of transaction costs — optional so a stale cached report still renders.
net_avg_r?: number | null;
net_total_r?: number | null;
best_r?: number | null;
worst_r?: number | null;
avg_hold_days?: number | null;
net_r_per_day?: number | null;
// Robustness: distribution shape, and expectancy without the top winners.
median_net_r?: number | null;
profit_factor?: number | null;
net_avg_r_ex_top5?: number | null;
}
export interface BacktestSweepRow extends BacktestBucket {
min_momentum_percentile: number;
}
export interface BacktestTimeExitRow {
hold_days: number;
total: number;
wins: number;
win_rate: number | null;
avg_r: number | null;
total_r: number | null;
net_avg_r?: number | null;
net_total_r?: number | null;
best_r?: number | null;
worst_r?: number | null;
avg_hold_days?: number | null;
net_r_per_day?: number | null;
median_net_r?: number | null;
profit_factor?: number | null;
net_avg_r_ex_top5?: number | null;
}
export interface BacktestPortfolioPolicy {
policy: string;
starting_capital: number;
final_equity: number;
total_return_pct: number;
cagr_pct: number | null;
max_drawdown_pct: number;
sharpe: number | null;
trades: number;
win_rate: number | null;
avg_trade_pnl: number | null;
best_trade_r: number | null;
worst_trade_r: number | null;
best_trade_pnl: number | null;
worst_trade_pnl: number | null;
avg_hold_days: number | null;
skipped_book_full: number;
spy_return_pct: number | null;
yearly_returns?: { year: number; return_pct: number | null }[];
start_date: string;
end_date: string;
}
export interface BacktestRecommendation {
headline: string | null;
items: { topic: string; text: string }[];
note?: string;
}
export interface BacktestResearchRecommendation {
items: { topic: string; text: string; candidate?: boolean }[];
note?: string;
}
export interface BacktestPortfolioSim {
params: {
starting_capital: number;
max_positions: number;
risk_per_trade_pct: number;
notional_cap_pct: number;
cost_per_side_pct: number;
hold_days: number;
};
policies: BacktestPortfolioPolicy[];
note?: string;
}
export interface BacktestStrategyVariant extends BacktestPortfolioPolicy {
variant: string;
label: string;
ranking: 'raw' | 'residual' | string;
cutoff: number;
max_positions: number;
risk_per_trade_pct: number;
risk_scale: string | null;
}
export interface BacktestStrategyVariants {
variants: BacktestStrategyVariant[];
note?: string;
}
export interface BacktestGateAblationRow extends BacktestBucket {
variant: string;
// The same variant graded under the hold-to-horizon time exit.
hold_days?: number;
hold_avg_r?: number | null;
hold_net_avg_r?: number | null;
hold_total_r?: number | null;
}
export interface BacktestSignalEvalRow {
signal: string;
weeks: number;
@@ -240,13 +362,24 @@ export interface BacktestReport {
tickers: number;
candidates: number;
qualified: number;
params: { step_days: number; horizon_days: number; min_lookback: number };
params: {
step_days: number;
horizon_days: number;
min_lookback: number;
cost_per_side_pct?: number;
};
overall_qualified: BacktestBucket;
overall_all: BacktestBucket;
by_direction: Record<string, BacktestBucket>;
min_momentum_percentile: number;
sweep: BacktestSweepRow[];
calibration: BacktestCalibrationRow[];
gate_ablation?: BacktestGateAblationRow[];
gate_ablation_note?: string;
time_exit_sweep?: BacktestTimeExitRow[];
portfolio_sim?: BacktestPortfolioSim;
strategy_variants?: BacktestStrategyVariants;
recommendation?: BacktestRecommendation;
research_recommendation?: BacktestResearchRecommendation;
signal_eval?: BacktestSignalEvalRow[];
signal_eval_note?: string;
note: string;
@@ -275,6 +408,20 @@ export interface RegimeSignal {
contribution: number;
}
export interface RegimeSubScore {
score: number | null;
band: RegimeBand | null;
delta_7?: number | null;
delta_30?: number | null;
}
export interface RegimeHistoryPoint {
date: string;
index: number;
early_warning: number | null;
combined: number | null;
}
export interface RegimeMonitor {
available: boolean;
reason?: string;
@@ -289,6 +436,10 @@ export interface RegimeMonitor {
fundamentals_fetched_at: string | null;
};
trend?: { delta_7: number | null; delta_30: number | null };
// Separate, observational early-warning score (breadth divergence) + a small
// combined blend. Decoupled from the index above.
early_warning?: RegimeSubScore;
combined?: RegimeSubScore;
}
export interface RegimeFundamentals {
@@ -316,6 +467,7 @@ export interface EventStudyLeadStats {
median_lead_days: number | null;
events_with_signal: number;
events_total: number;
warn_threshold: number;
mean_path: { rel_day: number; value: number }[];
signal: {
base_rate: number;
@@ -324,6 +476,13 @@ export interface EventStudyLeadStats {
};
}
export interface EventStudyPerEvent {
date: string;
depth_pct: number;
breadth_lead: number | null;
coincident_lead: number | null;
}
export interface EventStudyReport {
available: boolean;
reason?: string;
@@ -331,14 +490,16 @@ export interface EventStudyReport {
params?: {
benchmark: string;
event_threshold_pct: number;
cooldown_days: number;
horizon_days: number;
warn_threshold: number;
warn_percentile: number;
};
events?: { date: string; index: number; depth_pct: number }[];
indicators?: {
breadth_divergence: EventStudyLeadStats;
coincident_price: EventStudyLeadStats;
};
per_event?: EventStudyPerEvent[];
lead_delta_days?: number | null;
recent_breadth?: { date: string; breadth: number; divergence: number | null }[];
}
@@ -352,6 +513,8 @@ export interface AlertConfig {
sr_proximity_enabled: boolean;
score_drop_enabled: boolean;
digest_enabled: boolean;
regime_quadrant_enabled: boolean;
trade_closed_enabled: boolean;
}
export interface AlertTestResult {
@@ -464,6 +627,7 @@ export interface EMACrossResult {
export interface Ticker {
id: number;
symbol: string;
name: string | null;
created_at: string;
}
+2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { ActivationSettings } from '../components/admin/ActivationSettings';
import { ExitPolicySettings } from '../components/admin/ExitPolicySettings';
import { AlertSettings } from '../components/admin/AlertSettings';
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
import { DataCleanup } from '../components/admin/DataCleanup';
@@ -33,6 +34,7 @@ export default function AdminPage() {
{activeTab === 'Settings' && (
<div className="space-y-4">
<ActivationSettings />
<ExitPolicySettings />
<AlertSettings />
<SentimentProviderSettings />
<TickerUniverseBootstrap />
+29 -13
View File
@@ -4,6 +4,7 @@ import { useActivation } from '../hooks/useActivation';
import { useTrades } from '../hooks/useTrades';
import { useWatchlist } from '../hooks/useWatchlist';
import { usePaperTrades } from '../hooks/usePaperTrades';
import { useTickerNames } from '../hooks/useTickers';
import { useMarketRegime } from '../hooks/useMarketRegime';
import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime';
import { Callout } from '../components/ui/Callout';
@@ -62,6 +63,7 @@ function DirectionTag({ direction }: { direction: string }) {
export default function DashboardPage() {
const trades = useTrades();
const watchlist = useWatchlist();
const tickerNames = useTickerNames();
const activation = useActivation();
const openTrades = usePaperTrades('open');
const regime = useMarketRegime();
@@ -74,15 +76,12 @@ export default function DashboardPage() {
[trades.data, activation.data],
);
// Show qualified setups first; fall back to the full list when none qualify.
// Rank by 12-1 momentum percentile so the strongest names sit at the top.
const showingQualified = qualifiedSetups.length > 0;
// Rank only actionable/qualified setups by residual 12-1 momentum percentile.
const topSetups: TradeSetup[] = useMemo(() => {
const pool = showingQualified ? qualifiedSetups : trades.data ?? [];
return [...pool]
return [...qualifiedSetups]
.sort((a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity))
.slice(0, 5);
}, [showingQualified, qualifiedSetups, trades.data]);
}, [qualifiedSetups]);
const topWatchlist = useMemo(
() =>
@@ -100,8 +99,10 @@ export default function DashboardPage() {
const exposure = useMemo(() => {
const rows = openTrades.data ?? [];
let riskUsd = 0, unrealUsd = 0, unrealR = 0, rPriced = 0, winners = 0, losers = 0;
let alphaUsd = 0, alphaPriced = 0;
for (const t of rows) {
riskUsd += Math.abs(t.entry_price - t.stop_loss) * t.shares;
if (t.alpha_usd != null) { alphaUsd += t.alpha_usd; alphaPriced += 1; }
const p = tradePnl(t);
if (!p) continue;
unrealUsd += p.pnl;
@@ -109,7 +110,7 @@ export default function DashboardPage() {
if (p.pnl > 0) winners += 1;
else if (p.pnl < 0) losers += 1;
}
return { count: rows.length, riskUsd, unrealUsd, unrealR, rPriced, winners, losers };
return { count: rows.length, riskUsd, unrealUsd, unrealR, rPriced, winners, losers, alphaUsd, alphaPriced };
}, [openTrades.data]);
return (
@@ -141,11 +142,11 @@ export default function DashboardPage() {
{/* Metric strip */}
{(trades.isLoading || openTrades.isLoading) ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<Metric
label="Live Setups"
value={String(trades.data?.length ?? 0)}
@@ -172,6 +173,16 @@ export default function DashboardPage() {
: 'mark-to-market'
}
/>
<Metric
label="Alpha vs S&P 500"
value={exposure.alphaPriced > 0 ? money(exposure.alphaUsd) : '—'}
valueClass={
exposure.alphaPriced > 0
? exposure.alphaUsd >= 0 ? 'text-emerald-400' : 'text-red-400'
: 'text-gray-100'
}
sub={exposure.alphaPriced > 0 ? `${exposure.alphaPriced} open · vs buy-and-hold SPY` : 'vs buy-and-hold SPY'}
/>
</div>
)}
@@ -183,12 +194,12 @@ export default function DashboardPage() {
<div className="xl:col-span-3">
<Section
title="Top Setups"
hint={showingQualified ? 'ranked by expected value' : 'none qualified — showing all'}
hint="qualified and ranked by residual momentum"
>
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
{trades.data && topSetups.length === 0 && (
<Callout variant="empty">No active setups. Run the scanner from the Signals page.</Callout>
<Callout variant="empty">No qualified actionable setups right now.</Callout>
)}
{topSetups.length > 0 && (
<div className="glass overflow-x-auto">
@@ -200,7 +211,7 @@ export default function DashboardPage() {
<th className="px-4 py-3 text-right">Entry</th>
<th className="px-4 py-3 text-right">R:R</th>
<th className="px-4 py-3 text-right">Target&nbsp;Prob</th>
<th className="px-4 py-3 text-right">Momentum</th>
<th className="px-4 py-3 text-right">Residual Mom.</th>
<th className="hidden px-4 py-3 md:table-cell">Action</th>
</tr>
</thead>
@@ -225,6 +236,11 @@ export default function DashboardPage() {
</span>
)}
</div>
{tickerNames.get(setup.symbol.toUpperCase()) && (
<div className="max-w-[160px] truncate text-[11px] text-gray-500">
{tickerNames.get(setup.symbol.toUpperCase())}
</div>
)}
</td>
<td className="px-4 py-3"><DirectionTag direction={setup.direction} /></td>
<td className="num px-4 py-3 text-right text-gray-200">{formatPrice(setup.entry_price)}</td>
+176 -57
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, lazy, Suspense, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PageHeader } from '../components/ui/PageHeader';
import { Callout } from '../components/ui/Callout';
@@ -15,14 +15,18 @@ import {
refreshRegimeFundamentals,
getEventStudy,
} from '../api/regime';
// Lazy so recharts (heavy) ships in its own chunk, loaded only on this tab.
const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart'));
const RegimeQuadrant = lazy(() => import('../components/regime/RegimeQuadrant'));
import type {
RegimeBand,
RegimeMonitor,
RegimeSignal,
RegimeConfig,
RegimeFundamentals,
EventStudyReport,
EventStudyLeadStats,
EventStudyPerEvent,
} from '../lib/types';
const BAND_STYLES: Record<RegimeBand, { text: string; bar: string; ring: string; label: string }> = {
@@ -48,54 +52,71 @@ function TrendChip({ label, delta }: { label: string; delta: number | null | und
);
}
function Gauge({ data }: { data: RegimeMonitor }) {
const band = (data.band ?? 'stable') as RegimeBand;
const style = BAND_STYLES[band];
const score = data.total_score ?? 0;
const threshold = data.alert_threshold ?? 65;
function ScoreGauge({
label,
score,
band,
trend,
threshold,
footnote,
size = 'lg',
}: {
label: string;
score: number | null | undefined;
band: RegimeBand | null | undefined;
trend?: { delta_7?: number | null; delta_30?: number | null };
threshold?: number;
footnote?: ReactNode;
size?: 'lg' | 'md';
}) {
const naa = score == null;
const style = BAND_STYLES[(band ?? 'stable') as RegimeBand];
const s = score ?? 0;
const clamp = (v: number) => Math.min(100, Math.max(0, v));
const numCls = size === 'lg' ? 'text-6xl' : 'text-4xl';
return (
<div className={`glass border ${style.ring} p-6`}>
<div className="flex flex-wrap items-end justify-between gap-4">
<div className={`glass border ${naa ? 'border-white/[0.06]' : style.ring} p-6`}>
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<div className="flex items-baseline gap-3">
<span className={`font-display text-6xl font-bold ${style.text}`}>{Math.round(score)}</span>
<span className="text-sm text-gray-500">/ 100</span>
<div className="text-[11px] uppercase tracking-wider text-gray-500">{label}</div>
<div className="mt-1 flex items-baseline gap-2">
<span className={`font-display font-bold ${numCls} ${naa ? 'text-gray-600' : style.text}`}>
{naa ? '—' : Math.round(s)}
</span>
{!naa && <span className="text-sm text-gray-500">/ 100</span>}
</div>
<p className={`mt-1 text-sm font-medium ${style.text}`}>{style.label}</p>
{!naa && <p className={`mt-0.5 text-sm font-medium ${style.text}`}>{style.label}</p>}
</div>
<div className="flex flex-wrap gap-2">
<TrendChip label="7d" delta={data.trend?.delta_7} />
<TrendChip label="30d" delta={data.trend?.delta_30} />
</div>
</div>
{/* Band track with score + threshold markers */}
<div className="relative mt-6 h-2 w-full rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
<div
className="absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded bg-gray-300/80"
style={{ left: `${clamp(threshold)}%` }}
title={`Alert threshold ${threshold}`}
/>
<div
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style.bar}`}
style={{ left: `${clamp(score)}%` }}
/>
</div>
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
<span>0</span><span>30</span><span>60</span><span>80</span><span>100</span>
</div>
<p className="mt-4 text-xs leading-relaxed text-gray-500">
An <span className="text-gray-400">index</span> (not a calibrated probability) of how far the AI/Tech bull regime
has deteriorated. Mostly coincident signals it shortens reaction time, it doesn't predict the exact turn.
{data.date && <> As of {data.date}.</>}
{data.inputs && (data.inputs.vix != null || data.inputs.hy_oas != null) && (
<span className="ml-1 text-gray-600">
VIX {data.inputs.vix ?? '—'} · HY OAS {data.inputs.hy_oas ?? '—'}
</span>
{trend && (
<div className="flex flex-wrap gap-2">
<TrendChip label="7d" delta={trend.delta_7} />
<TrendChip label="30d" delta={trend.delta_30} />
</div>
)}
</p>
</div>
{!naa && (
<>
{/* Band track with score (+ optional threshold) markers */}
<div className="relative mt-5 h-2 w-full rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
{threshold != null && (
<div
className="absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded bg-gray-300/80"
style={{ left: `${clamp(threshold)}%` }}
title={`Alert threshold ${threshold}`}
/>
)}
<div
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style.bar}`}
style={{ left: `${clamp(s)}%` }}
/>
</div>
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
<span>0</span><span>30</span><span>60</span><span>80</span><span>100</span>
</div>
</>
)}
{footnote && <p className="mt-4 text-xs leading-relaxed text-gray-500">{footnote}</p>}
</div>
);
}
@@ -285,7 +306,22 @@ function Sparkline({ values, color = '#60a5fa', height = 28 }: { values: number[
);
}
function pctLabel(v: number | null): string {
return v == null ? '—' : `${Math.round(v * 100)}%`;
}
function leadLabel(v: number | null): string {
return v == null ? 'missed' : `${v}d`;
}
function bestPr(stats: EventStudyLeadStats) {
const rows = stats.signal.rows.filter((r) => r.precision != null && r.recall != null && r.recall > 0);
if (!rows.length) return null;
return rows.reduce((a, b) => ((b.precision ?? 0) > (a.precision ?? 0) ? b : a));
}
function LeadStat({ label, stats, highlight }: { label: string; stats: EventStudyLeadStats; highlight?: boolean }) {
const pr = bestPr(stats);
return (
<div className={`rounded-lg border px-3 py-2 ${highlight ? 'border-blue-400/30 bg-blue-400/[0.06]' : 'border-white/[0.06] bg-white/[0.02]'}`}>
<div className="text-xs text-gray-500">{label}</div>
@@ -293,8 +329,46 @@ function LeadStat({ label, stats, highlight }: { label: string; stats: EventStud
{stats.median_lead_days != null ? `${stats.median_lead_days}d lead` : 'no signal'}
</div>
<div className="text-[11px] text-gray-600">
{stats.events_with_signal}/{stats.events_total} events warned
{stats.events_with_signal}/{stats.events_total} warned
{stats.warn_threshold != null ? ` · warn ≥ ${Math.round(stats.warn_threshold)}` : ''}
</div>
{pr && (
<div className="text-[11px] text-gray-600">
best P {pctLabel(pr.precision)} · R {pctLabel(pr.recall)} @ {pr.threshold}
</div>
)}
</div>
);
}
function PerEventTable({ rows }: { rows: EventStudyPerEvent[] }) {
return (
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-white/[0.06] text-left uppercase tracking-wider text-gray-500">
<th className="px-3 py-2 font-medium">Drawdown</th>
<th className="px-3 py-2 text-right font-medium">Depth</th>
<th className="px-3 py-2 text-right font-medium">Breadth lead</th>
<th className="px-3 py-2 text-right font-medium">Coincident lead</th>
</tr>
</thead>
<tbody>
{rows.map((e) => {
const earlier = e.breadth_lead != null && (e.coincident_lead == null || e.breadth_lead > e.coincident_lead);
return (
<tr key={e.date} className="border-b border-white/[0.03] last:border-0">
<td className="px-3 py-2 num text-gray-300">{e.date}</td>
<td className="px-3 py-2 text-right num text-gray-400">{e.depth_pct}%</td>
<td className={`px-3 py-2 text-right num ${earlier ? 'text-emerald-400' : 'text-gray-300'}`}>
{leadLabel(e.breadth_lead)}
</td>
<td className="px-3 py-2 text-right num text-gray-300">{leadLabel(e.coincident_lead)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
@@ -305,25 +379,30 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
const recent = report.recent_breadth ?? [];
const breadthVals = recent.map((r) => r.breadth);
const divVals = recent.map((r) => r.divergence ?? 0);
const lead = report.lead_delta_days;
const moreCoverage = bd.events_with_signal > cd.events_with_signal;
return (
<div className="space-y-4">
<p className="text-xs text-gray-500">
{report.events?.length ?? 0} drawdown events ({report.params?.event_threshold_pct}%) on{' '}
{report.params?.benchmark} over ~5y. Higher median lead = earlier warning.
{report.params?.benchmark} over ~5y. With so few events, coverage (how many it warned before) matters
more than the median lead.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<LeadStat label="Breadth divergence (leading candidate)" stats={bd} highlight={lead != null && lead > 0} />
<LeadStat label="Breadth divergence (leading candidate)" stats={bd} highlight={moreCoverage} />
<LeadStat label="Coincident price composite (baseline)" stats={cd} />
</div>
{lead != null && (
<p className="text-xs text-gray-400">
Breadth divergence warned a median{' '}
<span className={`font-medium ${lead > 0 ? 'text-emerald-400' : 'text-amber-400'}`}>
{lead > 0 ? '+' : ''}{lead} days
</span>{' '}
{lead >= 0 ? 'earlier' : 'later'} than the coincident baseline.
</p>
<p className="text-xs text-gray-400">
Breadth divergence warned before{' '}
<span className="font-medium text-emerald-400">{bd.events_with_signal}/{bd.events_total}</span> drawdowns
{bd.median_lead_days != null ? ` (median ${bd.median_lead_days}d lead)` : ''}; the coincident baseline only{' '}
<span className="font-medium text-gray-300">{cd.events_with_signal}/{cd.events_total}</span>. The median-lead
comparison is unreliable when coverage differs this much see per-drawdown below.
</p>
{report.per_event && report.per_event.length > 0 && (
<div className="space-y-1.5">
<div className="text-[11px] uppercase tracking-wider text-gray-500">Per drawdown (same events, both indicators)</div>
<PerEventTable rows={report.per_event} />
</div>
)}
{recent.length > 1 && (
<div className="flex flex-wrap items-end gap-6">
@@ -441,7 +520,47 @@ export default function RegimePage() {
{monitor.data && monitor.data.available && (
<>
<Gauge data={monitor.data} />
<div className="grid gap-4 lg:grid-cols-2">
<ScoreGauge
label="Regime index · coincident"
score={monitor.data.total_score}
band={monitor.data.band}
trend={monitor.data.trend}
threshold={monitor.data.alert_threshold}
footnote={
<>
An <span className="text-gray-400">index</span> (not a calibrated probability) of how far the AI/Tech
bull regime has deteriorated. Mostly coincident it shortens reaction time, it doesn&apos;t predict
the turn.
{monitor.data.date && <> As of {monitor.data.date}.</>}
{monitor.data.inputs && (monitor.data.inputs.vix != null || monitor.data.inputs.hy_oas != null) && (
<span className="ml-1 text-gray-600">
VIX {monitor.data.inputs.vix ?? '—'} · HY OAS {monitor.data.inputs.hy_oas ?? '—'}
</span>
)}
</>
}
/>
<ScoreGauge
label="Early warning · breadth divergence"
score={monitor.data.early_warning?.score}
band={monitor.data.early_warning?.band}
trend={monitor.data.early_warning}
footnote={
<>
Breadth narrowing while price holds. In the event study it led ~6 weeks on 7/11 past drawdowns, but
it&apos;s noisy (2× base rate) and blind to shocks. Observational separate from the index, not
wired into trades.
</>
}
/>
</div>
<Suspense fallback={<SkeletonCard className="h-80" />}>
<RegimeQuadrant />
</Suspense>
<Suspense fallback={<SkeletonCard className="h-72" />}>
<ScoreHistoryChart />
</Suspense>
{monitor.data.breakdown && <Breakdown breakdown={monitor.data.breakdown} />}
</>
)}
+128 -6
View File
@@ -1,11 +1,16 @@
import { useMemo, useEffect, useState } from 'react';
import { useMemo, useEffect, useState, lazy, Suspense } from 'react';
import { useParams } from 'react-router-dom';
import { useTickerDetail } from '../hooks/useTickerDetail';
import { useFetchSymbolData } from '../hooks/useFetchSymbolData';
import { useWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from '../hooks/useWatchlist';
import { useTrades } from '../hooks/useTrades';
import { usePaperTrades } from '../hooks/usePaperTrades';
import { useActivation } from '../hooks/useActivation';
import { topPickSymbol, qualifiesSetup } from '../lib/qualification';
import type { FetchSelector } from '../api/ingestion';
import { CandlestickChart } from '../components/charts/CandlestickChart';
import { ScoreCard } from '../components/ui/ScoreCard';
import { useTickerNames } from '../hooks/useTickers';
import { SkeletonCard } from '../components/ui/Skeleton';
import { SentimentPanel } from '../components/ticker/SentimentPanel';
import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel';
@@ -17,6 +22,10 @@ import { Section } from '../components/ui/Section';
import { Tabs } from '../components/ui/Tabs';
import { formatPrice } from '../lib/format';
import type { TradeSetup } from '../lib/types';
import type { FieldPoint } from '../components/ticker/StandingMatrix';
// Lazy so recharts (heavy) ships in its own chunk, not the main ticker bundle.
const StandingMatrix = lazy(() => import('../components/ticker/StandingMatrix'));
const detailTabs = ['Analysis', 'Indicators', 'S/R Levels'] as const;
type DetailTab = (typeof detailTabs)[number];
@@ -29,6 +38,21 @@ function SectionError({ message, onRetry }: { message: string; onRetry?: () => v
);
}
function StatusPill({ tone, label, title }: { tone: 'blue' | 'emerald'; label: string; title?: string }) {
const tones = {
blue: 'bg-blue-500/15 text-blue-300 border-blue-500/30',
emerald: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
} as const;
return (
<span
title={title}
className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium ${tones[tone]}`}
>
{label}
</span>
);
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
@@ -97,6 +121,7 @@ function DataFreshnessBar({
export default function TickerDetailPage() {
const { symbol = '' } = useParams<{ symbol: string }>();
const companyName = useTickerNames().get(symbol.toUpperCase());
const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol);
const ingestion = useFetchSymbolData();
const watchlist = useWatchlist();
@@ -107,6 +132,21 @@ export default function TickerDetailPage() {
[watchlist.data, symbol],
);
const watchlistBusy = addToWatchlist.isPending || removeFromWatchlist.isPending;
// Status labels: is there an open paper trade on this ticker, and is it the
// current top pick (same ranking the dashboard highlights)?
const openTrades = usePaperTrades('open');
const allTrades = useTrades();
const activation = useActivation();
const hasOpenTrade = useMemo(
() => (openTrades.data ?? []).some((t) => t.symbol.toUpperCase() === symbol.toUpperCase()),
[openTrades.data, symbol],
);
const isTopPick = useMemo(
() => topPickSymbol(allTrades.data, activation.data)?.toUpperCase() === symbol.toUpperCase(),
[allTrades.data, activation.data, symbol],
);
const [activeTab, setActiveTab] = useState<DetailTab>('Analysis');
const [refreshingLabel, setRefreshingLabel] = useState<string | null>(null);
@@ -176,6 +216,29 @@ export default function TickerDetailPage() {
[setupsForSymbol],
);
// Standing matrix: this ticker's residual momentum percentile + long confidence (from its
// setup), the field (every ticker's composite × momentum) for the cloud, and
// whether it qualifies / is the top pick.
const myMomentum = longSetup?.momentum_percentile ?? shortSetup?.momentum_percentile ?? null;
const myConfidence = longSetup?.confidence_score ?? null;
const standingField = useMemo<FieldPoint[]>(() => {
const seen = new Set<string>();
const out: FieldPoint[] = [];
for (const t of allTrades.data ?? []) {
const s = t.symbol.toUpperCase();
if (seen.has(s) || t.momentum_percentile == null) continue;
seen.add(s);
out.push({ symbol: s, composite: t.composite_score, momentum: t.momentum_percentile });
}
return out;
}, [allTrades.data]);
const standingStatus: 'top-pick' | 'qualified' | 'none' = useMemo(() => {
if (isTopPick) return 'top-pick';
if (longSetup && activation.data && qualifiesSetup(longSetup, activation.data)) return 'qualified';
return 'none';
}, [isTopPick, longSetup, activation.data]);
const gateMomentum = activation.data?.min_momentum_percentile ?? 80;
// Current price = latest close, with day-over-day change
const priceInfo = useMemo(() => {
const bars = ohlcv.data;
@@ -213,6 +276,9 @@ export default function TickerDetailPage() {
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-baseline gap-4">
<h1 className="text-3xl font-semibold text-gray-100">{symbol.toUpperCase()}</h1>
{companyName && (
<span className="max-w-[240px] truncate text-sm text-gray-500">{companyName}</span>
)}
{priceInfo && (
<div className="flex items-baseline gap-2">
<span className="num text-2xl font-semibold text-gray-100">{formatPrice(priceInfo.price)}</span>
@@ -226,6 +292,20 @@ export default function TickerDetailPage() {
)}
</div>
<div className="flex items-center gap-2">
{isTopPick && (
<StatusPill
tone="blue"
label="★ Top Pick"
title="Current top pick — highest residual-momentum qualified setup right now"
/>
)}
{hasOpenTrade && (
<StatusPill
tone="emerald"
label="● Open Trade"
title="You have an open paper trade on this ticker"
/>
)}
<Button
variant="ghost"
onClick={() =>
@@ -266,8 +346,8 @@ export default function TickerDetailPage() {
/>
{/* Chart — always visible */}
<Section title="Price Chart">
{ohlcv.isLoading && <SkeletonCard className="h-[400px]" />}
<Section title="Price & Volume">
{ohlcv.isLoading && <SkeletonCard className="h-[440px]" />}
{ohlcv.isError && (
<SectionError
message={ohlcv.error instanceof Error ? ohlcv.error.message : 'Failed to load OHLCV data'}
@@ -323,14 +403,55 @@ export default function TickerDetailPage() {
<Tabs tabs={detailTabs} active={activeTab} onChange={setActiveTab} />
{activeTab === 'Analysis' && (
<div className="grid gap-6 lg:grid-cols-3 animate-fade-in">
<Section title="Scores">
<div className="space-y-6 animate-fade-in">
<Section title="Standing" hint="how this ticker ranks vs. the field">
{scores.isLoading && <SkeletonCard className="h-80" />}
{scores.isError && (
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
)}
{scores.data && (
<>
<Suspense fallback={<SkeletonCard className="h-80" />}>
<StandingMatrix
symbol={symbol}
composite={scores.data.composite_score}
momentum={myMomentum}
field={standingField}
gateMomentum={gateMomentum}
status={standingStatus}
confidence={myConfidence}
/>
</Suspense>
{(() => {
const cb = scores.data?.composite_breakdown;
const adj = cb?.sentiment_adjustment;
const base = cb?.base_score;
if (adj == null || base == null || Math.abs(adj) < 0.05) return null;
const composite = scores.data?.composite_score ?? base + adj;
return (
<p className="mt-3 text-center text-[11px] text-gray-500">
Composite{' '}
<span className="font-semibold text-gray-300">{Math.round(composite)}</span>
{' '}= Base {Math.round(base)}{' '}
{adj >= 0 ? '+' : ''} Sentiment{' '}
<span className={adj >= 0 ? 'text-emerald-400/80' : 'text-red-400/80'}>
{Math.abs(adj).toFixed(1)}
</span>
</p>
);
})()}
</>
)}
</Section>
<div className="grid gap-6 lg:grid-cols-3">
<Section title="Dimensions">
{scores.isLoading && <SkeletonCard />}
{scores.isError && (
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
)}
{scores.data && (
<ScoreCard compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions} compositeBreakdown={scores.data.composite_breakdown} />
<ScoreCard showComposite={false} compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions} compositeBreakdown={scores.data.composite_breakdown} />
)}
</Section>
@@ -349,6 +470,7 @@ export default function TickerDetailPage() {
)}
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
</Section>
</div>
</div>
)}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/papertrades.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/schedulesettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/dashboard/opentradespanel.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/backtestpanel.tsx","./src/components/signals/mytradespanel.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/usepapertrades.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/papertrade.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/papertrades.ts","./src/api/performance.ts","./src/api/regime.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/exitpolicysettings.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/schedulesettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/dashboard/opentradespanel.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/tickersearch.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/regime/regimequadrant.tsx","./src/components/regime/scorehistorychart.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/backtestpanel.tsx","./src/components/signals/mytradespanel.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ticker/standingmatrix.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/usepapertrades.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/papertrade.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/regimepage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
-73
View File
@@ -1,73 +0,0 @@
# Anforderungsdokument — "AI/Tech Regime Change Monitor"
**Ziel:** Ein persönliches Hobby-Tool, das fundamentale *und* kursbasierte Signale überwacht und einen einzigen Wert von **0100** ausgibt: die geschätzte Wahrscheinlichkeit, dass das KI/Tech-Bullenregime in eine Neubewertung kippt.
**Zweck:** Disziplinierte Ausstiegs-Entscheidung für spekulative Einzelpositionen (NVDA, MSFT). **Kein** Auto-Trading, **keine** Anlageberatung, **keine** Timing-Garantie.
---
## 1. Scope
- **Beobachtete Instrumente:** SMH (Halbleiter, *schnelles* Frühsignal) + QQQ (breiter, *Bestätigung*) als Regime-Sensoren; SPY, RSP (Marktbreite-Kontext); VIX (Volatilität); Hyperscaler GOOGL, AMZN, META, MSFT (Capex-Signal). Bewusst **keine** Einzelaktien-Trades — das Tool misst das *Regime*, nicht einzelne Titel.
- **Optionaler "Kanarienvogel":** NVDA als reiner Frühindikator-Input (Lead-Aktie des Sektors, dreht oft vor SMH) — abschaltbar, **keine** Entscheidungsposition.
- **Read-only.** Tool gibt nur einen Score + Aufschlüsselung aus, führt keine Orders aus.
- **Lauf-Kadenz:** Kurssignale täglich, Fundamentalsignale quartalsweise (bzw. bei Earnings).
## 2. Output
- **Gesamtscore 0100** (0 = Regime stabil, 100 = Bruch im Gange) mit Label-Band:
- 030 stabil · 3060 beobachten · 6080 erhöht · 80100 Bruch sichtbar
- **Aufschlüsselung pro Signal** (Sub-Score 0100 + Gewicht + Beitrag).
- **Trend:** Veränderung des Gesamtscores über 7 und 30 Tage (steigend/fallend).
- Optional: einfacher Alert, wenn Gesamtscore eine konfigurierbare Schwelle (Default 65) überschreitet.
## 3. Signale
Jedes Signal liefert einen Sub-Score 0100 (0 = gesund, 100 = Regime bricht). Gewichte in `config` editierbar.
### Kursbasiert (automatisierbar, täglich)
Grundprinzip: **SMH ist das führende Signal, QQQ die Bestätigung.** Wo beide eingehen, zählt SMH stärker (Default 2:1), damit du Frühwarnung *und* Filter gegen Fehlalarme hast.
| ID | Signal | Logik (Sub-Score 0→100) | Default-Gewicht |
|----|--------|--------------------------|-----------------|
| P1 | Trendbruch 200-Tage-MA | Gewichteter Anteil unter der 200-Tage-MA: SMH zählt doppelt, QQQ einfach | 12 |
| P2 | Death Cross + Slope | 50-Tage-MA unter 200-Tage-MA und 200er-Slope negativ (graduell nach Abstand), SMH führend | 8 |
| P3 | Drawdown vom 52W-Hoch | max(SMH, QQQ)-Drawdown: 0 % → 0, ≥ 20 % → 100 (linear) | 10 |
| P4 | Relative Stärke Tech | Trend des Verhältnisses SMH/SPY (Tech underperformt → höher) | 8 |
| P5 | Volatilität | VIX: ≤ 15 → 0, ≥ 30 → 100 (linear) | 7 |
| P6 | *Optional:* Kanarienvogel NVDA | NVDA unter 50-Tage-MA bei gleichzeitig noch intaktem SMH (Lead-Divergenz) → Frühwarnung; abschaltbar | 0 (opt. 5) |
### Fundamental (teils manuell, quartalsweise)
| ID | Signal | Logik (Sub-Score 0→100) | Default-Gewicht |
|----|--------|--------------------------|-----------------|
| F1 | Hyperscaler-Capex-Guidance | Manuelle Eingabe je Name: anhebend = 0, haltend = 50, kürzend = 100; Mittel über die 4 | 25 |
| F2 | Kreditspreads | US High-Yield OAS (FRED `BAMLH0A0HYM2`): Perzentil der letzten 3 J → Score; Ausweitung = höher | 15 |
| F3 | Earnings-Reaktion | "Good news, stock down": fielen Hyperscaler/SMH im Schnitt trotz Gewinn-Beats nach den letzten Earnings? (Reaktion ±2 Tage, auto oder manuell) | 8 |
| F4 | Marktbreite | Trend RSP/SPY (gleichgewichtet schlägt kapgewichtet bei Tech-Schwäche → Verschlechterung der Breite → höher) | 7 |
**Gesamtscore = Σ(Sub-Score × Gewicht) / Σ(Gewichte).** Summe Defaults = 100.
## 4. Datenquellen (Vorschlag, alle frei)
- **Kurse/MA/Drawdown/VIX:** `yfinance` (Yahoo Finance). Alternativ deine IBKR-API.
- **Kreditspreads:** FRED-API (`BAMLH0A0HYM2`), kostenloser API-Key.
- **Capex-Guidance (F1):** manuell pflegbar in `signals.yaml` (4 Werte/Quartal). Keine zuverlässige Gratis-API; bewusst manuell.
- **Earnings-Termine/-Reaktion (F3):** `yfinance` earnings dates + Kursreaktion, optional manuell.
## 5. Konfiguration
- `config.yaml`: Gewichte je Signal, Alert-Schwelle, Tickerlisten, Lookback-Fenster.
- `signals.yaml`: manuelle Eingaben (F1, optional F3).
- Alle Schwellen/Gewichte ohne Code-Änderung anpassbar.
## 6. Tech-Vorschlag (optional)
- **Python** + `pandas` + `yfinance` + `requests` (FRED) + `pyyaml`.
- Ausgabe als **CLI-Report** (Tabelle + Gesamtscore) und/oder kleines **Streamlit**-Dashboard mit Gauge + Verlaufschart.
- Lokal lauffähig, ein `python monitor.py` reicht; Verlauf in lokaler CSV/SQLite für 7/30-Tage-Trend.
## 7. Explizite Nicht-Ziele / Grenzen
- Sagt **keinen** exakten Zeitpunkt voraus; ein hoher Score ≠ garantierter Crash.
- Die Gewichte sind subjektiv (Garbage-in → Garbage-out): Default ist ein Startpunkt, kein Optimum.
- Das eindeutige Signal kommt oft erst mit dem Einbruch — das Tool *senkt* die Reaktionszeit, eliminiert sie nicht.
- Reines Informations-/Disziplin-Werkzeug, keine Finanzberatung.
+162
View File
@@ -0,0 +1,162 @@
"""Create a minimal local SQLite snapshot for offline backtest research.
Copies only the data required by app.services.backtest_service.run_backtest:
tickers, OHLCV bars, SPY benchmark closes, and activation/recommendation
settings. Other system settings are intentionally skipped to avoid copying
secrets into local snapshot files.
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from pathlib import Path
from sqlalchemy import func, insert, or_, select
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _normalize_postgres_url(url: str) -> str:
if url.startswith("postgresql+asyncpg://"):
return url
if url.startswith("postgresql://"):
return "postgresql+asyncpg://" + url[len("postgresql://") :]
if url.startswith("postgres://"):
return "postgresql+asyncpg://" + url[len("postgres://") :]
return url
def _sqlite_url(path: Path) -> str:
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
def _hide_password(url: str) -> str:
return make_url(url).render_as_string(hide_password=True)
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--database-url",
default=os.getenv("DATABASE_URL"),
help="Source Postgres URL. Defaults to DATABASE_URL, then app .env database_url.",
)
parser.add_argument(
"--output",
default="backtest_snapshots/prod-backtest.sqlite",
help="SQLite snapshot path to create.",
)
parser.add_argument("--batch-size", type=int, default=5000)
parser.add_argument("--force", action="store_true", help="Overwrite an existing snapshot file.")
return parser.parse_args()
async def _copy_table(
source: AsyncSession,
dest: AsyncSession,
model: type,
*,
batch_size: int,
where=None,
) -> int:
table = model.__table__
columns = list(table.columns)
count_stmt = select(func.count()).select_from(table)
stmt = select(*columns)
if where is not None:
count_stmt = count_stmt.where(where)
stmt = stmt.where(where)
primary_key_columns = list(table.primary_key.columns)
if primary_key_columns:
stmt = stmt.order_by(*primary_key_columns)
expected = int((await source.execute(count_stmt)).scalar_one())
if expected == 0:
print(f"{table.name}: 0 rows")
return 0
copied = 0
stream = await source.stream(stmt.execution_options(yield_per=batch_size))
async for partition in stream.partitions(batch_size):
rows = [dict(row._mapping) for row in partition]
if not rows:
continue
await dest.execute(insert(table), rows)
await dest.commit()
copied += len(rows)
print(f"{table.name}: {copied}/{expected}", end="\r")
print(f"{table.name}: {copied} rows")
return copied
async def _main() -> None:
args = _parse_args()
from app.config import settings
from app.database import Base
import app.models # noqa: F401 - registers all metadata tables
from app.models.benchmark_price import BenchmarkPrice
from app.models.ohlcv import OHLCVRecord
from app.models.settings import SystemSetting
from app.models.ticker import Ticker
source_url = _normalize_postgres_url(args.database_url or settings.database_url)
output = Path(args.output)
if output.exists():
if not args.force:
raise SystemExit(f"{output} already exists. Pass --force to overwrite it.")
output.unlink()
output.parent.mkdir(parents=True, exist_ok=True)
source_engine = create_async_engine(
source_url,
pool_pre_ping=True,
connect_args={"server_settings": {"default_transaction_read_only": "on"}},
)
dest_engine = create_async_engine(_sqlite_url(output))
SourceSession = async_sessionmaker(source_engine, class_=AsyncSession, expire_on_commit=False)
DestSession = async_sessionmaker(dest_engine, class_=AsyncSession, expire_on_commit=False)
print(f"Source: {_hide_password(source_url)}")
print(f"Snapshot: {output}")
try:
async with dest_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with SourceSession() as source, DestSession() as dest:
counts = {
"tickers": await _copy_table(source, dest, Ticker, batch_size=args.batch_size),
"system_settings": await _copy_table(
source,
dest,
SystemSetting,
batch_size=args.batch_size,
where=or_(
SystemSetting.key.like("activation_%"),
SystemSetting.key.like("recommendation_%"),
),
),
"benchmark_prices": await _copy_table(source, dest, BenchmarkPrice, batch_size=args.batch_size),
"ohlcv_records": await _copy_table(source, dest, OHLCVRecord, batch_size=args.batch_size),
}
finally:
await source_engine.dispose()
await dest_engine.dispose()
print("Done:")
for name, count in counts.items():
print(f" {name}: {count}")
if __name__ == "__main__":
asyncio.run(_main())
+139
View File
@@ -0,0 +1,139 @@
"""Run the existing backtest service against a local SQLite snapshot.
The runner is offline/read-only: it does not refresh benchmark prices and does
not cache the report back to any database. It writes a local JSON report.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _sqlite_url(path: Path) -> str:
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("snapshot", help="SQLite snapshot created by create_backtest_snapshot.py.")
parser.add_argument(
"--out",
default=None,
help="JSON report path. Defaults to reports/backtest-<timestamp>.json.",
)
parser.add_argument("--workers", type=int, default=None, help="Override backtest worker count.")
parser.add_argument(
"--allow-spawn",
action="store_true",
help="Allow spawn multiprocessing for offline CLI runs, useful on Windows.",
)
parser.add_argument("--quiet", action="store_true", help="Hide progress output.")
return parser.parse_args()
def _default_output_path() -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
return Path("reports") / f"backtest-{stamp}.json"
def _pct(value: Any) -> str:
return "-" if value is None else f"{float(value):+.1f}%"
def _r(value: Any) -> str:
return "-" if value is None else f"{float(value):+.2f}R"
def _print_summary(report: dict) -> None:
qualified = report.get("overall_qualified") or {}
all_setups = report.get("overall_all") or {}
time_exit = {row.get("hold_days"): row for row in report.get("time_exit_sweep") or []}
hold_30 = time_exit.get(30) or {}
policies = {
row.get("policy"): row
for row in ((report.get("portfolio_sim") or {}).get("policies") or [])
}
hold_policy = policies.get("hold") or {}
print("")
print("Backtest summary")
print(f" candidates: {report.get('candidates')}")
print(f" qualified: {report.get('qualified')}")
print(f" all setups net avg R: {_r(all_setups.get('net_avg_r'))}")
print(f" qualified net avg R: {_r(qualified.get('net_avg_r'))}")
print(f" qualified total R: {_r(qualified.get('total_r'))}")
print(f" 30d hold net avg R: {_r(hold_30.get('net_avg_r'))}")
print(f" 30d hold total R: {_r(hold_30.get('total_r'))}")
if hold_policy:
print(f" hold CAGR: {_pct(hold_policy.get('cagr_pct'))}")
print(f" hold max drawdown: {_pct(hold_policy.get('max_drawdown_pct'))}")
print(f" hold Sharpe: {hold_policy.get('sharpe')}")
print(f" hold trades: {hold_policy.get('trades')}")
async def _main() -> None:
args = _parse_args()
snapshot = Path(args.snapshot)
if not snapshot.exists():
raise SystemExit(f"Snapshot not found: {snapshot}")
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
if args.allow_spawn:
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
from app.config import settings
from app.services.backtest_service import run_backtest
if args.workers is not None:
settings.backtest_workers = args.workers
output = Path(args.out) if args.out else _default_output_path()
output.parent.mkdir(parents=True, exist_ok=True)
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
last_progress: tuple[int, int] | None = None
def progress(done: int, total: int, symbol: str) -> None:
nonlocal last_progress
if args.quiet:
return
marker = (done, total)
if marker == last_progress:
return
last_progress = marker
label = f" {symbol}" if symbol else ""
print(f"progress: {done}/{total}{label}", end="\r")
try:
async with Session() as db:
report = await run_backtest(db, progress_cb=progress)
finally:
await engine.dispose()
if not args.quiet:
print("")
with output.open("w", encoding="utf-8") as fh:
json.dump(report, fh, indent=2)
fh.write("\n")
print(f"Report written: {output}")
_print_summary(report)
if __name__ == "__main__":
asyncio.run(_main())
+8 -1
View File
@@ -27,9 +27,10 @@ class TestActivationConfig:
assert config == {
"min_momentum_percentile": 80.0,
"min_rr": 1.2,
"min_confidence": 55.0,
"min_confidence": 0.0, # off — the July 2026 ablation showed it adds nothing
"require_high_conviction": False,
"exclude_conflicts": False,
"exclude_neutral": True,
}
async def test_update_and_read_back(self, session: AsyncSession):
@@ -62,6 +63,12 @@ class TestActivationConfig:
assert config["require_high_conviction"] is True
assert config["exclude_conflicts"] is True
async def test_exclude_neutral_round_trip(self, session: AsyncSession):
# On by default; can be turned off.
assert (await get_activation_config(session))["exclude_neutral"] is True
await update_activation_config(session, {"exclude_neutral": False})
assert (await get_activation_config(session))["exclude_neutral"] is False
async def test_rejects_negative_rr(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_activation_config(session, {"min_rr": -1.0})
+130
View File
@@ -3,11 +3,14 @@
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from sqlalchemy import select
from app.models.alert import AlertLog
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.score import CompositeScore
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
@@ -111,6 +114,26 @@ async def test_score_drop_seeds_then_alerts(session):
assert await svc._watermark(session, "AAA") == 60.0
def test_format_qualified_includes_current_price_and_target_move():
text = svc._format_qualified({
"symbol": "AAPL",
"direction": "long",
"current_price": 196.42,
"entry_price": 195.80,
"target": 207.50,
"stop_loss": 190.20,
"rr_ratio": 2.1,
"confidence_score": 76.0,
"targets": [{"probability": 63.0}],
})
assert "now 196.42" in text
assert "entry 195.80" in text
assert "target 207.50 (+5.6%)" in text
assert "stop 190.20" in text
assert "P(target) 63%" in text
async def _add_ticker(session, symbol: str, *, watchlisted: bool, close: float,
levels: list[tuple[float, str, int]]) -> int:
user = await session.get(User, 1)
@@ -140,6 +163,8 @@ async def test_sr_proximity_merges_close_levels_to_one_alert(session):
cvx = [m for m in msgs if "CVX" in m[1]]
assert len(cvx) == 1
assert "183.00185.00" in cvx[0][1]
assert "now 182.00 -> 183.00185.00 (+0.5%)" in cvx[0][1]
assert "strength 100" in cvx[0][1]
async def test_sr_proximity_skips_non_watchlist_unqualified(session):
@@ -168,3 +193,108 @@ async def test_dispatch_no_credentials(session):
await svc.update_alert_config(session, enabled=True) # enabled but no token/chat
res = await svc.dispatch_alerts(session)
assert res["status"] == "no_credentials"
async def test_dispatch_bundles_discovery_alerts_and_logs_each_item(session, monkeypatch):
async def fake_collect_qualified(_db):
return [
("qualified:AAPL:long", "🟢 <b>AAPL LONG</b> | now 196.42 | target 207.50 (+5.6%)"),
("qualified:TSLA:short", "🔴 <b>TSLA SHORT</b> | now 292.10 | target 276.50 (-5.3%)"),
]
async def fake_collect_sr(_db):
return [
("sr:MSFT:resistance", "📍 <b>MSFT</b> resistance | now 508.20 -> 512.00 (+0.7%)"),
]
sent: list[str] = []
async def fake_send(_client, _token, _chat_id, text):
sent.append(text)
monkeypatch.setattr(svc, "_collect_qualified", fake_collect_qualified)
monkeypatch.setattr(svc, "_collect_sr_proximity", fake_collect_sr)
monkeypatch.setattr(svc, "_send", fake_send)
await svc.update_alert_config(
session,
enabled=True,
bot_token="token",
telegram_chat_id="chat",
score_drop_enabled=False,
digest_enabled=False,
regime_quadrant_enabled=False,
trade_closed_enabled=False,
)
res = await svc.dispatch_alerts(session)
assert res == {"status": "ok", "sent": 1, "candidates": 3}
assert len(sent) == 1
assert "<b>Signal run</b> — 3 new alert(s)" in sent[0]
assert "<b>Qualified setups</b>" in sent[0]
assert "<b>Near support/resistance</b>" in sent[0]
assert "AAPL LONG" in sent[0]
assert "MSFT" in sent[0]
rows = (
await session.execute(
select(AlertLog.alert_type, AlertLog.dedup_key)
.order_by(AlertLog.alert_type, AlertLog.dedup_key)
)
).all()
assert rows == [
("qualified", "qualified:AAPL:long"),
("qualified", "qualified:TSLA:short"),
("sr_proximity", "sr:MSFT:resistance"),
]
async def _add_closed_trade(session, symbol: str, reason: str, *,
close: float = 110.0, closed_hours_ago: float = 1.0) -> None:
if await session.get(User, 1) is None:
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
await session.flush()
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
now = datetime.now(timezone.utc)
session.add(PaperTrade(
user_id=1, ticker_id=t.id, direction="long",
entry_price=100.0, shares=10.0, stop_loss=95.0, target=120.0,
status="closed", opened_at=now - timedelta(days=5),
close_price=close, closed_at=now - timedelta(hours=closed_hours_ago),
close_reason=reason,
))
await session.commit()
async def test_config_includes_trade_closed_toggle(session):
assert (await svc.get_alert_config(session))["trade_closed_enabled"] is True
cfg = await svc.update_alert_config(session, trade_closed_enabled=False)
assert cfg["trade_closed_enabled"] is False
async def test_collect_closed_trades_filters_manual_and_old(session):
await _add_closed_trade(session, "WIN", "trailing", close=110.0, closed_hours_ago=1)
await _add_closed_trade(session, "MAN", "manual", close=110.0, closed_hours_ago=1) # manual → skip
await _add_closed_trade(session, "OLD", "stop", close=95.0, closed_hours_ago=100) # too old → skip
out = await svc._collect_closed_trades(session)
assert len(out) == 1
_, text = out[0]
assert "WIN" in text and "trailing stop" in text
def test_format_closed_trade_win():
now = datetime.now(timezone.utc)
trade = SimpleNamespace(
direction="long", entry_price=100.0, close_price=110.0, shares=10.0,
stop_loss=95.0, opened_at=now - timedelta(days=12), closed_at=now,
close_reason="trailing",
)
txt = svc._format_closed_trade(trade, "AAA")
assert "" in txt # win
assert "+10.0%" in txt
assert "+2.00R" in txt # +10% over a 5% stop
assert "held 12d" in txt
+535 -18
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import math
from datetime import date, timedelta
from types import SimpleNamespace
import pytest
@@ -24,7 +25,15 @@ async def session():
yield s
def _cand(prob: float, outcome: str, rr: float, qualified: bool = True, direction: str = "long") -> dict:
def _cand(
prob: float,
outcome: str,
rr: float,
qualified: bool = True,
direction: str = "long",
risk_pct: float = 0.05,
hold_days: int = 10,
) -> dict:
target_hit = outcome == OUTCOME_TARGET_HIT
realized = rr if target_hit else (0.0 if outcome == OUTCOME_EXPIRED else -1.0)
return {
@@ -35,9 +44,408 @@ def _cand(prob: float, outcome: str, rr: float, qualified: bool = True, directio
"realized_r": realized,
"qualified": qualified,
"direction": direction,
"risk_pct": risk_pct,
"hold_days": hold_days,
}
# Round-trip cost in R for the default _cand risk_pct: 2 * 0.001 / 0.05 = 0.04R.
_COST_R_005 = 2 * bt.COST_PER_SIDE / 0.05
def _bar(high: float, low: float, close: float, open_: float | None = None) -> SimpleNamespace:
"""Synthetic daily bar. ``open`` defaults to the high so a stop is pierced
intraday (fill at the stop level); pass an explicit open beyond the stop to
model a gap through it."""
return SimpleNamespace(
high=high, low=low, close=close, open=open_ if open_ is not None else high
)
def _signal_test_series(extra_return: float = 0.0) -> tuple[list[date], list[float], list[float], dict[date, float]]:
base = date(2024, 1, 1)
dates = [base + timedelta(days=i) for i in range(280)]
benchmark = [100.0]
closes = [100.0]
for i in range(1, len(dates)):
market_ret = 0.0004 + 0.002 * math.sin(i / 9.0)
benchmark.append(benchmark[-1] * (1.0 + market_ret))
# Same market beta for both test stocks; only ``extra_return`` is
# idiosyncratic drift, which residual momentum should keep.
stock_ret = 1.4 * market_ret + extra_return
closes.append(closes[-1] * (1.0 + stock_ret))
highs = [c * 1.01 for c in closes]
benchmark_closes = dict(zip(dates, benchmark))
return dates, closes, highs, benchmark_closes
def test_signal_values_emit_residual_momentum_only_with_benchmark():
dates, closes, highs, benchmark = _signal_test_series(extra_return=0.0008)
no_benchmark = bt._signal_values(dates, closes, highs, 260)
with_benchmark = bt._signal_values(dates, closes, highs, 260, benchmark)
assert "mom_12_1" in no_benchmark
assert "mom_12_1_resid" not in no_benchmark
assert "mom_12_1_resid" in with_benchmark
def test_residual_momentum_removes_market_beta_but_keeps_specific_drift():
dates, pure_beta, highs, benchmark = _signal_test_series(extra_return=0.0)
_, drift_stock, drift_highs, _ = _signal_test_series(extra_return=0.0008)
pure = bt._signal_values(dates, pure_beta, highs, 260, benchmark)
drift = bt._signal_values(dates, drift_stock, drift_highs, 260, benchmark)
assert pure["mom_12_1_resid"] == pytest.approx(0.0, abs=0.03)
assert drift["mom_12_1_resid"] > pure["mom_12_1_resid"] + 0.12
def test_assigns_raw_and_residual_percentiles_independently():
cands = [
{"iso_week": (2026, 1), "momentum": 0.10, "residual_momentum": 0.30},
{"iso_week": (2026, 1), "momentum": 0.30, "residual_momentum": 0.10},
{"iso_week": (2026, 1), "momentum": 0.20, "residual_momentum": 0.20},
]
bt._assign_momentum_percentiles(cands)
bt._assign_residual_momentum_percentiles(cands)
by_raw = {c["momentum"]: c["momentum_percentile"] for c in cands}
by_resid = {c["residual_momentum"]: c["residual_momentum_percentile"] for c in cands}
assert by_raw[0.30] == 100.0
assert by_raw[0.10] == 0.0
assert by_resid[0.30] == 100.0
assert by_resid[0.10] == 0.0
def test_activation_percentile_prefers_residual_with_raw_fallback():
cands = [
{"momentum_percentile": 80.0, "residual_momentum_percentile": 95.0},
{"momentum_percentile": 70.0, "residual_momentum_percentile": None},
]
bt._assign_activation_momentum_percentiles(cands)
assert cands[0][bt.PRODUCTION_PERCENTILE_KEY] == 95.0
assert cands[1][bt.PRODUCTION_PERCENTILE_KEY] == 70.0
def test_strategy_variants_keep_only_current_research_candidates():
variants = {cfg["variant"]: cfg for cfg in bt.STRATEGY_VARIANTS}
assert "production_raw_80_fixed10" not in variants
assert "raw_80_regime_scaled" not in variants
assert "residual_80_regime_scaled" not in variants
assert "residual_90_fixed10" not in variants
assert "raw_90_fixed15" not in variants
assert "residual_80_fixed20" not in variants
assert variants["production_residual_80_fixed10"]["percentile_key"] == bt.PRODUCTION_PERCENTILE_KEY
assert variants["legacy_raw_80_fixed10"]["percentile_key"] == bt.RAW_PERCENTILE_KEY
assert variants["residual_80_fixed15"]["max_positions"] == 15
assert all(cfg["risk_scale"] is None for cfg in bt.STRATEGY_VARIANTS)
def test_strategy_variant_sims_emit_fixed_variants_without_mutating_qualified(monkeypatch):
cands = [{
"qualified": False,
"meets_core": True,
"direction": "long",
"momentum_percentile": 90.0,
"residual_momentum_percentile": 91.0,
"activation_momentum_percentile": 91.0,
}]
calls = []
def fake_sim(candidates, prices, spy_closes, exit_policy, hold_days, **kwargs):
calls.append({"exit_policy": exit_policy, "hold_days": hold_days, **kwargs})
return {
"starting_capital": bt.SIM_STARTING_CAPITAL,
"final_equity": 11_000.0,
"total_return_pct": 10.0,
"cagr_pct": 9.0,
"max_drawdown_pct": 5.0,
"sharpe": 1.1,
"trades": 1,
"win_rate": 100.0,
"avg_trade_pnl": 100.0,
"best_trade_r": 1.0,
"worst_trade_r": 1.0,
"best_trade_pnl": 100.0,
"worst_trade_pnl": 100.0,
"avg_hold_days": 30.0,
"skipped_book_full": 0,
"spy_return_pct": 1.0,
"yearly_returns": [],
"start_date": "2026-01-01",
"end_date": "2026-02-01",
}
monkeypatch.setattr(bt, "_simulate_portfolio", fake_sim)
rows = bt._strategy_variant_sims(cands, {}, {}, 30)
assert [r["variant"] for r in rows] == [cfg["variant"] for cfg in bt.STRATEGY_VARIANTS]
assert all(call["exit_policy"] == "hold" for call in calls)
assert any(call["ranking_key"] == bt.PRODUCTION_PERCENTILE_KEY for call in calls)
assert any(call["ranking_key"] == bt.RAW_PERCENTILE_KEY for call in calls)
assert any(call["max_positions"] == 15 for call in calls)
assert cands[0]["qualified"] is False
def test_build_research_recommendation_applies_promotion_rules():
report = {
"strategy_variants": {"variants": [
{"variant": "production_residual_80_fixed10", "label": "Base", "sharpe": 1.40,
"max_drawdown_pct": 20.0, "cagr_pct": 32.0, "skipped_book_full": 7},
{"variant": "residual_80_fixed15", "label": "Capacity", "sharpe": 1.39,
"max_drawdown_pct": 20.0, "cagr_pct": 32.0, "skipped_book_full": 0},
{"variant": "raw_90_fixed10", "label": "Cutoff 90", "sharpe": 1.25,
"max_drawdown_pct": 19.0, "cagr_pct": 28.0},
]},
}
rec = bt._build_research_recommendation(report)
by_topic = {item["topic"]: item for item in rec["items"]}
assert by_topic["capacity_15"]["candidate"] is False
assert "not needed yet" in by_topic["capacity_15"]["text"]
assert by_topic["cutoff_90"]["candidate"] is False
assert "Cutoff 90" in by_topic["cutoff_90"]["text"]
class TestStopFillR:
def test_intraday_fill_at_stop(self):
assert bt._stop_fill_r("long", 100.0, 95.0, _bar(101, 94, 96)) == pytest.approx(-1.0)
def test_gap_fill_at_open(self):
# Opens at 92, below the 95 stop → filled at the open, worse than 1R.
assert bt._stop_fill_r("long", 100.0, 95.0, _bar(93, 90, 91, open_=92)) == pytest.approx(-1.6)
def test_short_gap_fill_at_open(self):
# Short stop 105; opens at 107 above it → fill 107.
assert bt._stop_fill_r("short", 100.0, 105.0, _bar(110, 104, 108, open_=107)) == pytest.approx(-1.4)
class TestRiskAndStopDay:
def test_no_stop(self):
risk, stop_day = bt._risk_and_stop_day("long", 100.0, 95.0, [_bar(109, 101, 108)], 30)
assert risk == pytest.approx(0.05)
assert stop_day is None
def test_stop_day_is_one_based(self):
bars = [_bar(102, 99, 101), _bar(101, 94, 96)]
risk, stop_day = bt._risk_and_stop_day("long", 100.0, 95.0, bars, 30)
assert risk == pytest.approx(0.05)
assert stop_day == 2
def test_short_direction(self):
_, stop_day = bt._risk_and_stop_day("short", 100.0, 105.0, [_bar(106, 101, 104)], 30)
assert stop_day == 1
class TestTimeExits:
def test_long_exits_at_horizon_close(self):
bars = [_bar(103, 99, 102), _bar(105, 101, 104), _bar(107, 103, 106)]
res = bt._time_exits("long", 100.0, 95.0, bars, (2, 5))
assert res[2] == pytest.approx(0.8) # close 104 → +4% / 5% risk
assert res[5] == pytest.approx(1.2) # only 3 bars → last close 106
def test_stop_on_first_bar_loses_everywhere(self):
res = bt._time_exits("long", 100.0, 95.0, [_bar(101, 94, 96), _bar(105, 101, 104)], (1, 5))
assert res[1] == pytest.approx(-1.0)
assert res[5] == pytest.approx(-1.0)
def test_stop_after_short_horizon_only_hits_long_hold(self):
# Day-2 close banked by the 2-day hold; the stop on day 3 only hits n=5.
bars = [_bar(103, 99, 102), _bar(104, 100, 103), _bar(101, 94, 95)]
res = bt._time_exits("long", 100.0, 95.0, bars, (2, 5))
assert res[2] == pytest.approx(0.6) # close 103 → +3% / 5% risk
assert res[5] == pytest.approx(-1.0)
def test_short_direction(self):
res = bt._time_exits("short", 100.0, 105.0, [_bar(101, 95, 96)], (1,))
assert res[1] == pytest.approx(0.8) # close 96 → +4% / 5% risk
def test_zero_risk_returns_zero(self):
res = bt._time_exits("long", 100.0, 100.0, [_bar(103, 99, 102)], (5,))
assert res[5] == 0.0
def test_gap_through_stop_fills_at_open(self):
res = bt._time_exits("long", 100.0, 95.0, [_bar(93, 90, 91, open_=92)], (5,))
assert res[5] == pytest.approx(-1.6)
class TestTimeExitBucket:
def test_bucket(self):
cands = [
{"time_r": {5: 1.4, 21: 0.8}, "risk_pct": 0.10},
{"time_r": {5: -1.0, 21: -1.0}, "risk_pct": 0.10},
{"time_r": {5: 0.5, 21: 0.5}, "risk_pct": 0.10},
]
b = bt._time_exit_bucket(cands, 5)
assert b["hold_days"] == 5
assert b["total"] == 3
assert b["wins"] == 2
assert b["win_rate"] == pytest.approx(66.7, abs=0.1)
assert b["avg_r"] == pytest.approx(0.3, abs=0.01)
assert b["net_avg_r"] == pytest.approx(0.28, abs=0.01)
assert b["best_r"] == pytest.approx(1.4)
assert b["worst_r"] == pytest.approx(-1.0)
# No stop_day on any candidate → every hold runs the full 5 days.
assert b["avg_hold_days"] == 5.0
assert b["net_r_per_day"] == pytest.approx(0.28 / 5.0, abs=0.001)
# robustness on net rs [1.38, -1.02, 0.48]
assert b["median_net_r"] == pytest.approx(0.48, abs=0.001)
assert b["profit_factor"] == pytest.approx(1.86 / 1.02, abs=0.01)
assert b["net_avg_r_ex_top5"] == pytest.approx((0.48 - 1.02) / 2, abs=0.001)
def test_missing_hold_skipped(self):
b = bt._time_exit_bucket([{"time_r": {5: 1.0}}], 21)
assert b["total"] == 0
assert b["avg_r"] is None
def _acand(
rr: float = 2.0,
conf: float = 60.0,
action: str = "LONG_MODERATE",
mp: float | None = 90.0,
direction: str = "long",
) -> dict:
"""Ablation candidate: meets_core mirrors the default floors (min_rr 1.2,
min_confidence 55, exclude_neutral on)."""
action_dir = "long" if action.startswith("LONG") else "short" if action.startswith("SHORT") else "neutral"
meets = rr >= 1.2 and conf >= 55.0 and action_dir != "neutral" and action_dir == direction
return {
"rr": rr,
"confidence": conf,
"action": action,
"momentum_percentile": mp,
"activation_momentum_percentile": mp,
"direction": direction,
"meets_core": meets,
"risk_level": "Low",
"target_hit": True,
"outcome": OUTCOME_TARGET_HIT,
"realized_r": rr,
"risk_pct": 0.05,
"time_r": {d: 0.5 for d in bt.TIME_EXIT_DAYS},
}
class TestGateAblation:
ACTIVATION = {
"min_rr": 1.2,
"min_confidence": 55.0,
"exclude_neutral": True,
"require_high_conviction": False,
"exclude_conflicts": False,
}
def test_variant_counts(self):
cands = [
_acand(), # clears everything
_acand(conf=40.0), # fails confidence floor
_acand(rr=1.0), # fails R:R floor
_acand(action="NEUTRAL"), # fails NEUTRAL exclusion
_acand(mp=50.0), # fails the momentum cutoff
_acand(direction="short", action="SHORT_MODERATE", mp=95.0), # short — gated out
]
rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 80.0)}
assert rows["all_floors"]["total"] == 1
assert rows["no_confidence_floor"]["total"] == 2
assert rows["no_rr_floor"]["total"] == 2
assert rows["no_neutral_exclusion"]["total"] == 2
assert rows["momentum_only"]["total"] == 4
assert rows["all_floors"]["net_avg_r"] is not None
# Every variant is also graded under the hold-to-horizon exit.
assert rows["all_floors"]["hold_days"] == max(bt.TIME_EXIT_DAYS)
assert rows["all_floors"]["hold_avg_r"] == pytest.approx(0.5)
assert rows["all_floors"]["hold_net_avg_r"] is not None
assert rows["momentum_only"]["hold_total_r"] == pytest.approx(4 * 0.5, abs=0.01)
def test_threshold_zero_disables_momentum_gate(self):
# Floors only: the short and the low-momentum long both pass all_floors.
cands = [_acand(mp=50.0), _acand(direction="short", action="SHORT_MODERATE", mp=None)]
rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 0.0)}
assert rows["all_floors"]["total"] == 2
def _sim_prices(start_ord: int, closes: list[float]) -> tuple:
"""Column arrays for consecutive daily bars: open = close (no gaps),
high/low = close ± 1."""
ords = list(range(start_ord, start_ord + len(closes)))
return (
ords,
list(closes),
[c + 1.0 for c in closes],
[c - 1.0 for c in closes],
list(closes),
[1_000_000] * len(closes),
)
def _sim_cand(
sym: str, day_ord: int, entry: float, stop: float, target: float, mp: float = 90.0
) -> dict:
return {
"qualified": True,
"direction": "long",
"symbol": sym,
"date": date.fromordinal(day_ord).isoformat(),
"entry": entry,
"stop": stop,
"target": target,
"momentum_percentile": mp,
"activation_momentum_percentile": mp,
}
class TestSimulatePortfolio:
ORD = date(2025, 1, 6).toordinal()
def test_hold_policy_accounting(self):
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
prices = {"AAA": _sim_prices(self.ORD, closes)}
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0)
sim = bt._simulate_portfolio([cand], prices, None, "hold", 3)
assert sim is not None
assert sim["trades"] == 1
# 20 shares (1% risk / $5 stop distance), exit at the day-3 close 106:
# pnl = 2120 2000 2.00 entry cost 2.12 exit cost = 115.88
assert sim["final_equity"] == pytest.approx(10_115.88, abs=0.01)
assert sim["win_rate"] == 100.0
assert sim["best_trade_r"] == pytest.approx(1.2)
assert sim["avg_hold_days"] == 3.0
assert sim["max_drawdown_pct"] == 0.0
assert sim["cagr_pct"] is None # window far too short to annualize
assert sim["spy_return_pct"] is None
assert sim["yearly_returns"] == [
{"year": 2025, "return_pct": pytest.approx(1.2, abs=0.05)}
]
def test_target_policy_exits_at_target(self):
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
prices = {"AAA": _sim_prices(self.ORD, closes)}
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=105.0)
sim = bt._simulate_portfolio([cand], prices, None, "target", 30)
assert sim is not None
assert sim["trades"] == 1
assert sim["best_trade_r"] == pytest.approx(1.0) # filled exactly at 105
def test_stop_gap_fills_at_open(self):
# Day-1 bar gaps to a 90 open, below the 95 stop → fill at the open.
ords = list(range(self.ORD, self.ORD + 2))
prices = {"AAA": (ords, [100.0, 90.0], [101.0, 92.0], [99.0, 88.0], [100.0, 91.0], [1, 1])}
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=120.0)
sim = bt._simulate_portfolio([cand], prices, None, "hold", 30)
assert sim is not None
assert sim["trades"] == 1
assert sim["worst_trade_r"] == pytest.approx(-2.0) # (90 100) / 5
def test_nothing_qualified_returns_none(self):
assert bt._simulate_portfolio([], {}, None, "hold", 30) is None
def test_bucket_stats_counts_and_expectancy():
cands = [
_cand(70, OUTCOME_TARGET_HIT, 3.0), # +3R win
@@ -55,6 +463,18 @@ def test_bucket_stats_counts_and_expectancy():
# avg R = (3 + 2 - 1 + 0) / 4 = 1.0
assert s["avg_r"] == 1.0
assert s["total_r"] == 4.0
# net = gross minus a 0.04R round trip per candidate (risk_pct 0.05)
assert s["net_avg_r"] == pytest.approx(1.0 - _COST_R_005, abs=0.001)
assert s["net_total_r"] == pytest.approx(4.0 - 4 * _COST_R_005, abs=0.01)
assert s["best_r"] == 3.0
assert s["worst_r"] == -1.0
assert s["avg_hold_days"] == 10.0
assert s["net_r_per_day"] == pytest.approx((1.0 - _COST_R_005) / 10.0, abs=0.001)
# robustness: net rs are [2.96, 1.96, -1.04, -0.04]
assert s["median_net_r"] == pytest.approx(0.96, abs=0.001)
assert s["profit_factor"] == pytest.approx(4.92 / 1.08, abs=0.01)
# ex-top-5%: ceil(4 * 0.05) = 1 winner trimmed → mean of the remaining three
assert s["net_avg_r_ex_top5"] == pytest.approx((1.96 - 1.04 - 0.04) / 3, abs=0.001)
def test_bucket_stats_empty():
@@ -62,26 +482,102 @@ def test_bucket_stats_empty():
assert s["total"] == 0
assert s["hit_rate"] is None
assert s["avg_r"] is None
assert s["net_avg_r"] is None
def test_calibration_buckets():
cands = [
_cand(65, OUTCOME_TARGET_HIT, 2.0),
_cand(62, OUTCOME_STOP_HIT, 2.0),
_cand(15, OUTCOME_STOP_HIT, 2.0),
]
rows = bt._calibration(cands)
by_bucket = {r["bucket"]: r for r in rows}
assert by_bucket["60-80%"]["n"] == 2
assert by_bucket["60-80%"]["realized_hit_rate"] == 50.0 # 1 of 2 hit
assert by_bucket["0-20%"]["n"] == 1
assert by_bucket["0-20%"]["realized_hit_rate"] == 0.0
def test_bucket_stats_no_risk_pct_means_no_cost():
c = _cand(50, OUTCOME_TARGET_HIT, 2.0)
del c["risk_pct"]
s = bt._bucket_stats([c])
assert s["net_avg_r"] == s["avg_r"]
assert s["net_total_r"] == s["total_r"]
def test_build_recommendation_reads_the_report():
report = {
"overall_qualified": {"net_avg_r": 0.13, "net_avg_r_ex_top5": 0.05},
"time_exit_sweep": [
{"hold_days": 21, "net_avg_r": 0.38},
{"hold_days": 30, "net_avg_r": 0.50, "net_avg_r_ex_top5": 0.21},
],
"gate_ablation": [
{"variant": "all_floors", "total": 100, "hold_net_avg_r": 0.50},
{"variant": "no_confidence_floor", "total": 130, "hold_net_avg_r": 0.49},
{"variant": "no_rr_floor", "total": 400, "hold_net_avg_r": 0.34},
{"variant": "no_neutral_exclusion", "total": 120, "hold_net_avg_r": 0.46},
],
"sweep": [
{"min_momentum_percentile": 80.0, "net_avg_r": 0.13, "total": 100},
{"min_momentum_percentile": 60.0, "net_avg_r": 0.05, "total": 300},
{"min_momentum_percentile": 0.0, "net_avg_r": -0.12, "total": 1000},
],
"portfolio_sim": {"policies": [
{"policy": "target", "cagr_pct": 23.7, "total_return_pct": 134.8,
"spy_return_pct": 95.9, "max_drawdown_pct": 20.7},
{"policy": "hold", "cagr_pct": 31.9, "total_return_pct": 203.6,
"spy_return_pct": 95.9, "max_drawdown_pct": 21.2},
]},
}
rec = bt._build_recommendation(report)
by_topic: dict[str, list[str]] = {}
for item in rec["items"]:
by_topic.setdefault(item["topic"], []).append(item["text"])
assert rec["headline"] is not None and "hold 30" in rec["headline"]
assert any("hold 30 trading days" in t for t in by_topic["exit"])
gate_texts = " | ".join(by_topic["gate"])
assert "confidence floor adds nothing" in gate_texts
assert "keep the R:R floor" in gate_texts
assert "keep the NEUTRAL exclusion" in gate_texts
assert "80" in by_topic["cutoff"][0]
assert "beats" in by_topic["benchmark"][0]
# robustness is judged under the RECOMMENDED exit (the 30d hold), not the
# target model the recommendation advises abandoning
assert any(
"not a handful of outliers" in t and "under the recommended 30d hold" in t
for t in by_topic["robustness"]
)
def test_build_recommendation_flags_outlier_dependence():
rec = bt._build_recommendation({
"overall_qualified": {"net_avg_r": 0.13, "net_avg_r_ex_top5": -0.02},
})
robustness = [i["text"] for i in rec["items"] if i["topic"] == "robustness"]
assert robustness and "WARNING" in robustness[0]
def test_window_setups_too_short_returns_empty():
assert bt._window_setups([], {}, {}) == []
def test_replay_ticker_candidates_carry_gate_fields():
"""The ablation recomputes floors from candidate fields — a candidate missing
action/risk_level silently zeroes the ablation rows (July 2026 regression)."""
from app.services.admin_service import ACTIVATION_DEFAULTS
from app.services.recommendation_service import DEFAULT_RECOMMENDATION_CONFIG
base = date(2025, 1, 1)
bars = []
for i in range(160):
close = 100.0 + 8.0 * math.sin(i / 6.0)
bars.append(SimpleNamespace(
date=base + timedelta(days=i),
open=close,
high=close + 1.5,
low=close - 1.5,
close=close,
volume=1_000_000 + (i % 5) * 1000,
))
cands = bt._replay_ticker(
"OSC", bars, dict(DEFAULT_RECOMMENDATION_CONFIG), dict(ACTIVATION_DEFAULTS)
)
assert cands, "expected the oscillating series to produce candidates"
for c in cands:
assert c.get("action") is not None
assert "risk_level" in c
async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None:
t = Ticker(symbol=symbol)
session.add(t)
@@ -108,16 +604,37 @@ async def test_run_backtest_smoke(session):
# well-formed report
assert report["tickers"] == 1
assert isinstance(report["candidates"], int)
for key in ("overall_qualified", "overall_all", "by_direction", "calibration", "sweep"):
for key in (
"overall_qualified", "overall_all", "by_direction", "sweep",
"gate_ablation", "time_exit_sweep", "portfolio_sim", "strategy_variants",
"recommendation", "research_recommendation",
):
assert key in report
# the oscillating series should yield at least some resolved setups
assert report["candidates"] >= 1
# cost assumption is reported, and every bucket carries net numbers
assert report["params"]["cost_per_side_pct"] == pytest.approx(bt.COST_PER_SIDE * 100)
assert "net_avg_r" in report["overall_all"]
# ablation baseline reproduces the qualified set exactly, and every row
# carries the hold-to-horizon grading alongside the target model
ablation = {r["variant"]: r for r in report["gate_ablation"]}
assert ablation["all_floors"]["total"] == report["overall_qualified"]["total"]
for row in report["gate_ablation"]:
assert "hold_net_avg_r" in row
# time-exit sweep covers the configured hold lengths
assert [r["hold_days"] for r in report["time_exit_sweep"]] == list(bt.TIME_EXIT_DAYS)
# portfolio simulation section is always present (policies may be empty
# when nothing qualifies)
assert "portfolio_sim" in report
assert isinstance(report["portfolio_sim"]["policies"], list)
assert report["portfolio_sim"]["params"]["max_positions"] == bt.SIM_MAX_POSITIONS
assert isinstance(report["strategy_variants"]["variants"], list)
# sweep: lowering the momentum-percentile cutoff can only add qualifiers
sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True)
counts = [r["total"] for r in sweep]
assert counts == sorted(counts) # ascending as threshold descends
# every calibration row is internally consistent
for row in report["calibration"]:
assert 0 <= row["realized_hit_rate"] <= 100
assert row["n"] >= 1
+29
View File
@@ -0,0 +1,29 @@
"""Tests for benchmark return / alpha helper (pure, no DB)."""
from __future__ import annotations
from datetime import date
import pytest
from app.services.benchmark_service import benchmark_return_pct
def test_benchmark_return_basic():
closes = {date(2026, 1, 2): 100.0, date(2026, 1, 5): 110.0}
assert benchmark_return_pct(closes, date(2026, 1, 2), date(2026, 1, 5)) == pytest.approx(10.0)
def test_benchmark_return_uses_nearest_prior_trading_day():
# No bar on the 4th (weekend) → falls back to the 2nd; as-of the 12th → the 9th.
closes = {date(2026, 1, 2): 100.0, date(2026, 1, 9): 120.0}
assert benchmark_return_pct(closes, date(2026, 1, 4), date(2026, 1, 12)) == pytest.approx(20.0)
def test_benchmark_return_none_when_empty():
assert benchmark_return_pct({}, date(2026, 1, 2), date(2026, 1, 5)) is None
def test_benchmark_return_none_when_open_before_history():
closes = {date(2026, 1, 10): 100.0}
assert benchmark_return_pct(closes, date(2026, 1, 2), date(2026, 1, 12)) is None
+25
View File
@@ -6,6 +6,8 @@ from datetime import date, timedelta
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
from app.services.event_study_service import (
_lead,
_percentile,
detect_events,
event_centered,
signal_centered,
@@ -40,6 +42,29 @@ def test_detect_events_two_after_recovery():
assert len(events) == 2
def test_detect_events_cooldown_suppresses_close_recross():
# Dips below threshold then re-crosses only a few bars later.
closes = [100.0] * 300 + [85.0] * 3 + [100.0] * 3 + [85.0] * 3
dates = _days(len(closes))
assert len(detect_events(closes, dates, threshold_pct=15.0, cooldown=40)) == 1
assert len(detect_events(closes, dates, threshold_pct=15.0, cooldown=3)) == 2
def test_percentile_interpolation():
vals = [float(v) for v in range(0, 101, 10)] # 0,10,...,100
assert _percentile(vals, 50) == 50.0
assert _percentile(vals, 80) == 80.0
assert _percentile([], 50) is None
def test_lead_earliest_crossing():
dates = _days(200)
t0 = 120
indicator = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
assert _lead(indicator, t0, dates, pre=60, threshold=60.0) == 30
assert _lead(indicator, t0, dates, pre=60, threshold=80.0) is None
# ---------------------------------------------------------------------------
# Event-centered lead time
# ---------------------------------------------------------------------------
+62
View File
@@ -0,0 +1,62 @@
"""Tests for the ingestion service — honest reporting of empty provider fetches."""
from __future__ import annotations
from datetime import date, timedelta
import pytest
from app.models.ticker import Ticker
from app.providers.protocol import OHLCVData
from app.services import ingestion_service as svc
from tests.conftest import MockMarketDataProvider, _test_session_factory # type: ignore
@pytest.fixture
async def session():
async with _test_session_factory() as s:
yield s
async def _add_ticker(session, symbol: str) -> None:
session.add(Ticker(symbol=symbol))
await session.commit()
def _bars(symbol: str, n: int) -> list[OHLCVData]:
today = date.today()
return [
OHLCVData(ticker=symbol, date=today - timedelta(days=i),
open=100.0, high=101.0, low=99.0, close=100.0, volume=1000)
for i in range(n)
]
async def test_empty_fetch_on_new_ticker_reports_no_data(session):
# A non-US symbol (e.g. RHM/Rheinmetall) Alpaca doesn't cover → empty bars.
# This must NOT report success; it surfaces as no_data.
await _add_ticker(session, "RHM")
result = await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=[]), "RHM")
assert result.status == "no_data"
assert result.records_ingested == 0
assert "provider" in (result.message or "").lower()
async def test_happy_path_ingests_bars(session):
await _add_ticker(session, "AAA")
result = await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=_bars("AAA", 3)), "AAA")
assert result.status == "complete"
assert result.records_ingested == 3
async def test_empty_fetch_with_existing_history_is_up_to_date(session):
# Covered ticker, just no new bars in the window → complete, not no_data.
await _add_ticker(session, "BBB")
await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=_bars("BBB", 2)), "BBB")
result = await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=[]), "BBB")
assert result.status == "complete"
assert result.records_ingested == 0
+51 -4
View File
@@ -1,4 +1,4 @@
"""Unit tests for the cross-sectional 12-1 momentum ranking."""
"""Unit tests for the cross-sectional activation momentum ranking."""
from __future__ import annotations
@@ -35,6 +35,21 @@ async def _seed(session, symbol: str, rate: float, n: int = 280) -> None:
await session.commit()
async def _seed_closes(session, symbol: str, closes: list[float]) -> None:
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
base = date(2024, 1, 1)
for i, close in enumerate(closes):
session.add(OHLCVRecord(
ticker_id=t.id,
date=base + timedelta(days=i),
open=close, high=close, low=close, close=close,
volume=1_000_000,
))
await session.commit()
def test_compute_momentum_insufficient_history():
assert ms.compute_12_1_momentum([100.0] * 100) is None
@@ -47,7 +62,11 @@ def test_compute_momentum_value():
assert m > 0
async def test_ranks_universe_into_percentiles(session):
async def test_ranks_universe_into_raw_percentiles_when_benchmark_missing(session, monkeypatch):
async def no_benchmark(_db):
return {}
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
await _seed(session, "HIGH", rate=1.010) # strong uptrend → top momentum
await _seed(session, "MID", rate=1.002)
await _seed(session, "LOW", rate=0.999) # declining → bottom momentum
@@ -58,7 +77,31 @@ async def test_ranks_universe_into_percentiles(session):
assert pct["LOW"] == 0.0
async def test_short_history_ticker_is_unranked(session):
async def test_ranks_universe_into_residual_percentiles_when_benchmark_available(session, monkeypatch):
base = date(2024, 1, 1)
n = 280
benchmark = {base + timedelta(days=i): 100.0 * (1.001 ** i) for i in range(n)}
async def with_benchmark(_db):
return benchmark
monkeypatch.setattr(ms, "_load_activation_benchmark", with_benchmark)
market = [benchmark[base + timedelta(days=i)] for i in range(n)]
await _seed_closes(session, "DRIFT", [market[i] * (1.0008 ** i) for i in range(n)])
await _seed_closes(session, "BETA", market)
await _seed_closes(session, "LAG", [market[i] * (0.9992 ** i) for i in range(n)])
pct = await ms.compute_momentum_percentiles(session)
assert pct["DRIFT"] == 100.0
assert pct["BETA"] == 50.0
assert pct["LAG"] == 0.0
async def test_short_history_ticker_is_unranked(session, monkeypatch):
async def no_benchmark(_db):
return {}
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
await _seed(session, "LONG", rate=1.005)
await _seed(session, "SHORTHX", rate=1.005, n=100) # < 1y → no momentum
@@ -67,5 +110,9 @@ async def test_short_history_ticker_is_unranked(session):
assert "SHORTHX" not in pct
async def test_empty_universe_returns_empty(session):
async def test_empty_universe_returns_empty(session, monkeypatch):
async def no_benchmark(_db):
return {}
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
assert await ms.compute_momentum_percentiles(session) == {}
+23
View File
@@ -317,3 +317,26 @@ class TestGetPerformanceStats:
stats = await get_performance_stats(db_session)
assert stats["overall"]["total"] == 1
async def test_immature_setups_excluded_and_counted_as_maturing(self, db_session: AsyncSession):
ticker = await _make_ticker(db_session)
now = datetime.now(timezone.utc)
# Matured (detected well beyond the window) → counted in the stats.
db_session.add(_make_setup(
ticker, rr=2.0, actual_outcome=OUTCOME_TARGET_HIT, detected=now - timedelta(days=90),
))
# Young but already resolved → excluded from stats, reported as maturing.
db_session.add(_make_setup(
ticker, rr=2.0, actual_outcome=OUTCOME_STOP_HIT, detected=now,
))
# Young and still pending → also maturing.
db_session.add(_make_setup(ticker, detected=now))
await db_session.flush()
stats = await get_performance_stats(db_session)
assert stats["overall"]["total"] == 1 # only the matured win
assert stats["overall"]["wins"] == 1
assert stats["overall"]["hit_rate"] == 100.0
assert stats["pending"] == 1
assert stats["maturing"] == 2 # young resolved + pending
+176
View File
@@ -7,7 +7,9 @@ from datetime import date, datetime, timedelta, timezone
import pytest
from app.exceptions import ValidationError
from app.models.benchmark_price import BenchmarkPrice
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker
from app.models.user import User
from app.services import paper_trade_service as svc
@@ -92,6 +94,7 @@ async def _add_bars(session, ticker_id: int, highs_lows: list[tuple[float, float
async def test_resolve_closes_on_target(session):
await svc.set_exit_policy(session, mode="target")
tid = await _seed(session, "AAA", close=100.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0)
@@ -105,6 +108,7 @@ async def test_resolve_closes_on_target(session):
async def test_resolve_closes_on_stop(session):
await svc.set_exit_policy(session, mode="target")
tid = await _seed(session, "AAA", close=100.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0)
@@ -116,6 +120,7 @@ async def test_resolve_closes_on_stop(session):
async def test_resolve_leaves_open_when_neither_hit(session):
await svc.set_exit_policy(session, mode="target")
tid = await _seed(session, "AAA", close=100.0)
await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0)
@@ -124,3 +129,174 @@ async def test_resolve_leaves_open_when_neither_hit(session):
assert closed == 0
rows = await svc.list_trades(session, 1, status="open")
assert len(rows) == 1
async def _seed_benchmark(session, points: dict) -> None:
for d, close in points.items():
session.add(BenchmarkPrice(symbol="SPY", date=d, close=close))
await session.commit()
async def _add_open_trade(session, ticker_id: int, direction: str, *, entry: float,
shares: float, days_ago: int) -> None:
session.add(PaperTrade(
user_id=1, ticker_id=ticker_id, direction=direction, entry_price=entry,
shares=shares, stop_loss=entry * 0.95, target=entry * 1.2, status="open",
opened_at=datetime.now(timezone.utc) - timedelta(days=days_ago),
))
await session.commit()
async def test_alpha_long_open(session):
tid = await _seed(session, "AAA", close=110.0) # current price 110 → +10% on a 100 entry
today = date.today()
await _seed_benchmark(session, {today - timedelta(days=10): 400.0, today: 420.0}) # SPY +5%
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["benchmark_return_pct"] == pytest.approx(5.0)
assert row["alpha_pct"] == pytest.approx(5.0) # +10% trade 5% bench
assert row["alpha_usd"] == pytest.approx(50.0) # 5% of 100*10
async def test_alpha_short_and_missing_benchmark(session):
tid = await _seed(session, "BBB", close=90.0) # price fell to 90 → short +10%
today = date.today()
await _add_open_trade(session, tid, "short", entry=100.0, shares=4, days_ago=10)
# No benchmark data yet → alpha unset, not an error.
row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["alpha_pct"] is None
assert row["benchmark_return_pct"] is None
# Flat benchmark → alpha equals the (direction-signed) trade return.
await _seed_benchmark(session, {today - timedelta(days=10): 400.0, today: 400.0})
row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["benchmark_return_pct"] == pytest.approx(0.0)
assert row["alpha_pct"] == pytest.approx(10.0)
def _b(d: date, hi: float, lo: float):
return svc.Bar(date=d, high=hi, low=lo)
class TestTrailingClose:
def test_long_locks_gain(self):
# Runs to 120; the 12%-from-peak stop (120 → 105.6) is pierced on the drop.
bars = [_b(date(2026, 1, 2), 120, 110), _b(date(2026, 1, 3), 130, 100)]
hit = svc._trailing_close("long", 100.0, 95.0, 0.12, bars)
assert hit is not None
price, when, reason = hit
assert price == pytest.approx(105.6)
assert reason == "trailing"
assert when == date(2026, 1, 3)
def test_initial_stop_caps_loss(self):
bars = [_b(date(2026, 1, 2), 101, 94)] # through the initial stop before running
hit = svc._trailing_close("long", 100.0, 95.0, 0.12, bars)
assert hit is not None
price, _, reason = hit
assert price == pytest.approx(95.0)
assert reason == "stop"
def test_none_when_neither_hit(self):
bars = [_b(date(2026, 1, 2), 105, 99), _b(date(2026, 1, 3), 106, 100)]
assert svc._trailing_close("long", 100.0, 95.0, 0.12, bars) is None
async def test_exit_policy_defaults_and_round_trip(session):
# Default: the backtest-validated hold-to-horizon exit.
assert await svc.get_exit_policy(session) == {
"mode": "time", "trailing_pct": 12.0, "hold_days": 30,
}
updated = await svc.set_exit_policy(session, mode="target", trailing_pct=15.0, hold_days=21)
assert updated == {"mode": "target", "trailing_pct": 15.0, "hold_days": 21}
assert (await svc.get_exit_policy(session))["mode"] == "target"
async def test_exit_policy_rejects_bad_input(session):
with pytest.raises(ValidationError):
await svc.set_exit_policy(session, mode="bogus")
with pytest.raises(ValidationError):
await svc.set_exit_policy(session, trailing_pct=200.0)
with pytest.raises(ValidationError):
await svc.set_exit_policy(session, hold_days=1)
def _r(d: date, open_: float, hi: float, lo: float, close: float) -> tuple:
return (d, open_, hi, lo, close)
class TestTimeClose:
def test_closes_at_hold_days_close(self):
rows = [
_r(date(2026, 1, 2), 101, 103, 100, 102),
_r(date(2026, 1, 3), 102, 104, 101, 103),
_r(date(2026, 1, 4), 103, 106, 102, 105),
]
assert svc._time_close("long", 95.0, 3, rows) == (105.0, date(2026, 1, 4), "time")
def test_stop_before_horizon(self):
rows = [_r(date(2026, 1, 2), 100, 101, 94, 96)]
assert svc._time_close("long", 95.0, 30, rows) == (95.0, date(2026, 1, 2), "stop")
def test_gap_through_stop_fills_at_open(self):
rows = [_r(date(2026, 1, 2), 92, 93, 90, 91)]
assert svc._time_close("long", 95.0, 30, rows) == (92.0, date(2026, 1, 2), "stop")
def test_none_before_horizon(self):
rows = [_r(date(2026, 1, 2), 101, 103, 100, 102)]
assert svc._time_close("long", 95.0, 5, rows) is None
async def test_resolve_time_mode_closes_at_horizon(session):
await svc.set_exit_policy(session, mode="time", hold_days=2)
tid = await _seed(session, "AAA", close=100.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=200.0)
await _add_bars(session, tid, [(103, 101), (105, 102)], start=date.today())
assert await svc.resolve_open_trades(session) == 1
await session.refresh(trade)
assert trade.status == "closed"
assert trade.close_reason == "time"
assert trade.close_price == pytest.approx((105 + 102) / 2) # day-2 close (= bar mid)
async def test_resolve_time_mode_stop_still_governs(session):
await svc.set_exit_policy(session, mode="time", hold_days=30)
tid = await _seed(session, "AAA", close=100.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=200.0)
await _add_bars(session, tid, [(101, 94)], start=date.today()) # low pierces the stop
assert await svc.resolve_open_trades(session) == 1
await session.refresh(trade)
assert trade.close_reason == "stop"
assert trade.close_price == pytest.approx(95.0)
async def test_resolve_trailing_closes_with_reason(session):
await svc.set_exit_policy(session, mode="trailing", trailing_pct=12.0)
tid = await _seed(session, "AAA", close=100.0)
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
await _add_bars(session, tid, [(120, 110), (130, 100)], start=date.today()) # run up, pull back
assert await svc.resolve_open_trades(session) == 1
closed = await svc.list_trades(session, 1, status="closed")
assert closed[0]["close_reason"] == "trailing"
async def test_manual_close_sets_reason(session):
await _seed(session, "AAA", close=112.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=5, stop_loss=95.0, target=120.0)
await svc.close_trade(session, 1, trade.id)
assert (await svc.list_trades(session, 1, status="closed"))[0]["close_reason"] == "manual"
async def test_list_open_exposes_trailing_stop(session):
await svc.set_exit_policy(session, mode="trailing", trailing_pct=12.0)
tid = await _seed(session, "AAA", close=120.0)
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
await _add_bars(session, tid, [(125, 118)], start=date.today()) # peak 125
row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["trailing_stop"] == pytest.approx(110.0) # 125 * (1 - 0.12)
assert row["trailing_distance_pct"] is not None
+28
View File
@@ -31,6 +31,7 @@ STRICT_GATE = {
def _setup(**kwargs):
base = dict(
direction="long",
rr_ratio=3.0,
confidence_score=80.0,
recommended_action="LONG_HIGH",
@@ -111,6 +112,33 @@ class TestStrictTighteners:
assert setup_qualifies(s, STRICT_GATE) is False
NEUTRAL_GATE = {**DEFAULT_GATE, "exclude_neutral": True}
class TestExcludeNeutral:
def test_neutral_excluded_when_on(self):
assert setup_qualifies(_setup(recommended_action="NEUTRAL"), NEUTRAL_GATE) is False
def test_missing_action_treated_as_neutral(self):
assert setup_qualifies(_setup(recommended_action=None), NEUTRAL_GATE) is False
def test_directional_passes_when_on(self):
assert setup_qualifies(_setup(recommended_action="LONG_MODERATE"), NEUTRAL_GATE) is True
def test_opposing_short_action_fails_for_long_setup(self):
assert setup_qualifies(_setup(direction="long", recommended_action="SHORT_MODERATE"), NEUTRAL_GATE) is False
def test_matching_short_action_still_fails_long_only_momentum_gate(self):
assert setup_qualifies(
_setup(direction="short", recommended_action="SHORT_MODERATE", momentum_percentile=95.0),
{**NEUTRAL_GATE, "min_momentum_percentile": 80.0},
) is False
def test_neutral_allowed_when_off(self):
# Flag absent from the config → NEUTRAL still qualifies (backward compatible).
assert setup_qualifies(_setup(recommended_action="NEUTRAL"), DEFAULT_GATE) is True
class TestBestTargetProbability:
def test_returns_max(self):
s = _setup(targets=[{"probability": 40.0}, {"probability": 72.0}, {"probability": 55.0}])
+27
View File
@@ -6,6 +6,7 @@ from datetime import date, timedelta
from app.services.regime_monitor_service import (
DEFAULT_CONFIG,
_attach_early_warning,
band_for,
compute_regime_score,
f2_credit_spreads,
@@ -35,6 +36,32 @@ def test_band_for():
assert band_for(90) == "breaking"
def test_attach_early_warning_blends():
result = {"total_score": 80.0}
_attach_early_warning(result, 40.0, {"coincident": 0.6, "early_warning": 0.4})
assert result["early_warning"]["score"] == 40.0
assert result["early_warning"]["band"] == "watch"
# combined = (80*0.6 + 40*0.4) / 1.0 = 64
assert result["combined"]["score"] == 64.0
assert result["combined"]["band"] == "elevated"
def test_attach_early_warning_none_falls_back_to_index():
result = {"total_score": 80.0}
_attach_early_warning(result, None, {"coincident": 0.6, "early_warning": 0.4})
assert result["early_warning"]["score"] is None
assert result["combined"]["score"] == 80.0 # no early warning -> just the index
def test_divergence_asof_tolerates_small_lag():
from app.services.regime_monitor_service import _divergence_asof
items = [(date(2026, 6, 1), 55.0), (date(2026, 6, 3), 60.0)]
assert _divergence_asof(items, date(2026, 6, 3)) == 60.0 # exact date
assert _divergence_asof(items, date(2026, 6, 4)) == 60.0 # 1-day lag -> newest
assert _divergence_asof(items, date(2026, 6, 20)) is None # too stale
assert _divergence_asof([], date(2026, 6, 3)) is None
# ---------------------------------------------------------------------------
# Price sub-scores
# ---------------------------------------------------------------------------
+45
View File
@@ -0,0 +1,45 @@
"""Tests for the regime quadrant classification + hysteresis (anti-flicker)."""
from __future__ import annotations
from app.services.alert_service import _classify_quadrant
# Quadrant ids: 1=① hot&brittle (regime low, warning high), 2=② transition
# (both high), 3=③ healthy (both low), 4=④ real downturn (regime high, warning low).
# Dividers: regime 40, early-warning 60; margin 5.
def test_fresh_classification():
assert _classify_quadrant(20, 90, None) == "1" # low regime, high warning
assert _classify_quadrant(70, 90, None) == "2" # both high
assert _classify_quadrant(20, 30, None) == "3" # both low
assert _classify_quadrant(70, 30, None) == "4" # high regime, low warning
def test_hysteresis_holds_inside_deadband():
# From ③ (both low): early-warning nudging just past 60 stays ③ until it
# clears 60 + margin (65).
assert _classify_quadrant(20, 62, prev="3") == "3" # within deadband → no flip
assert _classify_quadrant(20, 66, prev="3") == "1" # clears 65 → flips to ①
def test_hysteresis_sticky_when_already_high():
# From ① (warning high): a dip below 60 keeps ① until it drops past 60 - margin (55).
assert _classify_quadrant(20, 58, prev="1") == "1" # still high (deadband)
assert _classify_quadrant(20, 54, prev="1") == "3" # drops past 55 → back to ③
def test_hysteresis_on_regime_axis():
# From ③: regime rising past 40 stays ③ until it clears 45.
assert _classify_quadrant(43, 30, prev="3") == "3"
assert _classify_quadrant(46, 30, prev="3") == "4"
# From ④: regime easing keeps ④ until below 35.
assert _classify_quadrant(37, 30, prev="4") == "4"
assert _classify_quadrant(34, 30, prev="4") == "3"
def test_boundary_sitting_does_not_flip():
# A point parked exactly on both dividers keeps whatever quadrant it had.
for q in ("1", "2", "3", "4"):
assert _classify_quadrant(40, 60, prev=q) == q
+396 -1
View File
@@ -11,20 +11,29 @@ Zero-candidate and single-candidate scenarios must produce identical results.
from __future__ import annotations
import json
from datetime import date, datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest
from hypothesis import given, settings, HealthCheck, strategies as st
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ohlcv import OHLCVRecord
from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.models.score import CompositeScore
from app.models.score import CompositeScore, DimensionScore
from app.models.sentiment import SentimentScore
from app.services.rr_scanner_service import scan_ticker, get_trade_setups
def _as_utc(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# Session fixtures
# ---------------------------------------------------------------------------
@@ -431,3 +440,389 @@ async def test_get_trade_setups_sorting_rr_desc_composite_desc(db_session: Async
f"Expected symbol order ['SORTD', 'SORTC', 'SORTB', 'SORTA'], "
f"got {symbols}"
)
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context
(bullish sentiment, composite 96) that yields live confidence 97."""
old_scan = datetime(2026, 7, 1, tzinfo=timezone.utc)
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
old_reasoning = (
"LONG (high confidence): 82% with aligned signals "
"(technical=88, momentum=60, sentiment=neutral)."
)
ticker = Ticker(symbol="TTWO")
db_session.add(ticker)
await db_session.flush()
stale_setup = TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=235.0,
stop_loss=220.0,
target=265.0,
rr_ratio=2.0,
composite_score=71.8,
detected_at=old_scan,
confidence_score=82.0,
recommended_action="LONG_HIGH",
reasoning=old_reasoning,
risk_level="High",
)
db_session.add(stale_setup)
db_session.add_all([
DimensionScore(
ticker_id=ticker.id,
dimension="technical",
score=88.0,
is_stale=False,
computed_at=current,
),
DimensionScore(
ticker_id=ticker.id,
dimension="momentum",
score=60.0,
is_stale=False,
computed_at=current,
),
DimensionScore(
ticker_id=ticker.id,
dimension="fundamental",
score=95.0,
is_stale=False,
computed_at=current,
),
DimensionScore(
ticker_id=ticker.id,
dimension="sentiment",
score=85.0,
is_stale=False,
computed_at=current,
),
CompositeScore(
ticker_id=ticker.id,
score=96.0,
is_stale=False,
weights_json="{}",
computed_at=current,
),
SentimentScore(
ticker_id=ticker.id,
classification="bullish",
confidence=85,
source="test",
timestamp=current,
reasoning="",
citations_json="[]",
),
])
await db_session.flush()
return stale_setup
@pytest.mark.asyncio
async def test_live_recommendation_payload_uses_current_score_and_sentiment(
db_session: AsyncSession,
):
"""Latest setup payload should not show stale scan text when score context moved."""
stale_setup = await _seed_stale_setup_with_current_scores(db_session)
old_reasoning = stale_setup.reasoning
rows = await get_trade_setups(
db_session,
symbol="TTWO",
live_recommendation=True,
)
assert len(rows) == 1
row = rows[0]
assert row["composite_score"] == pytest.approx(96.0)
assert row["confidence_score"] == pytest.approx(97.0)
assert row["recommended_action"] == "LONG_HIGH"
assert "sentiment=bullish" in row["reasoning"]
assert "sentiment=neutral" not in row["reasoning"]
persisted = await db_session.get(TradeSetup, stale_setup.id)
assert persisted is not None
assert persisted.composite_score == pytest.approx(71.8)
assert persisted.reasoning == old_reasoning
@pytest.mark.asyncio
async def test_live_recommendation_filters_apply_to_live_values(
db_session: AsyncSession,
):
"""min_confidence must judge the overlaid live confidence, not the stored one."""
await _seed_stale_setup_with_current_scores(db_session)
# Stored confidence is 82 — a stored-column filter would drop this row.
# Live confidence is 97, so it must pass.
rows = await get_trade_setups(
db_session,
symbol="TTWO",
min_confidence=90.0,
live_recommendation=True,
)
assert len(rows) == 1
assert rows[0]["confidence_score"] == pytest.approx(97.0)
# And a floor above the live value must drop it.
rows = await get_trade_setups(
db_session,
symbol="TTWO",
min_confidence=98.0,
live_recommendation=True,
)
assert rows == []
async def _seed_two_direction_setup(db_session: AsyncSession) -> None:
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
ticker = Ticker(symbol="BOTH")
db_session.add(ticker)
await db_session.flush()
db_session.add_all([
TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=100.0,
stop_loss=95.0,
target=112.0,
rr_ratio=2.4,
composite_score=30.0,
detected_at=current,
confidence_score=25.0,
recommended_action="NEUTRAL",
risk_level="Low",
),
TradeSetup(
ticker_id=ticker.id,
direction="short",
entry_price=100.0,
stop_loss=105.0,
target=88.0,
rr_ratio=2.4,
composite_score=30.0,
detected_at=current,
confidence_score=90.0,
recommended_action="SHORT_HIGH",
risk_level="Low",
),
DimensionScore(
ticker_id=ticker.id,
dimension="technical",
score=10.0,
is_stale=False,
computed_at=current,
),
DimensionScore(
ticker_id=ticker.id,
dimension="momentum",
score=10.0,
is_stale=False,
computed_at=current,
),
DimensionScore(
ticker_id=ticker.id,
dimension="fundamental",
score=10.0,
is_stale=False,
computed_at=current,
),
CompositeScore(
ticker_id=ticker.id,
score=30.0,
is_stale=False,
weights_json="{}",
computed_at=current,
),
SentimentScore(
ticker_id=ticker.id,
classification="bearish",
confidence=90,
source="test",
timestamp=current,
reasoning="",
citations_json="[]",
),
])
await db_session.flush()
@pytest.mark.asyncio
async def test_live_recommendation_action_independent_of_direction_filter(
db_session: AsyncSession,
):
await _seed_two_direction_setup(db_session)
all_rows = await get_trade_setups(
db_session,
symbol="BOTH",
live_recommendation=True,
)
filtered_rows = await get_trade_setups(
db_session,
symbol="BOTH",
direction="long",
live_recommendation=True,
)
long_from_all = next(row for row in all_rows if row["direction"] == "long")
assert len(filtered_rows) == 1
assert long_from_all["recommended_action"] == "SHORT_HIGH"
assert filtered_rows[0]["recommended_action"] == "SHORT_HIGH"
@pytest.mark.asyncio
async def test_live_overlay_preserves_setup_specific_risk_and_context(
db_session: AsyncSession,
):
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
ticker = Ticker(symbol="RISK")
db_session.add(ticker)
await db_session.flush()
db_session.add_all([
TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=100.0,
stop_loss=95.0,
target=112.0,
rr_ratio=2.4,
composite_score=50.0,
detected_at=current,
confidence_score=50.0,
recommended_action="NEUTRAL",
risk_level="Medium",
conflict_flags_json=json.dumps([
"target-availability: Fewer than 3 valid S/R targets available"
]),
),
DimensionScore(
ticker_id=ticker.id,
dimension="technical",
score=50.0,
is_stale=False,
computed_at=current,
),
DimensionScore(
ticker_id=ticker.id,
dimension="momentum",
score=50.0,
is_stale=False,
computed_at=current,
),
CompositeScore(
ticker_id=ticker.id,
score=50.0,
is_stale=False,
weights_json="{}",
computed_at=current,
),
SentimentScore(
ticker_id=ticker.id,
classification="neutral",
confidence=50,
source="test",
timestamp=current,
reasoning="",
citations_json="[]",
),
OHLCVRecord(
ticker_id=ticker.id,
date=date(2026, 7, 3),
open=101.0,
high=102.0,
low=100.0,
close=101.0,
volume=1000,
created_at=current,
),
])
await db_session.flush()
rows = await get_trade_setups(
db_session,
symbol="RISK",
live_recommendation=True,
)
assert len(rows) == 1
row = rows[0]
assert row["risk_level"] == "Medium"
assert row["conflict_flags"] == [
"target-availability: Fewer than 3 valid S/R targets available"
]
assert row["current_price"] == pytest.approx(101.0)
assert _as_utc(row["context_as_of"]["score_computed_at"]) == current
assert _as_utc(row["context_as_of"]["sentiment_at"]) == current
assert row["context_as_of"]["price_date"] == date(2026, 7, 3)
assert _as_utc(row["context_as_of"]["price_updated_at"]) == current
@pytest.mark.asyncio
async def test_live_trade_setup_read_does_not_recompute_scores(db_session: AsyncSession):
await _seed_stale_setup_with_current_scores(db_session)
with patch(
"app.services.scoring_service.compute_all_dimensions",
new=AsyncMock(side_effect=AssertionError("GET must not recompute dimensions")),
), patch(
"app.services.scoring_service.compute_composite_score",
new=AsyncMock(side_effect=AssertionError("GET must not recompute composite")),
):
rows = await get_trade_setups(
db_session,
symbol="TTWO",
live_recommendation=True,
)
assert len(rows) == 1
@pytest.mark.asyncio
async def test_intraday_price_update_changes_live_price_without_new_signal_rows(
db_session: AsyncSession,
):
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
ticker = Ticker(symbol="LIVEP")
db_session.add(ticker)
await db_session.flush()
setup = TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=100.0,
stop_loss=95.0,
target=112.0,
rr_ratio=2.4,
composite_score=50.0,
detected_at=current,
)
price = OHLCVRecord(
ticker_id=ticker.id,
date=date(2026, 7, 3),
open=100.0,
high=101.0,
low=99.0,
close=100.0,
volume=1000,
created_at=current,
)
db_session.add_all([setup, price])
await db_session.flush()
rows = await get_trade_setups(db_session, symbol="LIVEP", live_recommendation=True)
assert rows[0]["current_price"] == pytest.approx(100.0)
price.close = 102.0
await db_session.flush()
rows = await get_trade_setups(db_session, symbol="LIVEP", live_recommendation=True)
assert rows[0]["current_price"] == pytest.approx(102.0)
setup_count = await db_session.scalar(select(func.count()).select_from(TradeSetup))
snapshot_count = await db_session.scalar(select(func.count()).select_from(SignalContextSnapshot))
assert setup_count == 1
assert snapshot_count == 0
+2
View File
@@ -80,6 +80,7 @@ class TestConfigureScheduler:
assert job_ids == {
"data_collector",
"data_backfill",
"benchmark_collector",
"sentiment_collector",
"fundamental_collector",
"rr_scanner",
@@ -103,6 +104,7 @@ class TestConfigureScheduler:
assert sorted(job_ids) == sorted([
"alerts",
"backtest",
"benchmark_collector",
"daily_pipeline",
"intraday_pipeline",
"data_collector",
@@ -0,0 +1,45 @@
"""Guard: the scores router must forward the sentiment-adjustment breakdown fields.
get_score computes base_score / sentiment_adjustment, but the router builds the
response model by hand so it has to explicitly pass those through, or the ticker
page loses the "Composite = Base + Sentiment" caption and the ± dimension marker.
"""
from app.routers.scores import _map_composite_breakdown
def test_forwards_sentiment_fields():
raw = {
"weights": {"technical": 0.25},
"available_dimensions": ["technical"],
"missing_dimensions": ["sentiment"],
"renormalized_weights": {"technical": 1.0},
"formula": "x",
"base_score": 78.0,
"sentiment_score": 75.0,
"sentiment_adjustment": 5.0,
"max_sentiment_adjustment": 10.0,
}
model = _map_composite_breakdown(raw)
assert model is not None
assert model.base_score == 78.0
assert model.sentiment_score == 75.0
assert model.sentiment_adjustment == 5.0
assert model.max_sentiment_adjustment == 10.0
def test_none_stays_none():
assert _map_composite_breakdown(None) is None
def test_old_shaped_dict_defaults_new_fields_to_none():
raw = {
"weights": {},
"available_dimensions": [],
"missing_dimensions": [],
"renormalized_weights": {},
"formula": "x",
}
model = _map_composite_breakdown(raw)
assert model.sentiment_adjustment is None
assert model.base_score is None
@@ -0,0 +1,91 @@
"""Composite scoring: sentiment applied as a signed adjustment around neutral,
not averaged in. Going from no sentiment to bullish must never lower the score."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base
from app.models.score import DimensionScore
from app.models.ticker import Ticker
from app.services import scoring_service as svc
@pytest.fixture
async def db():
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
yield session
await engine.dispose()
# Non-sentiment dims all at 78 → base = 78 at any positive weights.
BASE_DIMS = {"technical": 78.0, "sr_quality": 78.0, "fundamental": 78.0, "momentum": 78.0}
async def _seed(session, dims: dict[str, float]) -> None:
ticker = Ticker(symbol="AAA")
session.add(ticker)
await session.flush()
now = datetime.now(timezone.utc)
for dim, score in dims.items():
session.add(DimensionScore(
ticker_id=ticker.id, dimension=dim, score=score, is_stale=False, computed_at=now
))
await session.flush()
def test_sentiment_adjustment_formula():
# weight 0.10 → MAX_ADJ = 10 points
assert svc._sentiment_adjustment(None, 0.10) == 0.0
assert svc._sentiment_adjustment(50.0, 0.10) == 0.0 # neutral / coin-flip
assert svc._sentiment_adjustment(75.0, 0.10) == pytest.approx(5.0) # bullish 75%
assert svc._sentiment_adjustment(100.0, 0.10) == pytest.approx(10.0)
assert svc._sentiment_adjustment(25.0, 0.10) == pytest.approx(-5.0) # bearish 75%
assert svc._sentiment_adjustment(0.0, 0.10) == pytest.approx(-10.0)
async def test_no_sentiment_equals_base(db):
await _seed(db, BASE_DIMS)
composite, missing = await svc.compute_composite_score(db, "AAA")
assert composite == pytest.approx(78.0)
assert "sentiment" in missing
async def test_bullish_raises_above_base(db):
await _seed(db, {**BASE_DIMS, "sentiment": 75.0}) # bullish, 75% confidence
composite, _ = await svc.compute_composite_score(db, "AAA")
assert composite == pytest.approx(83.0) # 78 + 5 — the whole point
async def test_neutral_leaves_base_unchanged(db):
await _seed(db, {**BASE_DIMS, "sentiment": 50.0})
composite, _ = await svc.compute_composite_score(db, "AAA")
assert composite == pytest.approx(78.0)
async def test_bearish_lowers_base(db):
await _seed(db, {**BASE_DIMS, "sentiment": 25.0}) # bearish, 75% confidence
composite, _ = await svc.compute_composite_score(db, "AAA")
assert composite == pytest.approx(73.0) # 78 - 5
async def test_only_sentiment_uses_neutral_base(db):
await _seed(db, {"sentiment": 75.0})
composite, _ = await svc.compute_composite_score(db, "AAA")
assert composite == pytest.approx(55.0) # base 50 + 5
async def test_no_dimensions_returns_none(db):
ticker = Ticker(symbol="AAA")
db.add(ticker)
await db.flush()
composite, missing = await svc.compute_composite_score(db, "AAA")
assert composite is None
assert len(missing) == 5
+65 -140
View File
@@ -1,24 +1,24 @@
"""Unit tests for get_score composite breakdown and dimension breakdown wiring."""
"""Unit tests for read-only get_score composite breakdown wiring."""
from __future__ import annotations
from datetime import date
from types import SimpleNamespace
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base
from app.models.score import CompositeScore, DimensionScore
from app.models.ticker import Ticker
from app.services.scoring_service import get_score, _DIMENSION_COMPUTERS
from app.services.scoring_service import get_score
TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest.fixture
async def fresh_db():
"""Provide a non-transactional session so get_score can commit."""
"""Provide a non-transactional session for persisted score reads."""
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -30,176 +30,101 @@ async def fresh_db():
await engine.dispose()
def _make_ohlcv_records(n: int, base_close: float = 100.0) -> list:
"""Create n mock OHLCV records with realistic price data."""
records = []
for i in range(n):
price = base_close + (i * 0.5)
records.append(
SimpleNamespace(
date=date(2024, 1, 1),
open=price - 0.5,
high=price + 1.0,
low=price - 1.0,
close=price,
volume=1000000,
)
)
return records
def _mock_none_computer():
"""Return an AsyncMock that returns (None, None) — simulates missing dimension data."""
return AsyncMock(return_value=(None, None))
def _mock_score_computer(score: float, breakdown: dict | None = None):
"""Return an AsyncMock that returns a fixed (score, breakdown) tuple."""
bd = breakdown or {
"sub_scores": [{"name": "mock", "score": score, "weight": 1.0, "raw_value": score, "description": "mock"}],
"formula": "mock formula",
"unavailable": [],
}
return AsyncMock(return_value=(score, bd))
async def _seed_ticker(session: AsyncSession, symbol: str = "AAPL") -> Ticker:
"""Insert a ticker row and return it."""
ticker = Ticker(symbol=symbol)
session.add(ticker)
await session.commit()
return ticker
async def _seed_scores(session: AsyncSession, ticker: Ticker, *, stale: bool = False) -> None:
now = datetime(2026, 7, 3, tzinfo=timezone.utc)
session.add_all([
DimensionScore(
ticker_id=ticker.id,
dimension="technical",
score=70.0,
is_stale=stale,
computed_at=now,
),
DimensionScore(
ticker_id=ticker.id,
dimension="momentum",
score=60.0,
is_stale=False,
computed_at=now,
),
CompositeScore(
ticker_id=ticker.id,
score=66.0,
is_stale=stale,
weights_json="{}",
computed_at=now,
),
])
await session.commit()
@pytest.mark.asyncio
async def test_get_score_returns_composite_breakdown(fresh_db):
"""get_score should include a composite_breakdown dict with weights and re-normalization info."""
await _seed_ticker(fresh_db, "AAPL")
original = dict(_DIMENSION_COMPUTERS)
try:
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0)
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0)
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
async def test_get_score_returns_composite_breakdown_without_recomputing(fresh_db):
ticker = await _seed_ticker(fresh_db, "AAPL")
await _seed_scores(fresh_db, ticker)
with patch(
"app.services.scoring_service.compute_dimension_score",
new=AsyncMock(side_effect=AssertionError("GET must not recompute dimensions")),
), patch(
"app.services.scoring_service.compute_composite_score",
new=AsyncMock(side_effect=AssertionError("GET must not recompute composite")),
):
result = await get_score(fresh_db, "AAPL")
finally:
_DIMENSION_COMPUTERS.update(original)
assert "composite_breakdown" in result
assert result["composite_score"] == 66.0
cb = result["composite_breakdown"]
assert cb is not None
assert "weights" in cb
assert "available_dimensions" in cb
assert "missing_dimensions" in cb
assert "renormalized_weights" in cb
assert "formula" in cb
@pytest.mark.asyncio
async def test_get_score_composite_breakdown_has_correct_available_missing(fresh_db):
"""Composite breakdown should correctly list available and missing dimensions."""
await _seed_ticker(fresh_db, "AAPL")
original = dict(_DIMENSION_COMPUTERS)
try:
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0)
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0)
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
result = await get_score(fresh_db, "AAPL")
finally:
_DIMENSION_COMPUTERS.update(original)
cb = result["composite_breakdown"]
assert "technical" in cb["available_dimensions"]
assert "momentum" in cb["available_dimensions"]
assert "sentiment" in cb["missing_dimensions"]
assert "fundamental" in cb["missing_dimensions"]
assert "sr_quality" in cb["missing_dimensions"]
@pytest.mark.asyncio
async def test_get_score_renormalized_weights_sum_to_one(fresh_db):
"""Re-normalized weights should sum to 1.0 when at least one dimension is available."""
await _seed_ticker(fresh_db, "AAPL")
original = dict(_DIMENSION_COMPUTERS)
try:
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0)
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0)
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
result = await get_score(fresh_db, "AAPL")
finally:
_DIMENSION_COMPUTERS.update(original)
cb = result["composite_breakdown"]
assert cb["renormalized_weights"]
total = sum(cb["renormalized_weights"].values())
assert abs(total - 1.0) < 1e-9
assert abs(sum(cb["renormalized_weights"].values()) - 1.0) < 1e-9
@pytest.mark.asyncio
async def test_get_score_dimensions_include_breakdowns(fresh_db):
"""Each available dimension entry should include a breakdown dict."""
await _seed_ticker(fresh_db, "AAPL")
async def test_get_score_dimensions_do_not_recompute_breakdowns(fresh_db):
ticker = await _seed_ticker(fresh_db, "AAPL")
await _seed_scores(fresh_db, ticker)
tech_breakdown = {
"sub_scores": [
{"name": "ADX", "score": 72.0, "weight": 0.4, "raw_value": 72.0, "description": "ADX value"},
{"name": "EMA", "score": 65.0, "weight": 0.3, "raw_value": 1.5, "description": "EMA diff"},
{"name": "RSI", "score": 62.0, "weight": 0.3, "raw_value": 62.0, "description": "RSI value"},
],
"formula": "Weighted average: 0.4*ADX + 0.3*EMA + 0.3*RSI",
"unavailable": [],
}
original = dict(_DIMENSION_COMPUTERS)
try:
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(68.2, tech_breakdown)
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(55.0)
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
result = await get_score(fresh_db, "AAPL")
finally:
_DIMENSION_COMPUTERS.update(original)
result = await get_score(fresh_db, "AAPL")
tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None)
assert tech_dim is not None
assert "breakdown" in tech_dim
assert tech_dim["breakdown"] is not None
assert len(tech_dim["breakdown"]["sub_scores"]) == 3
names = [s["name"] for s in tech_dim["breakdown"]["sub_scores"]]
assert "ADX" in names
assert "EMA" in names
assert "RSI" in names
assert tech_dim["breakdown"] is None
@pytest.mark.asyncio
async def test_get_score_all_dimensions_missing(fresh_db):
"""When all dimensions return None, composite_breakdown should list all as missing."""
await _seed_ticker(fresh_db, "AAPL")
original = dict(_DIMENSION_COMPUTERS)
try:
for dim in _DIMENSION_COMPUTERS:
_DIMENSION_COMPUTERS[dim] = _mock_none_computer()
result = await get_score(fresh_db, "AAPL")
finally:
_DIMENSION_COMPUTERS.update(original)
result = await get_score(fresh_db, "AAPL")
cb = result["composite_breakdown"]
assert cb["available_dimensions"] == []
assert len(cb["missing_dimensions"]) == 5
assert cb["renormalized_weights"] == {}
assert result["composite_score"] is None
@pytest.mark.asyncio
async def test_get_score_reports_stale_without_refreshing(fresh_db):
ticker = await _seed_ticker(fresh_db, "AAPL")
await _seed_scores(fresh_db, ticker, stale=True)
result = await get_score(fresh_db, "AAPL")
assert result["composite_stale"] is True
assert "technical" in result["missing_dimensions"]
tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None)
assert tech_dim is not None
assert tech_dim["is_stale"] is True
+12 -26
View File
@@ -1,5 +1,4 @@
"""Unit tests for get_rankings: bulk-load fast path, sorting, exclusion, and
lazy recompute of stale scores."""
"""Unit tests for read-only get_rankings: bulk-load, sorting, and staleness."""
from __future__ import annotations
@@ -7,7 +6,6 @@ from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base
@@ -20,7 +18,7 @@ TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest.fixture
async def fresh_db():
"""Non-transactional session so get_rankings can commit recomputes."""
"""Non-transactional session for persisted ranking reads."""
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -84,46 +82,34 @@ async def test_fast_path_sorts_and_does_not_recompute(fresh_db: AsyncSession):
@pytest.mark.asyncio
async def test_ticker_without_computable_composite_is_excluded(fresh_db: AsyncSession):
"""A ticker whose composite can't be computed (recompute yields no row) is
omitted from the rankings rather than appearing with a null score."""
"""A ticker without a persisted composite is omitted from rankings."""
fresh = await _seed_ticker(fresh_db, "OK")
await _seed_ticker(fresh_db, "NONE") # no composite; recompute can't make one
await _seed_ticker(fresh_db, "NONE")
fresh_db.add_all([_composite(fresh.id, 50.0), _dimension(fresh.id, "technical", 50.0)])
await fresh_db.commit()
# Recompute is a no-op that produces no composite row for NONE.
with patch("app.services.scoring_service.compute_dimension_score",
new=AsyncMock(return_value=None)), \
new=AsyncMock(side_effect=AssertionError("should not recompute"))), \
patch("app.services.scoring_service.compute_composite_score",
new=AsyncMock(return_value=(None, ["technical"]))):
new=AsyncMock(side_effect=AssertionError("should not recompute"))):
result = await get_rankings(fresh_db)
assert [r["symbol"] for r in result["rankings"]] == ["OK"]
@pytest.mark.asyncio
async def test_stale_composite_is_recomputed(fresh_db: AsyncSession):
"""A stale composite triggers a recompute and then appears in the rankings."""
async def test_stale_composite_is_reported_without_recompute(fresh_db: AsyncSession):
"""A stale composite appears with its stale flag and is not recomputed."""
ticker = await _seed_ticker(fresh_db, "STALE")
fresh_db.add(_composite(ticker.id, 10.0, stale=True))
await fresh_db.commit()
async def _fake_recompute(db, symbol, weights=None):
# Mirror the real upsert: refresh the existing row in place.
existing = (await db.execute(
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
)).scalar_one()
existing.score = 77.0
existing.is_stale = False
return 77.0, []
# Dimension recompute is a no-op; composite recompute refreshes the score.
with patch("app.services.scoring_service.compute_dimension_score",
new=AsyncMock(return_value=55.0)), \
new=AsyncMock(side_effect=AssertionError("should not recompute"))), \
patch("app.services.scoring_service.compute_composite_score",
new=AsyncMock(side_effect=_fake_recompute)) as comp_mock:
new=AsyncMock(side_effect=AssertionError("should not recompute"))):
result = await get_rankings(fresh_db)
comp_mock.assert_awaited() # recompute path was taken
assert [r["symbol"] for r in result["rankings"]] == ["STALE"]
assert result["rankings"][0]["composite_score"] == 77.0 # reflects the recompute
assert result["rankings"][0]["composite_score"] == 10.0
assert result["rankings"][0]["composite_stale"] is True
+206 -40
View File
@@ -1,21 +1,27 @@
"""Tests for sentiment-collection scoping (``_get_sentiment_priority_tickers``).
The activation gate qualifies setups on 12-1 momentum percentile, a different
axis than composite score. These tests pin the fix that adds the gate's momentum
leaders to the sentiment relevant-set so a freshly-qualifying ticker isn't left
without sentiment.
A dashboard 'top pick' is the highest residual-momentum *qualified* long setup. Sentiment
can never move a ticker's activation percentile (the gate's core axis) only its
confidence and EV ranking. So the tickers that are, or could become with positive
sentiment, a top pick are exactly the residual-momentum leaders that already carry a
tradeable long setup over the R:R floor. These tests pin that priority tier
(always refreshed, cap-exempt) and the capped filler tier behind it.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone
import pytest
from app import scheduler
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.score import CompositeScore
from app.models.sentiment import SentimentScore
from app.models.settings import SystemSetting
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.models.watchlist import WatchlistEntry
@pytest.fixture
@@ -26,56 +32,216 @@ async def session():
yield s
async def _seed_history(session, symbol: str, rate: float, n: int = 280) -> Ticker:
"""Seed a ticker with a full year+ of daily closes growing at ``rate``."""
async def _add_ticker(session, symbol: str) -> Ticker:
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
base = date(2024, 1, 1)
for i in range(n):
close = 100.0 * (rate ** i)
session.add(OHLCVRecord(
ticker_id=t.id,
date=base + timedelta(days=i),
open=close, high=close, low=close, close=close,
volume=1_000_000,
))
await session.commit()
return t
async def _set_min_momentum(session, value: str) -> None:
session.add(SystemSetting(
key="activation_min_momentum_percentile",
value=value,
updated_at=datetime.now(timezone.utc),
async def _add_setup(
session,
ticker: Ticker,
*,
direction: str = "long",
momentum_percentile: float | None = 95.0,
rr_ratio: float = 2.0,
detected_at: datetime | None = None,
) -> TradeSetup:
session.add(TradeSetup(
ticker_id=ticker.id,
direction=direction,
entry_price=100.0,
stop_loss=95.0,
target=110.0,
rr_ratio=rr_ratio,
composite_score=60.0,
momentum_percentile=momentum_percentile,
detected_at=detected_at or datetime.now(timezone.utc),
))
await session.commit()
async def test_momentum_leader_is_included_without_composite_or_watchlist(session):
"""A top-percentile momentum ticker is fetched even when it has no composite
score, no watchlist entry, and no open trade the case that previously left
qualifying setups with no sentiment."""
await _seed_history(session, "LEADER", rate=1.010) # strong uptrend → pct 100
await _seed_history(session, "LAGGARD", rate=0.999) # declining → pct 0
await _set_min_momentum(session, "80")
async def _add_composite(session, ticker: Ticker, score: float) -> None:
session.add(CompositeScore(
ticker_id=ticker.id,
score=score,
is_stale=False,
weights_json="{}",
computed_at=datetime.now(timezone.utc),
))
await session.commit()
async def _add_watchlist(session, ticker: Ticker) -> None:
session.add(WatchlistEntry(
user_id=1,
ticker_id=ticker.id,
entry_type="manual",
added_at=datetime.now(timezone.utc),
))
await session.commit()
async def _add_open_trade(session, ticker: Ticker) -> None:
session.add(PaperTrade(
user_id=1,
ticker_id=ticker.id,
direction="long",
entry_price=100.0,
shares=10.0,
stop_loss=95.0,
target=110.0,
status="open",
opened_at=datetime.now(timezone.utc),
))
await session.commit()
async def _add_sentiment(session, ticker: Ticker, hours_ago: float) -> None:
session.add(SentimentScore(
ticker_id=ticker.id,
classification="bullish",
confidence=80,
source="test",
timestamp=datetime.now(timezone.utc) - timedelta(hours=hours_ago),
))
await session.commit()
async def _set_setting(session, key: str, value: str) -> None:
session.add(SystemSetting(key=key, value=value, updated_at=datetime.now(timezone.utc)))
await session.commit()
async def test_top_pick_feeder_included_below_cutoff_excluded(session):
"""A momentum leader with a tradeable long setup over the R:R floor is fetched;
one whose setup is below the gate's percentile is not."""
feeder = await _add_ticker(session, "FEEDER")
await _add_setup(session, feeder, momentum_percentile=95.0)
laggard = await _add_ticker(session, "LAGGARD")
await _add_setup(session, laggard, momentum_percentile=50.0) # below the gate
await _set_setting(session, "activation_min_momentum_percentile", "80")
symbols = await scheduler._get_sentiment_priority_tickers(session)
assert "LEADER" in symbols
# Below the gate's percentile and not otherwise relevant → not fetched.
assert "FEEDER" in symbols
assert "LAGGARD" not in symbols
async def test_momentum_leaders_skipped_when_gate_disabled(session):
"""With the momentum gate off (min percentile 0), the leader is no longer
pulled in solely on momentum scoping falls back to the base relevant set."""
await _seed_history(session, "LEADER", rate=1.010)
await _seed_history(session, "LAGGARD", rate=0.999)
await _set_min_momentum(session, "0")
async def test_leader_without_a_setup_excluded(session):
"""A ticker with no long setup can't be a top pick, so it's no longer pulled in
on momentum alone the budget goes to actual top-pick feeders."""
await _add_ticker(session, "NOSETUP")
await _set_setting(session, "activation_min_momentum_percentile", "80")
symbols = await scheduler._get_sentiment_priority_tickers(session)
assert "LEADER" not in symbols
assert "LAGGARD" not in symbols
assert "NOSETUP" not in symbols
async def test_short_only_setup_excluded(session):
"""The gate is long-only while active; a short setup can never be a top pick,
so positive sentiment can't promote it and it stays out of scope."""
t = await _add_ticker(session, "SHORTY")
await _add_setup(session, t, direction="short", momentum_percentile=95.0)
await _set_setting(session, "activation_min_momentum_percentile", "80")
symbols = await scheduler._get_sentiment_priority_tickers(session)
assert "SHORTY" not in symbols
async def test_long_setup_below_rr_floor_excluded(session):
"""A long leader whose setup doesn't clear the R:R floor isn't tradeable as a
top pick regardless of sentiment."""
t = await _add_ticker(session, "THINRR")
await _add_setup(session, t, momentum_percentile=95.0, rr_ratio=0.5)
await _set_setting(session, "activation_min_momentum_percentile", "80")
await _set_setting(session, "activation_min_rr", "1.2")
symbols = await scheduler._get_sentiment_priority_tickers(session)
assert "THINRR" not in symbols
async def test_gate_disabled_no_priority_tier(session):
"""With the momentum gate off there is no leader axis to anchor on, so a strong
long setup is not pulled in on its own scope falls back to the filler set."""
t = await _add_ticker(session, "FEEDER")
await _add_setup(session, t, momentum_percentile=95.0)
await _set_setting(session, "activation_min_momentum_percentile", "0")
symbols = await scheduler._get_sentiment_priority_tickers(session)
assert "FEEDER" not in symbols
async def test_fresh_feeder_skipped_stale_refetched(session):
"""A feeder refreshed within the fresh window is skipped; one past it is
re-fetched."""
fresh = await _add_ticker(session, "FRESH")
await _add_setup(session, fresh, momentum_percentile=95.0)
await _add_sentiment(session, fresh, hours_ago=1.0)
stale = await _add_ticker(session, "STALE")
await _add_setup(session, stale, momentum_percentile=95.0)
await _add_sentiment(session, stale, hours_ago=settings_fresh_hours() + 50)
await _set_setting(session, "activation_min_momentum_percentile", "80")
symbols = await scheduler._get_sentiment_priority_tickers(session)
assert "FRESH" not in symbols
assert "STALE" in symbols
async def test_watchlist_and_open_trades_always_included(session):
"""The curated watchlist and open paper trades are always in scope — they're
the set we never want shown without sentiment, independent of any top pick."""
await _set_setting(session, "activation_min_momentum_percentile", "80")
wl = await _add_ticker(session, "WATCHED")
await _add_watchlist(session, wl)
held = await _add_ticker(session, "HELD")
await _add_open_trade(session, held)
symbols = await scheduler._get_sentiment_priority_tickers(session)
assert "WATCHED" in symbols
assert "HELD" in symbols
async def test_dismissed_watchlist_entry_excluded(session):
"""A dismissed watchlist entry is not refreshed."""
await _set_setting(session, "activation_min_momentum_percentile", "80")
t = await _add_ticker(session, "DISMISSED")
session.add(WatchlistEntry(
user_id=1,
ticker_id=t.id,
entry_type="dismissed",
added_at=datetime.now(timezone.utc),
))
await session.commit()
symbols = await scheduler._get_sentiment_priority_tickers(session)
assert "DISMISSED" not in symbols
async def test_no_per_run_cap_everything_stale_is_fetched(session, monkeypatch):
"""No truncation: every stale name in the relevant set is returned, however
many there are (the cap was removed)."""
await _set_setting(session, "activation_min_momentum_percentile", "80")
feeders = [f"F{i:02d}" for i in range(30)] # well past the old cap of 25
for sym in feeders:
t = await _add_ticker(session, sym)
await _add_setup(session, t, momentum_percentile=95.0)
filler = await _add_ticker(session, "FILL")
await _add_composite(session, filler, score=99.0)
symbols = await scheduler._get_sentiment_priority_tickers(session)
assert set(feeders).issubset(set(symbols)) # all feeders, no truncation
assert "FILL" in symbols # filler fetched too — nothing crowded out
def settings_fresh_hours() -> float:
return float(scheduler.settings.sentiment_fresh_hours)
+110
View File
@@ -0,0 +1,110 @@
"""Tests for point-in-time signal context snapshots."""
from __future__ import annotations
import json
from datetime import date, datetime, timezone
import pytest
from app.models.fundamental import FundamentalData
from app.models.score import CompositeScore, DimensionScore
from app.models.sentiment import SentimentScore
from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.services import rr_scanner_service as rr
from tests.conftest import _test_session_factory # type: ignore
@pytest.fixture
async def session():
async with _test_session_factory() as s:
yield s
async def test_create_signal_context_snapshot_captures_latest_context(session):
now = datetime(2026, 7, 2, 12, tzinfo=timezone.utc)
ticker = Ticker(symbol="CTX")
session.add(ticker)
await session.flush()
session.add_all([
DimensionScore(
ticker_id=ticker.id,
dimension="technical",
score=71.0,
is_stale=False,
computed_at=now,
),
DimensionScore(
ticker_id=ticker.id,
dimension="momentum",
score=82.0,
is_stale=False,
computed_at=now,
),
CompositeScore(
ticker_id=ticker.id,
score=76.5,
is_stale=False,
weights_json='{"technical": 0.25}',
computed_at=now,
),
SentimentScore(
ticker_id=ticker.id,
classification="BULLISH",
confidence=78,
source="test",
timestamp=now,
reasoning="",
citations_json="[]",
recommendation="BUY",
),
FundamentalData(
ticker_id=ticker.id,
pe_ratio=25.0,
revenue_growth=0.18,
earnings_surprise=0.05,
market_cap=1_000_000_000.0,
next_earnings_date=date(2026, 8, 1),
fetched_at=now,
unavailable_fields_json="{}",
),
])
setup = TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=100.0,
stop_loss=95.0,
target=120.0,
rr_ratio=4.0,
composite_score=76.5,
detected_at=now,
confidence_score=64.0,
momentum_percentile=88.0,
recommended_action="LONG_HIGH",
risk_level="Low",
)
session.add(setup)
await session.flush()
await rr._create_signal_context_snapshots(session, [setup])
await session.commit()
row = (await session.get(SignalContextSnapshot, 1))
assert row is not None
assert row.trade_setup_id == setup.id
assert row.strategy_version == rr.STRATEGY_VERSION
assert row.momentum_percentile == 88.0
score = json.loads(row.score_context_json)
sentiment = json.loads(row.sentiment_context_json)
fundamental = json.loads(row.fundamental_context_json)
assert score["composite_score"] == 76.5
assert score["dimensions"]["technical"]["score"] == 71.0
assert sentiment["classification"] == "BULLISH"
assert sentiment["confidence"] == 78
assert fundamental["pe_ratio"] == 25.0
assert fundamental["next_earnings_date"] == "2026-08-01"

Some files were not shown because too many files have changed in this diff Show More