Compare commits

...

42 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
92 changed files with 7180 additions and 984 deletions
+57 -32
View File
@@ -22,6 +22,12 @@ on:
type: boolean type: boolean
default: false 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: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -29,7 +35,8 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.11" python-version: "3.12"
cache: "pip"
- run: pip install ruff - run: pip install ruff
- run: ruff check app/ - run: ruff check app/
@@ -52,17 +59,21 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.11" python-version: "3.12"
cache: "pip"
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- run: pip install -e ".[dev]" - 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 - run: alembic upgrade head
env: env:
DATABASE_URL: postgresql+asyncpg://test_user:test_pass@postgres:5432/test_db DATABASE_URL: postgresql+asyncpg://test_user:test_pass@postgres:5432/test_db
- run: pytest --tb=short - run: pytest --tb=short
env:
DATABASE_URL: postgresql+asyncpg://test_user:test_pass@postgres:5432/test_db
- run: | - run: |
cd frontend cd frontend
npm ci npm ci
@@ -76,17 +87,6 @@ jobs:
deploy: deploy:
needs: test needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Build frontend
run: |
cd frontend
npm ci
npm run build
- name: Deploy to server
env: env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }} DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }} DEPLOY_USER: ${{ vars.DEPLOY_USER }}
@@ -94,19 +94,36 @@ jobs:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ vars.SSH_KNOWN_HOSTS }} SSH_KNOWN_HOSTS: ${{ vars.SSH_KNOWN_HOSTS }}
SSH_PORT: ${{ vars.SSH_PORT || '22' }} SSH_PORT: ${{ vars.SSH_PORT || '22' }}
run: | steps:
# Install tools missing from runner image - uses: actions/checkout@v4
sudo apt-get update -qq && sudo apt-get install -y -qq rsync openssh-client > /dev/null 2>&1 || true - uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
# Write SSH credentials - name: Build frontend
run: |
cd frontend
npm ci
npm run build
- 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 mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key
echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts 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" - name: Sync files to server
run: |
# Sync application files
rsync -avz --delete \ rsync -avz --delete \
--exclude '.git/' \ --exclude '.git/' \
--exclude '.gitea/' \ --exclude '.gitea/' \
@@ -118,10 +135,11 @@ jobs:
--exclude '*.pyc' \ --exclude '*.pyc' \
--exclude 'frontend/node_modules/' \ --exclude 'frontend/node_modules/' \
-e "ssh $SSH_OPTS" \ -e "ssh $SSH_OPTS" \
./ ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/ ./ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/"
# Install deps & restart on server - name: Install deps & run migrations
ssh $SSH_OPTS ${DEPLOY_USER}@${DEPLOY_HOST} << REMOTE_SCRIPT run: |
ssh $SSH_OPTS "${DEPLOY_USER}@${DEPLOY_HOST}" << REMOTE_SCRIPT
set -e set -e
cd ${DEPLOY_PATH} cd ${DEPLOY_PATH}
@@ -141,12 +159,19 @@ jobs:
else else
alembic upgrade head alembic upgrade head
fi fi
# Restart service
sudo systemctl restart signalplatform.service
echo "✓ signalplatform deployed"
REMOTE_SCRIPT REMOTE_SCRIPT
# Cleanup - name: Restart service & health check
rm -f ~/.ssh/deploy_key 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 # Generated SSL bundle
combined-ca-bundle.pem 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. **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 ## Stack
| Layer | Tech | | Layer | Tech |
|---|---| |---|---|
| Backend | Python 3.12+, FastAPI, Uvicorn, async SQLAlchemy, Alembic | | Backend | Python 3.12+, FastAPI, Uvicorn, async SQLAlchemy, Alembic |
| Database | PostgreSQL (asyncpg) | | 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 | | Frontend | React 18, TypeScript, Vite 5 |
| Styling | Tailwind CSS 3 with custom glassmorphism design system | | Styling | Tailwind CSS 3 with custom glassmorphism design system |
| State | TanStack React Query v5 (server), Zustand (client/auth) | | State | TanStack React Query v5 (server), Zustand (client/auth) |
| Charts | Canvas 2D candlestick chart with S/R overlays | | Charts | Canvas 2D candlestick chart with S/R overlays |
| Routing | React Router v6 (SPA) | | Routing | React Router v6 (SPA) |
| HTTP | Axios with JWT interceptor | | 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 ## Features
@@ -30,10 +120,15 @@ Investing-signal platform for NASDAQ stocks. Surfaces the best trading opportuni
- Sentiment analysis with time-decay weighted scoring - Sentiment analysis with time-decay weighted scoring
- Fundamental data tracking (P/E, revenue growth, earnings surprise, market cap) - 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 - 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) - Risk:Reward scanner — long and short setups, ATR-based stops, S/R-based targets, configurable R:R threshold (default 1.5:1)
- Auto-populated watchlist (top-10 by composite score) + manual entries (cap: 20) - 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 - 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 - Admin panel: user management, data cleanup, job control, system settings
### Frontend ### Frontend
@@ -56,12 +151,15 @@ Investing-signal platform for NASDAQ stocks. Surfaces the best trading opportuni
|---|---|---| |---|---|---|
| `/login` | Login | Public | | `/login` | Login | Public |
| `/register` | Register | Public (when enabled) | | `/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 | | `/ticker/:symbol` | Ticker Detail | Authenticated |
| `/scanner` | Trade Scanner | Authenticated |
| `/rankings` | Rankings | Authenticated |
| `/admin` | Admin Panel | Admin only | | `/admin` | Admin Panel | Admin only |
Legacy routes redirect: `/watchlist``/market`, `/rankings``/market?tab=rankings`, `/scanner``/signals`, `/performance``/signals?tab=track`.
## API Endpoints ## API Endpoints
All under `/api/v1/`. Interactive docs at `/docs` (Swagger) and `/redoc`. 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}` | | Sentiment | `GET /sentiment/{symbol}` |
| Fundamentals | `GET /fundamentals/{symbol}` | | Fundamentals | `GET /fundamentals/{symbol}` |
| Scores | `GET /scores/{symbol}`, `GET /rankings`, `PUT /scores/weights` | | 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}` | | 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` | | 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) # Backend tests (in-memory SQLite — no PostgreSQL needed)
pytest tests/ -v 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 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 ## Environment Variables
Configure in `.env` (copy from `.env.example`): 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) | | `FMP_API_KEY` | Optional (fundamentals) | — | Financial Modeling Prep API key (first provider in chain) |
| `FINNHUB_API_KEY` | Optional (fundamentals) | — | Finnhub API key (fallback provider) | | `FINNHUB_API_KEY` | Optional (fundamentals) | — | Finnhub API key (fallback provider) |
| `ALPHA_VANTAGE_API_KEY` | Optional (fundamentals) | — | Alpha Vantage 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 | | `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 | | `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_RETRIES` | No | `3` | Retries per ticker on fundamentals rate-limit |
| `FUNDAMENTAL_RATE_LIMIT_BACKOFF_SECONDS` | No | `15` | Base backoff seconds for fundamentals retry (exponential) | | `FUNDAMENTAL_RATE_LIMIT_BACKOFF_SECONDS` | No | `15` | Base backoff seconds for fundamentals retry (exponential) |
| `DEFAULT_WATCHLIST_AUTO_SIZE` | No | `10` | Auto-watchlist size | | `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 | | `DB_POOL_SIZE` | No | `5` | Database connection pool size |
| `LOG_LEVEL` | No | `INFO` | Logging level | | `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) ## 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 ### 1. Install dependencies
```bash ```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 ```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 ```bash
sudo mkdir -p /opt/stock-data-backend # After the first pipeline run has synced the files:
# Copy project files to /opt/stock-data-backend cp /opt/signalplatform/.env.example /opt/signalplatform/.env
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
# Edit .env with production values (strong JWT_SECRET, real API keys, etc.) # 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 ### 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 ```bash
DB_NAME=stock_data_backend DB_USER=stock_backend DB_PASS=strong_password ./deploy/setup_db.sh DB_NAME=stock_data_backend DB_USER=stock_backend DB_PASS=strong_password ./deploy/setup_db.sh
``` ```
### 6. Build frontend ### 6. Systemd service
```bash ```bash
cd frontend sudo cp deploy/signalplatform.service /etc/systemd/system/
npm ci
npm run build
```
### 7. Systemd service
```bash
sudo cp deploy/stock-data-backend.service /etc/systemd/system/
sudo systemctl daemon-reload 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 ```bash
sudo cp deploy/nginx.conf /etc/nginx/sites-available/stock-data-backend sudo cp deploy/nginx.conf /etc/nginx/sites-available/signalplatform
sudo ln -s /etc/nginx/sites-available/stock-data-backend /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/signalplatform /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx 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 ```bash
sudo apt install certbot python3-certbot-nginx sudo apt install certbot python3-certbot-nginx
@@ -292,7 +441,7 @@ frontend/
│ └── watchlist/ # Watchlist table, add ticker form │ └── watchlist/ # Watchlist table, add ticker form
├── hooks/ # React Query hooks (one per resource) ├── hooks/ # React Query hooks (one per resource)
├── lib/ # Types, formatting utilities ├── lib/ # Types, formatting utilities
├── pages/ # Page components (7 pages) ├── pages/ # Page components (Login, Register, Dashboard, Market, Signals, Regime, Ticker, Admin)
├── stores/ # Zustand auth store ├── stores/ # Zustand auth store
└── styles/ # Global CSS with glassmorphism classes └── styles/ # Global CSS with glassmorphism classes
@@ -306,3 +455,64 @@ tests/
├── unit/ # Unit tests ├── unit/ # Unit tests
└── property/ # Property-based tests (Hypothesis) └── 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): 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
database_url: str = "postgresql+asyncpg://stock_backend:changeme@localhost:5432/stock_data_backend" 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" data_collector_frequency: str = "daily"
sentiment_poll_interval_minutes: int = 30 sentiment_poll_interval_minutes: int = 30
# Sentiment search-budget controls (Gemini grounding free tier = 5000/month). # Sentiment search-budget controls (Gemini grounding free tier = 5000/month).
# Only fetch sentiment for relevant tickers (watchlist + open trades + top-N by # Scope (see _get_sentiment_priority_tickers): everything that matters is always
# composite), skip ones refreshed within fresh_hours, and cap per run. # refreshed in full — open paper trades + the curated watchlist + top-pick
sentiment_fresh_hours: int = 72 # feeders (residual-momentum leaders with a tradeable long setup) — plus a top-N composite
sentiment_max_per_run: int = 25 # 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 sentiment_top_composite: int = 30
fundamental_fetch_frequency: str = "weekly" # quarterly-ish data; weekly conserves API quota fundamental_fetch_frequency: str = "weekly" # quarterly-ish data; weekly conserves API quota
rr_scan_frequency: str = "daily" 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.alert import AlertLog
from app.models.paper_trade import PaperTrade from app.models.paper_trade import PaperTrade
from app.models.regime_snapshot import RegimeSnapshot from app.models.regime_snapshot import RegimeSnapshot
from app.models.benchmark_price import BenchmarkPrice
from app.models.signal_context_snapshot import SignalContextSnapshot
__all__ = [ __all__ = [
"Ticker", "Ticker",
@@ -28,4 +30,6 @@ __all__ = [
"AlertLog", "AlertLog",
"PaperTrade", "PaperTrade",
"RegimeSnapshot", "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) close_price: Mapped[float | None] = mapped_column(Float, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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) id: Mapped[int] = mapped_column(primary_key=True)
symbol: Mapped[str] = mapped_column(String(10), unique=True, nullable=False) 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False 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) confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True)
# Ticker's 12-1 momentum percentile across the universe at detection time # Ticker's activation momentum percentile across the universe at detection
# (0100, 100 = strongest). Drives the activation gate's core selection. # 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) momentum_percentile: Mapped[float | None] = mapped_column(Float, nullable=True)
targets_json: Mapped[str | None] = mapped_column(Text, nullable=True) targets_json: Mapped[str | None] = mapped_column(Text, nullable=True)
conflict_flags_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) 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 # Data cleanup
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2 -1
View File
@@ -114,8 +114,9 @@ async def fetch_symbol(
result = await ingestion_service.fetch_and_ingest( result = await ingestion_service.fetch_and_ingest(
db, provider, symbol_upper, start_date, end_date db, provider, symbol_upper, start_date, end_date
) )
status_map = {"complete": "ok", "partial": "ok", "no_data": "warning"}
sources_out["ohlcv"] = { sources_out["ohlcv"] = {
"status": "ok" if result.status in ("complete", "partial") else "error", "status": status_map.get(result.status, "error"),
"records": result.records_ingested, "records": result.records_ingested,
"message": result.message, "message": result.message,
} }
+29 -2
View File
@@ -3,10 +3,15 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession 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.models.user import User
from app.schemas.common import APIEnvelope 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 from app.services import paper_trade_service
router = APIRouter(tags=["paper-trades"]) router = APIRouter(tags=["paper-trades"])
@@ -40,6 +45,28 @@ async def list_paper_trades(
return APIEnvelope(status="success", data=data) 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) @router.post("/paper-trades", response_model=APIEnvelope, status_code=201)
async def create_paper_trade( async def create_paper_trade(
body: PaperTradeCreate, body: PaperTradeCreate,
+6 -1
View File
@@ -39,6 +39,10 @@ def _map_composite_breakdown(raw: dict | None) -> CompositeBreakdownResponse | N
missing_dimensions=raw["missing_dimensions"], missing_dimensions=raw["missing_dimensions"],
renormalized_weights=raw["renormalized_weights"], renormalized_weights=raw["renormalized_weights"],
formula=raw["formula"], 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"]) router = APIRouter(tags=["scores"])
@@ -50,7 +54,7 @@ async def read_score(
_user=Depends(require_access), _user=Depends(require_access),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> APIEnvelope: ) -> 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) result = await get_score(db, symbol)
data = ScoreResponse( data = ScoreResponse(
@@ -90,6 +94,7 @@ async def read_rankings(
RankingEntry( RankingEntry(
symbol=r["symbol"], symbol=r["symbol"],
composite_score=r["composite_score"], composite_score=r["composite_score"],
composite_stale=r.get("composite_stale", False),
dimensions=[ dimensions=[
DimensionScoreResponse(**d) for d in r["dimensions"] DimensionScoreResponse(**d) for d in r["dimensions"]
], ],
+6 -1
View File
@@ -34,6 +34,7 @@ async def list_trade_setups(
direction=direction, direction=direction,
min_confidence=min_confidence, min_confidence=min_confidence,
recommended_action=recommended_action, recommended_action=recommended_action,
live_recommendation=True,
) )
data = [] data = []
@@ -92,7 +93,11 @@ async def get_ticker_trade_setups(
_user=Depends(require_access), _user=Depends(require_access),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> APIEnvelope: ) -> APIEnvelope:
rows = await get_trade_setups(db, symbol=symbol) rows = await get_trade_setups(
db,
symbol=symbol,
live_recommendation=True,
)
data = [] data = []
for row in rows: for row in rows:
summary = RecommendationSummaryResponse( 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.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger 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 sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings 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 import fundamental_service, ingestion_service, sentiment_service, settings_store
from app.services.alert_service import dispatch_alerts 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.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.market_regime_service import update_market_regime
from app.services.regime_monitor_service import update_regime_monitor 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 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()) return list(result.scalars().all())
async def _get_sentiment_priority_tickers(db: AsyncSession) -> list[str]: async def _get_top_pick_feeder_ids(db: AsyncSession) -> set[int]:
"""Symbols to fetch sentiment for, budgeted to stay in the free search tier. """Ticker ids whose latest LONG setup makes them a top-pick feeder.
Scope: only tickers that matter — watchlist + open paper trades + top-N by A dashboard 'top pick' is the highest residual-momentum *qualified* setup.
composite score + the momentum leaders the activation gate qualifies on. Skip Sentiment can never move a ticker's activation percentile (the gate's core
any refreshed within ``sentiment_fresh_hours``. Cap the run at axis) — only its confidence and EV ranking. So the only tickers that are, or
``sentiment_max_per_run``, oldest/missing first. Once the relevant set is could become with positive sentiment, a top pick are residual-momentum leaders
fresh, runs make zero grounded searches until it ages out. 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.paper_trade import PaperTrade
from app.models.score import CompositeScore from app.models.score import CompositeScore
from app.models.watchlist import WatchlistEntry 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( wl = await db.execute(
select(WatchlistEntry.ticker_id) select(WatchlistEntry.ticker_id)
.where(WatchlistEntry.entry_type != "dismissed") .where(WatchlistEntry.entry_type != "dismissed")
.distinct() .distinct()
) )
relevant.update(r[0] for r in wl.all()) priority_ids.update(r[0] for r in wl.all())
pt = await db.execute( pt = await db.execute(
select(PaperTrade.ticker_id).where(PaperTrade.status == "open").distinct() 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( top = await db.execute(
select(CompositeScore.ticker_id) select(CompositeScore.ticker_id)
.order_by(CompositeScore.score.desc()) .order_by(CompositeScore.score.desc())
.limit(settings.sentiment_top_composite) .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 if not priority_ids and not filler_ids:
# 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:
return [] return []
cutoff = datetime.now(timezone.utc) - timedelta(hours=settings.sentiment_fresh_hours) # No cap — fetch every stale name. Priority first so a rate limit mid-run still
latest_ts = func.max(SentimentScore.timestamp) # covers the curated/at-risk set before the discovery net.
missing_first = case((latest_ts.is_(None), 0), else_=1) priority_syms = await _stale_sentiment_symbols(db, priority_ids, cutoff)
result = await db.execute( filler_syms = await _stale_sentiment_symbols(db, filler_ids, cutoff)
select(Ticker.symbol) return priority_syms + filler_syms
.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())
async def _get_fundamental_priority_tickers(db: AsyncSession) -> list[str]: 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)) _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 # Job: Regime Monitor
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -953,6 +1045,7 @@ async def sync_ticker_universe() -> None:
# Daily (full): the complete data→signal refresh, once a day. # Daily (full): the complete data→signal refresh, once a day.
_DAILY_PIPELINE_STEPS = [ _DAILY_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv"), ("data_collector", "collect_ohlcv"),
("benchmark_collector", "collect_benchmark"),
("sentiment_collector", "collect_sentiment"), ("sentiment_collector", "collect_sentiment"),
("rr_scanner", "scan_rr"), ("rr_scanner", "scan_rr"),
("outcome_evaluator", "evaluate_outcomes"), ("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: async def run_daily_pipeline() -> None:
"""Full daily flow: OHLCV → sentiment → R:R scan → outcome eval (+paper """Full daily flow: OHLCV → benchmark → sentiment → R:R scan → outcome eval
close) → market regime.""" (+paper close) → market regime."""
await _run_pipeline("daily_pipeline", _DAILY_PIPELINE_STEPS) 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. # interval job). They stay manually triggerable from Admin → Jobs.
_members = [ _members = [
(collect_ohlcv, "data_collector", "Data Collector (OHLCV)"), (collect_ohlcv, "data_collector", "Data Collector (OHLCV)"),
(collect_benchmark, "benchmark_collector", "Benchmark Collector"),
(collect_sentiment, "sentiment_collector", "Sentiment Collector"), (collect_sentiment, "sentiment_collector", "Sentiment Collector"),
(scan_rr, "rr_scanner", "R:R Scanner"), (scan_rr, "rr_scanner", "R:R Scanner"),
(evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"), (evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"),
+2
View File
@@ -64,6 +64,7 @@ class ActivationConfigUpdate(BaseModel):
min_confidence: float | None = Field(default=None, ge=0, le=100) min_confidence: float | None = Field(default=None, ge=0, le=100)
require_high_conviction: bool | None = None require_high_conviction: bool | None = None
exclude_conflicts: bool | None = None exclude_conflicts: bool | None = None
exclude_neutral: bool | None = None
class ScheduleConfigUpdate(BaseModel): class ScheduleConfigUpdate(BaseModel):
@@ -99,3 +100,4 @@ class AlertConfigUpdate(BaseModel):
score_drop_enabled: bool | None = None score_drop_enabled: bool | None = None
digest_enabled: bool | None = None digest_enabled: bool | None = None
regime_quadrant_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) 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): class PaperTradeResponse(BaseModel):
id: int id: int
symbol: str symbol: str
@@ -33,3 +40,13 @@ class PaperTradeResponse(BaseModel):
close_price: float | None = None close_price: float | None = None
closed_at: datetime | None = None closed_at: datetime | None = None
current_price: float | 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] missing_dimensions: list[str]
renormalized_weights: dict[str, float] renormalized_weights: dict[str, float]
formula: str 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): class DimensionScoreResponse(BaseModel):
@@ -72,6 +78,7 @@ class RankingEntry(BaseModel):
symbol: str symbol: str
composite_score: float composite_score: float
composite_stale: bool = False
dimensions: list[DimensionScoreResponse] = [] dimensions: list[DimensionScoreResponse] = []
+1
View File
@@ -12,6 +12,7 @@ class TickerCreate(BaseModel):
class TickerResponse(BaseModel): class TickerResponse(BaseModel):
id: int id: int
symbol: str symbol: str
name: str | None = None
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+9
View File
@@ -26,6 +26,14 @@ class RecommendationSummaryResponse(BaseModel):
composite_score: float 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): class TradeSetupResponse(BaseModel):
"""A single trade setup detected by the R:R scanner.""" """A single trade setup detected by the R:R scanner."""
@@ -49,4 +57,5 @@ class TradeSetupResponse(BaseModel):
evaluated_at: datetime | None = None evaluated_at: datetime | None = None
current_price: float | None = None current_price: float | None = None
momentum_percentile: float | None = None momentum_percentile: float | None = None
context_as_of: TradeSetupContextAsOfResponse | None = None
recommendation_summary: RecommendationSummaryResponse | None = None recommendation_summary: RecommendationSummaryResponse | None = None
+1
View File
@@ -32,6 +32,7 @@ class WatchlistEntryResponse(BaseModel):
dimensions: list[DimensionScoreSummary] = [] dimensions: list[DimensionScoreSummary] = []
rr_ratio: float | None = None rr_ratio: float | None = None
rr_direction: str | None = None rr_direction: str | None = None
momentum_percentile: float | None = None
sr_levels: list[SRLevelSummary] = [] sr_levels: list[SRLevelSummary] = []
last_close: float | None = None last_close: float | None = None
change_pct: 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 # Track Record's qualified stats. The outcome evaluator deliberately ignores
# these — every setup is evaluated so the gate itself can be validated. # 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 # The core selection is residual cross-sectional 12-1 momentum (top percentile
# fat-but-improbable target and a likely-but-thin one are both rejected. R:R and # of the universe, long-only). R:R and confidence are floors; high-conviction /
# confidence are floors; high-conviction / clean-read / target-probability are # clean-read are optional tighteners (off by default).
# optional tighteners (off by default — turn on to be more selective).
_ACTIVATION_FLOAT_KEYS: dict[str, str] = { _ACTIVATION_FLOAT_KEYS: dict[str, str] = {
"min_momentum_percentile": "activation_min_momentum_percentile", "min_momentum_percentile": "activation_min_momentum_percentile",
"min_rr": "activation_min_rr", "min_rr": "activation_min_rr",
@@ -51,13 +50,20 @@ _ACTIVATION_FLOAT_KEYS: dict[str, str] = {
_ACTIVATION_BOOL_KEYS: dict[str, str] = { _ACTIVATION_BOOL_KEYS: dict[str, str] = {
"require_high_conviction": "activation_require_high_conviction", "require_high_conviction": "activation_require_high_conviction",
"exclude_conflicts": "activation_exclude_conflicts", "exclude_conflicts": "activation_exclude_conflicts",
"exclude_neutral": "activation_exclude_neutral",
} }
ACTIVATION_DEFAULTS: dict[str, float | bool] = { ACTIVATION_DEFAULTS: dict[str, float | bool] = {
"min_momentum_percentile": 80.0, "min_momentum_percentile": 80.0,
"min_rr": 1.2, "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, "require_high_conviction": False,
"exclude_conflicts": 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 = { VALID_JOB_NAMES = {
"data_collector", "data_collector",
"data_backfill", "data_backfill",
"benchmark_collector",
"sentiment_collector", "sentiment_collector",
"fundamental_collector", "fundamental_collector",
"rr_scanner", "rr_scanner",
@@ -529,6 +536,7 @@ VALID_JOB_NAMES = {
JOB_LABELS = { JOB_LABELS = {
"data_collector": "Data Collector (OHLCV)", "data_collector": "Data Collector (OHLCV)",
"data_backfill": "Data Backfill (deep history)", "data_backfill": "Data Backfill (deep history)",
"benchmark_collector": "Benchmark Collector",
"sentiment_collector": "Sentiment Collector", "sentiment_collector": "Sentiment Collector",
"fundamental_collector": "Fundamental Collector", "fundamental_collector": "Fundamental Collector",
"rr_scanner": "R:R Scanner", "rr_scanner": "R:R Scanner",
@@ -546,6 +554,7 @@ JOB_LABELS = {
# Jobs driven by the daily_pipeline (in order) rather than their own timer. # Jobs driven by the daily_pipeline (in order) rather than their own timer.
PIPELINE_MEMBERS = { PIPELINE_MEMBERS = {
"data_collector", "data_collector",
"benchmark_collector",
"sentiment_collector", "sentiment_collector",
"rr_scanner", "rr_scanner",
"outcome_evaluator", "outcome_evaluator",
+214 -23
View File
@@ -26,6 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.models.alert import AlertLog from app.models.alert import AlertLog
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.score import CompositeScore from app.models.score import CompositeScore
from app.models.sr_level import SRLevel from app.models.sr_level import SRLevel
from app.models.ticker import Ticker from app.models.ticker import Ticker
@@ -47,6 +48,7 @@ KEY_SR = "alerts_sr_proximity_enabled"
KEY_SCORE_DROP = "alerts_score_drop_enabled" KEY_SCORE_DROP = "alerts_score_drop_enabled"
KEY_DIGEST = "alerts_digest_enabled" KEY_DIGEST = "alerts_digest_enabled"
KEY_REGIME_QUADRANT = "alerts_regime_quadrant_enabled" KEY_REGIME_QUADRANT = "alerts_regime_quadrant_enabled"
KEY_TRADE_CLOSED = "alerts_trade_closed_enabled"
_BOOL_DEFAULTS = { _BOOL_DEFAULTS = {
KEY_ENABLED: False, KEY_ENABLED: False,
@@ -55,8 +57,15 @@ _BOOL_DEFAULTS = {
KEY_SCORE_DROP: True, KEY_SCORE_DROP: True,
KEY_DIGEST: True, KEY_DIGEST: True,
KEY_REGIME_QUADRANT: 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) # Tunables (kept as constants for now; promote to settings if needed)
SR_PROXIMITY_PCT = 2.0 # within this % of a strong zone → alert SR_PROXIMITY_PCT = 2.0 # within this % of a strong zone → alert
SR_MIN_STRENGTH = 60 # only strong zones are alert-worthy SR_MIN_STRENGTH = 60 # only strong zones are alert-worthy
@@ -66,6 +75,13 @@ 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 DIGEST_HOUR_UTC = 22 # send the daily digest on the first run at/after this hour
WATERMARK_TYPE = "score_watermark" 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. # Regime quadrant-change alert: (regime index x early-warning) quadrant.
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary # Hysteresis (a deadband around each divider) stops a point sitting on a boundary
@@ -82,6 +98,9 @@ QUAD_LABELS = {
"4": "④ Real downturn", "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: def _as_bool(value: str | None, default: bool) -> bool:
if value is None: if value is None:
@@ -90,7 +109,7 @@ def _as_bool(value: str | None, default: bool) -> bool:
async def _resolve(db: AsyncSession) -> dict: async def _resolve(db: AsyncSession) -> dict:
keys = [KEY_ENABLED, KEY_TOKEN, KEY_CHAT_ID, KEY_QUALIFIED, KEY_SR, KEY_SCORE_DROP, KEY_DIGEST, KEY_REGIME_QUADRANT] 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) stored = await settings_store.get_map(db, keys)
db_token = (stored.get(KEY_TOKEN) or "").strip() db_token = (stored.get(KEY_TOKEN) or "").strip()
@@ -113,6 +132,7 @@ async def _resolve(db: AsyncSession) -> dict:
"score_drop": _as_bool(stored.get(KEY_SCORE_DROP), _BOOL_DEFAULTS[KEY_SCORE_DROP]), "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]), "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]), "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]),
} }
@@ -129,6 +149,7 @@ async def get_alert_config(db: AsyncSession) -> dict:
"score_drop_enabled": r["score_drop"], "score_drop_enabled": r["score_drop"],
"digest_enabled": r["digest"], "digest_enabled": r["digest"],
"regime_quadrant_enabled": r["regime_quadrant"], "regime_quadrant_enabled": r["regime_quadrant"],
"trade_closed_enabled": r["trade_closed"],
} }
@@ -143,6 +164,7 @@ async def update_alert_config(
score_drop_enabled: bool | None = None, score_drop_enabled: bool | None = None,
digest_enabled: bool | None = None, digest_enabled: bool | None = None,
regime_quadrant_enabled: bool | None = None, regime_quadrant_enabled: bool | None = None,
trade_closed_enabled: bool | None = None,
) -> dict: ) -> dict:
"""Persist config. An empty/omitted bot_token leaves the stored token intact.""" """Persist config. An empty/omitted bot_token leaves the stored token intact."""
bool_updates = { bool_updates = {
@@ -152,6 +174,7 @@ async def update_alert_config(
KEY_SCORE_DROP: score_drop_enabled, KEY_SCORE_DROP: score_drop_enabled,
KEY_DIGEST: digest_enabled, KEY_DIGEST: digest_enabled,
KEY_REGIME_QUADRANT: regime_quadrant_enabled, KEY_REGIME_QUADRANT: regime_quadrant_enabled,
KEY_TRADE_CLOSED: trade_closed_enabled,
} }
for key, val in bool_updates.items(): for key, val in bool_updates.items():
if val is not None: if val is not None:
@@ -241,19 +264,37 @@ async def _watchlist_tickers(db: AsyncSession) -> list[tuple[int, str]]:
async def _qualified_setups(db: AsyncSession) -> list[dict]: 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) config = await get_activation_config(db)
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)] 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: def _format_qualified(s: dict) -> str:
prob = best_target_probability(SimpleNamespace(**s)) prob = best_target_probability(SimpleNamespace(**s))
arrow = "🟢" if s["direction"] == "long" else "🔴" arrow = "🟢" if s["direction"] == "long" else "🔴"
current = s.get("current_price") or s.get("entry_price")
return ( return (
f"{arrow} <b>{s['symbol']} {s['direction'].upper()}</b> — qualified setup\n" f"{arrow} <b>{s['symbol']} {s['direction'].upper()}</b> | "
f"entry {s['entry_price']:.2f} → target {s['target']:.2f} " f"now {_fmt_price(current)} | entry {_fmt_price(s['entry_price'])} | "
f"(R:R {s['rr_ratio']:.1f}:1)\n" f"target {_fmt_price(s['target'])} ({_fmt_signed_move(current, s['target'])}) | "
f"confidence {(s.get('confidence_score') or 0):.0f}% · P(target) {prob:.0f}%" 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}%"
) )
@@ -276,6 +317,34 @@ async def _latest_close(db: AsyncSession, ticker_id: int) -> float | None:
return float(row[0]) if row else 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]]: 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. """One alert per watchlist ticker for the NEAREST strong S/R zone within range.
@@ -305,21 +374,12 @@ async def _collect_sr_proximity(db: AsyncSession) -> list[tuple[str, str]]:
# Nearest strong zone only. # Nearest strong zone only.
nearest = min(strong, key=lambda z: abs(price - z["midpoint"])) 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: if dist_pct > SR_PROXIMITY_PCT:
continue 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 key = f"sr:{symbol}:{nearest['type']}" # one per side per ticker per cooldown
out.append(( out.append((key, _format_sr_proximity(symbol, nearest, price)))
key,
f"📍 <b>{symbol}</b> approaching {nearest['type']} {label} "
f"(now {price:.2f}, {dist_pct:.1f}% away)",
))
return out return out
@@ -376,9 +436,81 @@ async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None:
) )
else: else:
lines.append("No qualified setups today.") 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) 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) # Regime quadrant-change trigger (hysteresis + cooldown)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -465,6 +597,49 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
# Dispatch # 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: async def dispatch_alerts(db: AsyncSession) -> dict:
"""Gather all enabled triggers, dedup, and push to Telegram. Job entrypoint.""" """Gather all enabled triggers, dedup, and push to Telegram. Job entrypoint."""
cfg = await _resolve(db) cfg = await _resolve(db)
@@ -473,22 +648,23 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
if not cfg["token"] or not cfg["chat_id"]: if not cfg["token"] or not cfg["chat_id"]:
return {"status": "no_credentials", "sent": 0} 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"]: if cfg["qualified"]:
for key, text in await _collect_qualified(db): for key, text in await _collect_qualified(db):
if not await _recently_alerted(db, "qualified", key): if not await _recently_alerted(db, "qualified", key):
outgoing.append(("qualified", key, text)) signal_outgoing.append(("qualified", key, text))
if cfg["sr"]: if cfg["sr"]:
for key, text in await _collect_sr_proximity(db): for key, text in await _collect_sr_proximity(db):
if not await _recently_alerted(db, "sr_proximity", key): 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"]: if cfg["score_drop"]:
# also seeds/advances watermarks as a side effect # also seeds/advances watermarks as a side effect
for key, text in await _collect_score_drops(db): 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"]: if cfg["digest"]:
digest = await _collect_digest(db) digest = await _collect_digest(db)
@@ -500,9 +676,24 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
for key, text in await _collect_regime_quadrant(db): for key, text in await _collect_regime_quadrant(db):
outgoing.append((QUAD_TYPE, key, text)) 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 sent = 0
if outgoing: candidates = len(signal_outgoing) + len(outgoing)
if signal_outgoing or outgoing:
async with httpx.AsyncClient(timeout=15) as client: 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: for alert_type, key, text in outgoing:
try: try:
await _send(client, cfg["token"], cfg["chat_id"], text) await _send(client, cfg["token"], cfg["chat_id"], text)
@@ -512,7 +703,7 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
logger.exception("Failed to send alert %s", key) logger.exception("Failed to send alert %s", key)
await db.commit() # persist watermark seeds/advances and sent-logs 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: 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
+26 -1
View File
@@ -30,7 +30,7 @@ class IngestionResult:
symbol: str symbol: str
records_ingested: int records_ingested: int
last_date: date | None last_date: date | None
status: str # "complete" | "partial" | "error" status: str # "complete" | "partial" | "error" | "no_data"
message: str | None = None message: str | None = None
@@ -143,6 +143,31 @@ async def fetch_and_ingest(
message=str(exc), 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 # Sort records by date to ensure ordered ingestion
records.sort(key=lambda r: r.date) 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 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 by residual 12-1 month momentum: the stock's 12-1 return after subtracting its
price signal the backtest showed sorts forward returns). The daily scan ranks estimated benchmark beta contribution over the same formation window. The daily
every ticker and stores each setup's percentile (see ``rr_scanner_service``), so scan ranks every ticker and stores each setup's percentile (see
the live list, the Track Record's qualified stats, and outcome evaluation all gate ``rr_scanner_service``), so the live list, the Track Record's qualified stats,
on the same value. and outcome evaluation all gate on the same value.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
from datetime import date
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -35,29 +36,101 @@ def compute_12_1_momentum(closes: list[float]) -> float | None:
return 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]: async def compute_momentum_percentiles(db: AsyncSession) -> dict[str, float]:
"""Compute each ticker's 12-1 momentum and rank the universe into a """Compute each ticker's activation momentum rank.
``{symbol: percentile}`` map (0100, 100 = strongest momentum). Tickers
without a full year of history are absent (can't be ranked).""" 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)) result = await db.execute(select(Ticker).order_by(Ticker.symbol))
tickers = list(result.scalars().all()) 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: for ticker in tickers:
try: try:
records = await query_ohlcv(db, ticker.symbol) records = await query_ohlcv(db, ticker.symbol)
except Exception: except Exception:
logger.exception("Momentum fetch failed for %s", ticker.symbol) logger.exception("Momentum fetch failed for %s", ticker.symbol)
continue continue
m = compute_12_1_momentum([float(r.close) for r in records]) closes = [float(r.close) for r in records]
if m is not None: value = (
momentum[ticker.symbol] = m 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) n = len(ranked)
percentiles = { percentiles = {
sym: round((rank / (n - 1) * 100.0) if n > 1 else 100.0, 2) sym: round((rank / (n - 1) * 100.0) if n > 1 else 100.0, 2)
for rank, sym in enumerate(ranked) 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 return percentiles
+26 -3
View File
@@ -16,7 +16,7 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime, timezone from datetime import date, datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -34,6 +34,13 @@ OUTCOME_EXPIRED = "expired"
DEFAULT_MAX_BARS = 30 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 for the performance breakdown
_CONFIDENCE_BUCKETS = [ _CONFIDENCE_BUCKETS = [
("<50%", 0.0, 50.0), ("<50%", 0.0, 50.0),
@@ -183,7 +190,12 @@ async def get_performance_stats(
db: AsyncSession, db: AsyncSession,
config: dict | None = None, config: dict | None = None,
) -> dict: ) -> 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, 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 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( result = await db.execute(
select(TradeSetup).where(TradeSetup.actual_outcome.is_not(None)) 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( pending_result = await db.execute(
select(TradeSetup.id).where(TradeSetup.actual_outcome.is_(None)) select(TradeSetup.id).where(TradeSetup.actual_outcome.is_(None))
) )
pending_count = len(pending_result.scalars().all()) 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: if config is not None:
qualified = [s for s in evaluated if setup_qualifies(s, config)] qualified = [s for s in evaluated if setup_qualifies(s, config)]
else: else:
@@ -229,6 +251,7 @@ async def get_performance_stats(
return { return {
"overall": _bucket_stats(qualified), "overall": _bucket_stats(qualified),
"pending": pending_count, "pending": pending_count,
"maturing": maturing_count,
"by_direction": {k: _bucket_stats(v) for k, v in sorted(by_direction.items())}, "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_action": {k: _bucket_stats(v) for k, v in sorted(by_action.items())},
"by_confidence": { "by_confidence": {
+218 -19
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import date, datetime, timezone
from sqlalchemy import and_, func, select from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession 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.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.services import benchmark_service, settings_store
from app.services.outcome_service import ( from app.services.outcome_service import (
OUTCOME_AMBIGUOUS, OUTCOME_AMBIGUOUS,
OUTCOME_STOP_HIT, OUTCOME_STOP_HIT,
@@ -19,6 +20,66 @@ from app.services.outcome_service import (
evaluate_setup_against_bars, 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: async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper() 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()} 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( async def create_trade(
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int,
@@ -85,7 +198,35 @@ async def create_trade(
return 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 { return {
"id": trade.id, "id": trade.id,
"symbol": symbol, "symbol": symbol,
@@ -98,21 +239,27 @@ def _to_dict(trade: PaperTrade, symbol: str, current_price: float | None) -> dic
"opened_at": trade.opened_at, "opened_at": trade.opened_at,
"close_price": trade.close_price, "close_price": trade.close_price,
"closed_at": trade.closed_at, "closed_at": trade.closed_at,
# For open trades, mark to market; for closed, the realized exit price. "current_price": ref,
"current_price": current_price if trade.status == "open" else trade.close_price, "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( async def list_trades(
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int | None = None,
status: str | None = None, status: str | None = None,
) -> list[dict]: ) -> list[dict]:
stmt = ( stmt = (
select(PaperTrade, Ticker.symbol) select(PaperTrade, Ticker.symbol)
.join(Ticker, PaperTrade.ticker_id == Ticker.id) .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: if status is not None:
stmt = stmt.where(PaperTrade.status == status) stmt = stmt.where(PaperTrade.status == status)
stmt = stmt.order_by(PaperTrade.opened_at.desc()) stmt = stmt.order_by(PaperTrade.opened_at.desc())
@@ -120,7 +267,38 @@ async def list_trades(
rows = (await db.execute(stmt)).all() rows = (await db.execute(stmt)).all()
open_ids = {t.ticker_id for t, _ in rows if t.status == "open"} open_ids = {t.ticker_id for t, _ in rows if t.status == "open"}
prices = await _latest_closes(db, open_ids) 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( async def close_trade(
@@ -149,6 +327,7 @@ async def close_trade(
trade.status = "closed" trade.status = "closed"
trade.close_price = float(close_price) trade.close_price = float(close_price)
trade.close_reason = "manual"
trade.closed_at = datetime.now(timezone.utc) trade.closed_at = datetime.now(timezone.utc)
await db.commit() await db.commit()
await db.refresh(trade) await db.refresh(trade)
@@ -156,47 +335,67 @@ async def close_trade(
async def resolve_open_trades(db: AsyncSession) -> int: 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). Walks the bars after each trade's open. 'time' closes at the initial stop or
Target hit → close at the target; stop (or an ambiguous same-bar touch) → the hold_days-th close; 'trailing' at the trailing/initial stop; 'target' at
close at the stop. Trades that have hit neither stay open. Returns the count the setup's target or stop (same logic as the outcome evaluator). Trades that
closed. have hit nothing stay open. Returns the count closed.
""" """
result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open")) result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open"))
open_trades = list(result.scalars().all()) open_trades = list(result.scalars().all())
if not open_trades: if not open_trades:
return 0 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 closed = 0
for trade in open_trades: for trade in open_trades:
bars_result = await db.execute( bars_result = await db.execute(
select(OHLCVRecord.date, OHLCVRecord.high, OHLCVRecord.low) select(
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
OHLCVRecord.low, OHLCVRecord.close,
)
.where( .where(
OHLCVRecord.ticker_id == trade.ticker_id, OHLCVRecord.ticker_id == trade.ticker_id,
OHLCVRecord.date > trade.opened_at.date(), OHLCVRecord.date > trade.opened_at.date(),
) )
.order_by(OHLCVRecord.date.asc()) .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: if not bars:
continue continue
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:
# max_bars beyond the data so a still-open trade returns undecided (not "expired"). # max_bars beyond the data so a still-open trade returns undecided (not "expired").
outcome, outcome_date = evaluate_setup_against_bars( outcome, outcome_date = evaluate_setup_against_bars(
trade.direction, trade.stop_loss, trade.target, bars, max_bars=len(bars) + 1 trade.direction, trade.stop_loss, trade.target, bars, max_bars=len(bars) + 1
) )
if outcome == OUTCOME_TARGET_HIT: if outcome == OUTCOME_TARGET_HIT:
trade.close_price = trade.target close_price, close_date, reason = trade.target, outcome_date, "target"
elif outcome in (OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS): elif outcome in (OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS):
trade.close_price = trade.stop_loss close_price, close_date, reason = trade.stop_loss, outcome_date, "stop"
else: else:
continue continue
trade.status = "closed" trade.status = "closed"
trade.closed_at = datetime.combine( trade.close_price = float(close_price)
outcome_date, datetime.min.time(), tzinfo=timezone.utc trade.close_reason = reason
) trade.closed_at = datetime.combine(close_date, datetime.min.time(), tzinfo=timezone.utc)
closed += 1 closed += 1
if closed: if closed:
+31 -12
View File
@@ -2,12 +2,12 @@
A single predicate, driven by the admin activation config, used by the A single predicate, driven by the admin activation config, used by the
performance stats (server) and mirrored on the frontend. The core selection is performance stats (server) and mirrored on the frontend. The core selection is
cross-sectional momentum: a setup's ticker must rank in the top residual 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 ``min_momentum_percentile`` of the universe by beta-adjusted 12-1 month momentum.
signal the backtest showed actually sorts forward returns. R:R and confidence R:R and confidence remain as floors, and conviction/conflict survive as optional
remain as floors, and conviction/conflict survive as optional tighteners (off by tighteners (off by default). The activation percentile is computed across the
default). The momentum percentile is computed across the universe and attached to universe and attached to each setup upstream; when it's absent the gate falls
each setup upstream; when it's absent the gate falls back to the floors. back to the floors.
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,6 +17,16 @@ from typing import Any
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"} 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: def best_target_probability(setup: Any) -> float:
"""Highest probability among a setup's targets, 0 if none.""" """Highest probability among a setup's targets, 0 if none."""
targets = getattr(setup, "targets", None) or [] targets = getattr(setup, "targets", None) or []
@@ -65,12 +75,12 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
return False return False
if (setup.confidence_score or 0.0) < config["min_confidence"]: if (setup.confidence_score or 0.0) < config["min_confidence"]:
return False return False
# Cross-sectional momentum: the core selection. A setup's ticker must rank in # Residual cross-sectional momentum: the core selection. A setup's ticker
# the top ``min_momentum_percentile`` of the universe by 12-1 momentum. The # must rank in the top ``min_momentum_percentile`` of the universe by
# validated edge is long-only, so while the gate is active shorts (which fight # beta-adjusted 12-1 momentum. The validated edge is long-only, so while the
# the trend) never qualify. The percentile floor is only enforced when a # gate is active shorts (which fight the trend) never qualify. The percentile
# percentile is attached (live setups / backtest); callers that don't attach # floor is only enforced when a percentile is attached (live setups /
# it defer to the floors above. # backtest); callers that don't attach it defer to the floors above.
min_pct = float(config.get("min_momentum_percentile", 0.0)) min_pct = float(config.get("min_momentum_percentile", 0.0))
if min_pct > 0: if min_pct > 0:
if (getattr(setup, "direction", "long") or "long") == "short": 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) momentum_percentile = getattr(setup, "momentum_percentile", None)
if momentum_percentile is not None and momentum_percentile < min_pct: if momentum_percentile is not None and momentum_percentile < min_pct:
return False 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 config.get("require_high_conviction"):
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS: if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
return False 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 PRIMARY_TARGET_MIN_RR = 1.5
@@ -559,24 +609,15 @@ async def enhance_trade_setup(
) -> TradeSetup: ) -> TradeSetup:
config = await get_recommendation_config(db) config = await get_recommendation_config(db)
conflicts = signal_conflict_detector.detect_conflicts( snapshot = build_recommendation_snapshot(
dimension_scores=dimension_scores, dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification, sentiment_classification=sentiment_classification,
config=config, config=config,
available_directions=available_directions,
) )
conflicts = list(snapshot["conflicts"])
long_confidence = direction_analyzer.calculate_confidence( long_confidence = float(snapshot["long_confidence"])
direction="long", short_confidence = float(snapshot["short_confidence"])
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,
)
direction = setup.direction.lower() direction = setup.direction.lower()
confidence = long_confidence if direction == "long" else short_confidence 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 # Action and reasoning are ticker-level: they consider both directions and
# which directions are actually tradeable, and are identical on every setup. # which directions are actually tradeable, and are identical on every setup.
action = _choose_recommended_action( action = str(snapshot["action"])
long_confidence, short_confidence, config, available_directions reasoning = str(snapshot["reasoning"])
)
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,
)
setup.confidence_score = round(confidence, 2) setup.confidence_score = round(confidence, 2)
setup.targets_json = json.dumps(targets) setup.targets_json = json.dumps(targets)
+346 -17
View File
@@ -11,24 +11,33 @@ from __future__ import annotations
import json import json
import logging import logging
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime, timezone from datetime import date, datetime, timezone
from sqlalchemy import and_, func, select from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import NotFoundError from app.exceptions import NotFoundError
from app.models.fundamental import FundamentalData
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.score import CompositeScore, DimensionScore from app.models.score import CompositeScore, DimensionScore
from app.models.sentiment import SentimentScore from app.models.sentiment import SentimentScore
from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.sr_level import SRLevel from app.models.sr_level import SRLevel
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup from app.models.trade_setup import TradeSetup
from app.services.indicator_service import _extract_ohlcv, compute_atr from app.services.indicator_service import _extract_ohlcv, compute_atr
from app.services.price_service import query_ohlcv 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__) logger = logging.getLogger(__name__)
STRATEGY_VERSION = "residual_momentum_12_1_rr_time_v2"
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker: async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper() 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 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( async def scan_ticker(
db: AsyncSession, db: AsyncSession,
symbol: str, symbol: str,
@@ -85,9 +351,9 @@ async def scan_ticker(
) -> list[TradeSetup]: ) -> list[TradeSetup]:
"""Scan a single ticker for trade setups meeting the R:R threshold. """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 ``momentum_percentile`` is the ticker's residual 12-1 momentum activation
(computed by the caller), stored on each setup so the activation gate can rank across the universe (computed by the caller), stored on each setup so
select the top slice.""" the activation gate can select the top slice."""
ticker = await _get_ticker(db, symbol) ticker = await _get_ticker(db, symbol)
records = await query_ohlcv(db, symbol) records = await query_ohlcv(db, symbol)
@@ -238,6 +504,9 @@ async def scan_ticker(
for s in enhanced_setups: for s in enhanced_setups:
await db.refresh(s) await db.refresh(s)
await _create_signal_context_snapshots(db, enhanced_setups)
await db.commit()
return enhanced_setups return enhanced_setups
@@ -256,8 +525,9 @@ async def scan_all_tickers(
tickers = list(result.scalars().all()) tickers = list(result.scalars().all())
total = len(tickers) total = len(tickers)
# Rank the universe by 12-1 momentum up front so each new setup carries its # Rank the universe by residual 12-1 momentum up front so each new setup
# ticker's percentile (used by the activation gate). Best-effort. # carries its activation percentile. Best-effort; the ranker falls back to
# raw 12-1 momentum only if benchmark data is unavailable.
try: try:
from app.services import momentum_service from app.services import momentum_service
@@ -303,6 +573,7 @@ async def get_trade_setups(
min_confidence: float | None = None, min_confidence: float | None = None,
recommended_action: str | None = None, recommended_action: str | None = None,
symbol: str | None = None, symbol: str | None = None,
live_recommendation: bool = False,
) -> list[dict]: ) -> list[dict]:
"""Get latest stored trade setups, optionally filtered.""" """Get latest stored trade setups, optionally filtered."""
stmt = ( stmt = (
@@ -313,9 +584,11 @@ async def get_trade_setups(
stmt = stmt.where(TradeSetup.direction == direction.lower()) stmt = stmt.where(TradeSetup.direction == direction.lower())
if symbol is not None: if symbol is not None:
stmt = stmt.where(Ticker.symbol == symbol.strip().upper()) 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) 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.where(TradeSetup.recommended_action == recommended_action)
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc()) stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
@@ -339,15 +612,37 @@ async def get_trade_setups(
reverse=True, reverse=True,
) )
prices = await _latest_closes(db, {s.ticker_id for s, _ in latest_rows}) prices = await _latest_price_context(db, {s.ticker_id for s, _ in latest_rows})
return [ rows_out = [
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id)) _trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
for setup, ticker_symbol in latest_rows 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]: async def _latest_price_context(db: AsyncSession, ticker_ids: set[int]) -> dict[int, dict]:
"""Most recent close per ticker — used to judge a setup's current relevance.""" """Most recent daily OHLCV row per ticker for live price context."""
if not ticker_ids: if not ticker_ids:
return {} return {}
latest = ( latest = (
@@ -356,7 +651,12 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
.group_by(OHLCVRecord.ticker_id) .group_by(OHLCVRecord.ticker_id)
.subquery() .subquery()
) )
stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join( stmt = select(
OHLCVRecord.ticker_id,
OHLCVRecord.close,
OHLCVRecord.date,
OHLCVRecord.created_at,
).join(
latest, latest,
and_( and_(
OHLCVRecord.ticker_id == latest.c.ticker_id, 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) 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( async def get_trade_setup_history(
@@ -381,16 +697,28 @@ async def get_trade_setup_history(
result = await db.execute(stmt) result = await db.execute(stmt)
rows = result.all() 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 [ return [
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id)) _trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
for setup, ticker_symbol in rows 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] = [] targets: list[dict] = []
conflicts: list[str] = [] 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: if setup.targets_json:
try: try:
@@ -429,4 +757,5 @@ def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float |
"evaluated_at": setup.evaluated_at, "evaluated_at": setup.evaluated_at,
"current_price": current_price, "current_price": current_price,
"momentum_percentile": setup.momentum_percentile, "momentum_percentile": setup.momentum_percentile,
"context_as_of": context_as_of,
} }
+89 -92
View File
@@ -2,8 +2,8 @@
Computes dimension scores (technical, sr_quality, sentiment, fundamental, Computes dimension scores (technical, sr_quality, sentiment, fundamental,
momentum) each 0-100, composite score as weighted average of available momentum) each 0-100, composite score as weighted average of available
dimensions with re-normalized weights, staleness marking/recomputation dimensions with re-normalized weights, staleness marking, explicit refresh
on demand, and weight update triggers full recomputation. paths, and weight update triggers full recomputation.
""" """
from __future__ import annotations from __future__ import annotations
@@ -28,13 +28,31 @@ DIMENSIONS = ["technical", "sr_quality", "sentiment", "fundamental", "momentum"]
DEFAULT_WEIGHTS: dict[str, float] = { DEFAULT_WEIGHTS: dict[str, float] = {
"technical": 0.25, "technical": 0.25,
"sr_quality": 0.20, "sr_quality": 0.20,
"sentiment": 0.15, "sentiment": 0.10,
"fundamental": 0.20, "fundamental": 0.20,
"momentum": 0.20, "momentum": 0.20,
} }
SCORING_WEIGHTS_KEY = "scoring_weights" 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 # Helpers
@@ -670,10 +688,15 @@ async def compute_composite_score(
symbol: str, symbol: str,
weights: dict[str, float] | None = None, weights: dict[str, float] | None = None,
) -> tuple[float | None, list[str]]: ) -> 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). Returns (composite_score, missing_dimensions).
Missing dimensions are excluded and weights re-normalized.
""" """
ticker = await _get_ticker(db, symbol) 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()} dim_scores = {ds.dimension: ds for ds in result.scalars().all()}
available: list[tuple[str, float, float]] = [] # (dim, weight, score) def _live(dim: str) -> float | None:
missing: list[str] = []
for dim in DIMENSIONS:
w = weights.get(dim, 0.0)
if w <= 0:
continue
ds = dim_scores.get(dim) ds = dim_scores.get(dim)
if ds is not None and not ds.is_stale and ds.score is not None: if ds is not None and not ds.is_stale and ds.score is not None:
available.append((dim, w, ds.score)) return ds.score
return None
missing = [dim for dim in DIMENSIONS if _live(dim) is None]
# 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")
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: else:
missing.append(dim) return None, missing # nothing to score
if not available: delta = _sentiment_adjustment(sentiment_score, weights.get("sentiment", 0.0))
return None, missing composite = max(0.0, min(100.0, base + delta))
# Re-normalize weights
total_weight = sum(w for _, w, _ in available)
if total_weight == 0:
return None, missing
composite = sum(w * s for _, w, s in available) / total_weight
composite = max(0.0, min(100.0, composite))
# Persist composite score # Persist composite score
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -739,73 +765,37 @@ async def compute_composite_score(
async def get_score( async def get_score(
db: AsyncSession, symbol: str db: AsyncSession, symbol: str
) -> dict: ) -> 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. GET endpoints use this path, so it must not mutate persisted score context.
Returns a dict suitable for ScoreResponse, including dimension breakdowns Scheduled/manual write paths are responsible for refreshing scores.
and composite breakdown with re-normalization info.
""" """
ticker = await _get_ticker(db, symbol) ticker = await _get_ticker(db, symbol)
weights = await _get_weights(db) 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( result = await db.execute(
select(DimensionScore).where(DimensionScore.ticker_id == ticker.id) select(DimensionScore).where(DimensionScore.ticker_id == ticker.id)
) )
dim_scores_list = list(result.scalars().all()) dim_scores_list = list(result.scalars().all())
dim_scores = {ds.dimension: ds for ds in dim_scores_list}
comp_result = await db.execute( comp_result = await db.execute(
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id) select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
) )
comp = comp_result.scalar_one_or_none() 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 = [] dimensions = []
missing = [] missing = []
available_dims: list[str] = [] available_dims: list[str] = []
for dim in DIMENSIONS: 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: if found is not None and not found.is_stale and found.score is not None:
dimensions.append({ dimensions.append({
"dimension": found.dimension, "dimension": found.dimension,
"score": found.score, "score": found.score,
"is_stale": found.is_stale, "is_stale": found.is_stale,
"computed_at": found.computed_at, "computed_at": found.computed_at,
"breakdown": breakdowns.get(dim), "breakdown": None,
}) })
w = weights.get(dim, 0.0) w = weights.get(dim, 0.0)
if w > 0: if w > 0:
@@ -819,25 +809,50 @@ async def get_score(
"score": found.score, "score": found.score,
"is_stale": found.is_stale, "is_stale": found.is_stale,
"computed_at": found.computed_at, "computed_at": found.computed_at,
"breakdown": breakdowns.get(dim), "breakdown": None,
}) })
# Build composite breakdown with re-normalization info # Build composite breakdown: the non-sentiment base (re-normalized weighted
composite_breakdown = None # average) plus sentiment as a signed adjustment around neutral.
available_weight_sum = sum(weights.get(d, 0.0) for d in available_dims) 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: if available_weight_sum > 0:
renormalized_weights = { 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: else:
renormalized_weights = {} 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 = { composite_breakdown = {
"weights": weights, "weights": weights,
"available_dimensions": available_dims, "available_dimensions": base_dims,
"missing_dimensions": missing, "missing_dimensions": missing,
"renormalized_weights": renormalized_weights, "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 { return {
@@ -874,31 +889,13 @@ async def get_rankings(db: AsyncSession) -> dict:
dims[ds.ticker_id][ds.dimension] = ds dims[ds.ticker_id][ds.dimension] = ds
return comps, dims 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() comps, dims_by_ticker = await _load_scores()
rankings = [ rankings = [
{ {
"symbol": ticker.symbol, "symbol": ticker.symbol,
"composite_score": comp.score, "composite_score": comp.score,
"composite_stale": comp.is_stale,
"dimensions": [ "dimensions": [
{ {
"dimension": ds.dimension, "dimension": ds.dimension,
+57
View File
@@ -6,6 +6,7 @@ well-known universes (S&P 500, NASDAQ-100, NASDAQ All).
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import os 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}") 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( async def bootstrap_universe(
db: AsyncSession, db: AsyncSession,
universe: str, universe: str,
@@ -387,6 +437,13 @@ async def bootstrap_universe(
await db.commit() 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 { return {
"universe": normalised_universe, "universe": normalised_universe,
"total_universe_symbols": len(symbols), "total_universe_symbols": len(symbols),
+3
View File
@@ -173,6 +173,9 @@ async def _enrich_entry(
"dimensions": dims, "dimensions": dims,
"rr_ratio": setup.rr_ratio if setup else None, "rr_ratio": setup.rr_ratio if setup else None,
"rr_direction": setup.direction 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, "sr_levels": sr_levels,
"last_close": last_close, "last_close": last_close,
"change_pct": change_pct, "change_pct": change_pct,
+8
View File
@@ -134,6 +134,8 @@ export function updateAlertSettings(payload: {
sr_proximity_enabled?: boolean; sr_proximity_enabled?: boolean;
score_drop_enabled?: boolean; score_drop_enabled?: boolean;
digest_enabled?: boolean; digest_enabled?: boolean;
regime_quadrant_enabled?: boolean;
trade_closed_enabled?: boolean;
}) { }) {
return apiClient return apiClient
.put<AlertConfig>('admin/settings/alerts', payload) .put<AlertConfig>('admin/settings/alerts', payload)
@@ -169,6 +171,12 @@ export function bootstrapTickers(universe: TickerUniverse, pruneMissing: boolean
.then((r) => r.data); .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 // Jobs
export interface JobStatus { export interface JobStatus {
name: string; name: string;
+1 -1
View File
@@ -1,7 +1,7 @@
import apiClient from './client'; import apiClient from './client';
export interface IngestionSourceResult { export interface IngestionSourceResult {
status: 'ok' | 'error' | 'skipped'; status: 'ok' | 'error' | 'skipped' | 'warning';
message?: string | null; message?: string | null;
records?: number; records?: number;
classification?: string; classification?: string;
+9 -1
View File
@@ -1,5 +1,5 @@
import apiClient from './client'; import apiClient from './client';
import type { PaperTrade } from '../lib/types'; import type { ExitPolicy, PaperTrade } from '../lib/types';
export function listPaperTrades(status?: 'open' | 'closed') { export function listPaperTrades(status?: 'open' | 'closed') {
return apiClient return apiClient
@@ -7,6 +7,14 @@ export function listPaperTrades(status?: 'open' | 'closed') {
.then((r) => r.data); .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 { export interface CreatePaperTradeBody {
symbol: string; symbol: string;
direction: 'long' | 'short'; direction: 'long' | 'short';
@@ -9,6 +9,7 @@ const DEFAULTS: ActivationConfig = {
min_confidence: 55, min_confidence: 55,
require_high_conviction: false, require_high_conviction: false,
exclude_conflicts: false, exclude_conflicts: false,
exclude_neutral: true,
}; };
export function ActivationSettings() { export function ActivationSettings() {
@@ -40,16 +41,16 @@ export function ActivationSettings() {
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the 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 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 <span className="text-gray-300"> residual 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 top slice of the universe by beta-adjusted 12-1 month momentum, the production signal promoted
forward returns. R:R and confidence stay as floors. Tune the cutoff against the Track Record's 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. momentum sweep to see what actually wins.
</p> </p>
</div> </div>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<label className="block space-y-1"> <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 <input
type="number" type="number"
min={0} min={0}
@@ -59,7 +60,7 @@ export function ActivationSettings() {
onChange={(e) => setForm((prev) => ({ ...prev, min_momentum_percentile: Number(e.target.value) }))} onChange={(e) => setForm((prev) => ({ ...prev, min_momentum_percentile: Number(e.target.value) }))}
className="w-full input-glass px-3 py-2 text-sm" 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>
<label className="block space-y-1"> <label className="block space-y-1">
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span> <span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
@@ -87,6 +88,24 @@ export function ActivationSettings() {
</label> </label>
</div> </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"> <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="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> <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>
@@ -13,14 +13,16 @@ type TriggerKey =
| 'sr_proximity_enabled' | 'sr_proximity_enabled'
| 'score_drop_enabled' | 'score_drop_enabled'
| 'digest_enabled' | 'digest_enabled'
| 'regime_quadrant_enabled'; | 'regime_quadrant_enabled'
| 'trade_closed_enabled';
const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [ const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [
{ key: 'qualified_enabled', label: 'Qualified setups', hint: 'a setup newly clears the activation gate' }, { 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: '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: '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: '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 }: { function Toggle({ checked, onChange, label, hint }: {
@@ -59,6 +61,7 @@ export function AlertSettings() {
score_drop_enabled: true, score_drop_enabled: true,
digest_enabled: true, digest_enabled: true,
regime_quadrant_enabled: true, regime_quadrant_enabled: true,
trade_closed_enabled: true,
}); });
useEffect(() => { useEffect(() => {
@@ -71,6 +74,7 @@ export function AlertSettings() {
score_drop_enabled: data.score_drop_enabled, score_drop_enabled: data.score_drop_enabled,
digest_enabled: data.digest_enabled, digest_enabled: data.digest_enabled,
regime_quadrant_enabled: data.regime_quadrant_enabled, regime_quadrant_enabled: data.regime_quadrant_enabled,
trade_closed_enabled: data.trade_closed_enabled,
}); });
} }
}, [data]); }, [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 { useEffect, useState } from 'react';
import { import {
useBackfillTickerNames,
useBootstrapTickers, useBootstrapTickers,
useTickerUniverseSetting, useTickerUniverseSetting,
useUpdateTickerUniverseSetting, useUpdateTickerUniverseSetting,
@@ -17,6 +18,7 @@ export function TickerUniverseBootstrap() {
const { data, isLoading, isError, error } = useTickerUniverseSetting(); const { data, isLoading, isError, error } = useTickerUniverseSetting();
const updateDefault = useUpdateTickerUniverseSetting(); const updateDefault = useUpdateTickerUniverseSetting();
const bootstrap = useBootstrapTickers(); const bootstrap = useBootstrapTickers();
const backfillNames = useBackfillTickerNames();
const [universe, setUniverse] = useState<TickerUniverse>('sp500'); const [universe, setUniverse] = useState<TickerUniverse>('sp500');
const [pruneMissing, setPruneMissing] = useState(false); const [pruneMissing, setPruneMissing] = useState(false);
@@ -85,6 +87,14 @@ export function TickerUniverseBootstrap() {
> >
{bootstrap.isPending ? 'Bootstrapping…' : 'Bootstrap Now'} {bootstrap.isPending ? 'Bootstrapping…' : 'Bootstrap Now'}
</button> </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>
</div> </div>
); );
@@ -1,6 +1,6 @@
import { useRef, useEffect, useCallback, useState } from 'react'; import { useRef, useEffect, useCallback, useState } from 'react';
import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types'; import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types';
import { formatPrice, formatDate } from '../../lib/format'; import { formatPrice, formatDate, formatLargeNumber } from '../../lib/format';
interface CandlestickChartProps { interface CandlestickChartProps {
data: OHLCVBar[]; data: OHLCVBar[];
@@ -50,6 +50,9 @@ interface TooltipState {
} }
const MIN_VISIBLE_BARS = 10; 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'; type RangePreset = '1M' | '3M' | '6M' | 'YTD' | '1Y' | '3Y' | '5Y' | 'All';
const RANGE_PRESETS: 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 dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
const W = rect.width; const W = rect.width;
const H = 400; const H = CHART_HEIGHT;
canvas.width = W * dpr; canvas.width = W * dpr;
canvas.height = H * dpr; canvas.height = H * dpr;
@@ -124,7 +127,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
// Margins // Margins
const ml = 12, mr = 70, mt = 12, mb = 32; const ml = 12, mr = 70, mt = 12, mb = 32;
const cw = W - ml - mr; 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 // Current price = explicit prop, else latest close
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].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 yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
const barW = cw / visibleData.length; const barW = cw / visibleData.length;
const candleW = Math.max(barW * 0.65, 1); 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) // Grid lines (horizontal)
const nTicks = 6; const nTicks = 6;
@@ -172,6 +182,34 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
ctx.fillText(formatDate(visibleData[i].date), x, H - 6); 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) // Nearest support/resistance only (band if it came from a zone)
markers.forEach((m) => { markers.forEach((m) => {
const isSupport = m.role === 'support'; const isSupport = m.role === 'support';
@@ -312,7 +350,22 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
}); });
// Store geometry for hit testing (includes visibleRange offset) // 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 // Size the overlay canvas to match
const overlay = overlayCanvasRef.current; const overlay = overlayCanvasRef.current;
@@ -342,12 +395,14 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
const meta = (canvas as any).__chartMeta; const meta = (canvas as any).__chartMeta;
if (!meta) return; 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 H = overlay.height / dpr;
const priceBottom = mt + ch;
const chartBottom = volumeBottom ?? priceBottom;
// Clamp crosshair to chart area // Clamp crosshair to chart area
const cx = Math.max(ml, Math.min(ml + cw, pos.x)); 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 // Dashed crosshair lines
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)'; ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
@@ -357,24 +412,30 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
// Vertical line // Vertical line
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(cx, mt); ctx.moveTo(cx, mt);
ctx.lineTo(cx, mt + ch); ctx.lineTo(cx, chartBottom);
ctx.stroke(); ctx.stroke();
// Horizontal line ctx.setLineDash([]);
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
const labelPadX = 5;
const labelPadY = 3;
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.beginPath();
ctx.moveTo(ml, cy); ctx.moveTo(ml, cy);
ctx.lineTo(ml + cw, cy); ctx.lineTo(ml + cw, cy);
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
// Price label on y-axis (right side) // Price label on y-axis (right side)
const price = hi - ((cy - mt) / ch) * (hi - lo); const price = hi - ((cy - mt) / ch) * (hi - lo);
const priceText = formatPrice(price); const priceText = formatPrice(price);
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
const priceMetrics = ctx.measureText(priceText); const priceMetrics = ctx.measureText(priceText);
const labelPadX = 5;
const labelPadY = 3;
const labelW = priceMetrics.width + labelPadX * 2; const labelW = priceMetrics.width + labelPadX * 2;
const labelH = 16 + labelPadY * 2; const labelH = 16 + labelPadY * 2;
const labelX = ml + cw + 2; const labelX = ml + cw + 2;
@@ -388,6 +449,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillText(priceText, labelX + labelPadX, cy); ctx.fillText(priceText, labelX + labelPadX, cy);
}
// Date label on x-axis (bottom) // Date label on x-axis (bottom)
const localIdx = Math.floor((cx - ml) / barW); 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>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>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>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}`; </div>${tradeTooltipHtml}`;
} else { } else {
tip.style.display = 'none'; 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> <span className="ml-1 text-[10px] text-gray-600">scroll to zoom · drag to pan</span>
</div> </div>
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}> <div ref={containerRef} className="relative w-full" style={{ height: CHART_HEIGHT }}>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="w-full" className="w-full"
style={{ height: 400 }} style={{ height: CHART_HEIGHT }}
/> />
<canvas <canvas
ref={overlayCanvasRef} ref={overlayCanvasRef}
className="absolute top-0 left-0 w-full cursor-crosshair" className="absolute top-0 left-0 w-full cursor-crosshair"
style={{ height: 400 }} style={{ height: CHART_HEIGHT }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
@@ -1,6 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Link } from 'react-router-dom'; 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 { tradePnl } from '../../lib/paperTrade';
import { formatPrice } from '../../lib/format'; import { formatPrice } from '../../lib/format';
import { Section } from '../ui/Section'; import { Section } from '../ui/Section';
@@ -18,19 +19,32 @@ function pnlColor(v: number): string {
export function OpenTradesPanel() { export function OpenTradesPanel() {
const { data: trades, isLoading } = usePaperTrades('open'); const { data: trades, isLoading } = usePaperTrades('open');
const { data: policy } = useExitPolicy();
const tickerNames = useTickerNames();
const close = useClosePaperTrade(); 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(() => { 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 ?? []) { for (const t of trades ?? []) {
const p = tradePnl(t); const p = tradePnl(t);
if (!p) continue; if (p) {
priced += 1; priced += 1;
pnl += p.pnl; pnl += p.pnl;
if (p.pnl > 0) winners += 1; if (p.pnl > 0) winners += 1;
else if (p.pnl < 0) losers += 1; else if (p.pnl < 0) losers += 1;
} }
return { pnl, winners, losers, priced }; if (t.alpha_usd != null) {
alphaUsd += t.alpha_usd;
alphaPriced += 1;
}
}
return { pnl, winners, losers, priced, alphaUsd, alphaPriced };
}, [trades]); }, [trades]);
if (isLoading) return null; if (isLoading) return null;
@@ -39,7 +53,7 @@ export function OpenTradesPanel() {
return ( return (
<Section <Section
title="Open Trades" 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 ? ( {rows.length === 0 ? (
<Callout variant="empty"> <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">P&L</th>
<th className="px-4 py-3 text-right">%</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">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> <th className="px-4 py-3"></th>
</tr> </tr>
</thead> </thead>
@@ -70,6 +86,11 @@ export function OpenTradesPanel() {
<Link to={`/ticker/${t.symbol}`} className="font-medium text-blue-300 hover:text-blue-200"> <Link to={`/ticker/${t.symbol}`} className="font-medium text-blue-300 hover:text-blue-200">
{t.symbol} {t.symbol}
</Link> </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>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`num text-[10px] font-semibold uppercase ${t.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}> <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'}`}> <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` : '—'} {p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
</td> </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"> <td className="px-4 py-3 text-right">
<button <button
onClick={() => { onClick={() => {
@@ -110,12 +148,16 @@ export function OpenTradesPanel() {
<tfoot> <tfoot>
<tr className="border-t border-white/[0.08]"> <tr className="border-t border-white/[0.08]">
<td className="px-4 py-2.5 text-xs text-gray-500" colSpan={5}> <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>
<td className={`num px-4 py-2.5 text-right font-semibold ${pnlColor(totals.pnl)}`}> <td className={`num px-4 py-2.5 text-right font-semibold ${pnlColor(totals.pnl)}`}>
{money(totals.pnl)} {money(totals.pnl)}
</td> </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> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import TickerSearch from './TickerSearch';
const navItems = [ const navItems = [
{ to: '/', label: 'Overview', end: true }, { to: '/', label: 'Overview', end: true },
@@ -46,6 +47,9 @@ export default function MobileNav() {
}`} }`}
> >
<nav className="px-3 py-2 space-y-1"> <nav className="px-3 py-2 space-y-1">
<div className="pb-2">
<TickerSearch onNavigate={() => setOpen(false)} />
</div>
{navItems.map(({ to, label, end }) => ( {navItems.map(({ to, label, end }) => (
<NavLink <NavLink
key={to} key={to}
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { check as healthCheck } from '../../api/health'; import { check as healthCheck } from '../../api/health';
import { getRunningJobs } from '../../api/jobs'; import { getRunningJobs } from '../../api/jobs';
import TickerSearch from './TickerSearch';
const navItems = [ const navItems = [
{ to: '/', label: 'Overview', index: '01', end: true }, { 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> <p className="text-[10px] text-gray-500 mt-1.5 font-mono uppercase tracking-[0.22em]">Trading Intelligence</p>
</div> </div>
<div className="px-3 pt-4">
<TickerSearch />
</div>
<nav className="flex-1 px-3 py-5 space-y-1"> <nav className="flex-1 px-3 py-5 space-y-1">
{navItems.map(({ to, label, index, end }) => ( {navItems.map(({ to, label, index, end }) => (
<NavLink key={to} to={to} end={end} className={({ isActive }) => linkClasses(isActive)}> <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'; import { useUpdateWeights } from '../../hooks/useScores';
interface WeightsFormProps { interface WeightsFormProps {
weights: Record<string, number>; weights: Record<string, number>;
} }
const SENTIMENT = 'sentiment';
export function WeightsForm({ weights }: WeightsFormProps) { 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>>(() => const [sliderValues, setSliderValues] = useState<Record<string, number>>(() =>
Object.fromEntries( Object.fromEntries(
Object.entries(weights).map(([key, w]) => [key, Math.round(w * 100)]) Object.entries(weights).map(([key, w]) => [key, Math.round(w * 100)])
@@ -14,10 +18,10 @@ export function WeightsForm({ weights }: WeightsFormProps) {
); );
const updateWeights = useUpdateWeights(); const updateWeights = useUpdateWeights();
const allZero = useMemo( const baseKeys = Object.keys(weights).filter((k) => k !== SENTIMENT);
() => Object.values(sliderValues).every((v) => v === 0), const hasSentiment = SENTIMENT in weights;
[sliderValues] const baseTotal = baseKeys.reduce((sum, k) => sum + (sliderValues[k] ?? 0), 0);
); const sentimentPts = sliderValues[SENTIMENT] ?? 0;
const handleChange = (key: string, value: string) => { const handleChange = (key: string, value: string) => {
const num = parseInt(value, 10); const num = parseInt(value, 10);
@@ -26,24 +30,35 @@ export function WeightsForm({ weights }: WeightsFormProps) {
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (allZero) return; if (baseTotal === 0) return;
const total = Object.values(sliderValues).reduce((sum, v) => sum + v, 0); // Base dimensions normalize among themselves; sentiment passes through raw
const normalized = Object.fromEntries( // (slider value / 100) so it stays independent of the base.
Object.entries(sliderValues).map(([key, v]) => [key, v / total]) 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 ( return (
<form onSubmit={handleSubmit} className="glass p-5"> <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 Scoring Weights
</h3> </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"> <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"> <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"> <div className="flex items-center gap-2">
<input <input
type="range" type="range"
@@ -61,14 +76,39 @@ export function WeightsForm({ weights }: WeightsFormProps) {
</label> </label>
))} ))}
</div> </div>
{allZero && (
<p className="mt-3 text-xs text-red-400"> {hasSentiment && (
At least one weight must be greater than zero <div className="mt-4 border-t border-white/[0.06] pt-4">
</p> <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 <button
type="submit" 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" className="mt-4 btn-primary px-4 py-2 text-sm disabled:opacity-50"
> >
<span>{updateWeights.isPending ? 'Updating…' : 'Update Weights'}</span> <span>{updateWeights.isPending ? 'Updating…' : 'Update Weights'}</span>
+339 -26
View File
@@ -6,15 +6,30 @@ import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure'; import { Disclosure } from '../ui/Disclosure';
import { Section } from '../ui/Section'; import { Section } from '../ui/Section';
import { useToast } from '../ui/Toast'; 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 { function fmtR(v: number | null | undefined): string {
if (v === null) return '—'; if (v === null || v === undefined) return '—';
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`; return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
} }
function fmtPct(v: number | null): string { function fmtPct(v: number | null): string {
return v === null ? '—' : `${v.toFixed(1)}%`; 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 { function rColor(v: number | null): string {
if (v === null) return 'text-gray-400'; if (v === null) return 'text-gray-400';
if (v > 0) return 'text-emerald-400'; if (v > 0) return 'text-emerald-400';
@@ -24,6 +39,7 @@ function rColor(v: number | null): string {
const SIGNAL_LABELS: Record<string, string> = { const SIGNAL_LABELS: Record<string, string> = {
mom_12_1: '121 month momentum', mom_12_1: '121 month momentum',
mom_12_1_resid: '121 residual momentum',
mom_6_1: '61 month momentum', mom_6_1: '61 month momentum',
mom_3_1: '31 month momentum', mom_3_1: '31 month momentum',
reversal_1m: '1-month reversal', reversal_1m: '1-month reversal',
@@ -32,6 +48,25 @@ const SIGNAL_LABELS: Record<string, string> = {
vol_6m: '6-month realized volatility', 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 // 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. // building on; below it, ranking on the signal sorts essentially nothing.
const IC_EDGE_THRESHOLD = 0.03; 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-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 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.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> </tr>
); );
} }
@@ -85,6 +125,12 @@ export function BacktestPanel() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const toast = useToast(); 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({ const run = useMutation({
mutationFn: () => triggerJob('backtest'), mutationFn: () => triggerJob('backtest'),
onSuccess: (res) => { onSuccess: (res) => {
@@ -131,8 +177,54 @@ export function BacktestPanel() {
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-gray-500">
Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups
({report.qualified} qualified) · weekly cadence, {report.params.horizon_days}-day horizon ({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> </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"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat <Stat
label="Qualified Hit Rate" label="Qualified Hit Rate"
@@ -157,6 +249,30 @@ export function BacktestPanel() {
valueClass={rColor(report.overall_qualified.total_r)} valueClass={rColor(report.overall_qualified.total_r)}
sub="cumulative, risk-adjusted" 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>
<div className="glass overflow-x-auto"> <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">Expired</th>
<th className="px-4 py-2.5 text-right">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">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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -187,11 +308,11 @@ export function BacktestPanel() {
{report.sweep && report.sweep.length > 0 && report.sweep[0].min_momentum_percentile != null && ( {report.sweep && report.sweep.length > 0 && report.sweep[0].min_momentum_percentile != null && (
<div> <div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500"> <p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Momentum-percentile sweep Residual-momentum percentile sweep
</p> </p>
<p className="mb-2 text-[11px] text-gray-500"> <p className="mb-2 text-[11px] text-gray-500">
How many setups qualify and how they perform at each momentum-rank cutoff (floors 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 12-1 momentum each week; 0 = 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 floors only. Lower = more trades, watch that expectancy holds. Your current setting is
highlighted; set it in Admin Settings Activation. highlighted; set it in Admin Settings Activation.
</p> </p>
@@ -199,12 +320,13 @@ export function BacktestPanel() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500"> <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">Qualified</th>
<th className="px-4 py-2.5 text-right">Wins</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">Losses</th>
<th className="px-4 py-2.5 text-right">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">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">Total R</th>
</tr> </tr>
</thead> </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-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 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 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> <td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
</tr> </tr>
); );
@@ -232,46 +355,236 @@ export function BacktestPanel() {
</div> </div>
)} )}
{report.gate_ablation && report.gate_ablation.length > 0 && (
<div> <div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500"> <p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Probability calibration Gate ablation which floors earn their keep
</p> </p>
<p className="mb-2 text-[11px] text-gray-500"> <p className="mb-2 text-[11px] text-gray-500">
Do targets we call X% likely actually hit that often? Realized below predicted = {report.gate_ablation_note ??
the model is over-confident. 'Each row re-qualifies the same candidates at the current momentum cutoff with one floor removed (long-only throughout).'}
</p> </p>
{report.calibration.length === 0 ? (
<Callout variant="empty">Not enough resolved setups to calibrate.</Callout>
) : (
<div className="glass overflow-x-auto"> <div className="glass overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500"> <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">Setups</th>
<th className="px-4 py-2.5 text-right">Avg Predicted</th> <th className="px-4 py-2.5 text-right">Hit Rate</th>
<th className="px-4 py-2.5 text-right">Realized 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> </tr>
</thead> </thead>
<tbody> <tbody>
{report.calibration.map((row) => { {report.gate_ablation.map((row) => (
const over = row.realized_hit_rate < row.predicted_avg; <tr
return ( key={row.variant}
<tr key={row.bucket} className="border-b border-white/[0.04]"> className={`border-b border-white/[0.04] ${row.variant === 'all_floors' ? 'bg-blue-400/10' : ''}`}
<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="px-4 py-2.5 font-medium text-gray-200">
<td className="num px-4 py-2.5 text-right text-gray-400">{row.predicted_avg.toFixed(0)}%</td> {ABLATION_LABELS[row.variant] ?? row.variant}
<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)}%
</td> </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.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> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
</div> </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 && ( {report.signal_eval && report.signal_eval.length > 0 && (
<div> <div>
@@ -38,6 +38,7 @@ export function MyTradesPanel() {
const rows = (closed ?? []).map((t) => ({ t, p: tradePnl(t) })); 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 rs = rows.map((r) => r.p?.r).filter((r): r is number => r != null);
const pnls = rows.map((r) => r.p?.pnl ?? 0); 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 wins = pnls.filter((p) => p > 0).length;
const losses = pnls.filter((p) => p < 0).length; const losses = pnls.filter((p) => p < 0).length;
const decided = wins + losses; const decided = wins + losses;
@@ -49,6 +50,7 @@ export function MyTradesPanel() {
avgR: rs.length ? rs.reduce((a, b) => a + b, 0) / rs.length : null, avgR: rs.length ? rs.reduce((a, b) => a + b, 0) / rs.length : null,
totalR: rs.length ? rs.reduce((a, b) => a + b, 0) : null, totalR: rs.length ? rs.reduce((a, b) => a + b, 0) : null,
totalPnl: pnls.reduce((a, b) => a + b, 0), totalPnl: pnls.reduce((a, b) => a + b, 0),
totalAlpha: alphas.length ? alphas.reduce((a, b) => a + b, 0) : null,
rows, rows,
}; };
}, [closed]); }, [closed]);
@@ -64,11 +66,12 @@ export function MyTradesPanel() {
</Callout> </Callout>
) : ( ) : (
<div className="space-y-4"> <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="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="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 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="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>
<div className="glass overflow-x-auto"> <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">Exit</th>
<th className="px-4 py-2.5 text-right">P&L</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">R</th>
<th className="px-4 py-2.5 text-right">Alpha</th>
<th className="px-4 py-2.5 text-right">Closed</th> <th className="px-4 py-2.5 text-right">Closed</th>
</tr> </tr>
</thead> </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 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 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 ${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> <td className="num px-4 py-2.5 text-right text-gray-500">{t.closed_at ? new Date(t.closed_at).toLocaleDateString() : '—'}</td>
</tr> </tr>
))} ))}
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useActivation } from '../../hooks/useActivation'; import { useActivation } from '../../hooks/useActivation';
import { activationSummary } from '../../lib/qualification'; import { activationSummary } from '../../lib/qualification';
import { usePerformance } from '../../hooks/usePerformance'; import { usePerformance } from '../../hooks/usePerformance';
import { useBacktestReport } from '../../hooks/useMarketRegime';
import { triggerJob, resetTrackRecord } from '../../api/admin'; import { triggerJob, resetTrackRecord } from '../../api/admin';
import { Button } from '../ui/Button'; import { Button } from '../ui/Button';
import { Callout } from '../ui/Callout'; import { Callout } from '../ui/Callout';
@@ -15,6 +16,14 @@ import { BacktestPanel } from './BacktestPanel';
import { MyTradesPanel } from './MyTradesPanel'; import { MyTradesPanel } from './MyTradesPanel';
import type { OutcomeBucketStats } from '../../lib/types'; 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 { function fmtR(value: number | null): string {
if (value === null) return '—'; if (value === null) return '—';
return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`; return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`;
@@ -31,6 +40,17 @@ function rColor(value: number | null): string {
return 'text-gray-300'; 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 }: { function StatCard({ label, value, valueClass = 'text-gray-100', sub }: {
label: string; label: string;
value: string; value: string;
@@ -57,7 +77,7 @@ function BreakdownTable({ rows, labelHeader, mapLabel }: {
}) { }) {
const entries = Object.entries(rows); const entries = Object.entries(rows);
if (entries.length === 0) { 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 ( return (
<div className="glass overflow-x-auto"> <div className="glass overflow-x-auto">
@@ -100,6 +120,7 @@ export function TrackRecordPanel() {
const { data, isLoading, isError, error } = usePerformance( const { data, isLoading, isError, error } = usePerformance(
qualifiedOnly ? { qualified_only: true } : undefined, qualifiedOnly ? { qualified_only: true } : undefined,
); );
const backtest = useBacktestReport();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const toast = useToast(); const toast = useToast();
@@ -137,14 +158,60 @@ 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 ( return (
<div className="space-y-6"> <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 /> <MyTradesPanel />
<div className="border-t border-white/[0.06]" /> <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"> <Section title="Live vs Backtest" hint="is the live system tracking the backtest?">
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-gray-300"> {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>
<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 <input
type="checkbox" type="checkbox"
checked={qualifiedOnly} checked={qualifiedOnly}
@@ -158,31 +225,6 @@ export function TrackRecordPanel() {
)} )}
</span> </span>
</label> </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 && ( {isLoading && (
<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-4">
@@ -190,18 +232,12 @@ export function TrackRecordPanel() {
</div> </div>
)} )}
{isError && (
<Callout variant="error">
{error instanceof Error ? error.message : 'Failed to load performance stats'}
</Callout>
)}
{data && data.overall.total === 0 && ( {data && data.overall.total === 0 && (
<Callout variant="empty"> <Callout variant="empty">
{qualifiedOnly {data.maturing > 0
? 'No evaluated setups meet the activation thresholds yet. Untick "Qualified signals only" to see all evaluated setups, or wait for more outcomes.' ? `No setups have completed their ~30-day window yet — ${data.maturing} still maturing. ` +
: '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.'} 'Counting them earlier would skew toward quick stop-outs.'
{data.pending > 0 && ` ${data.pending} setup${data.pending === 1 ? '' : 's'} pending evaluation.`} : 'No matured setups yet. Outcomes appear once setups complete their evaluation window — the evaluator runs nightly, or click Evaluate Now.'}
</Callout> </Callout>
)} )}
@@ -226,26 +262,42 @@ export function TrackRecordPanel() {
sub="cumulative risk-adjusted result" sub="cumulative risk-adjusted result"
/> />
<StatCard <StatCard
label="Evaluated" label="Matured"
value={String(data.overall.total)} value={String(data.overall.total)}
sub={`${data.pending} pending · ${data.overall.expired} expired`} sub={`${data.maturing} maturing · ${data.overall.expired} expired`}
/> />
</div> </div>
<Section title="By Direction">
<BreakdownTable rows={data.by_direction} labelHeader="Direction" />
</Section>
<Section title="By Recommended Action"> <Section title="By Recommended Action">
<BreakdownTable rows={data.by_action} labelHeader="Action" mapLabel={actionLabel} /> <BreakdownTable rows={data.by_action} labelHeader="Action" mapLabel={actionLabel} />
</Section> </Section>
<Section title="By Confidence" hint="at detection time"> <Section title="By Confidence" hint="at detection time · all setups">
<BreakdownTable rows={data.by_confidence} labelHeader="Confidence" /> <BreakdownTable rows={data.by_confidence} labelHeader="Confidence" />
</Section> </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" /> <div className="border-t border-white/[0.06] pt-2" />
<BacktestPanel /> <BacktestPanel />
</div> </div>
@@ -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>
);
}
+33 -7
View File
@@ -5,7 +5,10 @@ import type { DimensionScoreDetail, CompositeBreakdown } from '../../lib/types';
interface ScoreCardProps { interface ScoreCardProps {
compositeScore: number | null; compositeScore: number | null;
dimensions: DimensionScoreDetail[]; 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 { 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 [expanded, setExpanded] = useState<Record<string, boolean>>({});
const toggleExpand = (dimension: string) => { const toggleExpand = (dimension: string) => {
@@ -60,6 +63,7 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: Sc
return ( return (
<div className="glass p-5"> <div className="glass p-5">
{showComposite && (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{compositeScore !== null ? ( {compositeScore !== null ? (
<ScoreRing score={compositeScore} /> <ScoreRing score={compositeScore} />
@@ -72,15 +76,22 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: Sc
{compositeScore !== null ? Math.round(compositeScore) : '—'} {compositeScore !== null ? Math.round(compositeScore) : '—'}
</p> </p>
{compositeBreakdown && ( {compositeBreakdown && (
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[200px]" data-testid="renorm-explanation"> <p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[220px]" data-testid="renorm-explanation">
Weighted average of available dimensions with re-normalized weights. {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> </p>
)} )}
</div> </div>
</div> </div>
)}
{dimensions.length > 0 && ( {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> <p className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Dimensions</p>
{dimensions.map((d) => { {dimensions.map((d) => {
const isExpanded = expanded[d.dimension] ?? false; const isExpanded = expanded[d.dimension] ?? false;
@@ -102,11 +113,26 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: Sc
{d.dimension} {d.dimension}
</span> </span>
<div className="flex items-center gap-2"> <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}`}> <span className="text-[10px] text-gray-500 tabular-nums" data-testid={`weight-${d.dimension}`}>
{Math.round(weight * 100)}% {Math.round(weight * 100)}%
</span> </span>
)} ) : null}
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden"> <div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
<div <div
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`} 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">Dimensions</th>
<th className="px-4 py-3">R:R</th> <th className="px-4 py-3">R:R</th>
<th className="px-4 py-3">Direction</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> <th className="px-4 py-3"></th>
</tr> </tr>
</thead> </thead>
@@ -114,15 +114,9 @@ export function WatchlistTable({ entries }: WatchlistTableProps) {
<span className="text-gray-500"></span> <span className="text-gray-500"></span>
)} )}
</td> </td>
<td className="px-4 py-3.5"> <td className="px-4 py-3.5 num text-gray-200">
{entry.sr_levels.length > 0 ? ( {entry.momentum_percentile !== null ? (
<div className="flex flex-wrap gap-1"> `${Math.round(entry.momentum_percentile)}%ile`
{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>
) : ( ) : (
<span className="text-gray-500"></span> <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 ── // ── Jobs ──
export function useJobs() { export function useJobs() {
+22
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as api from '../api/paperTrades'; import * as api from '../api/paperTrades';
import type { ExitPolicy } from '../lib/types';
import { useToast } from '../components/ui/Toast'; import { useToast } from '../components/ui/Toast';
export function usePaperTrades(status?: 'open' | 'closed') { 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() { export function useCreatePaperTrade() {
const qc = useQueryClient(); const qc = useQueryClient();
const { addToast } = useToast(); const { addToast } = useToast();
+14
View File
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as tickersApi from '../api/tickers'; import * as tickersApi from '../api/tickers';
import { useToast } from '../components/ui/Toast'; 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() { export function useAddTicker() {
const qc = useQueryClient(); const qc = useQueryClient();
const { addToast } = useToast(); const { addToast } = useToast();
+6 -1
View File
@@ -25,6 +25,9 @@ export function summarizeIngestionResult(
if (info.status === 'ok') { if (info.status === 'ok') {
return `${label}`; return `${label}`;
} }
if (info.status === 'warning') {
return `${label}${info.message ? `: ${info.message}` : ': no data'}`;
}
if (info.status === 'skipped') { if (info.status === 'skipped') {
return `${label}: skipped${info.message ? ` (${info.message})` : ''}`; return `${label}: skipped${info.message ? ` (${info.message})` : ''}`;
} }
@@ -32,8 +35,10 @@ export function summarizeIngestionResult(
}); });
const hasError = entries.some(([, source]) => source.status === 'error'); 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 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 { return {
toastType, 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']); 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 { export function bestTargetProbability(setup: TradeSetup): number {
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0; 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; return false;
} }
if ((setup.confidence_score ?? 0) < config.min_confidence) 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 // Residual cross-sectional momentum is the core selection (long-only). While
// active, shorts never qualify; the percentile floor is enforced only when a // the gate is active, shorts never qualify; the percentile floor is enforced
// percentile is attached, otherwise defer to the floors. // only when a percentile is attached, otherwise defer to the floors.
if (config.min_momentum_percentile > 0) { if (config.min_momentum_percentile > 0) {
if (setup.direction === 'short') return false; if (setup.direction === 'short') return false;
if (setup.momentum_percentile != null && setup.momentum_percentile < config.min_momentum_percentile) { if (setup.momentum_percentile != null && setup.momentum_percentile < config.min_momentum_percentile) {
return false; 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 ?? '')) { if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
return false; return false;
} }
@@ -49,11 +61,31 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
return true; 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. */ /** Short human summary of the active gate, e.g. for tooltips/labels. */
export function activationSummary(config: ActivationConfig): string { export function activationSummary(config: ActivationConfig): string {
const parts = []; 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)}%`); 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.require_high_conviction) parts.push('high-conviction');
if (config.exclude_conflicts) parts.push('clean'); if (config.exclude_conflicts) parts.push('clean');
return parts.join(' · '); return parts.join(' · ');
+146 -11
View File
@@ -19,6 +19,7 @@ export interface WatchlistEntry {
dimensions: DimensionScore[]; dimensions: DimensionScore[];
rr_ratio: number | null; rr_ratio: number | null;
rr_direction: string | null; rr_direction: string | null;
momentum_percentile: number | null;
sr_levels: SRLevelSummary[]; sr_levels: SRLevelSummary[];
last_close: number | null; last_close: number | null;
change_pct: number | null; change_pct: number | null;
@@ -81,6 +82,10 @@ export interface CompositeBreakdown {
missing_dimensions: string[]; missing_dimensions: string[];
renormalized_weights: Record<string, number>; renormalized_weights: Record<string, number>;
formula: string; formula: string;
base_score?: number | null;
sentiment_score?: number | null;
sentiment_adjustment?: number | null;
max_sentiment_adjustment?: number | null;
} }
export interface ScoreResponse { export interface ScoreResponse {
@@ -91,7 +96,7 @@ export interface ScoreResponse {
dimensions: DimensionScoreDetail[]; dimensions: DimensionScoreDetail[];
missing_dimensions: string[]; missing_dimensions: string[];
computed_at: string | null; computed_at: string | null;
composite_breakdown?: CompositeBreakdown; composite_breakdown?: CompositeBreakdown | null;
} }
export interface DimensionScoreDetail { export interface DimensionScoreDetail {
@@ -99,12 +104,13 @@ export interface DimensionScoreDetail {
score: number; score: number;
is_stale: boolean; is_stale: boolean;
computed_at: string | null; computed_at: string | null;
breakdown?: ScoreBreakdown; breakdown?: ScoreBreakdown | null;
} }
export interface RankingEntry { export interface RankingEntry {
symbol: string; symbol: string;
composite_score: number; composite_score: number;
composite_stale: boolean;
dimensions: DimensionScoreDetail[]; dimensions: DimensionScoreDetail[];
} }
@@ -135,9 +141,18 @@ export interface TradeSetup {
evaluated_at: string | null; evaluated_at: string | null;
current_price: number | null; current_price: number | null;
momentum_percentile?: number | null; momentum_percentile?: number | null;
context_as_of?: TradeSetupContextAsOf | null;
recommendation_summary?: RecommendationSummary; 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 // Performance / outcome statistics
export interface OutcomeBucketStats { export interface OutcomeBucketStats {
total: number; total: number;
@@ -152,6 +167,7 @@ export interface OutcomeBucketStats {
export interface PerformanceStats { export interface PerformanceStats {
overall: OutcomeBucketStats; overall: OutcomeBucketStats;
pending: number; pending: number;
maturing: number;
by_direction: Record<string, OutcomeBucketStats>; by_direction: Record<string, OutcomeBucketStats>;
by_action: Record<string, OutcomeBucketStats>; by_action: Record<string, OutcomeBucketStats>;
by_confidence: Record<string, OutcomeBucketStats>; by_confidence: Record<string, OutcomeBucketStats>;
@@ -164,6 +180,7 @@ export interface ActivationConfig {
min_confidence: number; min_confidence: number;
require_high_conviction: boolean; require_high_conviction: boolean;
exclude_conflicts: boolean; exclude_conflicts: boolean;
exclude_neutral: boolean;
} }
// Cron schedule for the daily/intraday pipelines + fundamentals // Cron schedule for the daily/intraday pipelines + fundamentals
@@ -201,6 +218,18 @@ export interface PaperTrade {
close_price: number | null; close_price: number | null;
closed_at: string | null; closed_at: string | null;
current_price: number | 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 { export interface BacktestBucket {
@@ -211,19 +240,112 @@ export interface BacktestBucket {
hit_rate: number | null; hit_rate: number | null;
avg_r: number | null; avg_r: number | null;
total_r: number | null; total_r: number | null;
} // Net of transaction costs — optional so a stale cached report still renders.
net_avg_r?: number | null;
export interface BacktestCalibrationRow { net_total_r?: number | null;
bucket: string; best_r?: number | null;
n: number; worst_r?: number | null;
predicted_avg: number; avg_hold_days?: number | null;
realized_hit_rate: number; 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 { export interface BacktestSweepRow extends BacktestBucket {
min_momentum_percentile: number; 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 { export interface BacktestSignalEvalRow {
signal: string; signal: string;
weeks: number; weeks: number;
@@ -240,13 +362,24 @@ export interface BacktestReport {
tickers: number; tickers: number;
candidates: number; candidates: number;
qualified: 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_qualified: BacktestBucket;
overall_all: BacktestBucket; overall_all: BacktestBucket;
by_direction: Record<string, BacktestBucket>; by_direction: Record<string, BacktestBucket>;
min_momentum_percentile: number; min_momentum_percentile: number;
sweep: BacktestSweepRow[]; 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?: BacktestSignalEvalRow[];
signal_eval_note?: string; signal_eval_note?: string;
note: string; note: string;
@@ -381,6 +514,7 @@ export interface AlertConfig {
score_drop_enabled: boolean; score_drop_enabled: boolean;
digest_enabled: boolean; digest_enabled: boolean;
regime_quadrant_enabled: boolean; regime_quadrant_enabled: boolean;
trade_closed_enabled: boolean;
} }
export interface AlertTestResult { export interface AlertTestResult {
@@ -493,6 +627,7 @@ export interface EMACrossResult {
export interface Ticker { export interface Ticker {
id: number; id: number;
symbol: string; symbol: string;
name: string | null;
created_at: string; created_at: string;
} }
+2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { ActivationSettings } from '../components/admin/ActivationSettings'; import { ActivationSettings } from '../components/admin/ActivationSettings';
import { ExitPolicySettings } from '../components/admin/ExitPolicySettings';
import { AlertSettings } from '../components/admin/AlertSettings'; import { AlertSettings } from '../components/admin/AlertSettings';
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings'; import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
import { DataCleanup } from '../components/admin/DataCleanup'; import { DataCleanup } from '../components/admin/DataCleanup';
@@ -33,6 +34,7 @@ export default function AdminPage() {
{activeTab === 'Settings' && ( {activeTab === 'Settings' && (
<div className="space-y-4"> <div className="space-y-4">
<ActivationSettings /> <ActivationSettings />
<ExitPolicySettings />
<AlertSettings /> <AlertSettings />
<SentimentProviderSettings /> <SentimentProviderSettings />
<TickerUniverseBootstrap /> <TickerUniverseBootstrap />
+29 -13
View File
@@ -4,6 +4,7 @@ import { useActivation } from '../hooks/useActivation';
import { useTrades } from '../hooks/useTrades'; import { useTrades } from '../hooks/useTrades';
import { useWatchlist } from '../hooks/useWatchlist'; import { useWatchlist } from '../hooks/useWatchlist';
import { usePaperTrades } from '../hooks/usePaperTrades'; import { usePaperTrades } from '../hooks/usePaperTrades';
import { useTickerNames } from '../hooks/useTickers';
import { useMarketRegime } from '../hooks/useMarketRegime'; import { useMarketRegime } from '../hooks/useMarketRegime';
import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime'; import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime';
import { Callout } from '../components/ui/Callout'; import { Callout } from '../components/ui/Callout';
@@ -62,6 +63,7 @@ function DirectionTag({ direction }: { direction: string }) {
export default function DashboardPage() { export default function DashboardPage() {
const trades = useTrades(); const trades = useTrades();
const watchlist = useWatchlist(); const watchlist = useWatchlist();
const tickerNames = useTickerNames();
const activation = useActivation(); const activation = useActivation();
const openTrades = usePaperTrades('open'); const openTrades = usePaperTrades('open');
const regime = useMarketRegime(); const regime = useMarketRegime();
@@ -74,15 +76,12 @@ export default function DashboardPage() {
[trades.data, activation.data], [trades.data, activation.data],
); );
// Show qualified setups first; fall back to the full list when none qualify. // Rank only actionable/qualified setups by residual 12-1 momentum percentile.
// Rank by 12-1 momentum percentile so the strongest names sit at the top.
const showingQualified = qualifiedSetups.length > 0;
const topSetups: TradeSetup[] = useMemo(() => { const topSetups: TradeSetup[] = useMemo(() => {
const pool = showingQualified ? qualifiedSetups : trades.data ?? []; return [...qualifiedSetups]
return [...pool]
.sort((a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity)) .sort((a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity))
.slice(0, 5); .slice(0, 5);
}, [showingQualified, qualifiedSetups, trades.data]); }, [qualifiedSetups]);
const topWatchlist = useMemo( const topWatchlist = useMemo(
() => () =>
@@ -100,8 +99,10 @@ export default function DashboardPage() {
const exposure = useMemo(() => { const exposure = useMemo(() => {
const rows = openTrades.data ?? []; const rows = openTrades.data ?? [];
let riskUsd = 0, unrealUsd = 0, unrealR = 0, rPriced = 0, winners = 0, losers = 0; let riskUsd = 0, unrealUsd = 0, unrealR = 0, rPriced = 0, winners = 0, losers = 0;
let alphaUsd = 0, alphaPriced = 0;
for (const t of rows) { for (const t of rows) {
riskUsd += Math.abs(t.entry_price - t.stop_loss) * t.shares; 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); const p = tradePnl(t);
if (!p) continue; if (!p) continue;
unrealUsd += p.pnl; unrealUsd += p.pnl;
@@ -109,7 +110,7 @@ export default function DashboardPage() {
if (p.pnl > 0) winners += 1; if (p.pnl > 0) winners += 1;
else if (p.pnl < 0) losers += 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]); }, [openTrades.data]);
return ( return (
@@ -141,11 +142,11 @@ export default function DashboardPage() {
{/* Metric strip */} {/* Metric strip */}
{(trades.isLoading || openTrades.isLoading) ? ( {(trades.isLoading || openTrades.isLoading) ? (
<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">
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard /> <SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
</div> </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 <Metric
label="Live Setups" label="Live Setups"
value={String(trades.data?.length ?? 0)} value={String(trades.data?.length ?? 0)}
@@ -172,6 +173,16 @@ export default function DashboardPage() {
: 'mark-to-market' : '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> </div>
)} )}
@@ -183,12 +194,12 @@ export default function DashboardPage() {
<div className="xl:col-span-3"> <div className="xl:col-span-3">
<Section <Section
title="Top Setups" 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.isLoading && <SkeletonTable rows={5} cols={5} />}
{trades.isError && <Callout variant="error">Failed to load setups</Callout>} {trades.isError && <Callout variant="error">Failed to load setups</Callout>}
{trades.data && topSetups.length === 0 && ( {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 && ( {topSetups.length > 0 && (
<div className="glass overflow-x-auto"> <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">Entry</th>
<th className="px-4 py-3 text-right">R:R</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">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> <th className="hidden px-4 py-3 md:table-cell">Action</th>
</tr> </tr>
</thead> </thead>
@@ -225,6 +236,11 @@ export default function DashboardPage() {
</span> </span>
)} )}
</div> </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>
<td className="px-4 py-3"><DirectionTag direction={setup.direction} /></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> <td className="num px-4 py-3 text-right text-gray-200">{formatPrice(setup.entry_price)}</td>
+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 { useParams } from 'react-router-dom';
import { useTickerDetail } from '../hooks/useTickerDetail'; import { useTickerDetail } from '../hooks/useTickerDetail';
import { useFetchSymbolData } from '../hooks/useFetchSymbolData'; import { useFetchSymbolData } from '../hooks/useFetchSymbolData';
import { useWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from '../hooks/useWatchlist'; 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 type { FetchSelector } from '../api/ingestion';
import { CandlestickChart } from '../components/charts/CandlestickChart'; import { CandlestickChart } from '../components/charts/CandlestickChart';
import { ScoreCard } from '../components/ui/ScoreCard'; import { ScoreCard } from '../components/ui/ScoreCard';
import { useTickerNames } from '../hooks/useTickers';
import { SkeletonCard } from '../components/ui/Skeleton'; import { SkeletonCard } from '../components/ui/Skeleton';
import { SentimentPanel } from '../components/ticker/SentimentPanel'; import { SentimentPanel } from '../components/ticker/SentimentPanel';
import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel'; import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel';
@@ -17,6 +22,10 @@ import { Section } from '../components/ui/Section';
import { Tabs } from '../components/ui/Tabs'; import { Tabs } from '../components/ui/Tabs';
import { formatPrice } from '../lib/format'; import { formatPrice } from '../lib/format';
import type { TradeSetup } from '../lib/types'; 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; const detailTabs = ['Analysis', 'Indicators', 'S/R Levels'] as const;
type DetailTab = (typeof detailTabs)[number]; 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 { function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime(); const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000); const mins = Math.floor(diff / 60_000);
@@ -97,6 +121,7 @@ function DataFreshnessBar({
export default function TickerDetailPage() { export default function TickerDetailPage() {
const { symbol = '' } = useParams<{ symbol: string }>(); const { symbol = '' } = useParams<{ symbol: string }>();
const companyName = useTickerNames().get(symbol.toUpperCase());
const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol); const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol);
const ingestion = useFetchSymbolData(); const ingestion = useFetchSymbolData();
const watchlist = useWatchlist(); const watchlist = useWatchlist();
@@ -107,6 +132,21 @@ export default function TickerDetailPage() {
[watchlist.data, symbol], [watchlist.data, symbol],
); );
const watchlistBusy = addToWatchlist.isPending || removeFromWatchlist.isPending; 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 [activeTab, setActiveTab] = useState<DetailTab>('Analysis');
const [refreshingLabel, setRefreshingLabel] = useState<string | null>(null); const [refreshingLabel, setRefreshingLabel] = useState<string | null>(null);
@@ -176,6 +216,29 @@ export default function TickerDetailPage() {
[setupsForSymbol], [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 // Current price = latest close, with day-over-day change
const priceInfo = useMemo(() => { const priceInfo = useMemo(() => {
const bars = ohlcv.data; 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 flex-wrap items-center justify-between gap-4">
<div className="flex items-baseline gap-4"> <div className="flex items-baseline gap-4">
<h1 className="text-3xl font-semibold text-gray-100">{symbol.toUpperCase()}</h1> <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 && ( {priceInfo && (
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="num text-2xl font-semibold text-gray-100">{formatPrice(priceInfo.price)}</span> <span className="num text-2xl font-semibold text-gray-100">{formatPrice(priceInfo.price)}</span>
@@ -226,6 +292,20 @@ export default function TickerDetailPage() {
)} )}
</div> </div>
<div className="flex items-center gap-2"> <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 <Button
variant="ghost" variant="ghost"
onClick={() => onClick={() =>
@@ -266,8 +346,8 @@ export default function TickerDetailPage() {
/> />
{/* Chart — always visible */} {/* Chart — always visible */}
<Section title="Price Chart"> <Section title="Price & Volume">
{ohlcv.isLoading && <SkeletonCard className="h-[400px]" />} {ohlcv.isLoading && <SkeletonCard className="h-[440px]" />}
{ohlcv.isError && ( {ohlcv.isError && (
<SectionError <SectionError
message={ohlcv.error instanceof Error ? ohlcv.error.message : 'Failed to load OHLCV data'} 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} /> <Tabs tabs={detailTabs} active={activeTab} onChange={setActiveTab} />
{activeTab === 'Analysis' && ( {activeTab === 'Analysis' && (
<div className="grid gap-6 lg:grid-cols-3 animate-fade-in"> <div className="space-y-6 animate-fade-in">
<Section title="Scores"> <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.isLoading && <SkeletonCard />}
{scores.isError && ( {scores.isError && (
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} /> <SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
)} )}
{scores.data && ( {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> </Section>
@@ -350,6 +471,7 @@ export default function TickerDetailPage() {
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />} {fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
</Section> </Section>
</div> </div>
</div>
)} )}
{activeTab === 'Indicators' && ( {activeTab === 'Indicators' && (
+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 == { assert config == {
"min_momentum_percentile": 80.0, "min_momentum_percentile": 80.0,
"min_rr": 1.2, "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, "require_high_conviction": False,
"exclude_conflicts": False, "exclude_conflicts": False,
"exclude_neutral": True,
} }
async def test_update_and_read_back(self, session: AsyncSession): 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["require_high_conviction"] is True
assert config["exclude_conflicts"] 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): async def test_rejects_negative_rr(self, session: AsyncSession):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
await update_activation_config(session, {"min_rr": -1.0}) await update_activation_config(session, {"min_rr": -1.0})
+130
View File
@@ -3,11 +3,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from types import SimpleNamespace
import pytest import pytest
from sqlalchemy import select
from app.models.alert import AlertLog from app.models.alert import AlertLog
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.score import CompositeScore from app.models.score import CompositeScore
from app.models.sr_level import SRLevel from app.models.sr_level import SRLevel
from app.models.ticker import Ticker 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 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, async def _add_ticker(session, symbol: str, *, watchlisted: bool, close: float,
levels: list[tuple[float, str, int]]) -> int: levels: list[tuple[float, str, int]]) -> int:
user = await session.get(User, 1) 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]] cvx = [m for m in msgs if "CVX" in m[1]]
assert len(cvx) == 1 assert len(cvx) == 1
assert "183.00185.00" in cvx[0][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): 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 await svc.update_alert_config(session, enabled=True) # enabled but no token/chat
res = await svc.dispatch_alerts(session) res = await svc.dispatch_alerts(session)
assert res["status"] == "no_credentials" 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 import math
from datetime import date, timedelta from datetime import date, timedelta
from types import SimpleNamespace
import pytest import pytest
@@ -24,7 +25,15 @@ async def session():
yield s 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 target_hit = outcome == OUTCOME_TARGET_HIT
realized = rr if target_hit else (0.0 if outcome == OUTCOME_EXPIRED else -1.0) realized = rr if target_hit else (0.0 if outcome == OUTCOME_EXPIRED else -1.0)
return { return {
@@ -35,9 +44,408 @@ def _cand(prob: float, outcome: str, rr: float, qualified: bool = True, directio
"realized_r": realized, "realized_r": realized,
"qualified": qualified, "qualified": qualified,
"direction": direction, "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(): def test_bucket_stats_counts_and_expectancy():
cands = [ cands = [
_cand(70, OUTCOME_TARGET_HIT, 3.0), # +3R win _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 # avg R = (3 + 2 - 1 + 0) / 4 = 1.0
assert s["avg_r"] == 1.0 assert s["avg_r"] == 1.0
assert s["total_r"] == 4.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(): def test_bucket_stats_empty():
@@ -62,26 +482,102 @@ def test_bucket_stats_empty():
assert s["total"] == 0 assert s["total"] == 0
assert s["hit_rate"] is None assert s["hit_rate"] is None
assert s["avg_r"] is None assert s["avg_r"] is None
assert s["net_avg_r"] is None
def test_calibration_buckets(): def test_bucket_stats_no_risk_pct_means_no_cost():
cands = [ c = _cand(50, OUTCOME_TARGET_HIT, 2.0)
_cand(65, OUTCOME_TARGET_HIT, 2.0), del c["risk_pct"]
_cand(62, OUTCOME_STOP_HIT, 2.0), s = bt._bucket_stats([c])
_cand(15, OUTCOME_STOP_HIT, 2.0), assert s["net_avg_r"] == s["avg_r"]
] assert s["net_total_r"] == s["total_r"]
rows = bt._calibration(cands)
by_bucket = {r["bucket"]: r for r in rows}
assert by_bucket["60-80%"]["n"] == 2 def test_build_recommendation_reads_the_report():
assert by_bucket["60-80%"]["realized_hit_rate"] == 50.0 # 1 of 2 hit report = {
assert by_bucket["0-20%"]["n"] == 1 "overall_qualified": {"net_avg_r": 0.13, "net_avg_r_ex_top5": 0.05},
assert by_bucket["0-20%"]["realized_hit_rate"] == 0.0 "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(): def test_window_setups_too_short_returns_empty():
assert bt._window_setups([], {}, {}) == [] 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: async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None:
t = Ticker(symbol=symbol) t = Ticker(symbol=symbol)
session.add(t) session.add(t)
@@ -108,16 +604,37 @@ async def test_run_backtest_smoke(session):
# well-formed report # well-formed report
assert report["tickers"] == 1 assert report["tickers"] == 1
assert isinstance(report["candidates"], int) 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 assert key in report
# the oscillating series should yield at least some resolved setups # the oscillating series should yield at least some resolved setups
assert report["candidates"] >= 1 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: lowering the momentum-percentile cutoff can only add qualifiers
sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True) sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True)
counts = [r["total"] for r in sweep] counts = [r["total"] for r in sweep]
assert counts == sorted(counts) # ascending as threshold descends 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
+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 from __future__ import annotations
@@ -35,6 +35,21 @@ async def _seed(session, symbol: str, rate: float, n: int = 280) -> None:
await session.commit() 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(): def test_compute_momentum_insufficient_history():
assert ms.compute_12_1_momentum([100.0] * 100) is None assert ms.compute_12_1_momentum([100.0] * 100) is None
@@ -47,7 +62,11 @@ def test_compute_momentum_value():
assert m > 0 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, "HIGH", rate=1.010) # strong uptrend → top momentum
await _seed(session, "MID", rate=1.002) await _seed(session, "MID", rate=1.002)
await _seed(session, "LOW", rate=0.999) # declining → bottom momentum 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 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, "LONG", rate=1.005)
await _seed(session, "SHORTHX", rate=1.005, n=100) # < 1y → no momentum 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 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) == {} assert await ms.compute_momentum_percentiles(session) == {}
+23
View File
@@ -317,3 +317,26 @@ class TestGetPerformanceStats:
stats = await get_performance_stats(db_session) stats = await get_performance_stats(db_session)
assert stats["overall"]["total"] == 1 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 import pytest
from app.exceptions import ValidationError from app.exceptions import ValidationError
from app.models.benchmark_price import BenchmarkPrice
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.models.user import User from app.models.user import User
from app.services import paper_trade_service as svc 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): async def test_resolve_closes_on_target(session):
await svc.set_exit_policy(session, mode="target")
tid = await _seed(session, "AAA", close=100.0) tid = await _seed(session, "AAA", close=100.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long", trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0) 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): async def test_resolve_closes_on_stop(session):
await svc.set_exit_policy(session, mode="target")
tid = await _seed(session, "AAA", close=100.0) tid = await _seed(session, "AAA", close=100.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long", trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0) 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): 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) tid = await _seed(session, "AAA", close=100.0)
await svc.create_trade(session, 1, symbol="AAA", direction="long", await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0) 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 assert closed == 0
rows = await svc.list_trades(session, 1, status="open") rows = await svc.list_trades(session, 1, status="open")
assert len(rows) == 1 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): def _setup(**kwargs):
base = dict( base = dict(
direction="long",
rr_ratio=3.0, rr_ratio=3.0,
confidence_score=80.0, confidence_score=80.0,
recommended_action="LONG_HIGH", recommended_action="LONG_HIGH",
@@ -111,6 +112,33 @@ class TestStrictTighteners:
assert setup_qualifies(s, STRICT_GATE) is False 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: class TestBestTargetProbability:
def test_returns_max(self): def test_returns_max(self):
s = _setup(targets=[{"probability": 40.0}, {"probability": 72.0}, {"probability": 55.0}]) s = _setup(targets=[{"probability": 40.0}, {"probability": 72.0}, {"probability": 55.0}])
+396 -1
View File
@@ -11,20 +11,29 @@ Zero-candidate and single-candidate scenarios must produce identical results.
from __future__ import annotations from __future__ import annotations
import json
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest import pytest
from hypothesis import given, settings, HealthCheck, strategies as st from hypothesis import given, settings, HealthCheck, strategies as st
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.sr_level import SRLevel from app.models.sr_level import SRLevel
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup 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 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 # 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"Expected symbol order ['SORTD', 'SORTC', 'SORTB', 'SORTA'], "
f"got {symbols}" 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 == { assert job_ids == {
"data_collector", "data_collector",
"data_backfill", "data_backfill",
"benchmark_collector",
"sentiment_collector", "sentiment_collector",
"fundamental_collector", "fundamental_collector",
"rr_scanner", "rr_scanner",
@@ -103,6 +104,7 @@ class TestConfigureScheduler:
assert sorted(job_ids) == sorted([ assert sorted(job_ids) == sorted([
"alerts", "alerts",
"backtest", "backtest",
"benchmark_collector",
"daily_pipeline", "daily_pipeline",
"intraday_pipeline", "intraday_pipeline",
"data_collector", "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
+63 -138
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 __future__ import annotations
from datetime import date from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base from app.database import Base
from app.models.score import CompositeScore, DimensionScore
from app.models.ticker import Ticker 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://" TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest.fixture @pytest.fixture
async def fresh_db(): 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) engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
@@ -30,176 +30,101 @@ async def fresh_db():
await engine.dispose() 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: async def _seed_ticker(session: AsyncSession, symbol: str = "AAPL") -> Ticker:
"""Insert a ticker row and return it."""
ticker = Ticker(symbol=symbol) ticker = Ticker(symbol=symbol)
session.add(ticker) session.add(ticker)
await session.commit() await session.commit()
return ticker 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 @pytest.mark.asyncio
async def test_get_score_returns_composite_breakdown(fresh_db): async def test_get_score_returns_composite_breakdown_without_recomputing(fresh_db):
"""get_score should include a composite_breakdown dict with weights and re-normalization info.""" ticker = await _seed_ticker(fresh_db, "AAPL")
await _seed_ticker(fresh_db, "AAPL") await _seed_scores(fresh_db, ticker)
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()
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") 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"] cb = result["composite_breakdown"]
assert cb is not None 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 "technical" in cb["available_dimensions"]
assert "momentum" in cb["available_dimensions"] assert "momentum" in cb["available_dimensions"]
assert "sentiment" in cb["missing_dimensions"] assert "sentiment" in cb["missing_dimensions"]
assert "fundamental" in cb["missing_dimensions"] assert "fundamental" in cb["missing_dimensions"]
assert "sr_quality" 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"] assert cb["renormalized_weights"]
total = sum(cb["renormalized_weights"].values()) assert abs(sum(cb["renormalized_weights"].values()) - 1.0) < 1e-9
assert abs(total - 1.0) < 1e-9
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_score_dimensions_include_breakdowns(fresh_db): async def test_get_score_dimensions_do_not_recompute_breakdowns(fresh_db):
"""Each available dimension entry should include a breakdown dict.""" ticker = await _seed_ticker(fresh_db, "AAPL")
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") result = await get_score(fresh_db, "AAPL")
finally:
_DIMENSION_COMPUTERS.update(original)
tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None) tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None)
assert tech_dim is not None assert tech_dim is not None
assert "breakdown" in tech_dim assert tech_dim["breakdown"] is None
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
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_score_all_dimensions_missing(fresh_db): 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") 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") result = await get_score(fresh_db, "AAPL")
finally:
_DIMENSION_COMPUTERS.update(original)
cb = result["composite_breakdown"] cb = result["composite_breakdown"]
assert cb["available_dimensions"] == [] assert cb["available_dimensions"] == []
assert len(cb["missing_dimensions"]) == 5 assert len(cb["missing_dimensions"]) == 5
assert cb["renormalized_weights"] == {} assert cb["renormalized_weights"] == {}
assert result["composite_score"] is None 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 """Unit tests for read-only get_rankings: bulk-load, sorting, and staleness."""
lazy recompute of stale scores."""
from __future__ import annotations from __future__ import annotations
@@ -7,7 +6,6 @@ from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base from app.database import Base
@@ -20,7 +18,7 @@ TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest.fixture @pytest.fixture
async def fresh_db(): 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) engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) 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 @pytest.mark.asyncio
async def test_ticker_without_computable_composite_is_excluded(fresh_db: AsyncSession): 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 """A ticker without a persisted composite is omitted from rankings."""
omitted from the rankings rather than appearing with a null score."""
fresh = await _seed_ticker(fresh_db, "OK") 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)]) fresh_db.add_all([_composite(fresh.id, 50.0), _dimension(fresh.id, "technical", 50.0)])
await fresh_db.commit() 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", 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", 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) result = await get_rankings(fresh_db)
assert [r["symbol"] for r in result["rankings"]] == ["OK"] assert [r["symbol"] for r in result["rankings"]] == ["OK"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stale_composite_is_recomputed(fresh_db: AsyncSession): async def test_stale_composite_is_reported_without_recompute(fresh_db: AsyncSession):
"""A stale composite triggers a recompute and then appears in the rankings.""" """A stale composite appears with its stale flag and is not recomputed."""
ticker = await _seed_ticker(fresh_db, "STALE") ticker = await _seed_ticker(fresh_db, "STALE")
fresh_db.add(_composite(ticker.id, 10.0, stale=True)) fresh_db.add(_composite(ticker.id, 10.0, stale=True))
await fresh_db.commit() 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", 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", 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) result = await get_rankings(fresh_db)
comp_mock.assert_awaited() # recompute path was taken
assert [r["symbol"] for r in result["rankings"]] == ["STALE"] 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``). """Tests for sentiment-collection scoping (``_get_sentiment_priority_tickers``).
The activation gate qualifies setups on 12-1 momentum percentile, a different A dashboard 'top pick' is the highest residual-momentum *qualified* long setup. Sentiment
axis than composite score. These tests pin the fix that adds the gate's momentum can never move a ticker's activation percentile (the gate's core axis) only its
leaders to the sentiment relevant-set so a freshly-qualifying ticker isn't left confidence and EV ranking. So the tickers that are, or could become with positive
without sentiment. 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 __future__ import annotations
from datetime import date, datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import pytest import pytest
from app import scheduler 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.settings import SystemSetting
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.models.watchlist import WatchlistEntry
@pytest.fixture @pytest.fixture
@@ -26,56 +32,216 @@ async def session():
yield s yield s
async def _seed_history(session, symbol: str, rate: float, n: int = 280) -> Ticker: async def _add_ticker(session, symbol: str) -> Ticker:
"""Seed a ticker with a full year+ of daily closes growing at ``rate``."""
t = Ticker(symbol=symbol) t = Ticker(symbol=symbol)
session.add(t) session.add(t)
await session.flush() 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 return t
async def _set_min_momentum(session, value: str) -> None: async def _add_setup(
session.add(SystemSetting( session,
key="activation_min_momentum_percentile", ticker: Ticker,
value=value, *,
updated_at=datetime.now(timezone.utc), 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() await session.commit()
async def test_momentum_leader_is_included_without_composite_or_watchlist(session): async def _add_composite(session, ticker: Ticker, score: float) -> None:
"""A top-percentile momentum ticker is fetched even when it has no composite session.add(CompositeScore(
score, no watchlist entry, and no open trade the case that previously left ticker_id=ticker.id,
qualifying setups with no sentiment.""" score=score,
await _seed_history(session, "LEADER", rate=1.010) # strong uptrend → pct 100 is_stale=False,
await _seed_history(session, "LAGGARD", rate=0.999) # declining → pct 0 weights_json="{}",
await _set_min_momentum(session, "80") 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) symbols = await scheduler._get_sentiment_priority_tickers(session)
assert "LEADER" in symbols assert "FEEDER" in symbols
# Below the gate's percentile and not otherwise relevant → not fetched.
assert "LAGGARD" not in symbols assert "LAGGARD" not in symbols
async def test_momentum_leaders_skipped_when_gate_disabled(session): async def test_leader_without_a_setup_excluded(session):
"""With the momentum gate off (min percentile 0), the leader is no longer """A ticker with no long setup can't be a top pick, so it's no longer pulled in
pulled in solely on momentum scoping falls back to the base relevant set.""" on momentum alone the budget goes to actual top-pick feeders."""
await _seed_history(session, "LEADER", rate=1.010) await _add_ticker(session, "NOSETUP")
await _seed_history(session, "LAGGARD", rate=0.999) await _set_setting(session, "activation_min_momentum_percentile", "80")
await _set_min_momentum(session, "0")
symbols = await scheduler._get_sentiment_priority_tickers(session) symbols = await scheduler._get_sentiment_priority_tickers(session)
assert "LEADER" not in symbols assert "NOSETUP" not in symbols
assert "LAGGARD" 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"
+4 -2
View File
@@ -60,8 +60,9 @@ def test_quintile_spread_none_when_too_few():
def test_signal_values_momentum_and_trend(): def test_signal_values_momentum_and_trend():
# Steadily rising series so every lookback is positive and trend is above SMA. # Steadily rising series so every lookback is positive and trend is above SMA.
closes = [100.0 * (1.01 ** k) for k in range(300)] closes = [100.0 * (1.01 ** k) for k in range(300)]
dates = [date(2024, 1, 1) + timedelta(days=k) for k in range(300)]
i = 299 i = 299
vals = bt._signal_values(closes, closes, i) vals = bt._signal_values(dates, closes, closes, i)
assert vals["mom_12_1"] > 0 # up over the 12→1 month window assert vals["mom_12_1"] > 0 # up over the 12→1 month window
assert vals["trend_200"] > 0 # price above its 200-bar SMA in an uptrend assert vals["trend_200"] > 0 # price above its 200-bar SMA in an uptrend
# 12-1 momentum skips the last month: close[i-21] / close[i-252] - 1 # 12-1 momentum skips the last month: close[i-21] / close[i-252] - 1
@@ -73,7 +74,8 @@ def test_signal_values_momentum_and_trend():
def test_signal_values_drops_signals_without_enough_history(): def test_signal_values_drops_signals_without_enough_history():
closes = [100.0 + k for k in range(80)] # only 80 bars closes = [100.0 + k for k in range(80)] # only 80 bars
vals = bt._signal_values(closes, closes, 79) dates = [date(2024, 1, 1) + timedelta(days=k) for k in range(80)]
vals = bt._signal_values(dates, closes, closes, 79)
assert "mom_3_1" in vals # needs 63 bars of lookback — present assert "mom_3_1" in vals # needs 63 bars of lookback — present
assert "mom_6_1" not in vals # needs 126 — absent assert "mom_6_1" not in vals # needs 126 — absent
assert "mom_12_1" not in vals # needs 252 — absent assert "mom_12_1" not in vals # needs 252 — absent
+19
View File
@@ -60,6 +60,25 @@ async def _make_ticker(session, symbol: str, *, score: float | None = None) -> i
return t.id return t.id
async def test_enrich_includes_momentum_percentile(session):
"""The watchlist row carries the ticker's momentum percentile (from its setup),
which replaces the old S/R-levels column in the UI."""
from app.models.trade_setup import TradeSetup
user_id = await _make_user(session)
tid = await _make_ticker(session, "AAA", score=70.0)
session.add(TradeSetup(
ticker_id=tid, direction="long", entry_price=100.0, stop_loss=95.0,
target=110.0, rr_ratio=2.0, composite_score=70.0,
momentum_percentile=88.0, detected_at=datetime.now(timezone.utc),
))
await session.commit()
await add_manual_entry(session, user_id, "AAA")
rows = await get_watchlist(session, user_id)
assert rows[0]["momentum_percentile"] == 88.0
async def test_add_and_remove_sticks(session): async def test_add_and_remove_sticks(session):
user_id = await _make_user(session) user_id = await _make_user(session)
await _make_ticker(session, "AAA", score=80.0) await _make_ticker(session, "AAA", score=80.0)