Compare commits

...

21 Commits

Author SHA1 Message Date
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
50 changed files with 4037 additions and 949 deletions
+157 -44
View File
@@ -13,9 +13,9 @@ Scheduled pipelines turn raw prices into a ranked, gated list of tradeable setup
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 (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 121 momentum percentile.
4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and close paper trades that hit a level.
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.
@@ -33,13 +33,66 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we
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 and confidence floors **and** ranks in the top momentum percentile of the universe (the validated edge is long-only momentum).
4. **Top pick** — the highest-momentum qualified setup; highlighted on the Dashboard and labelled on the ticker page.
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 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 on stop/target, and its sentiment stays fresh while open. *Signals → Track Record* shows the realized edge.
- **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
@@ -68,9 +121,9 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we
- Fundamental data tracking (P/E, revenue growth, earnings surprise, market cap)
- 5-dimension scoring engine (technical, S/R quality, sentiment, fundamental, momentum) with configurable weights
- Risk:Reward scanner — long and short setups, ATR-based stops, S/R-based targets, configurable R:R threshold (default 1.5:1)
- Activation gate — qualifies setups on a momentum-percentile floor plus R:R/confidence (validated long-only edge); ranks the rest by expected value
- 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 on stop/target, realized track record + outcome evaluation
- 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
@@ -188,9 +241,10 @@ npm run preview # Preview the production build locally
# Backend tests (in-memory SQLite — no PostgreSQL needed)
pytest tests/ -v
# Frontend tests
# Frontend: there is no test suite — `npm test` calls vitest, which is not
# installed. The frontend check is the full TypeScript build:
cd frontend
npm test
npm run build
```
## Environment Variables
@@ -230,70 +284,68 @@ Configure in `.env` (copy from `.env.example`):
## Production Deployment (Debian 12)
**Ongoing deploys are automated.** Every push to `main` triggers the Gitea Actions pipeline (`.gitea/workflows/deploy.yml`): lint → test → rsync to the server → `pip install``alembic upgrade head` → restart `signalplatform.service` → health check. There is no manual deploy step; the steps below are only for provisioning a new server.
### 1. Install dependencies
```bash
sudo apt update && sudo apt install -y python3.12 python3.12-venv postgresql nginx nodejs npm
sudo apt update && sudo apt install -y python3.12 python3.12-venv postgresql nginx rsync
```
### 2. Create service user
### 2. Create the deploy user
The pipeline connects over SSH as this user; it owns the app directory and needs passwordless permission to restart the service:
```bash
sudo useradd -r -s /usr/sbin/nologin stockdata
sudo useradd -m deploy
sudo mkdir -p /opt/signalplatform
sudo chown deploy:deploy /opt/signalplatform
echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart signalplatform.service' | sudo tee /etc/sudoers.d/deploy-restart
```
### 3. Deploy application
### 3. Configure the pipeline (Gitea repo settings)
Variables: `DEPLOY_HOST`, `DEPLOY_USER` (`deploy`), `DEPLOY_PATH` (`/opt/signalplatform`), `SSH_KNOWN_HOSTS` (host fingerprint), `SSH_PORT`. Secret: `SSH_PRIVATE_KEY` (matching the deploy user's authorized key).
### 4. Configure the app
```bash
sudo mkdir -p /opt/stock-data-backend
# Copy project files to /opt/stock-data-backend
cd /opt/stock-data-backend
python3.12 -m venv .venv
source .venv/bin/activate
pip install .
```
### 4. Configure
```bash
sudo cp .env.example /opt/stock-data-backend/.env
sudo chown stockdata:stockdata /opt/stock-data-backend/.env
# After the first pipeline run has synced the files:
cp /opt/signalplatform/.env.example /opt/signalplatform/.env
# Edit .env with production values (strong JWT_SECRET, real API keys, etc.)
```
`.env` is excluded from the rsync, so it survives every deploy.
### 5. Database
Either trigger the workflow manually (workflow_dispatch) with `run_setup_db: true` — the deploy then runs `deploy/setup_db.sh` instead of plain migrations — or run it once by hand:
```bash
DB_NAME=stock_data_backend DB_USER=stock_backend DB_PASS=strong_password ./deploy/setup_db.sh
```
### 6. Build frontend
### 6. Systemd service
```bash
cd frontend
npm ci
npm run build
```
### 7. Systemd service
```bash
sudo cp deploy/stock-data-backend.service /etc/systemd/system/
sudo cp deploy/signalplatform.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now stock-data-backend
sudo systemctl enable --now signalplatform
```
### 8. Nginx reverse proxy
The unit runs uvicorn on `127.0.0.1:8998` as the `deploy` user, with `WorkingDirectory=/opt/signalplatform`.
### 7. Nginx reverse proxy
```bash
sudo cp deploy/nginx.conf /etc/nginx/sites-available/stock-data-backend
sudo ln -s /etc/nginx/sites-available/stock-data-backend /etc/nginx/sites-enabled/
sudo cp deploy/nginx.conf /etc/nginx/sites-available/signalplatform
sudo ln -s /etc/nginx/sites-available/signalplatform /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
Nginx serves the frontend static files from `frontend/dist/` and proxies `/api/v1/` to the backend.
Nginx serves the frontend static files from `frontend/dist/` (built on the CI runner and rsynced) and proxies `/api/v1/` to the backend.
### 9. SSL (recommended)
### 8. SSL (recommended)
```bash
sudo apt install certbot python3-certbot-nginx
@@ -359,3 +411,64 @@ tests/
├── unit/ # Unit tests
└── property/ # Property-based tests (Hypothesis)
```
## Maintainer Guide
Context for whoever — human or AI — continues this work. The owner pushes straight to `main` on a self-hosted Gitea remote (no PRs); deployment is automated by the Gitea Actions workflow at `.gitea/workflows/deploy.yml`.
### Invariants — do not break these
- **`app/services/qualification.py` is mirrored in `frontend/src/lib/qualification.ts`.** Any gate change must land in both, or the UI's "qualified" flags silently disagree with the server.
- **Live scan and backtest share the same pure functions.** The backtest replays production logic through DB-free functions (`compute_technical_from_arrays`, `compute_momentum_from_closes`, `detect_sr_levels`, the recommendation helpers). New strategy logic must stay in pure functions consumed by both paths, or the backtest stops measuring what production actually does.
- **One S/R model app-wide:** `sr_service.detect_sr_levels` + `cluster_sr_zones` (2% tolerance) feed the chart, alerts, and target generation identically.
- **The outcome evaluator evaluates ALL setups**, not just qualified ones — unqualified setups are the control group that makes the Track Record meaningful.
- **`SystemSetting` access goes through `app/services/settings_store.py`** — don't query the model directly.
- **Time-series data gets a real table** (see `benchmark_prices`, `regime_snapshots`); `SystemSetting` JSON is only for config and cached reports.
- **Discretionary overlay data is forward-only.** `signal_context_snapshots` captures composite/dimension/sentiment/fundamental context for new setups. Do not approximate historical sentiment/fundamental snapshots from today's data.
- Style: surgical changes, minimal new files; extend existing services rather than adding parallel ones.
### Where the strategy lives
| Concern | File |
|---|---|
| Composite + 5 dimension scores, weights | `app/services/scoring_service.py` |
| Residual 12-1 momentum ranking (the validated activation factor) | `app/services/momentum_service.py` |
| Setup construction (ATR stop, S/R targets) | `app/services/rr_scanner_service.py` |
| Confidence, targets, reach-probability, action | `app/services/recommendation_service.py` |
| Activation gate predicate (mirrored in TS) | `app/services/qualification.py` |
| Gate defaults / admin config | `app/services/admin_service.py` (`ACTIVATION_DEFAULTS`) |
| Backtest + factor rank-IC harness ("Signal edge") | `app/services/backtest_service.py` |
| Outcome resolution (target/stop/expired/ambiguous) | `app/services/outcome_service.py` |
| Paper trades + time/trailing/target auto-exit | `app/services/paper_trade_service.py` |
| Point-in-time setup context snapshots | `app/models/signal_context_snapshot.py` + `app/services/rr_scanner_service.py` |
| S/R detection & zone clustering | `app/services/sr_service.py` |
| SPY benchmark for residual momentum + paper-trade alpha | `app/services/benchmark_service.py` |
| Pipelines & job registration | `app/scheduler.py` |
### Verifying changes
```bash
pytest tests/ -q # backend; in-memory SQLite, no Postgres needed
cd frontend && npm run build # full tsc check — this IS the frontend "test"
```
- `npm test` in `frontend/` is dead (vitest isn't installed; there are no frontend test files). Use `npm run build`.
- Backend tests that exercise services which `commit()` need a plain session fixture, not the rolling-back `db_session` — copy the pattern in `tests/unit/test_rr_scanner_integration.py`.
- `ruff` reports ~11 pre-existing errors in old test files; those are not regressions.
### Deploying
Automated by Gitea Actions (`.gitea/workflows/deploy.yml`) on every push to `main`: **lint** (`ruff check app/`) → **test** (pytest; `alembic upgrade head` validated against a real Postgres 16 service; frontend `npm ci && npm run build`) → **deploy** (frontend built on the runner, rsync to the server excluding `.env`, `pip install -e .`, `alembic upgrade head`, restart `signalplatform.service`, health check on `127.0.0.1:8998`). Deploys are serialized by a concurrency group so overlapping pushes can't race.
Practical consequences:
- **A `ruff` error in `app/` or any failing backend test blocks the deploy.** (CI lints only `app/`, so the pre-existing ruff noise in old test files doesn't.)
- **Migrations run automatically on deploy** — no manual `alembic` step. A migration that only works on SQLite will fail CI against Postgres, by design.
- Pushing to `main` **is** deploying to production — there is no separate release step.
- After a gate or scanner change ships, trigger an R:R scan (Admin → Jobs) so live setups pick up new fields.
### Roadmap (agreed June 2026)
1. **Forward paper-test the momentum book** — the out-of-sample proof the backtest can't give. Watch Signals → Track Record (live vs backtest).
2. **Full IBKR integration** — read real positions, overlay entries/stops on charts, alert on holdings' score deterioration. (Paper trading, the lighter alternative, is done.)
3. Strategy experiments in the order listed under **Strategy Status** above — each one goes through the factor harness first.
@@ -0,0 +1,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")
+1 -1
View File
@@ -51,7 +51,7 @@ class Settings(BaseSettings):
# Sentiment search-budget controls (Gemini grounding free tier = 5000/month).
# Scope (see _get_sentiment_priority_tickers): everything that matters is always
# refreshed in full — open paper trades + the curated watchlist + top-pick
# feeders (momentum leaders with a tradeable long setup) — plus a top-N composite
# feeders (residual-momentum leaders with a tradeable long setup) — plus a top-N composite
# discovery net. No per-run cap: the set is naturally bounded (watchlist <= 20,
# composite <= top_composite), so a full refresh stays well inside the free tier.
# Skip anything refreshed within fresh_hours (5 days: sentiment shifts slowly and
+2
View File
@@ -12,6 +12,7 @@ from app.models.alert import AlertLog
from app.models.paper_trade import PaperTrade
from app.models.regime_snapshot import RegimeSnapshot
from app.models.benchmark_price import BenchmarkPrice
from app.models.signal_context_snapshot import SignalContextSnapshot
__all__ = [
"Ticker",
@@ -30,4 +31,5 @@ __all__ = [
"PaperTrade",
"RegimeSnapshot",
"BenchmarkPrice",
"SignalContextSnapshot",
]
+2 -2
View File
@@ -10,8 +10,8 @@ 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 enters the scanner, the momentum-percentile ranking, or the
rankings table. One row per (symbol, date).
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"
+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 -2
View File
@@ -26,8 +26,9 @@ class TradeSetup(Base):
)
confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True)
# Ticker's 12-1 momentum percentile across the universe at detection time
# (0100, 100 = strongest). Drives the activation gate's core selection.
# Ticker's activation momentum percentile across the universe at detection
# time. Since July 2026 this is residual 12-1 momentum when benchmark data is
# available, with raw 12-1 as a fallback.
momentum_percentile: Mapped[float | None] = mapped_column(Float, nullable=True)
targets_json: Mapped[str | None] = mapped_column(Text, nullable=True)
conflict_flags_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+3 -1
View File
@@ -61,7 +61,9 @@ async def write_exit_policy(
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)
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)
+2 -1
View File
@@ -54,7 +54,7 @@ async def read_score(
_user=Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Get composite + dimension scores for a symbol. Recomputes stale scores."""
"""Get the latest persisted composite + dimension scores for a symbol."""
result = await get_score(db, symbol)
data = ScoreResponse(
@@ -94,6 +94,7 @@ async def read_rankings(
RankingEntry(
symbol=r["symbol"],
composite_score=r["composite_score"],
composite_stale=r.get("composite_stale", False),
dimensions=[
DimensionScoreResponse(**d) for d in r["dimensions"]
],
+6 -1
View File
@@ -34,6 +34,7 @@ async def list_trade_setups(
direction=direction,
min_confidence=min_confidence,
recommended_action=recommended_action,
live_recommendation=True,
)
data = []
@@ -92,7 +93,11 @@ async def get_ticker_trade_setups(
_user=Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
rows = await get_trade_setups(db, symbol=symbol)
rows = await get_trade_setups(
db,
symbol=symbol,
live_recommendation=True,
)
data = []
for row in rows:
summary = RecommendationSummaryResponse(
+6 -6
View File
@@ -222,11 +222,11 @@ async def _get_ohlcv_priority_tickers(db: AsyncSession) -> list[str]:
async def _get_top_pick_feeder_ids(db: AsyncSession) -> set[int]:
"""Ticker ids whose latest LONG setup makes them a top-pick feeder.
A dashboard 'top pick' is the highest-momentum *qualified* setup. Sentiment
can never move a ticker's momentum percentile (the gate's core axis) — only
its confidence and EV ranking. So the only tickers that are, or could become
with positive sentiment, a top pick are momentum leaders that already have a
tradeable long setup clearing the R:R floor. That set is exactly:
A dashboard 'top pick' is the highest residual-momentum *qualified* setup.
Sentiment can never move a ticker's activation percentile (the gate's core
axis) — only its confidence and EV ranking. So the only tickers that are, or
could become with positive sentiment, a top pick are residual-momentum leaders
that already have a tradeable long setup clearing the R:R floor. That set is exactly:
latest long setup with momentum_percentile >= gate AND rr_ratio >= floor.
@@ -311,7 +311,7 @@ async def _get_sentiment_priority_tickers(db: AsyncSession) -> list[str]:
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 (momentum leaders with a tradeable long setup, see
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.
+2 -1
View File
@@ -22,8 +22,9 @@ class PaperTradeClose(BaseModel):
class ExitPolicyUpdate(BaseModel):
"""Auto-exit policy for open paper trades."""
mode: str | None = Field(default=None, pattern=r"^(trailing|target)$")
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):
+1
View File
@@ -78,6 +78,7 @@ class RankingEntry(BaseModel):
symbol: str
composite_score: float
composite_stale: bool = False
dimensions: list[DimensionScoreResponse] = []
+9
View File
@@ -26,6 +26,14 @@ class RecommendationSummaryResponse(BaseModel):
composite_score: float
class TradeSetupContextAsOfResponse(BaseModel):
setup_detected_at: datetime
score_computed_at: datetime | None = None
sentiment_at: datetime | None = None
price_date: date | None = None
price_updated_at: datetime | None = None
class TradeSetupResponse(BaseModel):
"""A single trade setup detected by the R:R scanner."""
@@ -49,4 +57,5 @@ class TradeSetupResponse(BaseModel):
evaluated_at: datetime | None = None
current_price: float | None = None
momentum_percentile: float | None = None
context_as_of: TradeSetupContextAsOfResponse | None = None
recommendation_summary: RecommendationSummaryResponse | None = None
+7 -5
View File
@@ -39,10 +39,9 @@ SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
# Track Record's qualified stats. The outcome evaluator deliberately ignores
# these — every setup is evaluated so the gate itself can be validated.
#
# The core test is expected value (in R): probability-weighted asymmetry, so a
# fat-but-improbable target and a likely-but-thin one are both rejected. R:R and
# confidence are floors; high-conviction / clean-read / target-probability are
# optional tighteners (off by default — turn on to be more selective).
# The core selection is residual cross-sectional 12-1 momentum (top percentile
# of the universe, long-only). R:R and confidence are floors; high-conviction /
# clean-read are optional tighteners (off by default).
_ACTIVATION_FLOAT_KEYS: dict[str, str] = {
"min_momentum_percentile": "activation_min_momentum_percentile",
"min_rr": "activation_min_rr",
@@ -56,7 +55,10 @@ _ACTIVATION_BOOL_KEYS: dict[str, str] = {
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
"min_momentum_percentile": 80.0,
"min_rr": 1.2,
"min_confidence": 55.0,
# 0 = off. The July 2026 gate ablation showed the confidence floor added
# nothing (identical net/trade with it removed, under both exit models)
# while cutting ~25% of qualified trades.
"min_confidence": 0.0,
"require_high_conviction": False,
"exclude_conflicts": False,
# On by default: a NEUTRAL ("no clear setup") recommendation isn't an
+123 -22
View File
@@ -75,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
WATERMARK_TYPE = "score_watermark"
SIGNAL_BUNDLE_ALERT_TYPES = ("qualified", "sr_proximity", "score_drop")
SIGNAL_BUNDLE_SECTIONS = (
("qualified", "Qualified setups"),
("sr_proximity", "Near support/resistance"),
("score_drop", "Score drops"),
)
SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML parsing
# Regime quadrant-change alert: (regime index x early-warning) quadrant.
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary
@@ -91,6 +98,9 @@ QUAD_LABELS = {
"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:
if value is None:
@@ -254,19 +264,37 @@ async def _watchlist_tickers(db: AsyncSession) -> list[tuple[int, str]]:
async def _qualified_setups(db: AsyncSession) -> list[dict]:
setups = await get_trade_setups(db)
# live_recommendation: gate and format on current score/sentiment context,
# not the values frozen into the setup at scan time.
setups = await get_trade_setups(db, live_recommendation=True)
config = await get_activation_config(db)
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
def _fmt_price(value: float | int | None) -> str:
return "n/a" if value is None else f"{float(value):.2f}"
def _fmt_signed_move(from_price: float | int | None, to_price: float | int | None) -> str:
if from_price is None or to_price is None:
return "n/a"
from_float = float(from_price)
if from_float == 0:
return "n/a"
pct = (float(to_price) - from_float) / from_float * 100.0
return f"{pct:+.1f}%"
def _format_qualified(s: dict) -> str:
prob = best_target_probability(SimpleNamespace(**s))
arrow = "🟢" if s["direction"] == "long" else "🔴"
current = s.get("current_price") or s.get("entry_price")
return (
f"{arrow} <b>{s['symbol']} {s['direction'].upper()}</b> — qualified setup\n"
f"entry {s['entry_price']:.2f} → target {s['target']:.2f} "
f"(R:R {s['rr_ratio']:.1f}:1)\n"
f"confidence {(s.get('confidence_score') or 0):.0f}% · P(target) {prob:.0f}%"
f"{arrow} <b>{s['symbol']} {s['direction'].upper()}</b> | "
f"now {_fmt_price(current)} | entry {_fmt_price(s['entry_price'])} | "
f"target {_fmt_price(s['target'])} ({_fmt_signed_move(current, s['target'])}) | "
f"stop {_fmt_price(s['stop_loss'])} | R:R {s['rr_ratio']:.1f} | "
f"conf {(s.get('confidence_score') or 0):.0f}% | P(target) {prob:.0f}%"
)
@@ -289,6 +317,34 @@ async def _latest_close(db: AsyncSession, ticker_id: int) -> float | None:
return float(row[0]) if row else None
def _sr_zone_label(zone: dict) -> str:
return (
f"{zone['low']:.2f}{zone['high']:.2f}"
if zone["level_count"] > 1
else f"{zone['midpoint']:.2f}"
)
def _sr_touch_price(zone: dict, current_price: float) -> float:
low = float(zone["low"])
high = float(zone["high"])
if current_price < low:
return low
if current_price > high:
return high
return float(zone["midpoint"])
def _format_sr_proximity(symbol: str, zone: dict, current_price: float) -> str:
touch_price = _sr_touch_price(zone, current_price)
return (
f"📍 <b>{symbol}</b> {zone['type']} | "
f"now {_fmt_price(current_price)} -> {_sr_zone_label(zone)} "
f"({_fmt_signed_move(current_price, touch_price)}) | "
f"strength {float(zone['strength']):.0f}"
)
async def _collect_sr_proximity(db: AsyncSession) -> list[tuple[str, str]]:
"""One alert per watchlist ticker for the NEAREST strong S/R zone within range.
@@ -318,21 +374,12 @@ async def _collect_sr_proximity(db: AsyncSession) -> list[tuple[str, str]]:
# Nearest strong zone only.
nearest = min(strong, key=lambda z: abs(price - z["midpoint"]))
dist_pct = abs(price - nearest["midpoint"]) / price * 100
dist_pct = abs(price - _sr_touch_price(nearest, price)) / price * 100
if dist_pct > SR_PROXIMITY_PCT:
continue
label = (
f"{nearest['low']:.2f}{nearest['high']:.2f}"
if nearest["level_count"] > 1
else f"{nearest['midpoint']:.2f}"
)
key = f"sr:{symbol}:{nearest['type']}" # one per side per ticker per cooldown
out.append((
key,
f"📍 <b>{symbol}</b> approaching {nearest['type']} {label} "
f"(now {price:.2f}, {dist_pct:.1f}% away)",
))
out.append((key, _format_sr_proximity(symbol, nearest, price)))
return out
@@ -550,6 +597,49 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
# Dispatch
# ---------------------------------------------------------------------------
def _signal_bundle_messages(items: list[AlertItem]) -> list[tuple[list[AlertLogRef], str]]:
if not items:
return []
by_type: dict[str, list[AlertItem]] = {key: [] for key in SIGNAL_BUNDLE_ALERT_TYPES}
for item in items:
by_type.setdefault(item[0], []).append(item)
total = sum(len(group) for group in by_type.values())
header = f"📣 <b>Signal run</b> — {total} new alert(s)"
bundles: list[tuple[list[AlertLogRef], str]] = []
lines = [header]
logs: list[AlertLogRef] = []
current_section: str | None = None
def flush() -> None:
nonlocal lines, logs, current_section
if logs:
bundles.append((logs.copy(), "\n".join(lines)))
lines = [f"{header} (continued)"]
logs = []
current_section = None
for alert_type, section_title in SIGNAL_BUNDLE_SECTIONS:
for item_type, key, text in by_type.get(alert_type, []):
block: list[str] = []
if current_section != alert_type:
block.extend(["", f"<b>{section_title}</b>"])
block.append(text)
if logs and len("\n".join(lines + block)) > SIGNAL_BUNDLE_MAX_CHARS:
flush()
block = ["", f"<b>{section_title}</b>", text]
lines.extend(block)
logs.append((item_type, key))
current_section = alert_type
if logs:
bundles.append((logs.copy(), "\n".join(lines)))
return bundles
async def dispatch_alerts(db: AsyncSession) -> dict:
"""Gather all enabled triggers, dedup, and push to Telegram. Job entrypoint."""
cfg = await _resolve(db)
@@ -558,22 +648,23 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
if not cfg["token"] or not cfg["chat_id"]:
return {"status": "no_credentials", "sent": 0}
outgoing: list[tuple[str, str, str]] = [] # (alert_type, key, text)
signal_outgoing: list[AlertItem] = []
outgoing: list[AlertItem] = []
if cfg["qualified"]:
for key, text in await _collect_qualified(db):
if not await _recently_alerted(db, "qualified", key):
outgoing.append(("qualified", key, text))
signal_outgoing.append(("qualified", key, text))
if cfg["sr"]:
for key, text in await _collect_sr_proximity(db):
if not await _recently_alerted(db, "sr_proximity", key):
outgoing.append(("sr_proximity", key, text))
signal_outgoing.append(("sr_proximity", key, text))
if cfg["score_drop"]:
# also seeds/advances watermarks as a side effect
for key, text in await _collect_score_drops(db):
outgoing.append(("score_drop", key, text))
signal_outgoing.append(("score_drop", key, text))
if cfg["digest"]:
digest = await _collect_digest(db)
@@ -591,8 +682,18 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
outgoing.append((TRADE_CLOSED_TYPE, key, text))
sent = 0
if outgoing:
candidates = len(signal_outgoing) + len(outgoing)
if signal_outgoing or outgoing:
async with httpx.AsyncClient(timeout=15) as client:
for log_refs, text in _signal_bundle_messages(signal_outgoing):
try:
await _send(client, cfg["token"], cfg["chat_id"], text)
for alert_type, key in log_refs:
_log_alert(db, alert_type, key)
sent += 1
except Exception:
logger.exception("Failed to send signal alert bundle")
for alert_type, key, text in outgoing:
try:
await _send(client, cfg["token"], cfg["chat_id"], text)
@@ -602,7 +703,7 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
logger.exception("Failed to send alert %s", key)
await db.commit() # persist watermark seeds/advances and sent-logs
return {"status": "ok", "sent": sent, "candidates": len(outgoing)}
return {"status": "ok", "sent": sent, "candidates": candidates}
async def send_test_alert(db: AsyncSession) -> dict:
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -3,8 +3,8 @@
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``, so it never contaminates the scanner, momentum-percentile
ranking, or rankings.
tracked ``Ticker``; its closes feed residual momentum and alpha, but it never
becomes a trade candidate or rankings-table row.
"""
from __future__ import annotations
+88 -15
View File
@@ -1,17 +1,18 @@
"""Cross-sectional 12-1 momentum ranking for the universe.
"""Cross-sectional residual 12-1 momentum ranking for the universe.
The activation gate selects the top ``min_momentum_percentile`` of the universe
by 12-1 month momentum (return from ~12 months ago to ~1 month ago — the one
price signal the backtest showed sorts forward returns). The daily scan ranks
every ticker and stores each setup's percentile (see ``rr_scanner_service``), so
the live list, the Track Record's qualified stats, and outcome evaluation all gate
on the same value.
by residual 12-1 month momentum: the stock's 12-1 return after subtracting its
estimated benchmark beta contribution over the same formation window. The daily
scan ranks every ticker and stores each setup's percentile (see
``rr_scanner_service``), so the live list, the Track Record's qualified stats,
and outcome evaluation all gate on the same value.
"""
from __future__ import annotations
import json
import logging
from datetime import date
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -35,29 +36,101 @@ def compute_12_1_momentum(closes: list[float]) -> float | None:
return None
def compute_residual_12_1_momentum(
dates: list[date],
closes: list[float],
benchmark_closes: dict[date, float],
) -> float | None:
"""12-1 momentum after removing linear benchmark exposure.
Estimate beta from daily stock/benchmark returns over the standard 12-1
formation window, then sum stock return minus beta * benchmark return. No
intercept is subtracted: fitting an intercept over the same window would make
residuals sum to roughly zero and destroy the ranking signal.
"""
i = len(closes) - 1
if not benchmark_closes or len(dates) != len(closes) or i - _MOM_LOOKBACK < 0:
return None
stock_rets: list[float] = []
market_rets: list[float] = []
for k in range(i - _MOM_LOOKBACK + 1, i - _MOM_SKIP + 1):
prev_close = closes[k - 1]
bench_prev = benchmark_closes.get(dates[k - 1])
bench_cur = benchmark_closes.get(dates[k])
if prev_close <= 0 or bench_prev is None or bench_cur is None or bench_prev <= 0:
continue
stock_rets.append(closes[k] / prev_close - 1.0)
market_rets.append(bench_cur / bench_prev - 1.0)
if len(stock_rets) < 100:
return None
mean_market = sum(market_rets) / len(market_rets)
mean_stock = sum(stock_rets) / len(stock_rets)
var_market = sum((x - mean_market) ** 2 for x in market_rets)
if var_market <= 0:
return None
cov = sum(
(stock_rets[k] - mean_stock) * (market_rets[k] - mean_market)
for k in range(len(stock_rets))
)
beta = cov / var_market
return sum(stock_rets[k] - beta * market_rets[k] for k in range(len(stock_rets)))
async def _load_activation_benchmark(db: AsyncSession) -> dict[date, float]:
"""Load SPY closes for residual momentum; refresh once if the table is empty."""
try:
from app.services.benchmark_service import load_benchmark_closes, refresh_benchmark_prices
closes = await load_benchmark_closes(db)
if closes:
return closes
await refresh_benchmark_prices(db)
return await load_benchmark_closes(db)
except Exception:
logger.exception("Residual momentum benchmark load failed; falling back to raw momentum")
return {}
async def compute_momentum_percentiles(db: AsyncSession) -> dict[str, float]:
"""Compute each ticker's 12-1 momentum and rank the universe into a
``{symbol: percentile}`` map (0100, 100 = strongest momentum). Tickers
without a full year of history are absent (can't be ranked)."""
"""Compute each ticker's activation momentum rank.
Production uses residual 12-1 momentum when benchmark data is available. If
SPY data is absent, fall back to raw 12-1 momentum rather than disabling the
scanner. Tickers without enough stock/benchmark history are absent.
"""
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
tickers = list(result.scalars().all())
momentum: dict[str, float] = {}
benchmark_closes = await _load_activation_benchmark(db)
using_residual = len(benchmark_closes) >= _MOM_LOOKBACK
values: dict[str, float] = {}
for ticker in tickers:
try:
records = await query_ohlcv(db, ticker.symbol)
except Exception:
logger.exception("Momentum fetch failed for %s", ticker.symbol)
continue
m = compute_12_1_momentum([float(r.close) for r in records])
if m is not None:
momentum[ticker.symbol] = m
closes = [float(r.close) for r in records]
value = (
compute_residual_12_1_momentum([r.date for r in records], closes, benchmark_closes)
if using_residual
else compute_12_1_momentum(closes)
)
if value is not None:
values[ticker.symbol] = value
ranked = sorted(momentum, key=lambda s: momentum[s])
ranked = sorted(values, key=lambda s: values[s])
n = len(ranked)
percentiles = {
sym: round((rank / (n - 1) * 100.0) if n > 1 else 100.0, 2)
for rank, sym in enumerate(ranked)
}
logger.info(json.dumps({"event": "momentum_ranked", "tickers": n}))
logger.info(json.dumps({
"event": "momentum_ranked",
"signal": "residual_12_1" if using_residual else "raw_12_1_fallback",
"tickers": n,
}))
return percentiles
+66 -18
View File
@@ -20,19 +20,26 @@ from app.services.outcome_service import (
evaluate_setup_against_bars,
)
# Exit policy for OPEN paper trades (auto-close). "trailing" rides a trailing stop
# (validated as the best exit in the backtest); "target" closes at the setup's
# stop/target. Stored in SystemSetting so it's tunable + transparent in the UI.
# 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"
DEFAULT_EXIT_MODE = "trailing"
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': 'trailing'|'target', 'trailing_pct': float}."""
"""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 ("trailing", "target"):
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:
@@ -40,22 +47,36 @@ async def get_exit_policy(db: AsyncSession) -> dict:
except (TypeError, ValueError):
pct = DEFAULT_TRAILING_PCT
pct = max(0.5, min(90.0, pct))
return {"mode": mode, "trailing_pct": 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
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 ("trailing", "target"):
raise ValidationError("mode must be 'trailing' or 'target'")
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)
@@ -101,6 +122,23 @@ async def _max_high_after(db: AsyncSession, ticker_id: int, since: date) -> floa
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:
@@ -297,12 +335,12 @@ async def close_trade(
async def resolve_open_trades(db: AsyncSession) -> int:
"""Auto-close open trades whose stop or target was hit in the daily bars.
"""Auto-close open trades per the active exit policy, from the daily bars.
Walks the bars after each trade's open (same logic as the outcome evaluator).
Target hit → close at the target; stop (or an ambiguous same-bar touch) →
close at the stop. Trades that have hit neither stay open. Returns the count
closed.
Walks the bars after each trade's open. 'time' closes at the initial stop or
the hold_days-th close; 'trailing' at the trailing/initial stop; 'target' at
the setup's target or stop (same logic as the outcome evaluator). Trades that
have hit nothing stay open. Returns the count closed.
"""
result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open"))
open_trades = list(result.scalars().all())
@@ -312,22 +350,32 @@ async def resolve_open_trades(db: AsyncSession) -> int:
policy = await get_exit_policy(db)
mode = policy["mode"]
trail_frac = policy["trailing_pct"] / 100.0
hold_days = policy["hold_days"]
closed = 0
for trade in open_trades:
bars_result = await db.execute(
select(OHLCVRecord.date, OHLCVRecord.high, OHLCVRecord.low)
select(
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
OHLCVRecord.low, OHLCVRecord.close,
)
.where(
OHLCVRecord.ticker_id == trade.ticker_id,
OHLCVRecord.date > trade.opened_at.date(),
)
.order_by(OHLCVRecord.date.asc())
)
bars = [Bar(date=d, high=h, low=lo) for d, h, lo in bars_result.all()]
rows = bars_result.all()
bars = [Bar(date=d, high=h, low=lo) for d, _, h, lo, _ in rows]
if not bars:
continue
if mode == "trailing":
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
+29 -17
View File
@@ -2,12 +2,12 @@
A single predicate, driven by the admin activation config, used by the
performance stats (server) and mirrored on the frontend. The core selection is
cross-sectional momentum: a setup's ticker must rank in the top
``min_momentum_percentile`` of the universe by 12-1 month momentum — the one
signal the backtest showed actually sorts forward returns. R:R and confidence
remain as floors, and conviction/conflict survive as optional tighteners (off by
default). The momentum percentile is computed across the universe and attached to
each setup upstream; when it's absent the gate falls back to the floors.
residual cross-sectional momentum: a setup's ticker must rank in the top
``min_momentum_percentile`` of the universe by beta-adjusted 12-1 month momentum.
R:R and confidence remain as floors, and conviction/conflict survive as optional
tighteners (off by default). The activation percentile is computed across the
universe and attached to each setup upstream; when it's absent the gate falls
back to the floors.
"""
from __future__ import annotations
@@ -17,6 +17,16 @@ from typing import Any
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
def _action_direction(action: str | None) -> str:
if not action or action == "NEUTRAL":
return "neutral"
if action.startswith("LONG"):
return "long"
if action.startswith("SHORT"):
return "short"
return "neutral"
def best_target_probability(setup: Any) -> float:
"""Highest probability among a setup's targets, 0 if none."""
targets = getattr(setup, "targets", None) or []
@@ -65,12 +75,12 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
return False
if (setup.confidence_score or 0.0) < config["min_confidence"]:
return False
# Cross-sectional momentum: the core selection. A setup's ticker must rank in
# the top ``min_momentum_percentile`` of the universe by 12-1 momentum. The
# validated edge is long-only, so while the gate is active shorts (which fight
# the trend) never qualify. The percentile floor is only enforced when a
# percentile is attached (live setups / backtest); callers that don't attach
# it defer to the floors above.
# Residual cross-sectional momentum: the core selection. A setup's ticker
# must rank in the top ``min_momentum_percentile`` of the universe by
# beta-adjusted 12-1 momentum. The validated edge is long-only, so while the
# gate is active shorts (which fight the trend) never qualify. The percentile
# floor is only enforced when a percentile is attached (live setups /
# backtest); callers that don't attach it defer to the floors above.
min_pct = float(config.get("min_momentum_percentile", 0.0))
if min_pct > 0:
if (getattr(setup, "direction", "long") or "long") == "short":
@@ -78,12 +88,14 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
momentum_percentile = getattr(setup, "momentum_percentile", None)
if momentum_percentile is not None and momentum_percentile < min_pct:
return False
# A NEUTRAL recommendation means the engine found no clear directional setup —
# not an actionable signal, so by default it doesn't qualify (and can't be a
# top pick). ``exclude_neutral`` defaults on; turn it off to also count
# no-clear-direction momentum leaders.
# 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"):
if (setup.recommended_action or "NEUTRAL") == "NEUTRAL":
action_direction = _action_direction(getattr(setup, "recommended_action", None))
setup_direction = (getattr(setup, "direction", "long") or "long").lower()
if action_direction == "neutral" or action_direction != setup_direction:
return False
if config.get("require_high_conviction"):
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
+57 -27
View File
@@ -524,6 +524,56 @@ def _build_reasoning(
)
def build_recommendation_snapshot(
dimension_scores: dict[str, float],
sentiment_classification: str | None,
config: dict[str, float],
available_directions: set[str] | None = None,
) -> dict[str, Any]:
"""Build the ticker-level recommendation from the supplied live context."""
conflicts = signal_conflict_detector.detect_conflicts(
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
config=config,
)
long_confidence = direction_analyzer.calculate_confidence(
direction="long",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
short_confidence = direction_analyzer.calculate_confidence(
direction="short",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
action = _choose_recommended_action(
long_confidence, short_confidence, config, available_directions
)
reasoning = _build_reasoning(
action=action,
long_confidence=long_confidence,
short_confidence=short_confidence,
conflicts=conflicts,
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
config=config,
available_directions=available_directions,
)
return {
"action": action,
"reasoning": reasoning,
"risk_level": _risk_level_from_conflicts(conflicts),
"long_confidence": long_confidence,
"short_confidence": short_confidence,
"conflicts": conflicts,
}
PRIMARY_TARGET_MIN_RR = 1.5
@@ -559,24 +609,15 @@ async def enhance_trade_setup(
) -> TradeSetup:
config = await get_recommendation_config(db)
conflicts = signal_conflict_detector.detect_conflicts(
snapshot = build_recommendation_snapshot(
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
config=config,
available_directions=available_directions,
)
long_confidence = direction_analyzer.calculate_confidence(
direction="long",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
short_confidence = direction_analyzer.calculate_confidence(
direction="short",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
conflicts = list(snapshot["conflicts"])
long_confidence = float(snapshot["long_confidence"])
short_confidence = float(snapshot["short_confidence"])
direction = setup.direction.lower()
confidence = long_confidence if direction == "long" else short_confidence
@@ -622,19 +663,8 @@ async def enhance_trade_setup(
# Action and reasoning are ticker-level: they consider both directions and
# which directions are actually tradeable, and are identical on every setup.
action = _choose_recommended_action(
long_confidence, short_confidence, config, available_directions
)
reasoning = _build_reasoning(
action=action,
long_confidence=long_confidence,
short_confidence=short_confidence,
conflicts=conflicts,
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
config=config,
available_directions=available_directions,
)
action = str(snapshot["action"])
reasoning = str(snapshot["reasoning"])
setup.confidence_score = round(confidence, 2)
setup.targets_json = json.dumps(targets)
+346 -17
View File
@@ -11,24 +11,33 @@ from __future__ import annotations
import json
import logging
from collections.abc import Callable
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import NotFoundError
from app.models.fundamental import FundamentalData
from app.models.ohlcv import OHLCVRecord
from app.models.score import CompositeScore, DimensionScore
from app.models.sentiment import SentimentScore
from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.services.indicator_service import _extract_ohlcv, compute_atr
from app.services.price_service import query_ohlcv
from app.services.recommendation_service import enhance_trade_setup
from app.services.recommendation_service import (
_risk_level_from_conflicts,
build_recommendation_snapshot,
enhance_trade_setup,
get_recommendation_config,
)
logger = logging.getLogger(__name__)
STRATEGY_VERSION = "residual_momentum_12_1_rr_time_v2"
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper()
@@ -76,6 +85,263 @@ async def _get_latest_sentiment(db: AsyncSession, ticker_id: int) -> str | None:
return row.classification if row else None
async def _apply_live_recommendation_context(
db: AsyncSession,
setup_rows: list[tuple[TradeSetup, str]],
rows: list[dict],
) -> list[dict]:
"""Decorate latest setup rows with current score/sentiment recommendation data.
This intentionally updates only the API payload. Stored trade setups and
history remain point-in-time records for outcome analysis.
"""
if not rows or not setup_rows:
return rows
ticker_ids = {setup.ticker_id for setup, _ in setup_rows}
setups_by_id = {setup.id: setup for setup, _ in setup_rows}
directions_by_ticker = await _latest_available_directions_by_ticker(db, ticker_ids)
dim_result = await db.execute(
select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids))
)
dims_by_ticker: dict[int, dict[str, float]] = {}
for ds in dim_result.scalars().all():
dims_by_ticker.setdefault(ds.ticker_id, {})[ds.dimension] = float(ds.score)
comp_result = await db.execute(
select(CompositeScore)
.where(CompositeScore.ticker_id.in_(ticker_ids))
.order_by(CompositeScore.ticker_id, CompositeScore.computed_at.desc())
)
composites: dict[int, CompositeScore] = {}
for comp in comp_result.scalars().all():
composites.setdefault(comp.ticker_id, comp)
sent_result = await db.execute(
select(SentimentScore)
.where(SentimentScore.ticker_id.in_(ticker_ids))
.order_by(SentimentScore.ticker_id, SentimentScore.timestamp.desc())
)
sentiments: dict[int, SentimentScore] = {}
for sent in sent_result.scalars().all():
sentiments.setdefault(sent.ticker_id, sent)
config = await get_recommendation_config(db)
live_rows: list[dict] = []
for row in rows:
setup = setups_by_id.get(row["id"])
if setup is None:
live_rows.append(row)
continue
ticker_id = setup.ticker_id
live_row = dict(row)
comp = composites.get(ticker_id)
if comp is not None:
live_row["composite_score"] = float(comp.score)
live_row["context_as_of"]["score_computed_at"] = comp.computed_at
dimension_scores = dims_by_ticker.get(ticker_id)
sentiment = sentiments.get(ticker_id)
if sentiment is not None:
live_row["context_as_of"]["sentiment_at"] = sentiment.timestamp
if dimension_scores:
snapshot = build_recommendation_snapshot(
dimension_scores=dimension_scores,
sentiment_classification=sentiment.classification if sentiment else None,
config=config,
available_directions=directions_by_ticker.get(ticker_id),
)
direction = setup.direction.lower()
confidence_key = "long_confidence" if direction == "long" else "short_confidence"
live_row["confidence_score"] = round(float(snapshot[confidence_key]), 2)
live_row["recommended_action"] = snapshot["action"]
live_row["reasoning"] = snapshot["reasoning"]
setup_conflicts = _setup_specific_conflicts(live_row.get("conflict_flags", []))
live_conflicts = [str(item) for item in snapshot["conflicts"]]
live_row["conflict_flags"] = live_conflicts + setup_conflicts
live_row["risk_level"] = _risk_level_from_conflicts(live_row["conflict_flags"])
live_rows.append(live_row)
return live_rows
def _setup_specific_conflicts(conflicts: list[str]) -> list[str]:
signal_prefixes = (
"sentiment-technical:",
"sentiment-momentum:",
"momentum-technical:",
"fundamental-technical:",
)
return [
str(conflict)
for conflict in conflicts
if not str(conflict).startswith(signal_prefixes)
]
async def _latest_available_directions_by_ticker(
db: AsyncSession,
ticker_ids: set[int],
) -> dict[int, set[str]]:
if not ticker_ids:
return {}
result = await db.execute(
select(TradeSetup)
.where(TradeSetup.ticker_id.in_(ticker_ids))
.order_by(
TradeSetup.ticker_id,
TradeSetup.direction,
TradeSetup.detected_at.desc(),
TradeSetup.id.desc(),
)
)
latest_by_key: set[tuple[int, str]] = set()
directions: dict[int, set[str]] = {}
for setup in result.scalars().all():
direction = setup.direction.lower()
key = (setup.ticker_id, direction)
if key in latest_by_key:
continue
latest_by_key.add(key)
directions.setdefault(setup.ticker_id, set()).add(direction)
return directions
def _json_default(value):
if isinstance(value, (datetime, date)):
return value.isoformat()
return str(value)
async def _create_signal_context_snapshots(
db: AsyncSession,
setups: list[TradeSetup],
*,
strategy_version: str = STRATEGY_VERSION,
) -> None:
"""Capture point-in-time discretionary context for freshly generated setups.
The scanner stores the setup itself first so each snapshot can be keyed by
``trade_setup_id``. This is intentionally forward-only: old sentiment,
fundamentals and composite scores are not reconstructed from today's data.
"""
if not setups:
return
ticker_ids = {s.ticker_id for s in setups}
dims: dict[int, dict[str, dict]] = {}
dim_rows = (
await db.execute(select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids)))
).scalars().all()
for row in dim_rows:
dims.setdefault(row.ticker_id, {})[row.dimension] = {
"score": float(row.score),
"is_stale": bool(row.is_stale),
"computed_at": row.computed_at,
}
composites: dict[int, CompositeScore] = {}
comp_rows = (
await db.execute(
select(CompositeScore)
.where(CompositeScore.ticker_id.in_(ticker_ids))
.order_by(CompositeScore.ticker_id, CompositeScore.computed_at.desc())
)
).scalars().all()
for row in comp_rows:
composites.setdefault(row.ticker_id, row)
sentiments: dict[int, SentimentScore] = {}
sent_rows = (
await db.execute(
select(SentimentScore)
.where(SentimentScore.ticker_id.in_(ticker_ids))
.order_by(SentimentScore.ticker_id, SentimentScore.timestamp.desc())
)
).scalars().all()
for row in sent_rows:
sentiments.setdefault(row.ticker_id, row)
fundamentals: dict[int, FundamentalData] = {}
fund_rows = (
await db.execute(
select(FundamentalData)
.where(FundamentalData.ticker_id.in_(ticker_ids))
.order_by(FundamentalData.ticker_id, FundamentalData.fetched_at.desc())
)
).scalars().all()
for row in fund_rows:
fundamentals.setdefault(row.ticker_id, row)
now = datetime.now(timezone.utc)
for setup in setups:
comp = composites.get(setup.ticker_id)
sent = sentiments.get(setup.ticker_id)
fund = fundamentals.get(setup.ticker_id)
score_context = {
"composite_score": float(comp.score) if comp else float(setup.composite_score),
"composite_is_stale": bool(comp.is_stale) if comp else None,
"composite_computed_at": comp.computed_at if comp else None,
"dimensions": dims.get(setup.ticker_id, {}),
}
sentiment_context = (
{
"classification": sent.classification,
"confidence": int(sent.confidence),
"recommendation": sent.recommendation,
"timestamp": sent.timestamp,
"source": sent.source,
}
if sent
else {}
)
fundamental_context = (
{
"pe_ratio": fund.pe_ratio,
"revenue_growth": fund.revenue_growth,
"earnings_surprise": fund.earnings_surprise,
"market_cap": fund.market_cap,
"next_earnings_date": fund.next_earnings_date,
"fetched_at": fund.fetched_at,
}
if fund
else {}
)
db.add(
SignalContextSnapshot(
trade_setup_id=setup.id,
ticker_id=setup.ticker_id,
detected_at=setup.detected_at,
created_at=now,
strategy_version=strategy_version,
direction=setup.direction,
entry_price=float(setup.entry_price),
stop_loss=float(setup.stop_loss),
target=float(setup.target),
rr_ratio=float(setup.rr_ratio),
confidence_score=(
float(setup.confidence_score) if setup.confidence_score is not None else None
),
recommended_action=setup.recommended_action,
risk_level=setup.risk_level,
momentum_percentile=(
float(setup.momentum_percentile)
if setup.momentum_percentile is not None
else None
),
score_context_json=json.dumps(score_context, default=_json_default),
sentiment_context_json=json.dumps(sentiment_context, default=_json_default),
fundamental_context_json=json.dumps(fundamental_context, default=_json_default),
)
)
async def scan_ticker(
db: AsyncSession,
symbol: str,
@@ -85,9 +351,9 @@ async def scan_ticker(
) -> list[TradeSetup]:
"""Scan a single ticker for trade setups meeting the R:R threshold.
``momentum_percentile`` is the ticker's 12-1 momentum rank across the universe
(computed by the caller), stored on each setup so the activation gate can
select the top slice."""
``momentum_percentile`` is the ticker's residual 12-1 momentum activation
rank across the universe (computed by the caller), stored on each setup so
the activation gate can select the top slice."""
ticker = await _get_ticker(db, symbol)
records = await query_ohlcv(db, symbol)
@@ -238,6 +504,9 @@ async def scan_ticker(
for s in enhanced_setups:
await db.refresh(s)
await _create_signal_context_snapshots(db, enhanced_setups)
await db.commit()
return enhanced_setups
@@ -256,8 +525,9 @@ async def scan_all_tickers(
tickers = list(result.scalars().all())
total = len(tickers)
# Rank the universe by 12-1 momentum up front so each new setup carries its
# ticker's percentile (used by the activation gate). Best-effort.
# Rank the universe by residual 12-1 momentum up front so each new setup
# carries its activation percentile. Best-effort; the ranker falls back to
# raw 12-1 momentum only if benchmark data is unavailable.
try:
from app.services import momentum_service
@@ -303,6 +573,7 @@ async def get_trade_setups(
min_confidence: float | None = None,
recommended_action: str | None = None,
symbol: str | None = None,
live_recommendation: bool = False,
) -> list[dict]:
"""Get latest stored trade setups, optionally filtered."""
stmt = (
@@ -313,9 +584,11 @@ async def get_trade_setups(
stmt = stmt.where(TradeSetup.direction == direction.lower())
if symbol is not None:
stmt = stmt.where(Ticker.symbol == symbol.strip().upper())
if min_confidence is not None:
# With live_recommendation these fields are overlaid with current values
# below, so filtering happens there instead of against the stored columns.
if min_confidence is not None and not live_recommendation:
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
if recommended_action is not None:
if recommended_action is not None and not live_recommendation:
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
@@ -339,15 +612,37 @@ async def get_trade_setups(
reverse=True,
)
prices = await _latest_closes(db, {s.ticker_id for s, _ in latest_rows})
return [
prices = await _latest_price_context(db, {s.ticker_id for s, _ in latest_rows})
rows_out = [
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
for setup, ticker_symbol in latest_rows
]
if live_recommendation:
rows_out = await _apply_live_recommendation_context(db, latest_rows, rows_out)
if min_confidence is not None:
rows_out = [
row for row in rows_out
if row["confidence_score"] is not None
and row["confidence_score"] >= min_confidence
]
if recommended_action is not None:
rows_out = [
row for row in rows_out
if row["recommended_action"] == recommended_action
]
rows_out.sort(
key=lambda row: (
row["confidence_score"] if row["confidence_score"] is not None else -1.0,
row["rr_ratio"],
row["composite_score"],
),
reverse=True,
)
return rows_out
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
"""Most recent close per ticker — used to judge a setup's current relevance."""
async def _latest_price_context(db: AsyncSession, ticker_ids: set[int]) -> dict[int, dict]:
"""Most recent daily OHLCV row per ticker for live price context."""
if not ticker_ids:
return {}
latest = (
@@ -356,7 +651,12 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
.group_by(OHLCVRecord.ticker_id)
.subquery()
)
stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join(
stmt = select(
OHLCVRecord.ticker_id,
OHLCVRecord.close,
OHLCVRecord.date,
OHLCVRecord.created_at,
).join(
latest,
and_(
OHLCVRecord.ticker_id == latest.c.ticker_id,
@@ -364,7 +664,23 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
),
)
result = await db.execute(stmt)
return {tid: float(close) for tid, close in result.all()}
return {
tid: {
"current_price": float(close),
"price_date": price_date,
"price_updated_at": created_at,
}
for tid, close, price_date, created_at in result.all()
}
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
"""Most recent close per ticker, kept for callers that only need price."""
price_context = await _latest_price_context(db, ticker_ids)
return {
ticker_id: context["current_price"]
for ticker_id, context in price_context.items()
}
async def get_trade_setup_history(
@@ -381,16 +697,28 @@ async def get_trade_setup_history(
result = await db.execute(stmt)
rows = result.all()
prices = await _latest_closes(db, {s.ticker_id for s, _ in rows})
prices = await _latest_price_context(db, {s.ticker_id for s, _ in rows})
return [
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
for setup, ticker_symbol in rows
]
def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float | None = None) -> dict:
def _trade_setup_to_dict(setup: TradeSetup, symbol: str, price_context: dict | None = None) -> dict:
targets: list[dict] = []
conflicts: list[str] = []
current_price = (
float(price_context["current_price"])
if price_context and price_context.get("current_price") is not None
else None
)
context_as_of = {
"setup_detected_at": setup.detected_at,
"score_computed_at": None,
"sentiment_at": None,
"price_date": price_context.get("price_date") if price_context else None,
"price_updated_at": price_context.get("price_updated_at") if price_context else None,
}
if setup.targets_json:
try:
@@ -429,4 +757,5 @@ def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float |
"evaluated_at": setup.evaluated_at,
"current_price": current_price,
"momentum_percentile": setup.momentum_percentile,
"context_as_of": context_as_of,
}
+10 -64
View File
@@ -2,8 +2,8 @@
Computes dimension scores (technical, sr_quality, sentiment, fundamental,
momentum) each 0-100, composite score as weighted average of available
dimensions with re-normalized weights, staleness marking/recomputation
on demand, and weight update triggers full recomputation.
dimensions with re-normalized weights, staleness marking, explicit refresh
paths, and weight update triggers full recomputation.
"""
from __future__ import annotations
@@ -765,73 +765,37 @@ async def compute_composite_score(
async def get_score(
db: AsyncSession, symbol: str
) -> dict:
"""Get composite + all dimension scores for a ticker.
"""Read composite + dimension scores for a ticker without recomputing.
Recomputes stale dimensions on demand, then recomputes composite.
Returns a dict suitable for ScoreResponse, including dimension breakdowns
and composite breakdown with re-normalization info.
GET endpoints use this path, so it must not mutate persisted score context.
Scheduled/manual write paths are responsible for refreshing scores.
"""
ticker = await _get_ticker(db, symbol)
weights = await _get_weights(db)
# Check for stale dimension scores and recompute them
result = await db.execute(
select(DimensionScore).where(DimensionScore.ticker_id == ticker.id)
)
dim_scores = {ds.dimension: ds for ds in result.scalars().all()}
for dim in DIMENSIONS:
ds = dim_scores.get(dim)
if ds is None or ds.is_stale:
await compute_dimension_score(db, symbol, dim)
# Check composite staleness
comp_result = await db.execute(
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
)
comp = comp_result.scalar_one_or_none()
if comp is None or comp.is_stale:
await compute_composite_score(db, symbol, weights)
await db.commit()
# Re-fetch everything fresh
result = await db.execute(
select(DimensionScore).where(DimensionScore.ticker_id == ticker.id)
)
dim_scores_list = list(result.scalars().all())
dim_scores = {ds.dimension: ds for ds in dim_scores_list}
comp_result = await db.execute(
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
)
comp = comp_result.scalar_one_or_none()
# Compute breakdowns for each dimension by calling the dimension computers
breakdowns: dict[str, dict | None] = {}
for dim in DIMENSIONS:
try:
raw_result = await _DIMENSION_COMPUTERS[dim](db, symbol)
if isinstance(raw_result, tuple) and len(raw_result) == 2:
breakdowns[dim] = raw_result[1]
else:
breakdowns[dim] = None
except Exception:
breakdowns[dim] = None
# Build dimension entries with breakdowns
dimensions = []
missing = []
available_dims: list[str] = []
for dim in DIMENSIONS:
found = next((ds for ds in dim_scores_list if ds.dimension == dim), None)
found = dim_scores.get(dim)
if found is not None and not found.is_stale and found.score is not None:
dimensions.append({
"dimension": found.dimension,
"score": found.score,
"is_stale": found.is_stale,
"computed_at": found.computed_at,
"breakdown": breakdowns.get(dim),
"breakdown": None,
})
w = weights.get(dim, 0.0)
if w > 0:
@@ -845,7 +809,7 @@ async def get_score(
"score": found.score,
"is_stale": found.is_stale,
"computed_at": found.computed_at,
"breakdown": breakdowns.get(dim),
"breakdown": None,
})
# Build composite breakdown: the non-sentiment base (re-normalized weighted
@@ -925,31 +889,13 @@ async def get_rankings(db: AsyncSession) -> dict:
dims[ds.ticker_id][ds.dimension] = ds
return comps, dims
# Two bulk reads instead of ~4 queries per ticker.
comps, dims_by_ticker = await _load_scores()
# Lazily recompute any stale/missing scores (kept fresh by the daily scan;
# this self-heals tickers that aged out between scans), committing once.
recomputed = False
for ticker in tickers:
comp = comps.get(ticker.id)
if comp is None or comp.is_stale:
dim_scores = dims_by_ticker.get(ticker.id, {})
for dim in DIMENSIONS:
ds = dim_scores.get(dim)
if ds is None or ds.is_stale:
await compute_dimension_score(db, ticker.symbol, dim)
await compute_composite_score(db, ticker.symbol, weights)
recomputed = True
if recomputed:
await db.commit()
comps, dims_by_ticker = await _load_scores()
rankings = [
{
"symbol": ticker.symbol,
"composite_score": comp.score,
"composite_stale": comp.is_stale,
"dimensions": [
{
"dimension": ds.dimension,
+2 -2
View File
@@ -173,8 +173,8 @@ async def _enrich_entry(
"dimensions": dims,
"rr_ratio": setup.rr_ratio if setup else None,
"rr_direction": setup.direction if setup else None,
# 12-1 cross-sectional momentum percentile (the top-pick selector); ticker-
# level, so any of the ticker's setups carries the same value.
# Residual 12-1 activation percentile (the top-pick selector); ticker-level,
# so any of the ticker's setups carries the same value.
"momentum_percentile": setup.momentum_percentile if setup else None,
"sr_levels": sr_levels,
"last_close": last_close,
@@ -41,16 +41,16 @@ export function ActivationSettings() {
<p className="mt-1 text-xs text-gray-500">
What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the
Signals "Qualified only" view, and the Track Record's qualified stats. The core selection is
<span className="text-gray-300"> cross-sectional momentum</span> the ticker must rank in the
top slice of the universe by 12-1 month momentum, the one signal the backtest showed predicts
forward returns. R:R and confidence stay as floors. Tune the cutoff against the Track Record's
<span className="text-gray-300"> residual cross-sectional momentum</span> the ticker must rank in the
top slice of the universe by beta-adjusted 12-1 month momentum, the production signal promoted
from the backtest. R:R and confidence stay as floors. Tune the cutoff against the Track Record's
momentum sweep to see what actually wins.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Momentum Percentile</span>
<span className="text-xs text-gray-400">Min Residual Momentum Percentile</span>
<input
type="number"
min={0}
@@ -60,7 +60,7 @@ export function ActivationSettings() {
onChange={(e) => setForm((prev) => ({ ...prev, min_momentum_percentile: Number(e.target.value) }))}
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-600">Ticker's 12-1 momentum rank. 80 = top 20% of the universe. 0 disables. The core gate.</span>
<span className="text-[11px] text-gray-600">Ticker's residual 12-1 momentum rank. 80 = top 20% of the universe. 0 disables. The core gate.</span>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
@@ -100,7 +100,7 @@ export function ActivationSettings() {
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 momentum leaders.
never qualifies or becomes a top pick. Turn off to also count no-clear-direction residual momentum leaders.
</span>
</span>
</label>
@@ -6,13 +6,15 @@ import { SkeletonCard } from '../ui/Skeleton';
export function ExitPolicySettings() {
const { data, isLoading } = useExitPolicy();
const update = useUpdateExitPolicy();
const [mode, setMode] = useState<ExitPolicy['mode']>('trailing');
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]);
@@ -24,12 +26,14 @@ export function ExitPolicySettings() {
<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">Trailing</span> rides a trailing stop the backtest's best exit,
it lets winners run; <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.
<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-2">
<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
@@ -37,10 +41,25 @@ export function ExitPolicySettings() {
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
@@ -53,13 +72,13 @@ export function ExitPolicySettings() {
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. Backtest sweet spot ~1215%.</span>
<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 })}
onClick={() => update.mutate({ mode, trailing_pct: pct, hold_days: holdDays })}
>
{update.isPending ? 'Saving' : 'Save Exit Policy'}
</button>
@@ -1,6 +1,6 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types';
import { formatPrice, formatDate } from '../../lib/format';
import { formatPrice, formatDate, formatLargeNumber } from '../../lib/format';
interface CandlestickChartProps {
data: OHLCVBar[];
@@ -50,6 +50,9 @@ interface TooltipState {
}
const MIN_VISIBLE_BARS = 10;
const CHART_HEIGHT = 440;
const VOLUME_PANE_HEIGHT = 72;
const PANE_GAP = 18;
type RangePreset = '1M' | '3M' | '6M' | 'YTD' | '1Y' | '3Y' | '5Y' | 'All';
const RANGE_PRESETS: RangePreset[] = ['1M', '3M', '6M', 'YTD', '1Y', '3Y', '5Y', 'All'];
@@ -109,7 +112,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
const W = rect.width;
const H = 400;
const H = CHART_HEIGHT;
canvas.width = W * dpr;
canvas.height = H * dpr;
@@ -124,7 +127,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
// Margins
const ml = 12, mr = 70, mt = 12, mb = 32;
const cw = W - ml - mr;
const ch = H - mt - mb;
const volumeH = VOLUME_PANE_HEIGHT;
const ch = H - mt - mb - volumeH - PANE_GAP;
const priceBottom = mt + ch;
const volumeTop = priceBottom + PANE_GAP;
const volumeBottom = volumeTop + volumeH;
// Current price = explicit prop, else latest close
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
@@ -145,6 +152,9 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
const barW = cw / visibleData.length;
const candleW = Math.max(barW * 0.65, 1);
const volumeW = Math.max(barW * 0.65, 1);
const maxVolume = Math.max(...visibleData.map((b) => Math.max(0, b.volume)), 1);
const volumeScale = (v: number) => volumeTop + volumeH - (Math.max(0, v) / maxVolume) * volumeH;
// Grid lines (horizontal)
const nTicks = 6;
@@ -172,6 +182,34 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
ctx.fillText(formatDate(visibleData[i].date), x, H - 6);
}
// Volume pane
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ml, volumeTop - 9);
ctx.lineTo(ml + cw, volumeTop - 9);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(ml, volumeBottom);
ctx.lineTo(ml + cw, volumeBottom);
ctx.stroke();
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
ctx.fillStyle = '#6b7280';
ctx.textAlign = 'left';
ctx.fillText('Volume', ml, volumeTop - 13);
ctx.textAlign = 'right';
ctx.fillText(formatLargeNumber(maxVolume), W - 8, volumeTop + 4);
visibleData.forEach((bar, i) => {
const x = ml + i * barW + barW / 2;
const bullish = bar.close >= bar.open;
const yVolume = volumeScale(bar.volume);
const hVolume = Math.max(volumeBottom - yVolume, bar.volume > 0 ? 1 : 0);
ctx.fillStyle = bullish ? 'rgba(16, 185, 129, 0.32)' : 'rgba(239, 68, 68, 0.28)';
ctx.fillRect(x - volumeW / 2, yVolume, volumeW, hVolume);
});
// Nearest support/resistance only (band if it came from a zone)
markers.forEach((m) => {
const isSupport = m.role === 'support';
@@ -312,7 +350,22 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
});
// Store geometry for hit testing (includes visibleRange offset)
(canvas as any).__chartMeta = { ml, mr, mt, mb, cw, ch, barW, lo, hi, yScale, visibleStart: start };
(canvas as any).__chartMeta = {
ml,
mr,
mt,
mb,
cw,
ch,
barW,
lo,
hi,
yScale,
visibleStart: start,
volumeTop,
volumeH,
volumeBottom,
};
// Size the overlay canvas to match
const overlay = overlayCanvasRef.current;
@@ -342,12 +395,14 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
const meta = (canvas as any).__chartMeta;
if (!meta) return;
const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart } = meta;
const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart, volumeBottom } = meta;
const H = overlay.height / dpr;
const priceBottom = mt + ch;
const chartBottom = volumeBottom ?? priceBottom;
// Clamp crosshair to chart area
const cx = Math.max(ml, Math.min(ml + cw, pos.x));
const cy = Math.max(mt, Math.min(mt + ch, pos.y));
const cy = Math.max(mt, Math.min(chartBottom, pos.y));
// Dashed crosshair lines
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
@@ -357,37 +412,44 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
// Vertical line
ctx.beginPath();
ctx.moveTo(cx, mt);
ctx.lineTo(cx, mt + ch);
ctx.stroke();
// Horizontal line
ctx.beginPath();
ctx.moveTo(ml, cy);
ctx.lineTo(ml + cw, cy);
ctx.lineTo(cx, chartBottom);
ctx.stroke();
ctx.setLineDash([]);
// Price label on y-axis (right side)
const price = hi - ((cy - mt) / ch) * (hi - lo);
const priceText = formatPrice(price);
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
const priceMetrics = ctx.measureText(priceText);
const labelPadX = 5;
const labelPadY = 3;
const labelW = priceMetrics.width + labelPadX * 2;
const labelH = 16 + labelPadY * 2;
const labelX = ml + cw + 2;
const labelY = cy - labelH / 2;
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
ctx.beginPath();
ctx.roundRect(labelX, labelY, labelW, labelH, 3);
ctx.fill();
ctx.fillStyle = '#e5e7eb';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(priceText, labelX + labelPadX, cy);
if (cy <= priceBottom) {
// Horizontal price crosshair only belongs in the price pane.
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
ctx.lineWidth = 0.75;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(ml, cy);
ctx.lineTo(ml + cw, cy);
ctx.stroke();
ctx.setLineDash([]);
// Price label on y-axis (right side)
const price = hi - ((cy - mt) / ch) * (hi - lo);
const priceText = formatPrice(price);
const priceMetrics = ctx.measureText(priceText);
const labelW = priceMetrics.width + labelPadX * 2;
const labelH = 16 + labelPadY * 2;
const labelX = ml + cw + 2;
const labelY = cy - labelH / 2;
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
ctx.beginPath();
ctx.roundRect(labelX, labelY, labelW, labelH, 3);
ctx.fill();
ctx.fillStyle = '#e5e7eb';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(priceText, labelX + labelPadX, cy);
}
// Date label on x-axis (bottom)
const localIdx = Math.floor((cx - ml) / barW);
@@ -619,7 +681,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
<span>High</span><span class="text-right text-gray-200">${formatPrice(bar.high)}</span>
<span>Low</span><span class="text-right text-gray-200">${formatPrice(bar.low)}</span>
<span>Close</span><span class="text-right text-gray-200">${formatPrice(bar.close)}</span>
<span>Vol</span><span class="text-right text-gray-200">${bar.volume.toLocaleString()}</span>
<span>Vol</span><span class="text-right text-gray-200" title="${bar.volume.toLocaleString()}">${formatLargeNumber(bar.volume)}</span>
</div>${tradeTooltipHtml}`;
} else {
tip.style.display = 'none';
@@ -670,16 +732,16 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
))}
<span className="ml-1 text-[10px] text-gray-600">scroll to zoom · drag to pan</span>
</div>
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}>
<div ref={containerRef} className="relative w-full" style={{ height: CHART_HEIGHT }}>
<canvas
ref={canvasRef}
className="w-full"
style={{ height: 400 }}
style={{ height: CHART_HEIGHT }}
/>
<canvas
ref={overlayCanvasRef}
className="absolute top-0 left-0 w-full cursor-crosshair"
style={{ height: 400 }}
style={{ height: CHART_HEIGHT }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
+291 -82
View File
@@ -6,15 +6,30 @@ import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure';
import { Section } from '../ui/Section';
import { useToast } from '../ui/Toast';
import type { BacktestBucket } from '../../lib/types';
import type { BacktestBucket, BacktestPortfolioPolicy, BacktestStrategyVariant } from '../../lib/types';
function fmtR(v: number | null): string {
if (v === null) return '—';
function fmtR(v: number | null | undefined): string {
if (v === null || v === undefined) return '—';
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
}
function fmtPct(v: number | null): string {
return v === null ? '—' : `${v.toFixed(1)}%`;
}
function fmtMoney(v: number | null | undefined): string {
if (v === null || v === undefined) return '—';
return v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function fmtSignedPct(v: number | null | undefined): string {
if (v === null || v === undefined) return '—';
return `${v > 0 ? '+' : ''}${v.toFixed(1)}%`;
}
function fmtDays(v: number | null | undefined): string {
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
}
function fmtRPerDay(v: number | null | undefined): string {
if (v === null || v === undefined) return '—';
return `${v > 0 ? '+' : ''}${v.toFixed(3)}R`;
}
function rColor(v: number | null): string {
if (v === null) return 'text-gray-400';
if (v > 0) return 'text-emerald-400';
@@ -24,6 +39,7 @@ function rColor(v: number | null): string {
const SIGNAL_LABELS: Record<string, string> = {
mom_12_1: '121 month momentum',
mom_12_1_resid: '121 residual momentum',
mom_6_1: '61 month momentum',
mom_3_1: '31 month momentum',
reversal_1m: '1-month reversal',
@@ -32,6 +48,25 @@ const SIGNAL_LABELS: Record<string, string> = {
vol_6m: '6-month realized volatility',
};
const ABLATION_LABELS: Record<string, string> = {
all_floors: 'All floors (current gate)',
no_confidence_floor: 'Without confidence floor',
no_rr_floor: 'Without R:R floor',
no_neutral_exclusion: 'Without NEUTRAL exclusion',
momentum_only: 'Momentum only (no floors)',
};
const POLICY_LABELS: Record<string, string> = {
target: 'S/R target exit',
hold: 'Hold to horizon',
};
// Prefer the net-of-costs number when the report carries it; older cached
// reports (pre-cost model) fall back to gross.
function netOrGross(r: { avg_r: number | null; net_avg_r?: number | null }): number | null {
return r.net_avg_r ?? r.avg_r;
}
// An |IC| this large, with a consistent sign, is a real (if small) edge worth
// building on; below it, ranking on the signal sorts essentially nothing.
const IC_EDGE_THRESHOLD = 0.03;
@@ -76,6 +111,11 @@ function BucketRow({ label, b }: { label: string; b: BacktestBucket }) {
<td className="num px-4 py-2.5 text-right text-gray-400">{b.expired}</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(b.hit_rate)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(b.avg_r)}`}>{fmtR(b.avg_r)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(b.net_avg_r ?? null)}`}>{fmtR(b.net_avg_r ?? null)}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{fmtR(b.best_r)}</td>
<td className="num px-4 py-2.5 text-right text-red-400">{fmtR(b.worst_r)}</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{fmtDays(b.avg_hold_days)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(b.net_r_per_day ?? null)}`}>{fmtRPerDay(b.net_r_per_day)}</td>
</tr>
);
}
@@ -85,14 +125,11 @@ export function BacktestPanel() {
const queryClient = useQueryClient();
const toast = useToast();
const bestTpAvgR =
report?.take_profit_sweep && report.take_profit_sweep.length > 0
? Math.max(...report.take_profit_sweep.map((r) => r.avg_r ?? -Infinity))
: null;
const bestTrailAvgR =
report?.trailing_sweep && report.trailing_sweep.length > 0
? Math.max(...report.trailing_sweep.map((r) => r.avg_r ?? -Infinity))
const bestTimeAvgR =
report?.time_exit_sweep && report.time_exit_sweep.length > 0
? Math.max(...report.time_exit_sweep.map((r) => netOrGross(r) ?? -Infinity))
: null;
const sim = report?.portfolio_sim ?? null;
const run = useMutation({
mutationFn: () => triggerJob('backtest'),
@@ -140,8 +177,54 @@ export function BacktestPanel() {
<p className="text-[11px] text-gray-500">
Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups
({report.qualified} qualified) · weekly cadence, {report.params.horizon_days}-day horizon
{report.params.cost_per_side_pct != null && (
<> · net assumes {report.params.cost_per_side_pct}%/side costs</>
)}
</p>
{report.recommendation && report.recommendation.items.length > 0 && (
<div className="glass border border-blue-400/20 p-4">
<p className="section-index">What this backtest recommends</p>
{report.recommendation.headline && (
<p className="mt-1.5 text-sm font-semibold text-gray-100">
{report.recommendation.headline}
</p>
)}
<ul className="mt-2 space-y-1">
{report.recommendation.items.map((item) => (
<li
key={item.topic + item.text}
className={`text-xs ${item.text.includes('WARNING') || item.text.includes('LAGS') ? 'text-amber-400' : 'text-gray-400'}`}
>
{item.text}
</li>
))}
</ul>
{report.recommendation.note && (
<p className="mt-2 text-[11px] text-gray-600">{report.recommendation.note}</p>
)}
</div>
)}
{report.research_recommendation && report.research_recommendation.items.length > 0 && (
<div className="glass border border-emerald-400/15 p-4">
<p className="section-index">Research candidates</p>
<ul className="mt-2 space-y-1">
{report.research_recommendation.items.map((item) => (
<li
key={item.topic + item.text}
className={`text-xs ${item.candidate ? 'text-emerald-400' : 'text-gray-400'}`}
>
{item.text}
</li>
))}
</ul>
{report.research_recommendation.note && (
<p className="mt-2 text-[11px] text-gray-600">{report.research_recommendation.note}</p>
)}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat
label="Qualified Hit Rate"
@@ -166,6 +249,30 @@ export function BacktestPanel() {
valueClass={rColor(report.overall_qualified.total_r)}
sub="cumulative, risk-adjusted"
/>
{report.overall_qualified.median_net_r != null && (
<Stat
label="Median Net R"
value={fmtR(report.overall_qualified.median_net_r)}
valueClass={rColor(report.overall_qualified.median_net_r)}
sub="qualified · the typical trade"
/>
)}
{report.overall_qualified.profit_factor != null && (
<Stat
label="Profit Factor"
value={report.overall_qualified.profit_factor.toFixed(2)}
valueClass={report.overall_qualified.profit_factor > 1 ? 'text-emerald-400' : 'text-red-400'}
sub="qualified · net wins / net losses"
/>
)}
{report.overall_qualified.net_avg_r_ex_top5 != null && (
<Stat
label="Ex-Top-5% Net R"
value={fmtR(report.overall_qualified.net_avg_r_ex_top5)}
valueClass={rColor(report.overall_qualified.net_avg_r_ex_top5)}
sub="expectancy without the biggest winners"
/>
)}
</div>
<div className="glass overflow-x-auto">
@@ -179,6 +286,11 @@ export function BacktestPanel() {
<th className="px-4 py-2.5 text-right">Expired</th>
<th className="px-4 py-2.5 text-right">Hit Rate</th>
<th className="px-4 py-2.5 text-right">Avg R</th>
<th className="px-4 py-2.5 text-right">Net Avg R</th>
<th className="px-4 py-2.5 text-right">Best R</th>
<th className="px-4 py-2.5 text-right">Worst R</th>
<th className="px-4 py-2.5 text-right">Avg Hold</th>
<th className="px-4 py-2.5 text-right">Net R/d</th>
</tr>
</thead>
<tbody>
@@ -196,11 +308,11 @@ export function BacktestPanel() {
{report.sweep && report.sweep.length > 0 && report.sweep[0].min_momentum_percentile != null && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Momentum-percentile sweep
Residual-momentum percentile sweep
</p>
<p className="mb-2 text-[11px] text-gray-500">
How many setups qualify and how they perform at each momentum-rank cutoff (floors
held fixed). 80 = only the top 20% of the universe by 12-1 momentum each week; 0 =
How many setups qualify and how they perform at each production-rank cutoff (floors
held fixed). 80 = only the top 20% of the universe by residual 12-1 momentum each week; 0 =
floors only. Lower = more trades, watch that expectancy holds. Your current setting is
highlighted; set it in Admin Settings Activation.
</p>
@@ -208,12 +320,13 @@ export function BacktestPanel() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Min momentum %ile</th>
<th className="px-4 py-2.5">Min residual %ile</th>
<th className="px-4 py-2.5 text-right">Qualified</th>
<th className="px-4 py-2.5 text-right">Wins</th>
<th className="px-4 py-2.5 text-right">Losses</th>
<th className="px-4 py-2.5 text-right">Hit Rate</th>
<th className="px-4 py-2.5 text-right">Avg R</th>
<th className="px-4 py-2.5 text-right">Net Avg R</th>
<th className="px-4 py-2.5 text-right">Total R</th>
</tr>
</thead>
@@ -231,6 +344,7 @@ export function BacktestPanel() {
<td className="num px-4 py-2.5 text-right text-red-400">{row.losses}</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.hit_rate)}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_avg_r ?? null)}`}>{fmtR(row.net_avg_r ?? null)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
</tr>
);
@@ -241,92 +355,110 @@ export function BacktestPanel() {
</div>
)}
{report.take_profit_sweep && report.take_profit_sweep.length > 0 && (
{report.gate_ablation && report.gate_ablation.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Take-profit exit (alternative to the target above)
Gate ablation which floors earn their keep
</p>
<p className="mb-2 text-[11px] text-gray-500">
Models a realistic exit instead of waiting for the far S/R target: bank{' '}
<span className="text-gray-300">+X%</span> if price reaches it before the stop, else 1R on
the stop, else exit at the {report.params.horizon_days}-day close. In R, so it compares to the
target model above. <span className="text-gray-300">Hit Rate = how often you'd have banked
+X%</span> (how far winners actually run) — no top-ticking, it's the level you'd really set.
The setup's own S/R target is <em>not</em> used here (exiting at that target is the model
above); this is a pure fixed-% exit. = best avg R.
{report.gate_ablation_note ??
'Each row re-qualifies the same candidates at the current momentum cutoff with one floor removed (long-only throughout).'}
</p>
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Take-profit</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">Hit (banked)</th>
<th className="px-4 py-2.5 text-right">Hit Rate</th>
<th className="px-4 py-2.5 text-right">Avg R</th>
<th className="px-4 py-2.5 text-right">Net Avg R</th>
<th className="px-4 py-2.5 text-right">Total R</th>
<th className="px-4 py-2.5 text-right">Hold Net Avg R</th>
<th className="px-4 py-2.5 text-right">Hold Total R</th>
</tr>
</thead>
<tbody>
{report.take_profit_sweep.map((row) => {
const best = row.avg_r != null && row.avg_r === bestTpAvgR;
return (
<tr key={row.tp_pct} 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.tp_pct}%
</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.hit_rate)}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
</tr>
);
})}
{report.gate_ablation.map((row) => (
<tr
key={row.variant}
className={`border-b border-white/[0.04] ${row.variant === 'all_floors' ? 'bg-blue-400/10' : ''}`}
>
<td className="px-4 py-2.5 font-medium text-gray-200">
{ABLATION_LABELS[row.variant] ?? row.variant}
</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.hit_rate)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.net_avg_r ?? null)}`}>
{fmtR(row.net_avg_r ?? null)}
</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.hold_net_avg_r ?? null)}`}>
{fmtR(row.hold_net_avg_r ?? null)}
</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.hold_total_r ?? null)}`}>
{fmtR(row.hold_total_r ?? null)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{report.trailing_sweep && report.trailing_sweep.length > 0 && (
{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">
Trailing-stop exit
Time-based exit
</p>
<p className="mb-2 text-[11px] text-gray-500">
Let it run, but exit when price gives back <span className="text-gray-300">X% from its
peak</span> (the stop only ratchets up, never below the initial stop). Captures the tail
without the fixed take-profit's all-or-nothing miss, and protects gains. In R vs the initial
risk. <span className="text-gray-300">Win Rate = share closed in profit.</span> ★ = best avg R.
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">Trail</th>
<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.trailing_sweep.map((row) => {
const best = row.avg_r != null && row.avg_r === bestTrailAvgR;
{report.time_exit_sweep.map((row) => {
const best = netOrGross(row) != null && netOrGross(row) === bestTimeAvgR;
return (
<tr key={row.trail_pct} className={`border-b border-white/[0.04] ${best ? 'bg-emerald-400/[0.06]' : ''}`}>
<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.trail_pct}%
{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 font-semibold ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</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>
);
})}
@@ -336,46 +468,123 @@ export function BacktestPanel() {
</div>
)}
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Probability calibration
</p>
<p className="mb-2 text-[11px] text-gray-500">
Do targets we call “X% likely” actually hit that often? Realized below predicted =
the model is over-confident.
</p>
{report.calibration.length === 0 ? (
<Callout variant="empty">Not enough resolved setups to calibrate.</Callout>
) : (
{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">Predicted Bucket</th>
<th className="px-4 py-2.5 text-right">Setups</th>
<th className="px-4 py-2.5 text-right">Avg Predicted</th>
<th className="px-4 py-2.5 text-right">Realized Hit Rate</th>
<th className="px-4 py-2.5">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>
{report.calibration.map((row) => {
const over = row.realized_hit_rate < row.predicted_avg;
return (
<tr key={row.bucket} className="border-b border-white/[0.04]">
<td className="px-4 py-2.5 text-gray-200">{row.bucket}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.n}</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{row.predicted_avg.toFixed(0)}%</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${over ? 'text-amber-400' : 'text-emerald-400'}`}>
{row.realized_hit_rate.toFixed(0)}%
{(
[
['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>
);
})}
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{report.strategy_variants && report.strategy_variants.variants.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Strategy variants
</p>
<p className="mb-2 text-[11px] text-gray-500">
{report.strategy_variants.note ?? 'Research-only portfolio variants.'}
</p>
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Variant</th>
<th className="px-4 py-2.5 text-right">Rank</th>
<th className="px-4 py-2.5 text-right">Cutoff</th>
<th className="px-4 py-2.5 text-right">Max Pos</th>
<th className="px-4 py-2.5 text-right">Risk</th>
<th className="px-4 py-2.5 text-right">CAGR</th>
<th className="px-4 py-2.5 text-right">Max DD</th>
<th className="px-4 py-2.5 text-right">Sharpe</th>
<th className="px-4 py-2.5 text-right">Total Ret</th>
<th className="px-4 py-2.5 text-right">Trades</th>
<th className="px-4 py-2.5 text-right">Skipped</th>
</tr>
</thead>
<tbody>
{report.strategy_variants.variants.map((row: BacktestStrategyVariant) => (
<tr key={row.variant} className="border-b border-white/[0.04]">
<td className="px-4 py-2.5 font-medium text-gray-200">{row.label}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.ranking}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.cutoff.toFixed(0)}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.max_positions}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">
{`${row.risk_per_trade_pct.toFixed(1)}%`}
</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.cagr_pct)}`}>{fmtSignedPct(row.cagr_pct)}</td>
<td className="num px-4 py-2.5 text-right text-amber-400">{row.max_drawdown_pct.toFixed(1)}%</td>
<td className="num px-4 py-2.5 text-right text-gray-200">
{row.sharpe === null ? '—' : row.sharpe.toFixed(2)}
</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_return_pct)}`}>{fmtSignedPct(row.total_return_pct)}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.trades}</td>
<td className="num px-4 py-2.5 text-right text-gray-500">{row.skipped_book_full}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{report.signal_eval && report.signal_eval.length > 0 && (
<div>
@@ -24,7 +24,7 @@ export interface FieldPoint {
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 (the ticker's 12-1 momentum percentile)
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';
@@ -186,7 +186,7 @@ export default function StandingMatrix({
<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="Momentum percentile" value={`${Math.round(here.momentum)}`} />
<StatRow label="Residual momentum percentile" value={`${Math.round(here.momentum)}`} />
{confidence != null && <StatRow label="Long confidence" value={`${Math.round(confidence)}%`} />}
</div>
</>
@@ -206,7 +206,7 @@ export default function StandingMatrix({
</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 momentum) above it qualifies for a top pick. Click any peer to open it.
activation gate ({Math.round(gate)}th-pct residual momentum) above it qualifies for a top pick. Click any peer to open it.
</p>
</div>
);
+1 -1
View File
@@ -5,7 +5,7 @@ import type { DimensionScoreDetail, CompositeBreakdown } from '../../lib/types';
interface ScoreCardProps {
compositeScore: number | null;
dimensions: DimensionScoreDetail[];
compositeBreakdown?: CompositeBreakdown;
compositeBreakdown?: CompositeBreakdown | null;
/** Hide the composite ring/header when the composite is shown elsewhere
* (e.g. the Standing matrix) and this card only carries the dimension detail. */
showComposite?: boolean;
+20 -11
View File
@@ -2,6 +2,13 @@ import type { ActivationConfig, TradeSetup } from './types';
const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'short' | 'neutral' {
if (!action || action === 'NEUTRAL') return 'neutral';
if (action.startsWith('LONG')) return 'long';
if (action.startsWith('SHORT')) return 'short';
return 'neutral';
}
export function bestTargetProbability(setup: TradeSetup): number {
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0;
}
@@ -33,17 +40,20 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
return false;
}
if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
// Cross-sectional momentum is the core selection (long-only). While the gate is
// active, shorts never qualify; the percentile floor is enforced only when a
// percentile is attached, otherwise defer to the floors.
// Residual cross-sectional momentum is the core selection (long-only). While
// the gate is active, shorts never qualify; the percentile floor is enforced
// only when a percentile is attached, otherwise defer to the floors.
if (config.min_momentum_percentile > 0) {
if (setup.direction === 'short') return false;
if (setup.momentum_percentile != null && setup.momentum_percentile < config.min_momentum_percentile) {
return false;
}
}
// NEUTRAL = "no clear setup" — not actionable, so by default it doesn't qualify.
if (config.exclude_neutral && (setup.recommended_action ?? 'NEUTRAL') === 'NEUTRAL') return false;
// NEUTRAL = "no clear setup"; an opposite action means this setup is counter-bias.
if (config.exclude_neutral) {
const actionDir = actionDirection(setup.recommended_action);
if (actionDir === 'neutral' || actionDir !== setup.direction) return false;
}
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
return false;
}
@@ -53,9 +63,9 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
/**
* Symbol of the current single 'top pick' — the #1 row the dashboard highlights:
* the highest 12-1 momentum percentile among qualified setups (or among all
* setups when none qualify). Returns null when there are no setups. Keep in step
* with the Top Setups ranking in DashboardPage.
* 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,
@@ -64,8 +74,7 @@ export function topPickSymbol(
const all = trades ?? [];
if (all.length === 0) return null;
const qualified = activation ? all.filter((t) => qualifiesSetup(t, activation)) : [];
const pool = qualified.length > 0 ? qualified : all;
const top = [...pool].sort(
const top = [...qualified].sort(
(a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity),
)[0];
return top?.symbol ?? null;
@@ -74,7 +83,7 @@ export function topPickSymbol(
/** Short human summary of the active gate, e.g. for tooltips/labels. */
export function activationSummary(config: ActivationConfig): string {
const parts = [];
if (config.min_momentum_percentile > 0) parts.push(`top ${(100 - config.min_momentum_percentile).toFixed(0)}% momentum`);
if (config.min_momentum_percentile > 0) parts.push(`top ${(100 - config.min_momentum_percentile).toFixed(0)}% residual momentum`);
parts.push(`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`);
if (config.exclude_neutral) parts.push('directional');
if (config.require_high_conviction) parts.push('high-conviction');
+121 -26
View File
@@ -96,7 +96,7 @@ export interface ScoreResponse {
dimensions: DimensionScoreDetail[];
missing_dimensions: string[];
computed_at: string | null;
composite_breakdown?: CompositeBreakdown;
composite_breakdown?: CompositeBreakdown | null;
}
export interface DimensionScoreDetail {
@@ -104,12 +104,13 @@ export interface DimensionScoreDetail {
score: number;
is_stale: boolean;
computed_at: string | null;
breakdown?: ScoreBreakdown;
breakdown?: ScoreBreakdown | null;
}
export interface RankingEntry {
symbol: string;
composite_score: number;
composite_stale: boolean;
dimensions: DimensionScoreDetail[];
}
@@ -140,9 +141,18 @@ export interface TradeSetup {
evaluated_at: string | null;
current_price: number | null;
momentum_percentile?: number | null;
context_as_of?: TradeSetupContextAsOf | null;
recommendation_summary?: RecommendationSummary;
}
export interface TradeSetupContextAsOf {
setup_detected_at: string;
score_computed_at: string | null;
sentiment_at: string | null;
price_date: string | null;
price_updated_at: string | null;
}
// Performance / outcome statistics
export interface OutcomeBucketStats {
total: number;
@@ -211,14 +221,15 @@ export interface PaperTrade {
benchmark_return_pct: number | null;
alpha_pct: number | null;
alpha_usd: number | null;
close_reason: 'trailing' | 'stop' | 'target' | 'manual' | null;
close_reason: 'time' | 'trailing' | 'stop' | 'target' | 'manual' | null;
trailing_stop: number | null;
trailing_distance_pct: number | null;
}
export interface ExitPolicy {
mode: 'trailing' | 'target';
mode: 'time' | 'trailing' | 'target';
trailing_pct: number;
hold_days: number;
}
export interface BacktestBucket {
@@ -229,35 +240,110 @@ export interface BacktestBucket {
hit_rate: number | null;
avg_r: number | null;
total_r: number | null;
}
export interface BacktestCalibrationRow {
bucket: string;
n: number;
predicted_avg: number;
realized_hit_rate: number;
// Net of transaction costs — optional so a stale cached report still renders.
net_avg_r?: number | null;
net_total_r?: number | null;
best_r?: number | null;
worst_r?: number | null;
avg_hold_days?: number | null;
net_r_per_day?: number | null;
// Robustness: distribution shape, and expectancy without the top winners.
median_net_r?: number | null;
profit_factor?: number | null;
net_avg_r_ex_top5?: number | null;
}
export interface BacktestSweepRow extends BacktestBucket {
min_momentum_percentile: number;
}
export interface BacktestTakeProfitRow {
tp_pct: number;
total: number;
wins: number;
hit_rate: number | null;
avg_r: number | null;
total_r: number | null;
}
export interface BacktestTrailingRow {
trail_pct: 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 {
@@ -276,15 +362,24 @@ export interface BacktestReport {
tickers: number;
candidates: number;
qualified: number;
params: { step_days: number; horizon_days: number; min_lookback: number };
params: {
step_days: number;
horizon_days: number;
min_lookback: number;
cost_per_side_pct?: number;
};
overall_qualified: BacktestBucket;
overall_all: BacktestBucket;
by_direction: Record<string, BacktestBucket>;
min_momentum_percentile: number;
sweep: BacktestSweepRow[];
take_profit_sweep?: BacktestTakeProfitRow[];
trailing_sweep?: BacktestTrailingRow[];
calibration: BacktestCalibrationRow[];
gate_ablation?: BacktestGateAblationRow[];
gate_ablation_note?: string;
time_exit_sweep?: BacktestTimeExitRow[];
portfolio_sim?: BacktestPortfolioSim;
strategy_variants?: BacktestStrategyVariants;
recommendation?: BacktestRecommendation;
research_recommendation?: BacktestResearchRecommendation;
signal_eval?: BacktestSignalEvalRow[];
signal_eval_note?: string;
note: string;
+6 -9
View File
@@ -76,15 +76,12 @@ export default function DashboardPage() {
[trades.data, activation.data],
);
// Show qualified setups first; fall back to the full list when none qualify.
// Rank by 12-1 momentum percentile so the strongest names sit at the top.
const showingQualified = qualifiedSetups.length > 0;
// Rank only actionable/qualified setups by residual 12-1 momentum percentile.
const topSetups: TradeSetup[] = useMemo(() => {
const pool = showingQualified ? qualifiedSetups : trades.data ?? [];
return [...pool]
return [...qualifiedSetups]
.sort((a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity))
.slice(0, 5);
}, [showingQualified, qualifiedSetups, trades.data]);
}, [qualifiedSetups]);
const topWatchlist = useMemo(
() =>
@@ -197,12 +194,12 @@ export default function DashboardPage() {
<div className="xl:col-span-3">
<Section
title="Top Setups"
hint={showingQualified ? 'ranked by expected value' : 'none qualified — showing all'}
hint="qualified and ranked by residual momentum"
>
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
{trades.data && topSetups.length === 0 && (
<Callout variant="empty">No active setups. Run the scanner from the Signals page.</Callout>
<Callout variant="empty">No qualified actionable setups right now.</Callout>
)}
{topSetups.length > 0 && (
<div className="glass overflow-x-auto">
@@ -214,7 +211,7 @@ export default function DashboardPage() {
<th className="px-4 py-3 text-right">Entry</th>
<th className="px-4 py-3 text-right">R:R</th>
<th className="px-4 py-3 text-right">Target&nbsp;Prob</th>
<th className="px-4 py-3 text-right">Momentum</th>
<th className="px-4 py-3 text-right">Residual Mom.</th>
<th className="hidden px-4 py-3 md:table-cell">Action</th>
</tr>
</thead>
+4 -4
View File
@@ -216,7 +216,7 @@ export default function TickerDetailPage() {
[setupsForSymbol],
);
// Standing matrix: this ticker's momentum percentile + long confidence (from its
// 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;
@@ -296,7 +296,7 @@ export default function TickerDetailPage() {
<StatusPill
tone="blue"
label="★ Top Pick"
title="Current top pick — highest-momentum qualified setup right now"
title="Current top pick — highest residual-momentum qualified setup right now"
/>
)}
{hasOpenTrade && (
@@ -346,8 +346,8 @@ export default function TickerDetailPage() {
/>
{/* Chart — always visible */}
<Section title="Price Chart">
{ohlcv.isLoading && <SkeletonCard className="h-[400px]" />}
<Section title="Price & Volume">
{ohlcv.isLoading && <SkeletonCard className="h-[440px]" />}
{ohlcv.isError && (
<SectionError
message={ohlcv.error instanceof Error ? ohlcv.error.message : 'Failed to load OHLCV data'}
+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.
+1 -1
View File
@@ -27,7 +27,7 @@ class TestActivationConfig:
assert config == {
"min_momentum_percentile": 80.0,
"min_rr": 1.2,
"min_confidence": 55.0,
"min_confidence": 0.0, # off — the July 2026 ablation showed it adds nothing
"require_high_conviction": False,
"exclude_conflicts": False,
"exclude_neutral": True,
+78
View File
@@ -6,6 +6,7 @@ from datetime import date, datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from sqlalchemy import select
from app.models.alert import AlertLog
from app.models.ohlcv import OHLCVRecord
@@ -113,6 +114,26 @@ async def test_score_drop_seeds_then_alerts(session):
assert await svc._watermark(session, "AAA") == 60.0
def test_format_qualified_includes_current_price_and_target_move():
text = svc._format_qualified({
"symbol": "AAPL",
"direction": "long",
"current_price": 196.42,
"entry_price": 195.80,
"target": 207.50,
"stop_loss": 190.20,
"rr_ratio": 2.1,
"confidence_score": 76.0,
"targets": [{"probability": 63.0}],
})
assert "now 196.42" in text
assert "entry 195.80" in text
assert "target 207.50 (+5.6%)" in text
assert "stop 190.20" in text
assert "P(target) 63%" in text
async def _add_ticker(session, symbol: str, *, watchlisted: bool, close: float,
levels: list[tuple[float, str, int]]) -> int:
user = await session.get(User, 1)
@@ -142,6 +163,8 @@ async def test_sr_proximity_merges_close_levels_to_one_alert(session):
cvx = [m for m in msgs if "CVX" in m[1]]
assert len(cvx) == 1
assert "183.00185.00" in cvx[0][1]
assert "now 182.00 -> 183.00185.00 (+0.5%)" in cvx[0][1]
assert "strength 100" in cvx[0][1]
async def test_sr_proximity_skips_non_watchlist_unqualified(session):
@@ -172,6 +195,61 @@ async def test_dispatch_no_credentials(session):
assert res["status"] == "no_credentials"
async def test_dispatch_bundles_discovery_alerts_and_logs_each_item(session, monkeypatch):
async def fake_collect_qualified(_db):
return [
("qualified:AAPL:long", "🟢 <b>AAPL LONG</b> | now 196.42 | target 207.50 (+5.6%)"),
("qualified:TSLA:short", "🔴 <b>TSLA SHORT</b> | now 292.10 | target 276.50 (-5.3%)"),
]
async def fake_collect_sr(_db):
return [
("sr:MSFT:resistance", "📍 <b>MSFT</b> resistance | now 508.20 -> 512.00 (+0.7%)"),
]
sent: list[str] = []
async def fake_send(_client, _token, _chat_id, text):
sent.append(text)
monkeypatch.setattr(svc, "_collect_qualified", fake_collect_qualified)
monkeypatch.setattr(svc, "_collect_sr_proximity", fake_collect_sr)
monkeypatch.setattr(svc, "_send", fake_send)
await svc.update_alert_config(
session,
enabled=True,
bot_token="token",
telegram_chat_id="chat",
score_drop_enabled=False,
digest_enabled=False,
regime_quadrant_enabled=False,
trade_closed_enabled=False,
)
res = await svc.dispatch_alerts(session)
assert res == {"status": "ok", "sent": 1, "candidates": 3}
assert len(sent) == 1
assert "<b>Signal run</b> — 3 new alert(s)" in sent[0]
assert "<b>Qualified setups</b>" in sent[0]
assert "<b>Near support/resistance</b>" in sent[0]
assert "AAPL LONG" in sent[0]
assert "MSFT" in sent[0]
rows = (
await session.execute(
select(AlertLog.alert_type, AlertLog.dedup_key)
.order_by(AlertLog.alert_type, AlertLog.dedup_key)
)
).all()
assert rows == [
("qualified", "qualified:AAPL:long"),
("qualified", "qualified:TSLA:short"),
("sr_proximity", "sr:MSFT:resistance"),
]
async def _add_closed_trade(session, symbol: str, reason: str, *,
close: float = 110.0, closed_hours_ago: float = 1.0) -> None:
if await session.get(User, 1) is None:
+512 -89
View File
@@ -25,7 +25,15 @@ async def session():
yield s
def _cand(prob: float, outcome: str, rr: float, qualified: bool = True, direction: str = "long") -> dict:
def _cand(
prob: float,
outcome: str,
rr: float,
qualified: bool = True,
direction: str = "long",
risk_pct: float = 0.05,
hold_days: int = 10,
) -> dict:
target_hit = outcome == OUTCOME_TARGET_HIT
realized = rr if target_hit else (0.0 if outcome == OUTCOME_EXPIRED else -1.0)
return {
@@ -36,100 +44,406 @@ def _cand(prob: float, outcome: str, rr: float, qualified: bool = True, directio
"realized_r": realized,
"qualified": qualified,
"direction": direction,
"risk_pct": risk_pct,
"hold_days": hold_days,
}
def _bar(high: float, low: float, close: float) -> SimpleNamespace:
return SimpleNamespace(high=high, low=low, close=close)
# 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
class TestTakeProfitPrimitives:
def test_long_tp_reachable_before_stop(self):
risk, stopped, mfe, close_pct = bt._tp_primitives("long", 100.0, 95.0, [_bar(109, 101, 108)], 30)
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 stopped is False
assert mfe == pytest.approx(0.09)
assert close_pct == pytest.approx(0.08)
assert stop_day is None
def test_long_stop_zeroes_mfe(self):
# Low pierces the stop on the only bar → loss, nothing banked before it.
risk, stopped, mfe, close_pct = bt._tp_primitives("long", 100.0, 95.0, [_bar(101, 94, 96)], 30)
assert stopped is True
assert mfe == pytest.approx(0.0)
assert close_pct == pytest.approx(-0.04)
def test_long_drift_no_trigger(self):
bars = [_bar(102, 99, 101), _bar(103, 100, 102)]
risk, stopped, mfe, close_pct = bt._tp_primitives("long", 100.0, 95.0, bars, 30)
assert stopped is False
assert mfe == pytest.approx(0.03)
assert close_pct == pytest.approx(0.02)
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):
# short entry 100, stop 105; price falls → favourable = (entry - low)/entry
risk, stopped, mfe, close_pct = bt._tp_primitives("short", 100.0, 105.0, [_bar(101, 92, 93)], 30)
assert risk == pytest.approx(0.05)
assert stopped is False
assert mfe == pytest.approx(0.08)
assert close_pct == pytest.approx(0.07)
_, stop_day = bt._risk_and_stop_day("short", 100.0, 105.0, [_bar(106, 101, 104)], 30)
assert stop_day == 1
class TestTakeProfitBucket:
def test_bucket_mix(self):
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 = [
{"risk_pct": 0.05, "mfe_pct": 0.09, "tp_stopped": False, "tp_close_pct": 0.08}, # +1.6R win
{"risk_pct": 0.05, "mfe_pct": 0.02, "tp_stopped": True, "tp_close_pct": -0.04}, # -1R stop
{"risk_pct": 0.05, "mfe_pct": 0.03, "tp_stopped": False, "tp_close_pct": 0.01}, # +0.2R timeout
{"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._take_profit_bucket(cands, 0.08)
b = bt._time_exit_bucket(cands, 5)
assert b["hold_days"] == 5
assert b["total"] == 3
assert b["wins"] == 1
assert b["hit_rate"] == pytest.approx(33.3, abs=0.1)
assert b["total_r"] == pytest.approx(0.8, abs=0.01)
assert b["avg_r"] == pytest.approx(0.267, abs=0.01)
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_zero_risk_skipped(self):
cands = [{"risk_pct": 0.0, "mfe_pct": 0.2, "tp_stopped": False, "tp_close_pct": 0.1}]
b = bt._take_profit_bucket(cands, 0.08)
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
class TestTrailingExits:
def test_locks_gain_on_pullback(self):
# Runs to 120, then a 10% trail (from peak 120 → 108) is pierced on the drop.
res = bt._trailing_exits("long", 100.0, 90.0, (0.10,), [_bar(120, 110, 118), _bar(130, 100, 105)], 30)
assert res[10] == pytest.approx(0.8) # (108-100)/100 / 0.10 risk
def test_initial_stop_caps_loss(self):
# Trail (20%) is looser than the initial stop → initial stop governs = -1R.
res = bt._trailing_exits("long", 100.0, 90.0, (0.20,), [_bar(101, 89, 90)], 30)
assert res[20] == pytest.approx(-1.0)
def test_timeout_exits_at_close(self):
res = bt._trailing_exits("long", 100.0, 90.0, (0.20,), [_bar(105, 98, 104), _bar(106, 100, 105)], 30)
assert res[20] == pytest.approx(0.5) # close 105 → +5% / 10% risk
def test_multiple_widths_one_pass(self):
# Tighter trail locks in more here (exit at 114 vs 108).
res = bt._trailing_exits("long", 100.0, 90.0, (0.10, 0.05), [_bar(120, 110, 118), _bar(130, 100, 105)], 30)
assert res[10] == pytest.approx(0.8)
assert res[5] == pytest.approx(1.4)
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 TestTrailingBucket:
def test_bucket(self):
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 = [
{"trail_r": {5: 1.4, 10: 0.8}},
{"trail_r": {5: -1.0, 10: -1.0}},
{"trail_r": {5: 0.5, 10: 0.5}},
_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
]
b = bt._trailing_bucket(cands, 5)
assert b["total"] == 3
assert b["wins"] == 2
assert b["win_rate"] == pytest.approx(66.7, abs=0.1)
assert b["total_r"] == pytest.approx(0.9, abs=0.01)
assert b["avg_r"] == pytest.approx(0.3, abs=0.01)
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():
@@ -149,6 +463,18 @@ def test_bucket_stats_counts_and_expectancy():
# avg R = (3 + 2 - 1 + 0) / 4 = 1.0
assert s["avg_r"] == 1.0
assert s["total_r"] == 4.0
# net = gross minus a 0.04R round trip per candidate (risk_pct 0.05)
assert s["net_avg_r"] == pytest.approx(1.0 - _COST_R_005, abs=0.001)
assert s["net_total_r"] == pytest.approx(4.0 - 4 * _COST_R_005, abs=0.01)
assert s["best_r"] == 3.0
assert s["worst_r"] == -1.0
assert s["avg_hold_days"] == 10.0
assert s["net_r_per_day"] == pytest.approx((1.0 - _COST_R_005) / 10.0, abs=0.001)
# robustness: net rs are [2.96, 1.96, -1.04, -0.04]
assert s["median_net_r"] == pytest.approx(0.96, abs=0.001)
assert s["profit_factor"] == pytest.approx(4.92 / 1.08, abs=0.01)
# ex-top-5%: ceil(4 * 0.05) = 1 winner trimmed → mean of the remaining three
assert s["net_avg_r_ex_top5"] == pytest.approx((1.96 - 1.04 - 0.04) / 3, abs=0.001)
def test_bucket_stats_empty():
@@ -156,26 +482,102 @@ def test_bucket_stats_empty():
assert s["total"] == 0
assert s["hit_rate"] is None
assert s["avg_r"] is None
assert s["net_avg_r"] is None
def test_calibration_buckets():
cands = [
_cand(65, OUTCOME_TARGET_HIT, 2.0),
_cand(62, OUTCOME_STOP_HIT, 2.0),
_cand(15, OUTCOME_STOP_HIT, 2.0),
]
rows = bt._calibration(cands)
by_bucket = {r["bucket"]: r for r in rows}
assert by_bucket["60-80%"]["n"] == 2
assert by_bucket["60-80%"]["realized_hit_rate"] == 50.0 # 1 of 2 hit
assert by_bucket["0-20%"]["n"] == 1
assert by_bucket["0-20%"]["realized_hit_rate"] == 0.0
def test_bucket_stats_no_risk_pct_means_no_cost():
c = _cand(50, OUTCOME_TARGET_HIT, 2.0)
del c["risk_pct"]
s = bt._bucket_stats([c])
assert s["net_avg_r"] == s["avg_r"]
assert s["net_total_r"] == s["total_r"]
def test_build_recommendation_reads_the_report():
report = {
"overall_qualified": {"net_avg_r": 0.13, "net_avg_r_ex_top5": 0.05},
"time_exit_sweep": [
{"hold_days": 21, "net_avg_r": 0.38},
{"hold_days": 30, "net_avg_r": 0.50, "net_avg_r_ex_top5": 0.21},
],
"gate_ablation": [
{"variant": "all_floors", "total": 100, "hold_net_avg_r": 0.50},
{"variant": "no_confidence_floor", "total": 130, "hold_net_avg_r": 0.49},
{"variant": "no_rr_floor", "total": 400, "hold_net_avg_r": 0.34},
{"variant": "no_neutral_exclusion", "total": 120, "hold_net_avg_r": 0.46},
],
"sweep": [
{"min_momentum_percentile": 80.0, "net_avg_r": 0.13, "total": 100},
{"min_momentum_percentile": 60.0, "net_avg_r": 0.05, "total": 300},
{"min_momentum_percentile": 0.0, "net_avg_r": -0.12, "total": 1000},
],
"portfolio_sim": {"policies": [
{"policy": "target", "cagr_pct": 23.7, "total_return_pct": 134.8,
"spy_return_pct": 95.9, "max_drawdown_pct": 20.7},
{"policy": "hold", "cagr_pct": 31.9, "total_return_pct": 203.6,
"spy_return_pct": 95.9, "max_drawdown_pct": 21.2},
]},
}
rec = bt._build_recommendation(report)
by_topic: dict[str, list[str]] = {}
for item in rec["items"]:
by_topic.setdefault(item["topic"], []).append(item["text"])
assert rec["headline"] is not None and "hold 30" in rec["headline"]
assert any("hold 30 trading days" in t for t in by_topic["exit"])
gate_texts = " | ".join(by_topic["gate"])
assert "confidence floor adds nothing" in gate_texts
assert "keep the R:R floor" in gate_texts
assert "keep the NEUTRAL exclusion" in gate_texts
assert "80" in by_topic["cutoff"][0]
assert "beats" in by_topic["benchmark"][0]
# robustness is judged under the RECOMMENDED exit (the 30d hold), not the
# target model the recommendation advises abandoning
assert any(
"not a handful of outliers" in t and "under the recommended 30d hold" in t
for t in by_topic["robustness"]
)
def test_build_recommendation_flags_outlier_dependence():
rec = bt._build_recommendation({
"overall_qualified": {"net_avg_r": 0.13, "net_avg_r_ex_top5": -0.02},
})
robustness = [i["text"] for i in rec["items"] if i["topic"] == "robustness"]
assert robustness and "WARNING" in robustness[0]
def test_window_setups_too_short_returns_empty():
assert bt._window_setups([], {}, {}) == []
def test_replay_ticker_candidates_carry_gate_fields():
"""The ablation recomputes floors from candidate fields — a candidate missing
action/risk_level silently zeroes the ablation rows (July 2026 regression)."""
from app.services.admin_service import ACTIVATION_DEFAULTS
from app.services.recommendation_service import DEFAULT_RECOMMENDATION_CONFIG
base = date(2025, 1, 1)
bars = []
for i in range(160):
close = 100.0 + 8.0 * math.sin(i / 6.0)
bars.append(SimpleNamespace(
date=base + timedelta(days=i),
open=close,
high=close + 1.5,
low=close - 1.5,
close=close,
volume=1_000_000 + (i % 5) * 1000,
))
cands = bt._replay_ticker(
"OSC", bars, dict(DEFAULT_RECOMMENDATION_CONFIG), dict(ACTIVATION_DEFAULTS)
)
assert cands, "expected the oscillating series to produce candidates"
for c in cands:
assert c.get("action") is not None
assert "risk_level" in c
async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None:
t = Ticker(symbol=symbol)
session.add(t)
@@ -202,16 +604,37 @@ async def test_run_backtest_smoke(session):
# well-formed report
assert report["tickers"] == 1
assert isinstance(report["candidates"], int)
for key in ("overall_qualified", "overall_all", "by_direction", "calibration", "sweep"):
for key in (
"overall_qualified", "overall_all", "by_direction", "sweep",
"gate_ablation", "time_exit_sweep", "portfolio_sim", "strategy_variants",
"recommendation", "research_recommendation",
):
assert key in report
# the oscillating series should yield at least some resolved setups
assert report["candidates"] >= 1
# cost assumption is reported, and every bucket carries net numbers
assert report["params"]["cost_per_side_pct"] == pytest.approx(bt.COST_PER_SIDE * 100)
assert "net_avg_r" in report["overall_all"]
# ablation baseline reproduces the qualified set exactly, and every row
# carries the hold-to-horizon grading alongside the target model
ablation = {r["variant"]: r for r in report["gate_ablation"]}
assert ablation["all_floors"]["total"] == report["overall_qualified"]["total"]
for row in report["gate_ablation"]:
assert "hold_net_avg_r" in row
# time-exit sweep covers the configured hold lengths
assert [r["hold_days"] for r in report["time_exit_sweep"]] == list(bt.TIME_EXIT_DAYS)
# portfolio simulation section is always present (policies may be empty
# when nothing qualifies)
assert "portfolio_sim" in report
assert isinstance(report["portfolio_sim"]["policies"], list)
assert report["portfolio_sim"]["params"]["max_positions"] == bt.SIM_MAX_POSITIONS
assert isinstance(report["strategy_variants"]["variants"], list)
# sweep: lowering the momentum-percentile cutoff can only add qualifiers
sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True)
counts = [r["total"] for r in sweep]
assert counts == sorted(counts) # ascending as threshold descends
# every calibration row is internally consistent
for row in report["calibration"]:
assert 0 <= row["realized_hit_rate"] <= 100
assert row["n"] >= 1
+51 -4
View File
@@ -1,4 +1,4 @@
"""Unit tests for the cross-sectional 12-1 momentum ranking."""
"""Unit tests for the cross-sectional activation momentum ranking."""
from __future__ import annotations
@@ -35,6 +35,21 @@ async def _seed(session, symbol: str, rate: float, n: int = 280) -> None:
await session.commit()
async def _seed_closes(session, symbol: str, closes: list[float]) -> None:
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
base = date(2024, 1, 1)
for i, close in enumerate(closes):
session.add(OHLCVRecord(
ticker_id=t.id,
date=base + timedelta(days=i),
open=close, high=close, low=close, close=close,
volume=1_000_000,
))
await session.commit()
def test_compute_momentum_insufficient_history():
assert ms.compute_12_1_momentum([100.0] * 100) is None
@@ -47,7 +62,11 @@ def test_compute_momentum_value():
assert m > 0
async def test_ranks_universe_into_percentiles(session):
async def test_ranks_universe_into_raw_percentiles_when_benchmark_missing(session, monkeypatch):
async def no_benchmark(_db):
return {}
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
await _seed(session, "HIGH", rate=1.010) # strong uptrend → top momentum
await _seed(session, "MID", rate=1.002)
await _seed(session, "LOW", rate=0.999) # declining → bottom momentum
@@ -58,7 +77,31 @@ async def test_ranks_universe_into_percentiles(session):
assert pct["LOW"] == 0.0
async def test_short_history_ticker_is_unranked(session):
async def test_ranks_universe_into_residual_percentiles_when_benchmark_available(session, monkeypatch):
base = date(2024, 1, 1)
n = 280
benchmark = {base + timedelta(days=i): 100.0 * (1.001 ** i) for i in range(n)}
async def with_benchmark(_db):
return benchmark
monkeypatch.setattr(ms, "_load_activation_benchmark", with_benchmark)
market = [benchmark[base + timedelta(days=i)] for i in range(n)]
await _seed_closes(session, "DRIFT", [market[i] * (1.0008 ** i) for i in range(n)])
await _seed_closes(session, "BETA", market)
await _seed_closes(session, "LAG", [market[i] * (0.9992 ** i) for i in range(n)])
pct = await ms.compute_momentum_percentiles(session)
assert pct["DRIFT"] == 100.0
assert pct["BETA"] == 50.0
assert pct["LAG"] == 0.0
async def test_short_history_ticker_is_unranked(session, monkeypatch):
async def no_benchmark(_db):
return {}
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
await _seed(session, "LONG", rate=1.005)
await _seed(session, "SHORTHX", rate=1.005, n=100) # < 1y → no momentum
@@ -67,5 +110,9 @@ async def test_short_history_ticker_is_unranked(session):
assert "SHORTHX" not in pct
async def test_empty_universe_returns_empty(session):
async def test_empty_universe_returns_empty(session, monkeypatch):
async def no_benchmark(_db):
return {}
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
assert await ms.compute_momentum_percentiles(session) == {}
+62 -4
View File
@@ -205,9 +205,12 @@ class TestTrailingClose:
async def test_exit_policy_defaults_and_round_trip(session):
assert await svc.get_exit_policy(session) == {"mode": "trailing", "trailing_pct": 12.0}
updated = await svc.set_exit_policy(session, mode="target", trailing_pct=15.0)
assert updated == {"mode": "target", "trailing_pct": 15.0}
# 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"
@@ -216,10 +219,64 @@ async def test_exit_policy_rejects_bad_input(session):
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):
tid = await _seed(session, "AAA", close=100.0) # default policy: trailing 12%
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
@@ -236,6 +293,7 @@ async def test_manual_close_sets_reason(session):
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
+10
View File
@@ -31,6 +31,7 @@ STRICT_GATE = {
def _setup(**kwargs):
base = dict(
direction="long",
rr_ratio=3.0,
confidence_score=80.0,
recommended_action="LONG_HIGH",
@@ -124,6 +125,15 @@ class TestExcludeNeutral:
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
+396 -1
View File
@@ -11,20 +11,29 @@ Zero-candidate and single-candidate scenarios must produce identical results.
from __future__ import annotations
import json
from datetime import date, datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest
from hypothesis import given, settings, HealthCheck, strategies as st
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ohlcv import OHLCVRecord
from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.models.score import CompositeScore
from app.models.score import CompositeScore, DimensionScore
from app.models.sentiment import SentimentScore
from app.services.rr_scanner_service import scan_ticker, get_trade_setups
def _as_utc(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# Session fixtures
# ---------------------------------------------------------------------------
@@ -431,3 +440,389 @@ async def test_get_trade_setups_sorting_rr_desc_composite_desc(db_session: Async
f"Expected symbol order ['SORTD', 'SORTC', 'SORTB', 'SORTA'], "
f"got {symbols}"
)
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context
(bullish sentiment, composite 96) that yields live confidence 97."""
old_scan = datetime(2026, 7, 1, tzinfo=timezone.utc)
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
old_reasoning = (
"LONG (high confidence): 82% with aligned signals "
"(technical=88, momentum=60, sentiment=neutral)."
)
ticker = Ticker(symbol="TTWO")
db_session.add(ticker)
await db_session.flush()
stale_setup = TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=235.0,
stop_loss=220.0,
target=265.0,
rr_ratio=2.0,
composite_score=71.8,
detected_at=old_scan,
confidence_score=82.0,
recommended_action="LONG_HIGH",
reasoning=old_reasoning,
risk_level="High",
)
db_session.add(stale_setup)
db_session.add_all([
DimensionScore(
ticker_id=ticker.id,
dimension="technical",
score=88.0,
is_stale=False,
computed_at=current,
),
DimensionScore(
ticker_id=ticker.id,
dimension="momentum",
score=60.0,
is_stale=False,
computed_at=current,
),
DimensionScore(
ticker_id=ticker.id,
dimension="fundamental",
score=95.0,
is_stale=False,
computed_at=current,
),
DimensionScore(
ticker_id=ticker.id,
dimension="sentiment",
score=85.0,
is_stale=False,
computed_at=current,
),
CompositeScore(
ticker_id=ticker.id,
score=96.0,
is_stale=False,
weights_json="{}",
computed_at=current,
),
SentimentScore(
ticker_id=ticker.id,
classification="bullish",
confidence=85,
source="test",
timestamp=current,
reasoning="",
citations_json="[]",
),
])
await db_session.flush()
return stale_setup
@pytest.mark.asyncio
async def test_live_recommendation_payload_uses_current_score_and_sentiment(
db_session: AsyncSession,
):
"""Latest setup payload should not show stale scan text when score context moved."""
stale_setup = await _seed_stale_setup_with_current_scores(db_session)
old_reasoning = stale_setup.reasoning
rows = await get_trade_setups(
db_session,
symbol="TTWO",
live_recommendation=True,
)
assert len(rows) == 1
row = rows[0]
assert row["composite_score"] == pytest.approx(96.0)
assert row["confidence_score"] == pytest.approx(97.0)
assert row["recommended_action"] == "LONG_HIGH"
assert "sentiment=bullish" in row["reasoning"]
assert "sentiment=neutral" not in row["reasoning"]
persisted = await db_session.get(TradeSetup, stale_setup.id)
assert persisted is not None
assert persisted.composite_score == pytest.approx(71.8)
assert persisted.reasoning == old_reasoning
@pytest.mark.asyncio
async def test_live_recommendation_filters_apply_to_live_values(
db_session: AsyncSession,
):
"""min_confidence must judge the overlaid live confidence, not the stored one."""
await _seed_stale_setup_with_current_scores(db_session)
# Stored confidence is 82 — a stored-column filter would drop this row.
# Live confidence is 97, so it must pass.
rows = await get_trade_setups(
db_session,
symbol="TTWO",
min_confidence=90.0,
live_recommendation=True,
)
assert len(rows) == 1
assert rows[0]["confidence_score"] == pytest.approx(97.0)
# And a floor above the live value must drop it.
rows = await get_trade_setups(
db_session,
symbol="TTWO",
min_confidence=98.0,
live_recommendation=True,
)
assert rows == []
async def _seed_two_direction_setup(db_session: AsyncSession) -> None:
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
ticker = Ticker(symbol="BOTH")
db_session.add(ticker)
await db_session.flush()
db_session.add_all([
TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=100.0,
stop_loss=95.0,
target=112.0,
rr_ratio=2.4,
composite_score=30.0,
detected_at=current,
confidence_score=25.0,
recommended_action="NEUTRAL",
risk_level="Low",
),
TradeSetup(
ticker_id=ticker.id,
direction="short",
entry_price=100.0,
stop_loss=105.0,
target=88.0,
rr_ratio=2.4,
composite_score=30.0,
detected_at=current,
confidence_score=90.0,
recommended_action="SHORT_HIGH",
risk_level="Low",
),
DimensionScore(
ticker_id=ticker.id,
dimension="technical",
score=10.0,
is_stale=False,
computed_at=current,
),
DimensionScore(
ticker_id=ticker.id,
dimension="momentum",
score=10.0,
is_stale=False,
computed_at=current,
),
DimensionScore(
ticker_id=ticker.id,
dimension="fundamental",
score=10.0,
is_stale=False,
computed_at=current,
),
CompositeScore(
ticker_id=ticker.id,
score=30.0,
is_stale=False,
weights_json="{}",
computed_at=current,
),
SentimentScore(
ticker_id=ticker.id,
classification="bearish",
confidence=90,
source="test",
timestamp=current,
reasoning="",
citations_json="[]",
),
])
await db_session.flush()
@pytest.mark.asyncio
async def test_live_recommendation_action_independent_of_direction_filter(
db_session: AsyncSession,
):
await _seed_two_direction_setup(db_session)
all_rows = await get_trade_setups(
db_session,
symbol="BOTH",
live_recommendation=True,
)
filtered_rows = await get_trade_setups(
db_session,
symbol="BOTH",
direction="long",
live_recommendation=True,
)
long_from_all = next(row for row in all_rows if row["direction"] == "long")
assert len(filtered_rows) == 1
assert long_from_all["recommended_action"] == "SHORT_HIGH"
assert filtered_rows[0]["recommended_action"] == "SHORT_HIGH"
@pytest.mark.asyncio
async def test_live_overlay_preserves_setup_specific_risk_and_context(
db_session: AsyncSession,
):
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
ticker = Ticker(symbol="RISK")
db_session.add(ticker)
await db_session.flush()
db_session.add_all([
TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=100.0,
stop_loss=95.0,
target=112.0,
rr_ratio=2.4,
composite_score=50.0,
detected_at=current,
confidence_score=50.0,
recommended_action="NEUTRAL",
risk_level="Medium",
conflict_flags_json=json.dumps([
"target-availability: Fewer than 3 valid S/R targets available"
]),
),
DimensionScore(
ticker_id=ticker.id,
dimension="technical",
score=50.0,
is_stale=False,
computed_at=current,
),
DimensionScore(
ticker_id=ticker.id,
dimension="momentum",
score=50.0,
is_stale=False,
computed_at=current,
),
CompositeScore(
ticker_id=ticker.id,
score=50.0,
is_stale=False,
weights_json="{}",
computed_at=current,
),
SentimentScore(
ticker_id=ticker.id,
classification="neutral",
confidence=50,
source="test",
timestamp=current,
reasoning="",
citations_json="[]",
),
OHLCVRecord(
ticker_id=ticker.id,
date=date(2026, 7, 3),
open=101.0,
high=102.0,
low=100.0,
close=101.0,
volume=1000,
created_at=current,
),
])
await db_session.flush()
rows = await get_trade_setups(
db_session,
symbol="RISK",
live_recommendation=True,
)
assert len(rows) == 1
row = rows[0]
assert row["risk_level"] == "Medium"
assert row["conflict_flags"] == [
"target-availability: Fewer than 3 valid S/R targets available"
]
assert row["current_price"] == pytest.approx(101.0)
assert _as_utc(row["context_as_of"]["score_computed_at"]) == current
assert _as_utc(row["context_as_of"]["sentiment_at"]) == current
assert row["context_as_of"]["price_date"] == date(2026, 7, 3)
assert _as_utc(row["context_as_of"]["price_updated_at"]) == current
@pytest.mark.asyncio
async def test_live_trade_setup_read_does_not_recompute_scores(db_session: AsyncSession):
await _seed_stale_setup_with_current_scores(db_session)
with patch(
"app.services.scoring_service.compute_all_dimensions",
new=AsyncMock(side_effect=AssertionError("GET must not recompute dimensions")),
), patch(
"app.services.scoring_service.compute_composite_score",
new=AsyncMock(side_effect=AssertionError("GET must not recompute composite")),
):
rows = await get_trade_setups(
db_session,
symbol="TTWO",
live_recommendation=True,
)
assert len(rows) == 1
@pytest.mark.asyncio
async def test_intraday_price_update_changes_live_price_without_new_signal_rows(
db_session: AsyncSession,
):
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
ticker = Ticker(symbol="LIVEP")
db_session.add(ticker)
await db_session.flush()
setup = TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=100.0,
stop_loss=95.0,
target=112.0,
rr_ratio=2.4,
composite_score=50.0,
detected_at=current,
)
price = OHLCVRecord(
ticker_id=ticker.id,
date=date(2026, 7, 3),
open=100.0,
high=101.0,
low=99.0,
close=100.0,
volume=1000,
created_at=current,
)
db_session.add_all([setup, price])
await db_session.flush()
rows = await get_trade_setups(db_session, symbol="LIVEP", live_recommendation=True)
assert rows[0]["current_price"] == pytest.approx(100.0)
price.close = 102.0
await db_session.flush()
rows = await get_trade_setups(db_session, symbol="LIVEP", live_recommendation=True)
assert rows[0]["current_price"] == pytest.approx(102.0)
setup_count = await db_session.scalar(select(func.count()).select_from(TradeSetup))
snapshot_count = await db_session.scalar(select(func.count()).select_from(SignalContextSnapshot))
assert setup_count == 1
assert snapshot_count == 0
+65 -140
View File
@@ -1,24 +1,24 @@
"""Unit tests for get_score composite breakdown and dimension breakdown wiring."""
"""Unit tests for read-only get_score composite breakdown wiring."""
from __future__ import annotations
from datetime import date
from types import SimpleNamespace
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base
from app.models.score import CompositeScore, DimensionScore
from app.models.ticker import Ticker
from app.services.scoring_service import get_score, _DIMENSION_COMPUTERS
from app.services.scoring_service import get_score
TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest.fixture
async def fresh_db():
"""Provide a non-transactional session so get_score can commit."""
"""Provide a non-transactional session for persisted score reads."""
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -30,176 +30,101 @@ async def fresh_db():
await engine.dispose()
def _make_ohlcv_records(n: int, base_close: float = 100.0) -> list:
"""Create n mock OHLCV records with realistic price data."""
records = []
for i in range(n):
price = base_close + (i * 0.5)
records.append(
SimpleNamespace(
date=date(2024, 1, 1),
open=price - 0.5,
high=price + 1.0,
low=price - 1.0,
close=price,
volume=1000000,
)
)
return records
def _mock_none_computer():
"""Return an AsyncMock that returns (None, None) — simulates missing dimension data."""
return AsyncMock(return_value=(None, None))
def _mock_score_computer(score: float, breakdown: dict | None = None):
"""Return an AsyncMock that returns a fixed (score, breakdown) tuple."""
bd = breakdown or {
"sub_scores": [{"name": "mock", "score": score, "weight": 1.0, "raw_value": score, "description": "mock"}],
"formula": "mock formula",
"unavailable": [],
}
return AsyncMock(return_value=(score, bd))
async def _seed_ticker(session: AsyncSession, symbol: str = "AAPL") -> Ticker:
"""Insert a ticker row and return it."""
ticker = Ticker(symbol=symbol)
session.add(ticker)
await session.commit()
return ticker
async def _seed_scores(session: AsyncSession, ticker: Ticker, *, stale: bool = False) -> None:
now = datetime(2026, 7, 3, tzinfo=timezone.utc)
session.add_all([
DimensionScore(
ticker_id=ticker.id,
dimension="technical",
score=70.0,
is_stale=stale,
computed_at=now,
),
DimensionScore(
ticker_id=ticker.id,
dimension="momentum",
score=60.0,
is_stale=False,
computed_at=now,
),
CompositeScore(
ticker_id=ticker.id,
score=66.0,
is_stale=stale,
weights_json="{}",
computed_at=now,
),
])
await session.commit()
@pytest.mark.asyncio
async def test_get_score_returns_composite_breakdown(fresh_db):
"""get_score should include a composite_breakdown dict with weights and re-normalization info."""
await _seed_ticker(fresh_db, "AAPL")
original = dict(_DIMENSION_COMPUTERS)
try:
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0)
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0)
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
async def test_get_score_returns_composite_breakdown_without_recomputing(fresh_db):
ticker = await _seed_ticker(fresh_db, "AAPL")
await _seed_scores(fresh_db, ticker)
with patch(
"app.services.scoring_service.compute_dimension_score",
new=AsyncMock(side_effect=AssertionError("GET must not recompute dimensions")),
), patch(
"app.services.scoring_service.compute_composite_score",
new=AsyncMock(side_effect=AssertionError("GET must not recompute composite")),
):
result = await get_score(fresh_db, "AAPL")
finally:
_DIMENSION_COMPUTERS.update(original)
assert "composite_breakdown" in result
assert result["composite_score"] == 66.0
cb = result["composite_breakdown"]
assert cb is not None
assert "weights" in cb
assert "available_dimensions" in cb
assert "missing_dimensions" in cb
assert "renormalized_weights" in cb
assert "formula" in cb
@pytest.mark.asyncio
async def test_get_score_composite_breakdown_has_correct_available_missing(fresh_db):
"""Composite breakdown should correctly list available and missing dimensions."""
await _seed_ticker(fresh_db, "AAPL")
original = dict(_DIMENSION_COMPUTERS)
try:
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0)
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0)
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
result = await get_score(fresh_db, "AAPL")
finally:
_DIMENSION_COMPUTERS.update(original)
cb = result["composite_breakdown"]
assert "technical" in cb["available_dimensions"]
assert "momentum" in cb["available_dimensions"]
assert "sentiment" in cb["missing_dimensions"]
assert "fundamental" in cb["missing_dimensions"]
assert "sr_quality" in cb["missing_dimensions"]
@pytest.mark.asyncio
async def test_get_score_renormalized_weights_sum_to_one(fresh_db):
"""Re-normalized weights should sum to 1.0 when at least one dimension is available."""
await _seed_ticker(fresh_db, "AAPL")
original = dict(_DIMENSION_COMPUTERS)
try:
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0)
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0)
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
result = await get_score(fresh_db, "AAPL")
finally:
_DIMENSION_COMPUTERS.update(original)
cb = result["composite_breakdown"]
assert cb["renormalized_weights"]
total = sum(cb["renormalized_weights"].values())
assert abs(total - 1.0) < 1e-9
assert abs(sum(cb["renormalized_weights"].values()) - 1.0) < 1e-9
@pytest.mark.asyncio
async def test_get_score_dimensions_include_breakdowns(fresh_db):
"""Each available dimension entry should include a breakdown dict."""
await _seed_ticker(fresh_db, "AAPL")
async def test_get_score_dimensions_do_not_recompute_breakdowns(fresh_db):
ticker = await _seed_ticker(fresh_db, "AAPL")
await _seed_scores(fresh_db, ticker)
tech_breakdown = {
"sub_scores": [
{"name": "ADX", "score": 72.0, "weight": 0.4, "raw_value": 72.0, "description": "ADX value"},
{"name": "EMA", "score": 65.0, "weight": 0.3, "raw_value": 1.5, "description": "EMA diff"},
{"name": "RSI", "score": 62.0, "weight": 0.3, "raw_value": 62.0, "description": "RSI value"},
],
"formula": "Weighted average: 0.4*ADX + 0.3*EMA + 0.3*RSI",
"unavailable": [],
}
original = dict(_DIMENSION_COMPUTERS)
try:
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(68.2, tech_breakdown)
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(55.0)
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
result = await get_score(fresh_db, "AAPL")
finally:
_DIMENSION_COMPUTERS.update(original)
result = await get_score(fresh_db, "AAPL")
tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None)
assert tech_dim is not None
assert "breakdown" in tech_dim
assert tech_dim["breakdown"] is not None
assert len(tech_dim["breakdown"]["sub_scores"]) == 3
names = [s["name"] for s in tech_dim["breakdown"]["sub_scores"]]
assert "ADX" in names
assert "EMA" in names
assert "RSI" in names
assert tech_dim["breakdown"] is None
@pytest.mark.asyncio
async def test_get_score_all_dimensions_missing(fresh_db):
"""When all dimensions return None, composite_breakdown should list all as missing."""
await _seed_ticker(fresh_db, "AAPL")
original = dict(_DIMENSION_COMPUTERS)
try:
for dim in _DIMENSION_COMPUTERS:
_DIMENSION_COMPUTERS[dim] = _mock_none_computer()
result = await get_score(fresh_db, "AAPL")
finally:
_DIMENSION_COMPUTERS.update(original)
result = await get_score(fresh_db, "AAPL")
cb = result["composite_breakdown"]
assert cb["available_dimensions"] == []
assert len(cb["missing_dimensions"]) == 5
assert cb["renormalized_weights"] == {}
assert result["composite_score"] is None
@pytest.mark.asyncio
async def test_get_score_reports_stale_without_refreshing(fresh_db):
ticker = await _seed_ticker(fresh_db, "AAPL")
await _seed_scores(fresh_db, ticker, stale=True)
result = await get_score(fresh_db, "AAPL")
assert result["composite_stale"] is True
assert "technical" in result["missing_dimensions"]
tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None)
assert tech_dim is not None
assert tech_dim["is_stale"] is True
+12 -26
View File
@@ -1,5 +1,4 @@
"""Unit tests for get_rankings: bulk-load fast path, sorting, exclusion, and
lazy recompute of stale scores."""
"""Unit tests for read-only get_rankings: bulk-load, sorting, and staleness."""
from __future__ import annotations
@@ -7,7 +6,6 @@ from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base
@@ -20,7 +18,7 @@ TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest.fixture
async def fresh_db():
"""Non-transactional session so get_rankings can commit recomputes."""
"""Non-transactional session for persisted ranking reads."""
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -84,46 +82,34 @@ async def test_fast_path_sorts_and_does_not_recompute(fresh_db: AsyncSession):
@pytest.mark.asyncio
async def test_ticker_without_computable_composite_is_excluded(fresh_db: AsyncSession):
"""A ticker whose composite can't be computed (recompute yields no row) is
omitted from the rankings rather than appearing with a null score."""
"""A ticker without a persisted composite is omitted from rankings."""
fresh = await _seed_ticker(fresh_db, "OK")
await _seed_ticker(fresh_db, "NONE") # no composite; recompute can't make one
await _seed_ticker(fresh_db, "NONE")
fresh_db.add_all([_composite(fresh.id, 50.0), _dimension(fresh.id, "technical", 50.0)])
await fresh_db.commit()
# Recompute is a no-op that produces no composite row for NONE.
with patch("app.services.scoring_service.compute_dimension_score",
new=AsyncMock(return_value=None)), \
new=AsyncMock(side_effect=AssertionError("should not recompute"))), \
patch("app.services.scoring_service.compute_composite_score",
new=AsyncMock(return_value=(None, ["technical"]))):
new=AsyncMock(side_effect=AssertionError("should not recompute"))):
result = await get_rankings(fresh_db)
assert [r["symbol"] for r in result["rankings"]] == ["OK"]
@pytest.mark.asyncio
async def test_stale_composite_is_recomputed(fresh_db: AsyncSession):
"""A stale composite triggers a recompute and then appears in the rankings."""
async def test_stale_composite_is_reported_without_recompute(fresh_db: AsyncSession):
"""A stale composite appears with its stale flag and is not recomputed."""
ticker = await _seed_ticker(fresh_db, "STALE")
fresh_db.add(_composite(ticker.id, 10.0, stale=True))
await fresh_db.commit()
async def _fake_recompute(db, symbol, weights=None):
# Mirror the real upsert: refresh the existing row in place.
existing = (await db.execute(
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
)).scalar_one()
existing.score = 77.0
existing.is_stale = False
return 77.0, []
# Dimension recompute is a no-op; composite recompute refreshes the score.
with patch("app.services.scoring_service.compute_dimension_score",
new=AsyncMock(return_value=55.0)), \
new=AsyncMock(side_effect=AssertionError("should not recompute"))), \
patch("app.services.scoring_service.compute_composite_score",
new=AsyncMock(side_effect=_fake_recompute)) as comp_mock:
new=AsyncMock(side_effect=AssertionError("should not recompute"))):
result = await get_rankings(fresh_db)
comp_mock.assert_awaited() # recompute path was taken
assert [r["symbol"] for r in result["rankings"]] == ["STALE"]
assert result["rankings"][0]["composite_score"] == 77.0 # reflects the recompute
assert result["rankings"][0]["composite_score"] == 10.0
assert result["rankings"][0]["composite_stale"] is True
+3 -3
View File
@@ -1,9 +1,9 @@
"""Tests for sentiment-collection scoping (``_get_sentiment_priority_tickers``).
A dashboard 'top pick' is the highest-momentum *qualified* long setup. Sentiment
can never move a ticker's momentum percentile (the gate's core axis) only its
A dashboard 'top pick' is the highest residual-momentum *qualified* long setup. Sentiment
can never move a ticker's activation percentile (the gate's core axis) only its
confidence and EV ranking. So the tickers that are, or could become with positive
sentiment, a top pick are exactly the momentum leaders that already carry a
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.
"""
+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():
# Steadily rising series so every lookback is positive and trend is above SMA.
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
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["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
@@ -73,7 +74,8 @@ def test_signal_values_momentum_and_trend():
def test_signal_values_drops_signals_without_enough_history():
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_6_1" not in vals # needs 126 — absent
assert "mom_12_1" not in vals # needs 252 — absent