Compare commits
20 Commits
29b1a9a28c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 66ef0564c1 | |||
| 14327ab25a | |||
| eaad935a2a | |||
| d4ccea2d69 | |||
| 8c36cfcef1 | |||
| ac51e23949 | |||
| 2b0068ae08 | |||
| 7fd34d6de8 | |||
| 8d5863bac4 | |||
| be4d6a05ca | |||
| aadec7d403 | |||
| 849489a4b5 | |||
| 80b4113280 | |||
| 13374087db | |||
| 1e82dfad7f | |||
| 29a61cb2ca | |||
| 243e369e9a | |||
| 0f43e755f4 | |||
| 942a22ce65 | |||
| 8750aac6d9 |
@@ -33,3 +33,7 @@ alembic/versions/__pycache__/
|
|||||||
|
|
||||||
# Generated SSL bundle
|
# Generated SSL bundle
|
||||||
combined-ca-bundle.pem
|
combined-ca-bundle.pem
|
||||||
|
|
||||||
|
# Local research artifacts
|
||||||
|
backtest_snapshots/
|
||||||
|
reports/backtest-*.json
|
||||||
|
|||||||
@@ -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:
|
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.
|
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.
|
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 12‑1 momentum percentile.
|
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 12‑1 momentum activation percentile.
|
||||||
4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and close paper trades that hit a level.
|
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).
|
5. **Market Regime** — recompute the regime index (breadth/trend).
|
||||||
6. **Regime Monitor** — observational early-warning snapshot (VIX, credit spreads via FRED); feeds nothing else.
|
6. **Regime Monitor** — observational early-warning snapshot (VIX, credit spreads via FRED); feeds nothing else.
|
||||||
|
|
||||||
@@ -33,8 +33,8 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we
|
|||||||
|
|
||||||
1. **Composite score** — technical, S/R-quality, sentiment, fundamental and momentum sub-scores (0–100) combine into a weighted composite (weights configurable; missing dimensions re-normalize).
|
1. **Composite score** — technical, S/R-quality, sentiment, fundamental and momentum sub-scores (0–100) 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.
|
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).
|
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-momentum qualified setup; highlighted on the Dashboard and labelled on the ticker page.
|
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
|
## Strategy Status — What's Validated and What Isn't
|
||||||
|
|
||||||
@@ -42,15 +42,35 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we
|
|||||||
|
|
||||||
| Component | Verdict | Evidence |
|
| Component | Verdict | Evidence |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **12-1 cross-sectional momentum** (the activation gate, long-only) | **The only demonstrated edge — in-sample** | Qualified setups ≈ **+0.25R** avg vs ≈ −0.05R all-setups baseline; the percentile sweep is cleanly monotonic (cutoff 50 → +0.14R, 70 → +0.21R, 80 → +0.25R). Rank-IC ≈ 0.05, t ≈ 1.6 — right sign and size for the classic factor, **not yet statistically significant** |
|
| **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) | **No selection edge — execution/timing only** | ≈ breakeven (+0.01R) before the momentum gate. The probability model is honest (calibrated) but does not discriminate winners |
|
| 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`) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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, no transaction costs or slippage modeled, and the factor is beta-heavy (6-month volatility posted the top IC — that's beta, not alpha). The **out-of-sample proof is the forward paper-trade record**: Signals → Track Record compares live qualified expectancy against the backtest.
|
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
|
### The iron rule for strategy changes
|
||||||
|
|
||||||
@@ -64,16 +84,15 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
|
|||||||
|
|
||||||
### Highest-value next experiments (in order)
|
### Highest-value next experiments (in order)
|
||||||
|
|
||||||
1. **Volatility-scaled momentum** — add `mom_12_1 / vol_6m` to `_signal_values`; risk-adjusted momentum typically beats raw and dampens momentum crashes.
|
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. **Regime filter on the gate** — momentum crashes cluster in post-bear rebounds; `market_regime_service` already computes the SPY 50/200 trend, so test "qualify only in Risk-On" in the backtest before wiring it live.
|
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. **Cost haircut in the backtest** — subtract a fixed per-trade cost (e.g. 0.1% per side) in the outcome aggregation so expectancy is net; a thin edge must survive costs.
|
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.)
|
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.)
|
||||||
5. **Exit tuning with the existing sweeps** — the report already sweeps fixed take-profits and trailing stops against the S/R-target model; momentum's edge lives in the right tail, so wide trailing exits (already the paper-trade default) tend to beat nearby S/R targets. Also worth testing: a pure time-based exit (hold ~1 month, re-rank) instead of the 30-day target/stop race.
|
|
||||||
|
|
||||||
## Key Use Cases
|
## 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.
|
- **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 on stop/target, and its sentiment stays fresh while open. *Signals → Track Record* shows the realized edge.
|
- **Track a trade you took.** Mark a setup as a **paper trade**: it's marked-to-market against the latest close, auto-closed by the active exit policy (default: 30 trading days with the initial stop), and its sentiment stays fresh while open. *Signals → Track Record* shows the realized edge.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -102,9 +121,9 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
|
|||||||
- Fundamental data tracking (P/E, revenue growth, earnings surprise, market cap)
|
- Fundamental data tracking (P/E, revenue growth, earnings surprise, market cap)
|
||||||
- 5-dimension scoring engine (technical, S/R quality, sentiment, fundamental, momentum) with configurable weights
|
- 5-dimension scoring engine (technical, S/R quality, sentiment, fundamental, momentum) with configurable weights
|
||||||
- Risk:Reward scanner — long and short setups, ATR-based stops, S/R-based targets, configurable R:R threshold (default 1.5:1)
|
- Risk:Reward scanner — long and short setups, ATR-based stops, S/R-based targets, configurable R:R threshold (default 1.5:1)
|
||||||
- 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
|
- 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
|
- Market-regime index + FRED early-warning monitor (VIX, credit spreads); weekly backtest + manual event study
|
||||||
- Telegram alerts (e.g. regime-quadrant changes)
|
- Telegram alerts (e.g. regime-quadrant changes)
|
||||||
- User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary
|
- User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary
|
||||||
@@ -228,6 +247,50 @@ cd frontend
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Local Backtest Snapshots
|
||||||
|
|
||||||
|
For research loops, run the production backtest locally from a SQLite snapshot
|
||||||
|
instead of deploying and clicking the Admin job. The snapshot contains only the
|
||||||
|
tables needed by `run_backtest`: tickers, OHLCV bars, SPY benchmark closes, and
|
||||||
|
activation/recommendation settings. Secrets and cached reports are not copied.
|
||||||
|
|
||||||
|
1. Open an SSH tunnel to the production Postgres instance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -N -L 15432:127.0.0.1:5432 deploy@your-server
|
||||||
|
```
|
||||||
|
|
||||||
|
2. In another terminal, create or refresh the snapshot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS/Linux
|
||||||
|
python scripts/create_backtest_snapshot.py \
|
||||||
|
--database-url "postgresql+asyncpg://stock_backend:PASSWORD@127.0.0.1:15432/stock_data_backend" \
|
||||||
|
--output backtest_snapshots/prod.sqlite \
|
||||||
|
--force
|
||||||
|
|
||||||
|
# Windows PowerShell
|
||||||
|
.venv\Scripts\python.exe scripts\create_backtest_snapshot.py `
|
||||||
|
--database-url "postgresql+asyncpg://stock_backend:PASSWORD@127.0.0.1:15432/stock_data_backend" `
|
||||||
|
--output backtest_snapshots\prod.sqlite `
|
||||||
|
--force
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the backtest fully offline from the snapshot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS/Linux
|
||||||
|
python scripts/run_backtest_snapshot.py backtest_snapshots/prod.sqlite --workers 8
|
||||||
|
|
||||||
|
# Windows PowerShell
|
||||||
|
.venv\Scripts\python.exe scripts\run_backtest_snapshot.py backtest_snapshots\prod.sqlite --workers 12 --allow-spawn
|
||||||
|
```
|
||||||
|
|
||||||
|
The runner writes `reports/backtest-<timestamp>.json` and prints the headline
|
||||||
|
metrics. Keep the SSH tunnel open only while creating the snapshot; the backtest
|
||||||
|
run itself is local/offline. `backtest_snapshots/` and generated backtest reports
|
||||||
|
are git-ignored.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
Configure in `.env` (copy from `.env.example`):
|
Configure in `.env` (copy from `.env.example`):
|
||||||
@@ -395,7 +458,7 @@ tests/
|
|||||||
|
|
||||||
## Maintainer Guide
|
## 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) and deploys manually.
|
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
|
### Invariants — do not break these
|
||||||
|
|
||||||
@@ -405,6 +468,7 @@ Context for whoever — human or AI — continues this work. The owner pushes st
|
|||||||
- **The outcome evaluator evaluates ALL setups**, not just qualified ones — unqualified setups are the control group that makes the Track Record meaningful.
|
- **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.
|
- **`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.
|
- **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.
|
- Style: surgical changes, minimal new files; extend existing services rather than adding parallel ones.
|
||||||
|
|
||||||
### Where the strategy lives
|
### Where the strategy lives
|
||||||
@@ -412,16 +476,17 @@ Context for whoever — human or AI — continues this work. The owner pushes st
|
|||||||
| Concern | File |
|
| Concern | File |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Composite + 5 dimension scores, weights | `app/services/scoring_service.py` |
|
| Composite + 5 dimension scores, weights | `app/services/scoring_service.py` |
|
||||||
| 12-1 momentum ranking (the validated factor) | `app/services/momentum_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` |
|
| Setup construction (ATR stop, S/R targets) | `app/services/rr_scanner_service.py` |
|
||||||
| Confidence, targets, reach-probability, action | `app/services/recommendation_service.py` |
|
| Confidence, targets, reach-probability, action | `app/services/recommendation_service.py` |
|
||||||
| Activation gate predicate (mirrored in TS) | `app/services/qualification.py` |
|
| Activation gate predicate (mirrored in TS) | `app/services/qualification.py` |
|
||||||
| Gate defaults / admin config | `app/services/admin_service.py` (`ACTIVATION_DEFAULTS`) |
|
| Gate defaults / admin config | `app/services/admin_service.py` (`ACTIVATION_DEFAULTS`) |
|
||||||
| Backtest + factor rank-IC harness ("Signal edge") | `app/services/backtest_service.py` |
|
| Backtest + factor rank-IC harness ("Signal edge") | `app/services/backtest_service.py` |
|
||||||
| Outcome resolution (target/stop/expired/ambiguous) | `app/services/outcome_service.py` |
|
| Outcome resolution (target/stop/expired/ambiguous) | `app/services/outcome_service.py` |
|
||||||
| Paper trades + trailing auto-exit | `app/services/paper_trade_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` |
|
| S/R detection & zone clustering | `app/services/sr_service.py` |
|
||||||
| SPY benchmark for paper-trade alpha | `app/services/benchmark_service.py` |
|
| SPY benchmark for residual momentum + paper-trade alpha | `app/services/benchmark_service.py` |
|
||||||
| Pipelines & job registration | `app/scheduler.py` |
|
| Pipelines & job registration | `app/scheduler.py` |
|
||||||
|
|
||||||
### Verifying changes
|
### Verifying changes
|
||||||
|
|||||||
@@ -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
@@ -51,7 +51,7 @@ class Settings(BaseSettings):
|
|||||||
# Sentiment search-budget controls (Gemini grounding free tier = 5000/month).
|
# Sentiment search-budget controls (Gemini grounding free tier = 5000/month).
|
||||||
# Scope (see _get_sentiment_priority_tickers): everything that matters is always
|
# Scope (see _get_sentiment_priority_tickers): everything that matters is always
|
||||||
# refreshed in full — open paper trades + the curated watchlist + top-pick
|
# 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,
|
# 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.
|
# 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
|
# Skip anything refreshed within fresh_hours (5 days: sentiment shifts slowly and
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.models.alert import AlertLog
|
|||||||
from app.models.paper_trade import PaperTrade
|
from app.models.paper_trade import PaperTrade
|
||||||
from app.models.regime_snapshot import RegimeSnapshot
|
from app.models.regime_snapshot import RegimeSnapshot
|
||||||
from app.models.benchmark_price import BenchmarkPrice
|
from app.models.benchmark_price import BenchmarkPrice
|
||||||
|
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Ticker",
|
"Ticker",
|
||||||
@@ -30,4 +31,5 @@ __all__ = [
|
|||||||
"PaperTrade",
|
"PaperTrade",
|
||||||
"RegimeSnapshot",
|
"RegimeSnapshot",
|
||||||
"BenchmarkPrice",
|
"BenchmarkPrice",
|
||||||
|
"SignalContextSnapshot",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ class BenchmarkPrice(Base):
|
|||||||
"""Daily close for a benchmark index (e.g. SPY), used to compute trade alpha.
|
"""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
|
A standalone price series, deliberately NOT a tracked ``Ticker`` — so the
|
||||||
benchmark never enters the scanner, the momentum-percentile ranking, or the
|
benchmark never becomes a trade candidate or rankings-table row. Its closes
|
||||||
rankings table. One row per (symbol, date).
|
are used for residual momentum and trade alpha. One row per (symbol, date).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "benchmark_prices"
|
__tablename__ = "benchmark_prices"
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -26,8 +26,9 @@ class TradeSetup(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
# Ticker's 12-1 momentum percentile across the universe at detection time
|
# Ticker's activation momentum percentile across the universe at detection
|
||||||
# (0–100, 100 = strongest). Drives the activation gate's core selection.
|
# time. Since July 2026 this is residual 12-1 momentum when benchmark data is
|
||||||
|
# available, with raw 12-1 as a fallback.
|
||||||
momentum_percentile: Mapped[float | None] = mapped_column(Float, nullable=True)
|
momentum_percentile: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
targets_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
targets_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
conflict_flags_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
conflict_flags_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ async def write_exit_policy(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> APIEnvelope:
|
) -> APIEnvelope:
|
||||||
"""Change the auto-exit policy (admin)."""
|
"""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)
|
return APIEnvelope(status="success", data=data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ async def read_score(
|
|||||||
_user=Depends(require_access),
|
_user=Depends(require_access),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> APIEnvelope:
|
) -> APIEnvelope:
|
||||||
"""Get composite + dimension scores for a symbol. Recomputes stale scores."""
|
"""Get the latest persisted composite + dimension scores for a symbol."""
|
||||||
result = await get_score(db, symbol)
|
result = await get_score(db, symbol)
|
||||||
|
|
||||||
data = ScoreResponse(
|
data = ScoreResponse(
|
||||||
@@ -94,6 +94,7 @@ async def read_rankings(
|
|||||||
RankingEntry(
|
RankingEntry(
|
||||||
symbol=r["symbol"],
|
symbol=r["symbol"],
|
||||||
composite_score=r["composite_score"],
|
composite_score=r["composite_score"],
|
||||||
|
composite_stale=r.get("composite_stale", False),
|
||||||
dimensions=[
|
dimensions=[
|
||||||
DimensionScoreResponse(**d) for d in r["dimensions"]
|
DimensionScoreResponse(**d) for d in r["dimensions"]
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ async def list_trade_setups(
|
|||||||
direction=direction,
|
direction=direction,
|
||||||
min_confidence=min_confidence,
|
min_confidence=min_confidence,
|
||||||
recommended_action=recommended_action,
|
recommended_action=recommended_action,
|
||||||
|
live_recommendation=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
data = []
|
data = []
|
||||||
@@ -92,7 +93,11 @@ async def get_ticker_trade_setups(
|
|||||||
_user=Depends(require_access),
|
_user=Depends(require_access),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> APIEnvelope:
|
) -> APIEnvelope:
|
||||||
rows = await get_trade_setups(db, symbol=symbol)
|
rows = await get_trade_setups(
|
||||||
|
db,
|
||||||
|
symbol=symbol,
|
||||||
|
live_recommendation=True,
|
||||||
|
)
|
||||||
data = []
|
data = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
summary = RecommendationSummaryResponse(
|
summary = RecommendationSummaryResponse(
|
||||||
|
|||||||
+6
-6
@@ -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]:
|
async def _get_top_pick_feeder_ids(db: AsyncSession) -> set[int]:
|
||||||
"""Ticker ids whose latest LONG setup makes them a top-pick feeder.
|
"""Ticker ids whose latest LONG setup makes them a top-pick feeder.
|
||||||
|
|
||||||
A dashboard 'top pick' is the highest-momentum *qualified* setup. Sentiment
|
A dashboard 'top pick' is the highest residual-momentum *qualified* setup.
|
||||||
can never move a ticker's momentum percentile (the gate's core axis) — only
|
Sentiment can never move a ticker's activation percentile (the gate's core
|
||||||
its confidence and EV ranking. So the only tickers that are, or could become
|
axis) — only its confidence and EV ranking. So the only tickers that are, or
|
||||||
with positive sentiment, a top pick are momentum leaders that already have a
|
could become with positive sentiment, a top pick are residual-momentum leaders
|
||||||
tradeable long setup clearing the R:R floor. That set is exactly:
|
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.
|
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
|
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:
|
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 —
|
``_get_top_pick_feeder_ids``) + the curated watchlist + open paper trades —
|
||||||
the set we never want shown without sentiment.
|
the set we never want shown without sentiment.
|
||||||
Filler: top-N by composite — a cheap discovery net for names not yet covered.
|
Filler: top-N by composite — a cheap discovery net for names not yet covered.
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ class PaperTradeClose(BaseModel):
|
|||||||
|
|
||||||
class ExitPolicyUpdate(BaseModel):
|
class ExitPolicyUpdate(BaseModel):
|
||||||
"""Auto-exit policy for open paper trades."""
|
"""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)
|
trailing_pct: float | None = Field(default=None, ge=0.5, le=90)
|
||||||
|
hold_days: int | None = Field(default=None, ge=2, le=250)
|
||||||
|
|
||||||
|
|
||||||
class PaperTradeResponse(BaseModel):
|
class PaperTradeResponse(BaseModel):
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class RankingEntry(BaseModel):
|
|||||||
|
|
||||||
symbol: str
|
symbol: str
|
||||||
composite_score: float
|
composite_score: float
|
||||||
|
composite_stale: bool = False
|
||||||
dimensions: list[DimensionScoreResponse] = []
|
dimensions: list[DimensionScoreResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ class RecommendationSummaryResponse(BaseModel):
|
|||||||
composite_score: float
|
composite_score: float
|
||||||
|
|
||||||
|
|
||||||
|
class TradeSetupContextAsOfResponse(BaseModel):
|
||||||
|
setup_detected_at: datetime
|
||||||
|
score_computed_at: datetime | None = None
|
||||||
|
sentiment_at: datetime | None = None
|
||||||
|
price_date: date | None = None
|
||||||
|
price_updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class TradeSetupResponse(BaseModel):
|
class TradeSetupResponse(BaseModel):
|
||||||
"""A single trade setup detected by the R:R scanner."""
|
"""A single trade setup detected by the R:R scanner."""
|
||||||
|
|
||||||
@@ -49,4 +57,5 @@ class TradeSetupResponse(BaseModel):
|
|||||||
evaluated_at: datetime | None = None
|
evaluated_at: datetime | None = None
|
||||||
current_price: float | None = None
|
current_price: float | None = None
|
||||||
momentum_percentile: float | None = None
|
momentum_percentile: float | None = None
|
||||||
|
context_as_of: TradeSetupContextAsOfResponse | None = None
|
||||||
recommendation_summary: RecommendationSummaryResponse | None = None
|
recommendation_summary: RecommendationSummaryResponse | None = None
|
||||||
|
|||||||
@@ -39,10 +39,9 @@ SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
|
|||||||
# Track Record's qualified stats. The outcome evaluator deliberately ignores
|
# Track Record's qualified stats. The outcome evaluator deliberately ignores
|
||||||
# these — every setup is evaluated so the gate itself can be validated.
|
# these — every setup is evaluated so the gate itself can be validated.
|
||||||
#
|
#
|
||||||
# The core test is expected value (in R): probability-weighted asymmetry, so a
|
# The core selection is residual cross-sectional 12-1 momentum (top percentile
|
||||||
# fat-but-improbable target and a likely-but-thin one are both rejected. R:R and
|
# of the universe, long-only). R:R and confidence are floors; high-conviction /
|
||||||
# confidence are floors; high-conviction / clean-read / target-probability are
|
# clean-read are optional tighteners (off by default).
|
||||||
# optional tighteners (off by default — turn on to be more selective).
|
|
||||||
_ACTIVATION_FLOAT_KEYS: dict[str, str] = {
|
_ACTIVATION_FLOAT_KEYS: dict[str, str] = {
|
||||||
"min_momentum_percentile": "activation_min_momentum_percentile",
|
"min_momentum_percentile": "activation_min_momentum_percentile",
|
||||||
"min_rr": "activation_min_rr",
|
"min_rr": "activation_min_rr",
|
||||||
@@ -56,7 +55,10 @@ _ACTIVATION_BOOL_KEYS: dict[str, str] = {
|
|||||||
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
|
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
|
||||||
"min_momentum_percentile": 80.0,
|
"min_momentum_percentile": 80.0,
|
||||||
"min_rr": 1.2,
|
"min_rr": 1.2,
|
||||||
"min_confidence": 55.0,
|
# 0 = off. The July 2026 gate ablation showed the confidence floor added
|
||||||
|
# nothing (identical net/trade with it removed, under both exit models)
|
||||||
|
# while cutting ~25% of qualified trades.
|
||||||
|
"min_confidence": 0.0,
|
||||||
"require_high_conviction": False,
|
"require_high_conviction": False,
|
||||||
"exclude_conflicts": False,
|
"exclude_conflicts": False,
|
||||||
# On by default: a NEUTRAL ("no clear setup") recommendation isn't an
|
# On by default: a NEUTRAL ("no clear setup") recommendation isn't an
|
||||||
|
|||||||
+123
-22
@@ -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
|
DIGEST_HOUR_UTC = 22 # send the daily digest on the first run at/after this hour
|
||||||
|
|
||||||
WATERMARK_TYPE = "score_watermark"
|
WATERMARK_TYPE = "score_watermark"
|
||||||
|
SIGNAL_BUNDLE_ALERT_TYPES = ("qualified", "sr_proximity", "score_drop")
|
||||||
|
SIGNAL_BUNDLE_SECTIONS = (
|
||||||
|
("qualified", "Qualified setups"),
|
||||||
|
("sr_proximity", "Near support/resistance"),
|
||||||
|
("score_drop", "Score drops"),
|
||||||
|
)
|
||||||
|
SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML parsing
|
||||||
|
|
||||||
# Regime quadrant-change alert: (regime index x early-warning) quadrant.
|
# Regime quadrant-change alert: (regime index x early-warning) quadrant.
|
||||||
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary
|
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary
|
||||||
@@ -91,6 +98,9 @@ QUAD_LABELS = {
|
|||||||
"4": "④ Real downturn",
|
"4": "④ Real downturn",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AlertItem = tuple[str, str, str] # alert_type, dedup_key, text
|
||||||
|
AlertLogRef = tuple[str, str] # alert_type, dedup_key
|
||||||
|
|
||||||
|
|
||||||
def _as_bool(value: str | None, default: bool) -> bool:
|
def _as_bool(value: str | None, default: bool) -> bool:
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -254,19 +264,37 @@ async def _watchlist_tickers(db: AsyncSession) -> list[tuple[int, str]]:
|
|||||||
|
|
||||||
|
|
||||||
async def _qualified_setups(db: AsyncSession) -> list[dict]:
|
async def _qualified_setups(db: AsyncSession) -> list[dict]:
|
||||||
setups = await get_trade_setups(db)
|
# live_recommendation: gate and format on current score/sentiment context,
|
||||||
|
# not the values frozen into the setup at scan time.
|
||||||
|
setups = await get_trade_setups(db, live_recommendation=True)
|
||||||
config = await get_activation_config(db)
|
config = await get_activation_config(db)
|
||||||
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
|
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_price(value: float | int | None) -> str:
|
||||||
|
return "n/a" if value is None else f"{float(value):.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_signed_move(from_price: float | int | None, to_price: float | int | None) -> str:
|
||||||
|
if from_price is None or to_price is None:
|
||||||
|
return "n/a"
|
||||||
|
from_float = float(from_price)
|
||||||
|
if from_float == 0:
|
||||||
|
return "n/a"
|
||||||
|
pct = (float(to_price) - from_float) / from_float * 100.0
|
||||||
|
return f"{pct:+.1f}%"
|
||||||
|
|
||||||
|
|
||||||
def _format_qualified(s: dict) -> str:
|
def _format_qualified(s: dict) -> str:
|
||||||
prob = best_target_probability(SimpleNamespace(**s))
|
prob = best_target_probability(SimpleNamespace(**s))
|
||||||
arrow = "🟢" if s["direction"] == "long" else "🔴"
|
arrow = "🟢" if s["direction"] == "long" else "🔴"
|
||||||
|
current = s.get("current_price") or s.get("entry_price")
|
||||||
return (
|
return (
|
||||||
f"{arrow} <b>{s['symbol']} {s['direction'].upper()}</b> — qualified setup\n"
|
f"{arrow} <b>{s['symbol']} {s['direction'].upper()}</b> | "
|
||||||
f"entry {s['entry_price']:.2f} → target {s['target']:.2f} "
|
f"now {_fmt_price(current)} | entry {_fmt_price(s['entry_price'])} | "
|
||||||
f"(R:R {s['rr_ratio']:.1f}:1)\n"
|
f"target {_fmt_price(s['target'])} ({_fmt_signed_move(current, s['target'])}) | "
|
||||||
f"confidence {(s.get('confidence_score') or 0):.0f}% · P(target) {prob:.0f}%"
|
f"stop {_fmt_price(s['stop_loss'])} | R:R {s['rr_ratio']:.1f} | "
|
||||||
|
f"conf {(s.get('confidence_score') or 0):.0f}% | P(target) {prob:.0f}%"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -289,6 +317,34 @@ async def _latest_close(db: AsyncSession, ticker_id: int) -> float | None:
|
|||||||
return float(row[0]) if row else None
|
return float(row[0]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _sr_zone_label(zone: dict) -> str:
|
||||||
|
return (
|
||||||
|
f"{zone['low']:.2f}–{zone['high']:.2f}"
|
||||||
|
if zone["level_count"] > 1
|
||||||
|
else f"{zone['midpoint']:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sr_touch_price(zone: dict, current_price: float) -> float:
|
||||||
|
low = float(zone["low"])
|
||||||
|
high = float(zone["high"])
|
||||||
|
if current_price < low:
|
||||||
|
return low
|
||||||
|
if current_price > high:
|
||||||
|
return high
|
||||||
|
return float(zone["midpoint"])
|
||||||
|
|
||||||
|
|
||||||
|
def _format_sr_proximity(symbol: str, zone: dict, current_price: float) -> str:
|
||||||
|
touch_price = _sr_touch_price(zone, current_price)
|
||||||
|
return (
|
||||||
|
f"📍 <b>{symbol}</b> {zone['type']} | "
|
||||||
|
f"now {_fmt_price(current_price)} -> {_sr_zone_label(zone)} "
|
||||||
|
f"({_fmt_signed_move(current_price, touch_price)}) | "
|
||||||
|
f"strength {float(zone['strength']):.0f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _collect_sr_proximity(db: AsyncSession) -> list[tuple[str, str]]:
|
async def _collect_sr_proximity(db: AsyncSession) -> list[tuple[str, str]]:
|
||||||
"""One alert per watchlist ticker for the NEAREST strong S/R zone within range.
|
"""One alert per watchlist ticker for the NEAREST strong S/R zone within range.
|
||||||
|
|
||||||
@@ -318,21 +374,12 @@ async def _collect_sr_proximity(db: AsyncSession) -> list[tuple[str, str]]:
|
|||||||
|
|
||||||
# Nearest strong zone only.
|
# Nearest strong zone only.
|
||||||
nearest = min(strong, key=lambda z: abs(price - z["midpoint"]))
|
nearest = min(strong, key=lambda z: abs(price - z["midpoint"]))
|
||||||
dist_pct = abs(price - nearest["midpoint"]) / price * 100
|
dist_pct = abs(price - _sr_touch_price(nearest, price)) / price * 100
|
||||||
if dist_pct > SR_PROXIMITY_PCT:
|
if dist_pct > SR_PROXIMITY_PCT:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
label = (
|
|
||||||
f"{nearest['low']:.2f}–{nearest['high']:.2f}"
|
|
||||||
if nearest["level_count"] > 1
|
|
||||||
else f"{nearest['midpoint']:.2f}"
|
|
||||||
)
|
|
||||||
key = f"sr:{symbol}:{nearest['type']}" # one per side per ticker per cooldown
|
key = f"sr:{symbol}:{nearest['type']}" # one per side per ticker per cooldown
|
||||||
out.append((
|
out.append((key, _format_sr_proximity(symbol, nearest, price)))
|
||||||
key,
|
|
||||||
f"📍 <b>{symbol}</b> approaching {nearest['type']} {label} "
|
|
||||||
f"(now {price:.2f}, {dist_pct:.1f}% away)",
|
|
||||||
))
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -550,6 +597,49 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
|
|||||||
# Dispatch
|
# Dispatch
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _signal_bundle_messages(items: list[AlertItem]) -> list[tuple[list[AlertLogRef], str]]:
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
|
||||||
|
by_type: dict[str, list[AlertItem]] = {key: [] for key in SIGNAL_BUNDLE_ALERT_TYPES}
|
||||||
|
for item in items:
|
||||||
|
by_type.setdefault(item[0], []).append(item)
|
||||||
|
|
||||||
|
total = sum(len(group) for group in by_type.values())
|
||||||
|
header = f"📣 <b>Signal run</b> — {total} new alert(s)"
|
||||||
|
bundles: list[tuple[list[AlertLogRef], str]] = []
|
||||||
|
lines = [header]
|
||||||
|
logs: list[AlertLogRef] = []
|
||||||
|
current_section: str | None = None
|
||||||
|
|
||||||
|
def flush() -> None:
|
||||||
|
nonlocal lines, logs, current_section
|
||||||
|
if logs:
|
||||||
|
bundles.append((logs.copy(), "\n".join(lines)))
|
||||||
|
lines = [f"{header} (continued)"]
|
||||||
|
logs = []
|
||||||
|
current_section = None
|
||||||
|
|
||||||
|
for alert_type, section_title in SIGNAL_BUNDLE_SECTIONS:
|
||||||
|
for item_type, key, text in by_type.get(alert_type, []):
|
||||||
|
block: list[str] = []
|
||||||
|
if current_section != alert_type:
|
||||||
|
block.extend(["", f"<b>{section_title}</b>"])
|
||||||
|
block.append(text)
|
||||||
|
|
||||||
|
if logs and len("\n".join(lines + block)) > SIGNAL_BUNDLE_MAX_CHARS:
|
||||||
|
flush()
|
||||||
|
block = ["", f"<b>{section_title}</b>", text]
|
||||||
|
|
||||||
|
lines.extend(block)
|
||||||
|
logs.append((item_type, key))
|
||||||
|
current_section = alert_type
|
||||||
|
|
||||||
|
if logs:
|
||||||
|
bundles.append((logs.copy(), "\n".join(lines)))
|
||||||
|
return bundles
|
||||||
|
|
||||||
|
|
||||||
async def dispatch_alerts(db: AsyncSession) -> dict:
|
async def dispatch_alerts(db: AsyncSession) -> dict:
|
||||||
"""Gather all enabled triggers, dedup, and push to Telegram. Job entrypoint."""
|
"""Gather all enabled triggers, dedup, and push to Telegram. Job entrypoint."""
|
||||||
cfg = await _resolve(db)
|
cfg = await _resolve(db)
|
||||||
@@ -558,22 +648,23 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
|
|||||||
if not cfg["token"] or not cfg["chat_id"]:
|
if not cfg["token"] or not cfg["chat_id"]:
|
||||||
return {"status": "no_credentials", "sent": 0}
|
return {"status": "no_credentials", "sent": 0}
|
||||||
|
|
||||||
outgoing: list[tuple[str, str, str]] = [] # (alert_type, key, text)
|
signal_outgoing: list[AlertItem] = []
|
||||||
|
outgoing: list[AlertItem] = []
|
||||||
|
|
||||||
if cfg["qualified"]:
|
if cfg["qualified"]:
|
||||||
for key, text in await _collect_qualified(db):
|
for key, text in await _collect_qualified(db):
|
||||||
if not await _recently_alerted(db, "qualified", key):
|
if not await _recently_alerted(db, "qualified", key):
|
||||||
outgoing.append(("qualified", key, text))
|
signal_outgoing.append(("qualified", key, text))
|
||||||
|
|
||||||
if cfg["sr"]:
|
if cfg["sr"]:
|
||||||
for key, text in await _collect_sr_proximity(db):
|
for key, text in await _collect_sr_proximity(db):
|
||||||
if not await _recently_alerted(db, "sr_proximity", key):
|
if not await _recently_alerted(db, "sr_proximity", key):
|
||||||
outgoing.append(("sr_proximity", key, text))
|
signal_outgoing.append(("sr_proximity", key, text))
|
||||||
|
|
||||||
if cfg["score_drop"]:
|
if cfg["score_drop"]:
|
||||||
# also seeds/advances watermarks as a side effect
|
# also seeds/advances watermarks as a side effect
|
||||||
for key, text in await _collect_score_drops(db):
|
for key, text in await _collect_score_drops(db):
|
||||||
outgoing.append(("score_drop", key, text))
|
signal_outgoing.append(("score_drop", key, text))
|
||||||
|
|
||||||
if cfg["digest"]:
|
if cfg["digest"]:
|
||||||
digest = await _collect_digest(db)
|
digest = await _collect_digest(db)
|
||||||
@@ -591,8 +682,18 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
|
|||||||
outgoing.append((TRADE_CLOSED_TYPE, key, text))
|
outgoing.append((TRADE_CLOSED_TYPE, key, text))
|
||||||
|
|
||||||
sent = 0
|
sent = 0
|
||||||
if outgoing:
|
candidates = len(signal_outgoing) + len(outgoing)
|
||||||
|
if signal_outgoing or outgoing:
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
for log_refs, text in _signal_bundle_messages(signal_outgoing):
|
||||||
|
try:
|
||||||
|
await _send(client, cfg["token"], cfg["chat_id"], text)
|
||||||
|
for alert_type, key in log_refs:
|
||||||
|
_log_alert(db, alert_type, key)
|
||||||
|
sent += 1
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send signal alert bundle")
|
||||||
|
|
||||||
for alert_type, key, text in outgoing:
|
for alert_type, key, text in outgoing:
|
||||||
try:
|
try:
|
||||||
await _send(client, cfg["token"], cfg["chat_id"], text)
|
await _send(client, cfg["token"], cfg["chat_id"], text)
|
||||||
@@ -602,7 +703,7 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
|
|||||||
logger.exception("Failed to send alert %s", key)
|
logger.exception("Failed to send alert %s", key)
|
||||||
|
|
||||||
await db.commit() # persist watermark seeds/advances and sent-logs
|
await db.commit() # persist watermark seeds/advances and sent-logs
|
||||||
return {"status": "ok", "sent": sent, "candidates": len(outgoing)}
|
return {"status": "ok", "sent": sent, "candidates": candidates}
|
||||||
|
|
||||||
|
|
||||||
async def send_test_alert(db: AsyncSession) -> dict:
|
async def send_test_alert(db: AsyncSession) -> dict:
|
||||||
|
|||||||
+980
-229
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,8 @@
|
|||||||
Fetches the S&P 500 proxy (SPY) daily closes via Alpaca and persists them, so
|
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
|
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
|
holding period — can be computed. The benchmark is a standalone series, NOT a
|
||||||
tracked ``Ticker``, so it never contaminates the scanner, momentum-percentile
|
tracked ``Ticker``; its closes feed residual momentum and alpha, but it never
|
||||||
ranking, or rankings.
|
becomes a trade candidate or rankings-table row.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
"""Cross-sectional 12-1 momentum ranking for the universe.
|
"""Cross-sectional residual 12-1 momentum ranking for the universe.
|
||||||
|
|
||||||
The activation gate selects the top ``min_momentum_percentile`` of the universe
|
The activation gate selects the top ``min_momentum_percentile`` of the universe
|
||||||
by 12-1 month momentum (return from ~12 months ago to ~1 month ago — the one
|
by residual 12-1 month momentum: the stock's 12-1 return after subtracting its
|
||||||
price signal the backtest showed sorts forward returns). The daily scan ranks
|
estimated benchmark beta contribution over the same formation window. The daily
|
||||||
every ticker and stores each setup's percentile (see ``rr_scanner_service``), so
|
scan ranks every ticker and stores each setup's percentile (see
|
||||||
the live list, the Track Record's qualified stats, and outcome evaluation all gate
|
``rr_scanner_service``), so the live list, the Track Record's qualified stats,
|
||||||
on the same value.
|
and outcome evaluation all gate on the same value.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -35,29 +36,101 @@ def compute_12_1_momentum(closes: list[float]) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compute_residual_12_1_momentum(
|
||||||
|
dates: list[date],
|
||||||
|
closes: list[float],
|
||||||
|
benchmark_closes: dict[date, float],
|
||||||
|
) -> float | None:
|
||||||
|
"""12-1 momentum after removing linear benchmark exposure.
|
||||||
|
|
||||||
|
Estimate beta from daily stock/benchmark returns over the standard 12-1
|
||||||
|
formation window, then sum stock return minus beta * benchmark return. No
|
||||||
|
intercept is subtracted: fitting an intercept over the same window would make
|
||||||
|
residuals sum to roughly zero and destroy the ranking signal.
|
||||||
|
"""
|
||||||
|
i = len(closes) - 1
|
||||||
|
if not benchmark_closes or len(dates) != len(closes) or i - _MOM_LOOKBACK < 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stock_rets: list[float] = []
|
||||||
|
market_rets: list[float] = []
|
||||||
|
for k in range(i - _MOM_LOOKBACK + 1, i - _MOM_SKIP + 1):
|
||||||
|
prev_close = closes[k - 1]
|
||||||
|
bench_prev = benchmark_closes.get(dates[k - 1])
|
||||||
|
bench_cur = benchmark_closes.get(dates[k])
|
||||||
|
if prev_close <= 0 or bench_prev is None or bench_cur is None or bench_prev <= 0:
|
||||||
|
continue
|
||||||
|
stock_rets.append(closes[k] / prev_close - 1.0)
|
||||||
|
market_rets.append(bench_cur / bench_prev - 1.0)
|
||||||
|
|
||||||
|
if len(stock_rets) < 100:
|
||||||
|
return None
|
||||||
|
mean_market = sum(market_rets) / len(market_rets)
|
||||||
|
mean_stock = sum(stock_rets) / len(stock_rets)
|
||||||
|
var_market = sum((x - mean_market) ** 2 for x in market_rets)
|
||||||
|
if var_market <= 0:
|
||||||
|
return None
|
||||||
|
cov = sum(
|
||||||
|
(stock_rets[k] - mean_stock) * (market_rets[k] - mean_market)
|
||||||
|
for k in range(len(stock_rets))
|
||||||
|
)
|
||||||
|
beta = cov / var_market
|
||||||
|
return sum(stock_rets[k] - beta * market_rets[k] for k in range(len(stock_rets)))
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_activation_benchmark(db: AsyncSession) -> dict[date, float]:
|
||||||
|
"""Load SPY closes for residual momentum; refresh once if the table is empty."""
|
||||||
|
try:
|
||||||
|
from app.services.benchmark_service import load_benchmark_closes, refresh_benchmark_prices
|
||||||
|
|
||||||
|
closes = await load_benchmark_closes(db)
|
||||||
|
if closes:
|
||||||
|
return closes
|
||||||
|
await refresh_benchmark_prices(db)
|
||||||
|
return await load_benchmark_closes(db)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Residual momentum benchmark load failed; falling back to raw momentum")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
async def compute_momentum_percentiles(db: AsyncSession) -> dict[str, float]:
|
async def compute_momentum_percentiles(db: AsyncSession) -> dict[str, float]:
|
||||||
"""Compute each ticker's 12-1 momentum and rank the universe into a
|
"""Compute each ticker's activation momentum rank.
|
||||||
``{symbol: percentile}`` map (0–100, 100 = strongest momentum). Tickers
|
|
||||||
without a full year of history are absent (can't be ranked)."""
|
Production uses residual 12-1 momentum when benchmark data is available. If
|
||||||
|
SPY data is absent, fall back to raw 12-1 momentum rather than disabling the
|
||||||
|
scanner. Tickers without enough stock/benchmark history are absent.
|
||||||
|
"""
|
||||||
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
|
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
|
||||||
tickers = list(result.scalars().all())
|
tickers = list(result.scalars().all())
|
||||||
|
|
||||||
momentum: dict[str, float] = {}
|
benchmark_closes = await _load_activation_benchmark(db)
|
||||||
|
using_residual = len(benchmark_closes) >= _MOM_LOOKBACK
|
||||||
|
|
||||||
|
values: dict[str, float] = {}
|
||||||
for ticker in tickers:
|
for ticker in tickers:
|
||||||
try:
|
try:
|
||||||
records = await query_ohlcv(db, ticker.symbol)
|
records = await query_ohlcv(db, ticker.symbol)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Momentum fetch failed for %s", ticker.symbol)
|
logger.exception("Momentum fetch failed for %s", ticker.symbol)
|
||||||
continue
|
continue
|
||||||
m = compute_12_1_momentum([float(r.close) for r in records])
|
closes = [float(r.close) for r in records]
|
||||||
if m is not None:
|
value = (
|
||||||
momentum[ticker.symbol] = m
|
compute_residual_12_1_momentum([r.date for r in records], closes, benchmark_closes)
|
||||||
|
if using_residual
|
||||||
|
else compute_12_1_momentum(closes)
|
||||||
|
)
|
||||||
|
if value is not None:
|
||||||
|
values[ticker.symbol] = value
|
||||||
|
|
||||||
ranked = sorted(momentum, key=lambda s: momentum[s])
|
ranked = sorted(values, key=lambda s: values[s])
|
||||||
n = len(ranked)
|
n = len(ranked)
|
||||||
percentiles = {
|
percentiles = {
|
||||||
sym: round((rank / (n - 1) * 100.0) if n > 1 else 100.0, 2)
|
sym: round((rank / (n - 1) * 100.0) if n > 1 else 100.0, 2)
|
||||||
for rank, sym in enumerate(ranked)
|
for rank, sym in enumerate(ranked)
|
||||||
}
|
}
|
||||||
logger.info(json.dumps({"event": "momentum_ranked", "tickers": n}))
|
logger.info(json.dumps({
|
||||||
|
"event": "momentum_ranked",
|
||||||
|
"signal": "residual_12_1" if using_residual else "raw_12_1_fallback",
|
||||||
|
"tickers": n,
|
||||||
|
}))
|
||||||
return percentiles
|
return percentiles
|
||||||
|
|||||||
@@ -20,19 +20,26 @@ from app.services.outcome_service import (
|
|||||||
evaluate_setup_against_bars,
|
evaluate_setup_against_bars,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Exit policy for OPEN paper trades (auto-close). "trailing" rides a trailing stop
|
# Exit policy for OPEN paper trades (auto-close). "time" holds a fixed number of
|
||||||
# (validated as the best exit in the backtest); "target" closes at the setup's
|
# trading days with the initial stop and exits at that day's close — the exit the
|
||||||
# stop/target. Stored in SystemSetting so it's tunable + transparent in the UI.
|
# 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_EXIT_MODE = "paper_exit_mode"
|
||||||
KEY_TRAILING_PCT = "paper_trailing_pct"
|
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_TRAILING_PCT = 12.0
|
||||||
|
DEFAULT_HOLD_DAYS = 30
|
||||||
|
|
||||||
|
_VALID_EXIT_MODES = ("time", "trailing", "target")
|
||||||
|
|
||||||
|
|
||||||
async def get_exit_policy(db: AsyncSession) -> dict:
|
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()
|
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
|
mode = DEFAULT_EXIT_MODE
|
||||||
raw = await settings_store.get_value(db, KEY_TRAILING_PCT, str(DEFAULT_TRAILING_PCT))
|
raw = await settings_store.get_value(db, KEY_TRAILING_PCT, str(DEFAULT_TRAILING_PCT))
|
||||||
try:
|
try:
|
||||||
@@ -40,22 +47,36 @@ async def get_exit_policy(db: AsyncSession) -> dict:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pct = DEFAULT_TRAILING_PCT
|
pct = DEFAULT_TRAILING_PCT
|
||||||
pct = max(0.5, min(90.0, 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(
|
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:
|
) -> dict:
|
||||||
"""Persist the auto-exit policy (admin). Validates inputs."""
|
"""Persist the auto-exit policy (admin). Validates inputs."""
|
||||||
if mode is not None:
|
if mode is not None:
|
||||||
mode = mode.strip().lower()
|
mode = mode.strip().lower()
|
||||||
if mode not in ("trailing", "target"):
|
if mode not in _VALID_EXIT_MODES:
|
||||||
raise ValidationError("mode must be 'trailing' or 'target'")
|
raise ValidationError("mode must be 'time', 'trailing' or 'target'")
|
||||||
await settings_store.upsert_setting(db, KEY_EXIT_MODE, mode)
|
await settings_store.upsert_setting(db, KEY_EXIT_MODE, mode)
|
||||||
if trailing_pct is not None:
|
if trailing_pct is not None:
|
||||||
if not 0.5 <= float(trailing_pct) <= 90.0:
|
if not 0.5 <= float(trailing_pct) <= 90.0:
|
||||||
raise ValidationError("trailing_pct must be between 0.5 and 90")
|
raise ValidationError("trailing_pct must be between 0.5 and 90")
|
||||||
await settings_store.upsert_setting(db, KEY_TRAILING_PCT, str(float(trailing_pct)))
|
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()
|
await db.commit()
|
||||||
return await get_exit_policy(db)
|
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
|
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(
|
def _trailing_close(
|
||||||
direction: str, entry: float, init_stop: float, trail_frac: float, bars: list[Bar]
|
direction: str, entry: float, init_stop: float, trail_frac: float, bars: list[Bar]
|
||||||
) -> tuple[float, date, str] | None:
|
) -> tuple[float, date, str] | None:
|
||||||
@@ -297,12 +335,12 @@ async def close_trade(
|
|||||||
|
|
||||||
|
|
||||||
async def resolve_open_trades(db: AsyncSession) -> int:
|
async def resolve_open_trades(db: AsyncSession) -> int:
|
||||||
"""Auto-close open trades whose stop or target was hit in the daily bars.
|
"""Auto-close open trades per the active exit policy, from the daily bars.
|
||||||
|
|
||||||
Walks the bars after each trade's open (same logic as the outcome evaluator).
|
Walks the bars after each trade's open. 'time' closes at the initial stop or
|
||||||
Target hit → close at the target; stop (or an ambiguous same-bar touch) →
|
the hold_days-th close; 'trailing' at the trailing/initial stop; 'target' at
|
||||||
close at the stop. Trades that have hit neither stay open. Returns the count
|
the setup's target or stop (same logic as the outcome evaluator). Trades that
|
||||||
closed.
|
have hit nothing stay open. Returns the count closed.
|
||||||
"""
|
"""
|
||||||
result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open"))
|
result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open"))
|
||||||
open_trades = list(result.scalars().all())
|
open_trades = list(result.scalars().all())
|
||||||
@@ -312,22 +350,32 @@ async def resolve_open_trades(db: AsyncSession) -> int:
|
|||||||
policy = await get_exit_policy(db)
|
policy = await get_exit_policy(db)
|
||||||
mode = policy["mode"]
|
mode = policy["mode"]
|
||||||
trail_frac = policy["trailing_pct"] / 100.0
|
trail_frac = policy["trailing_pct"] / 100.0
|
||||||
|
hold_days = policy["hold_days"]
|
||||||
|
|
||||||
closed = 0
|
closed = 0
|
||||||
for trade in open_trades:
|
for trade in open_trades:
|
||||||
bars_result = await db.execute(
|
bars_result = await db.execute(
|
||||||
select(OHLCVRecord.date, OHLCVRecord.high, OHLCVRecord.low)
|
select(
|
||||||
|
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
|
||||||
|
OHLCVRecord.low, OHLCVRecord.close,
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
OHLCVRecord.ticker_id == trade.ticker_id,
|
OHLCVRecord.ticker_id == trade.ticker_id,
|
||||||
OHLCVRecord.date > trade.opened_at.date(),
|
OHLCVRecord.date > trade.opened_at.date(),
|
||||||
)
|
)
|
||||||
.order_by(OHLCVRecord.date.asc())
|
.order_by(OHLCVRecord.date.asc())
|
||||||
)
|
)
|
||||||
bars = [Bar(date=d, high=h, low=lo) for d, h, lo in bars_result.all()]
|
rows = bars_result.all()
|
||||||
|
bars = [Bar(date=d, high=h, low=lo) for d, _, h, lo, _ in rows]
|
||||||
if not bars:
|
if not bars:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if mode == "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)
|
hit = _trailing_close(trade.direction, trade.entry_price, trade.stop_loss, trail_frac, bars)
|
||||||
if hit is None:
|
if hit is None:
|
||||||
continue # neither the trailing nor the initial stop reached yet
|
continue # neither the trailing nor the initial stop reached yet
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
A single predicate, driven by the admin activation config, used by the
|
A single predicate, driven by the admin activation config, used by the
|
||||||
performance stats (server) and mirrored on the frontend. The core selection is
|
performance stats (server) and mirrored on the frontend. The core selection is
|
||||||
cross-sectional momentum: a setup's ticker must rank in the top
|
residual cross-sectional momentum: a setup's ticker must rank in the top
|
||||||
``min_momentum_percentile`` of the universe by 12-1 month momentum — the one
|
``min_momentum_percentile`` of the universe by beta-adjusted 12-1 month momentum.
|
||||||
signal the backtest showed actually sorts forward returns. R:R and confidence
|
R:R and confidence remain as floors, and conviction/conflict survive as optional
|
||||||
remain as floors, and conviction/conflict survive as optional tighteners (off by
|
tighteners (off by default). The activation percentile is computed across the
|
||||||
default). The momentum percentile is computed across the universe and attached to
|
universe and attached to each setup upstream; when it's absent the gate falls
|
||||||
each setup upstream; when it's absent the gate falls back to the floors.
|
back to the floors.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -17,6 +17,16 @@ from typing import Any
|
|||||||
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
|
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
|
||||||
|
|
||||||
|
|
||||||
|
def _action_direction(action: str | None) -> str:
|
||||||
|
if not action or action == "NEUTRAL":
|
||||||
|
return "neutral"
|
||||||
|
if action.startswith("LONG"):
|
||||||
|
return "long"
|
||||||
|
if action.startswith("SHORT"):
|
||||||
|
return "short"
|
||||||
|
return "neutral"
|
||||||
|
|
||||||
|
|
||||||
def best_target_probability(setup: Any) -> float:
|
def best_target_probability(setup: Any) -> float:
|
||||||
"""Highest probability among a setup's targets, 0 if none."""
|
"""Highest probability among a setup's targets, 0 if none."""
|
||||||
targets = getattr(setup, "targets", None) or []
|
targets = getattr(setup, "targets", None) or []
|
||||||
@@ -65,12 +75,12 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
|
|||||||
return False
|
return False
|
||||||
if (setup.confidence_score or 0.0) < config["min_confidence"]:
|
if (setup.confidence_score or 0.0) < config["min_confidence"]:
|
||||||
return False
|
return False
|
||||||
# Cross-sectional momentum: the core selection. A setup's ticker must rank in
|
# Residual cross-sectional momentum: the core selection. A setup's ticker
|
||||||
# the top ``min_momentum_percentile`` of the universe by 12-1 momentum. The
|
# must rank in the top ``min_momentum_percentile`` of the universe by
|
||||||
# validated edge is long-only, so while the gate is active shorts (which fight
|
# beta-adjusted 12-1 momentum. The validated edge is long-only, so while the
|
||||||
# the trend) never qualify. The percentile floor is only enforced when a
|
# gate is active shorts (which fight the trend) never qualify. The percentile
|
||||||
# percentile is attached (live setups / backtest); callers that don't attach
|
# floor is only enforced when a percentile is attached (live setups /
|
||||||
# it defer to the floors above.
|
# backtest); callers that don't attach it defer to the floors above.
|
||||||
min_pct = float(config.get("min_momentum_percentile", 0.0))
|
min_pct = float(config.get("min_momentum_percentile", 0.0))
|
||||||
if min_pct > 0:
|
if min_pct > 0:
|
||||||
if (getattr(setup, "direction", "long") or "long") == "short":
|
if (getattr(setup, "direction", "long") or "long") == "short":
|
||||||
@@ -78,12 +88,14 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
|
|||||||
momentum_percentile = getattr(setup, "momentum_percentile", None)
|
momentum_percentile = getattr(setup, "momentum_percentile", None)
|
||||||
if momentum_percentile is not None and momentum_percentile < min_pct:
|
if momentum_percentile is not None and momentum_percentile < min_pct:
|
||||||
return False
|
return False
|
||||||
# A NEUTRAL recommendation means the engine found no clear directional setup —
|
# A setup is actionable only when the live ticker action points in the same
|
||||||
# not an actionable signal, so by default it doesn't qualify (and can't be a
|
# direction. NEUTRAL means no clear signal; an opposite action means the
|
||||||
# top pick). ``exclude_neutral`` defaults on; turn it off to also count
|
# setup is counter-bias. ``exclude_neutral`` defaults on; callers that omit
|
||||||
# no-clear-direction momentum leaders.
|
# it keep legacy floor-only behavior.
|
||||||
if config.get("exclude_neutral"):
|
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
|
return False
|
||||||
if config.get("require_high_conviction"):
|
if config.get("require_high_conviction"):
|
||||||
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
|
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
|
||||||
|
|||||||
@@ -524,6 +524,56 @@ def _build_reasoning(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_recommendation_snapshot(
|
||||||
|
dimension_scores: dict[str, float],
|
||||||
|
sentiment_classification: str | None,
|
||||||
|
config: dict[str, float],
|
||||||
|
available_directions: set[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the ticker-level recommendation from the supplied live context."""
|
||||||
|
conflicts = signal_conflict_detector.detect_conflicts(
|
||||||
|
dimension_scores=dimension_scores,
|
||||||
|
sentiment_classification=sentiment_classification,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
long_confidence = direction_analyzer.calculate_confidence(
|
||||||
|
direction="long",
|
||||||
|
dimension_scores=dimension_scores,
|
||||||
|
sentiment_classification=sentiment_classification,
|
||||||
|
conflicts=conflicts,
|
||||||
|
)
|
||||||
|
short_confidence = direction_analyzer.calculate_confidence(
|
||||||
|
direction="short",
|
||||||
|
dimension_scores=dimension_scores,
|
||||||
|
sentiment_classification=sentiment_classification,
|
||||||
|
conflicts=conflicts,
|
||||||
|
)
|
||||||
|
|
||||||
|
action = _choose_recommended_action(
|
||||||
|
long_confidence, short_confidence, config, available_directions
|
||||||
|
)
|
||||||
|
reasoning = _build_reasoning(
|
||||||
|
action=action,
|
||||||
|
long_confidence=long_confidence,
|
||||||
|
short_confidence=short_confidence,
|
||||||
|
conflicts=conflicts,
|
||||||
|
dimension_scores=dimension_scores,
|
||||||
|
sentiment_classification=sentiment_classification,
|
||||||
|
config=config,
|
||||||
|
available_directions=available_directions,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"action": action,
|
||||||
|
"reasoning": reasoning,
|
||||||
|
"risk_level": _risk_level_from_conflicts(conflicts),
|
||||||
|
"long_confidence": long_confidence,
|
||||||
|
"short_confidence": short_confidence,
|
||||||
|
"conflicts": conflicts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
PRIMARY_TARGET_MIN_RR = 1.5
|
PRIMARY_TARGET_MIN_RR = 1.5
|
||||||
|
|
||||||
|
|
||||||
@@ -559,24 +609,15 @@ async def enhance_trade_setup(
|
|||||||
) -> TradeSetup:
|
) -> TradeSetup:
|
||||||
config = await get_recommendation_config(db)
|
config = await get_recommendation_config(db)
|
||||||
|
|
||||||
conflicts = signal_conflict_detector.detect_conflicts(
|
snapshot = build_recommendation_snapshot(
|
||||||
dimension_scores=dimension_scores,
|
dimension_scores=dimension_scores,
|
||||||
sentiment_classification=sentiment_classification,
|
sentiment_classification=sentiment_classification,
|
||||||
config=config,
|
config=config,
|
||||||
|
available_directions=available_directions,
|
||||||
)
|
)
|
||||||
|
conflicts = list(snapshot["conflicts"])
|
||||||
long_confidence = direction_analyzer.calculate_confidence(
|
long_confidence = float(snapshot["long_confidence"])
|
||||||
direction="long",
|
short_confidence = float(snapshot["short_confidence"])
|
||||||
dimension_scores=dimension_scores,
|
|
||||||
sentiment_classification=sentiment_classification,
|
|
||||||
conflicts=conflicts,
|
|
||||||
)
|
|
||||||
short_confidence = direction_analyzer.calculate_confidence(
|
|
||||||
direction="short",
|
|
||||||
dimension_scores=dimension_scores,
|
|
||||||
sentiment_classification=sentiment_classification,
|
|
||||||
conflicts=conflicts,
|
|
||||||
)
|
|
||||||
|
|
||||||
direction = setup.direction.lower()
|
direction = setup.direction.lower()
|
||||||
confidence = long_confidence if direction == "long" else short_confidence
|
confidence = long_confidence if direction == "long" else short_confidence
|
||||||
@@ -622,19 +663,8 @@ async def enhance_trade_setup(
|
|||||||
|
|
||||||
# Action and reasoning are ticker-level: they consider both directions and
|
# Action and reasoning are ticker-level: they consider both directions and
|
||||||
# which directions are actually tradeable, and are identical on every setup.
|
# which directions are actually tradeable, and are identical on every setup.
|
||||||
action = _choose_recommended_action(
|
action = str(snapshot["action"])
|
||||||
long_confidence, short_confidence, config, available_directions
|
reasoning = str(snapshot["reasoning"])
|
||||||
)
|
|
||||||
reasoning = _build_reasoning(
|
|
||||||
action=action,
|
|
||||||
long_confidence=long_confidence,
|
|
||||||
short_confidence=short_confidence,
|
|
||||||
conflicts=conflicts,
|
|
||||||
dimension_scores=dimension_scores,
|
|
||||||
sentiment_classification=sentiment_classification,
|
|
||||||
config=config,
|
|
||||||
available_directions=available_directions,
|
|
||||||
)
|
|
||||||
|
|
||||||
setup.confidence_score = round(confidence, 2)
|
setup.confidence_score = round(confidence, 2)
|
||||||
setup.targets_json = json.dumps(targets)
|
setup.targets_json = json.dumps(targets)
|
||||||
|
|||||||
@@ -11,24 +11,33 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.exceptions import NotFoundError
|
from app.exceptions import NotFoundError
|
||||||
|
from app.models.fundamental import FundamentalData
|
||||||
from app.models.ohlcv import OHLCVRecord
|
from app.models.ohlcv import OHLCVRecord
|
||||||
from app.models.score import CompositeScore, DimensionScore
|
from app.models.score import CompositeScore, DimensionScore
|
||||||
from app.models.sentiment import SentimentScore
|
from app.models.sentiment import SentimentScore
|
||||||
|
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||||
from app.models.sr_level import SRLevel
|
from app.models.sr_level import SRLevel
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.models.trade_setup import TradeSetup
|
from app.models.trade_setup import TradeSetup
|
||||||
from app.services.indicator_service import _extract_ohlcv, compute_atr
|
from app.services.indicator_service import _extract_ohlcv, compute_atr
|
||||||
from app.services.price_service import query_ohlcv
|
from app.services.price_service import query_ohlcv
|
||||||
from app.services.recommendation_service import enhance_trade_setup
|
from app.services.recommendation_service import (
|
||||||
|
_risk_level_from_conflicts,
|
||||||
|
build_recommendation_snapshot,
|
||||||
|
enhance_trade_setup,
|
||||||
|
get_recommendation_config,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STRATEGY_VERSION = "residual_momentum_12_1_rr_time_v2"
|
||||||
|
|
||||||
|
|
||||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||||
normalised = symbol.strip().upper()
|
normalised = symbol.strip().upper()
|
||||||
@@ -76,6 +85,263 @@ async def _get_latest_sentiment(db: AsyncSession, ticker_id: int) -> str | None:
|
|||||||
return row.classification if row else None
|
return row.classification if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_live_recommendation_context(
|
||||||
|
db: AsyncSession,
|
||||||
|
setup_rows: list[tuple[TradeSetup, str]],
|
||||||
|
rows: list[dict],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Decorate latest setup rows with current score/sentiment recommendation data.
|
||||||
|
|
||||||
|
This intentionally updates only the API payload. Stored trade setups and
|
||||||
|
history remain point-in-time records for outcome analysis.
|
||||||
|
"""
|
||||||
|
if not rows or not setup_rows:
|
||||||
|
return rows
|
||||||
|
|
||||||
|
ticker_ids = {setup.ticker_id for setup, _ in setup_rows}
|
||||||
|
setups_by_id = {setup.id: setup for setup, _ in setup_rows}
|
||||||
|
directions_by_ticker = await _latest_available_directions_by_ticker(db, ticker_ids)
|
||||||
|
|
||||||
|
dim_result = await db.execute(
|
||||||
|
select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids))
|
||||||
|
)
|
||||||
|
dims_by_ticker: dict[int, dict[str, float]] = {}
|
||||||
|
for ds in dim_result.scalars().all():
|
||||||
|
dims_by_ticker.setdefault(ds.ticker_id, {})[ds.dimension] = float(ds.score)
|
||||||
|
|
||||||
|
comp_result = await db.execute(
|
||||||
|
select(CompositeScore)
|
||||||
|
.where(CompositeScore.ticker_id.in_(ticker_ids))
|
||||||
|
.order_by(CompositeScore.ticker_id, CompositeScore.computed_at.desc())
|
||||||
|
)
|
||||||
|
composites: dict[int, CompositeScore] = {}
|
||||||
|
for comp in comp_result.scalars().all():
|
||||||
|
composites.setdefault(comp.ticker_id, comp)
|
||||||
|
|
||||||
|
sent_result = await db.execute(
|
||||||
|
select(SentimentScore)
|
||||||
|
.where(SentimentScore.ticker_id.in_(ticker_ids))
|
||||||
|
.order_by(SentimentScore.ticker_id, SentimentScore.timestamp.desc())
|
||||||
|
)
|
||||||
|
sentiments: dict[int, SentimentScore] = {}
|
||||||
|
for sent in sent_result.scalars().all():
|
||||||
|
sentiments.setdefault(sent.ticker_id, sent)
|
||||||
|
|
||||||
|
config = await get_recommendation_config(db)
|
||||||
|
live_rows: list[dict] = []
|
||||||
|
for row in rows:
|
||||||
|
setup = setups_by_id.get(row["id"])
|
||||||
|
if setup is None:
|
||||||
|
live_rows.append(row)
|
||||||
|
continue
|
||||||
|
|
||||||
|
ticker_id = setup.ticker_id
|
||||||
|
live_row = dict(row)
|
||||||
|
|
||||||
|
comp = composites.get(ticker_id)
|
||||||
|
if comp is not None:
|
||||||
|
live_row["composite_score"] = float(comp.score)
|
||||||
|
live_row["context_as_of"]["score_computed_at"] = comp.computed_at
|
||||||
|
|
||||||
|
dimension_scores = dims_by_ticker.get(ticker_id)
|
||||||
|
sentiment = sentiments.get(ticker_id)
|
||||||
|
if sentiment is not None:
|
||||||
|
live_row["context_as_of"]["sentiment_at"] = sentiment.timestamp
|
||||||
|
if dimension_scores:
|
||||||
|
snapshot = build_recommendation_snapshot(
|
||||||
|
dimension_scores=dimension_scores,
|
||||||
|
sentiment_classification=sentiment.classification if sentiment else None,
|
||||||
|
config=config,
|
||||||
|
available_directions=directions_by_ticker.get(ticker_id),
|
||||||
|
)
|
||||||
|
direction = setup.direction.lower()
|
||||||
|
confidence_key = "long_confidence" if direction == "long" else "short_confidence"
|
||||||
|
live_row["confidence_score"] = round(float(snapshot[confidence_key]), 2)
|
||||||
|
live_row["recommended_action"] = snapshot["action"]
|
||||||
|
live_row["reasoning"] = snapshot["reasoning"]
|
||||||
|
setup_conflicts = _setup_specific_conflicts(live_row.get("conflict_flags", []))
|
||||||
|
live_conflicts = [str(item) for item in snapshot["conflicts"]]
|
||||||
|
live_row["conflict_flags"] = live_conflicts + setup_conflicts
|
||||||
|
live_row["risk_level"] = _risk_level_from_conflicts(live_row["conflict_flags"])
|
||||||
|
|
||||||
|
live_rows.append(live_row)
|
||||||
|
|
||||||
|
return live_rows
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_specific_conflicts(conflicts: list[str]) -> list[str]:
|
||||||
|
signal_prefixes = (
|
||||||
|
"sentiment-technical:",
|
||||||
|
"sentiment-momentum:",
|
||||||
|
"momentum-technical:",
|
||||||
|
"fundamental-technical:",
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
str(conflict)
|
||||||
|
for conflict in conflicts
|
||||||
|
if not str(conflict).startswith(signal_prefixes)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_available_directions_by_ticker(
|
||||||
|
db: AsyncSession,
|
||||||
|
ticker_ids: set[int],
|
||||||
|
) -> dict[int, set[str]]:
|
||||||
|
if not ticker_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(TradeSetup)
|
||||||
|
.where(TradeSetup.ticker_id.in_(ticker_ids))
|
||||||
|
.order_by(
|
||||||
|
TradeSetup.ticker_id,
|
||||||
|
TradeSetup.direction,
|
||||||
|
TradeSetup.detected_at.desc(),
|
||||||
|
TradeSetup.id.desc(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
latest_by_key: set[tuple[int, str]] = set()
|
||||||
|
directions: dict[int, set[str]] = {}
|
||||||
|
for setup in result.scalars().all():
|
||||||
|
direction = setup.direction.lower()
|
||||||
|
key = (setup.ticker_id, direction)
|
||||||
|
if key in latest_by_key:
|
||||||
|
continue
|
||||||
|
latest_by_key.add(key)
|
||||||
|
directions.setdefault(setup.ticker_id, set()).add(direction)
|
||||||
|
return directions
|
||||||
|
|
||||||
|
|
||||||
|
def _json_default(value):
|
||||||
|
if isinstance(value, (datetime, date)):
|
||||||
|
return value.isoformat()
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_signal_context_snapshots(
|
||||||
|
db: AsyncSession,
|
||||||
|
setups: list[TradeSetup],
|
||||||
|
*,
|
||||||
|
strategy_version: str = STRATEGY_VERSION,
|
||||||
|
) -> None:
|
||||||
|
"""Capture point-in-time discretionary context for freshly generated setups.
|
||||||
|
|
||||||
|
The scanner stores the setup itself first so each snapshot can be keyed by
|
||||||
|
``trade_setup_id``. This is intentionally forward-only: old sentiment,
|
||||||
|
fundamentals and composite scores are not reconstructed from today's data.
|
||||||
|
"""
|
||||||
|
if not setups:
|
||||||
|
return
|
||||||
|
|
||||||
|
ticker_ids = {s.ticker_id for s in setups}
|
||||||
|
|
||||||
|
dims: dict[int, dict[str, dict]] = {}
|
||||||
|
dim_rows = (
|
||||||
|
await db.execute(select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids)))
|
||||||
|
).scalars().all()
|
||||||
|
for row in dim_rows:
|
||||||
|
dims.setdefault(row.ticker_id, {})[row.dimension] = {
|
||||||
|
"score": float(row.score),
|
||||||
|
"is_stale": bool(row.is_stale),
|
||||||
|
"computed_at": row.computed_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
composites: dict[int, CompositeScore] = {}
|
||||||
|
comp_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(CompositeScore)
|
||||||
|
.where(CompositeScore.ticker_id.in_(ticker_ids))
|
||||||
|
.order_by(CompositeScore.ticker_id, CompositeScore.computed_at.desc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for row in comp_rows:
|
||||||
|
composites.setdefault(row.ticker_id, row)
|
||||||
|
|
||||||
|
sentiments: dict[int, SentimentScore] = {}
|
||||||
|
sent_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(SentimentScore)
|
||||||
|
.where(SentimentScore.ticker_id.in_(ticker_ids))
|
||||||
|
.order_by(SentimentScore.ticker_id, SentimentScore.timestamp.desc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for row in sent_rows:
|
||||||
|
sentiments.setdefault(row.ticker_id, row)
|
||||||
|
|
||||||
|
fundamentals: dict[int, FundamentalData] = {}
|
||||||
|
fund_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(FundamentalData)
|
||||||
|
.where(FundamentalData.ticker_id.in_(ticker_ids))
|
||||||
|
.order_by(FundamentalData.ticker_id, FundamentalData.fetched_at.desc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for row in fund_rows:
|
||||||
|
fundamentals.setdefault(row.ticker_id, row)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for setup in setups:
|
||||||
|
comp = composites.get(setup.ticker_id)
|
||||||
|
sent = sentiments.get(setup.ticker_id)
|
||||||
|
fund = fundamentals.get(setup.ticker_id)
|
||||||
|
score_context = {
|
||||||
|
"composite_score": float(comp.score) if comp else float(setup.composite_score),
|
||||||
|
"composite_is_stale": bool(comp.is_stale) if comp else None,
|
||||||
|
"composite_computed_at": comp.computed_at if comp else None,
|
||||||
|
"dimensions": dims.get(setup.ticker_id, {}),
|
||||||
|
}
|
||||||
|
sentiment_context = (
|
||||||
|
{
|
||||||
|
"classification": sent.classification,
|
||||||
|
"confidence": int(sent.confidence),
|
||||||
|
"recommendation": sent.recommendation,
|
||||||
|
"timestamp": sent.timestamp,
|
||||||
|
"source": sent.source,
|
||||||
|
}
|
||||||
|
if sent
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
fundamental_context = (
|
||||||
|
{
|
||||||
|
"pe_ratio": fund.pe_ratio,
|
||||||
|
"revenue_growth": fund.revenue_growth,
|
||||||
|
"earnings_surprise": fund.earnings_surprise,
|
||||||
|
"market_cap": fund.market_cap,
|
||||||
|
"next_earnings_date": fund.next_earnings_date,
|
||||||
|
"fetched_at": fund.fetched_at,
|
||||||
|
}
|
||||||
|
if fund
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
SignalContextSnapshot(
|
||||||
|
trade_setup_id=setup.id,
|
||||||
|
ticker_id=setup.ticker_id,
|
||||||
|
detected_at=setup.detected_at,
|
||||||
|
created_at=now,
|
||||||
|
strategy_version=strategy_version,
|
||||||
|
direction=setup.direction,
|
||||||
|
entry_price=float(setup.entry_price),
|
||||||
|
stop_loss=float(setup.stop_loss),
|
||||||
|
target=float(setup.target),
|
||||||
|
rr_ratio=float(setup.rr_ratio),
|
||||||
|
confidence_score=(
|
||||||
|
float(setup.confidence_score) if setup.confidence_score is not None else None
|
||||||
|
),
|
||||||
|
recommended_action=setup.recommended_action,
|
||||||
|
risk_level=setup.risk_level,
|
||||||
|
momentum_percentile=(
|
||||||
|
float(setup.momentum_percentile)
|
||||||
|
if setup.momentum_percentile is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
score_context_json=json.dumps(score_context, default=_json_default),
|
||||||
|
sentiment_context_json=json.dumps(sentiment_context, default=_json_default),
|
||||||
|
fundamental_context_json=json.dumps(fundamental_context, default=_json_default),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def scan_ticker(
|
async def scan_ticker(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
@@ -85,9 +351,9 @@ async def scan_ticker(
|
|||||||
) -> list[TradeSetup]:
|
) -> list[TradeSetup]:
|
||||||
"""Scan a single ticker for trade setups meeting the R:R threshold.
|
"""Scan a single ticker for trade setups meeting the R:R threshold.
|
||||||
|
|
||||||
``momentum_percentile`` is the ticker's 12-1 momentum rank across the universe
|
``momentum_percentile`` is the ticker's residual 12-1 momentum activation
|
||||||
(computed by the caller), stored on each setup so the activation gate can
|
rank across the universe (computed by the caller), stored on each setup so
|
||||||
select the top slice."""
|
the activation gate can select the top slice."""
|
||||||
ticker = await _get_ticker(db, symbol)
|
ticker = await _get_ticker(db, symbol)
|
||||||
|
|
||||||
records = await query_ohlcv(db, symbol)
|
records = await query_ohlcv(db, symbol)
|
||||||
@@ -238,6 +504,9 @@ async def scan_ticker(
|
|||||||
for s in enhanced_setups:
|
for s in enhanced_setups:
|
||||||
await db.refresh(s)
|
await db.refresh(s)
|
||||||
|
|
||||||
|
await _create_signal_context_snapshots(db, enhanced_setups)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
return enhanced_setups
|
return enhanced_setups
|
||||||
|
|
||||||
|
|
||||||
@@ -256,8 +525,9 @@ async def scan_all_tickers(
|
|||||||
tickers = list(result.scalars().all())
|
tickers = list(result.scalars().all())
|
||||||
total = len(tickers)
|
total = len(tickers)
|
||||||
|
|
||||||
# Rank the universe by 12-1 momentum up front so each new setup carries its
|
# Rank the universe by residual 12-1 momentum up front so each new setup
|
||||||
# ticker's percentile (used by the activation gate). Best-effort.
|
# carries its activation percentile. Best-effort; the ranker falls back to
|
||||||
|
# raw 12-1 momentum only if benchmark data is unavailable.
|
||||||
try:
|
try:
|
||||||
from app.services import momentum_service
|
from app.services import momentum_service
|
||||||
|
|
||||||
@@ -303,6 +573,7 @@ async def get_trade_setups(
|
|||||||
min_confidence: float | None = None,
|
min_confidence: float | None = None,
|
||||||
recommended_action: str | None = None,
|
recommended_action: str | None = None,
|
||||||
symbol: str | None = None,
|
symbol: str | None = None,
|
||||||
|
live_recommendation: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get latest stored trade setups, optionally filtered."""
|
"""Get latest stored trade setups, optionally filtered."""
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -313,9 +584,11 @@ async def get_trade_setups(
|
|||||||
stmt = stmt.where(TradeSetup.direction == direction.lower())
|
stmt = stmt.where(TradeSetup.direction == direction.lower())
|
||||||
if symbol is not None:
|
if symbol is not None:
|
||||||
stmt = stmt.where(Ticker.symbol == symbol.strip().upper())
|
stmt = stmt.where(Ticker.symbol == symbol.strip().upper())
|
||||||
if min_confidence is not None:
|
# With live_recommendation these fields are overlaid with current values
|
||||||
|
# below, so filtering happens there instead of against the stored columns.
|
||||||
|
if min_confidence is not None and not live_recommendation:
|
||||||
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
|
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
|
||||||
if recommended_action is not None:
|
if recommended_action is not None and not live_recommendation:
|
||||||
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
|
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
|
||||||
|
|
||||||
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
|
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
|
||||||
@@ -339,15 +612,37 @@ async def get_trade_setups(
|
|||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
prices = await _latest_closes(db, {s.ticker_id for s, _ in latest_rows})
|
prices = await _latest_price_context(db, {s.ticker_id for s, _ in latest_rows})
|
||||||
return [
|
rows_out = [
|
||||||
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
|
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
|
||||||
for setup, ticker_symbol in latest_rows
|
for setup, ticker_symbol in latest_rows
|
||||||
]
|
]
|
||||||
|
if live_recommendation:
|
||||||
|
rows_out = await _apply_live_recommendation_context(db, latest_rows, rows_out)
|
||||||
|
if min_confidence is not None:
|
||||||
|
rows_out = [
|
||||||
|
row for row in rows_out
|
||||||
|
if row["confidence_score"] is not None
|
||||||
|
and row["confidence_score"] >= min_confidence
|
||||||
|
]
|
||||||
|
if recommended_action is not None:
|
||||||
|
rows_out = [
|
||||||
|
row for row in rows_out
|
||||||
|
if row["recommended_action"] == recommended_action
|
||||||
|
]
|
||||||
|
rows_out.sort(
|
||||||
|
key=lambda row: (
|
||||||
|
row["confidence_score"] if row["confidence_score"] is not None else -1.0,
|
||||||
|
row["rr_ratio"],
|
||||||
|
row["composite_score"],
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return rows_out
|
||||||
|
|
||||||
|
|
||||||
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
|
async def _latest_price_context(db: AsyncSession, ticker_ids: set[int]) -> dict[int, dict]:
|
||||||
"""Most recent close per ticker — used to judge a setup's current relevance."""
|
"""Most recent daily OHLCV row per ticker for live price context."""
|
||||||
if not ticker_ids:
|
if not ticker_ids:
|
||||||
return {}
|
return {}
|
||||||
latest = (
|
latest = (
|
||||||
@@ -356,7 +651,12 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
|
|||||||
.group_by(OHLCVRecord.ticker_id)
|
.group_by(OHLCVRecord.ticker_id)
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join(
|
stmt = select(
|
||||||
|
OHLCVRecord.ticker_id,
|
||||||
|
OHLCVRecord.close,
|
||||||
|
OHLCVRecord.date,
|
||||||
|
OHLCVRecord.created_at,
|
||||||
|
).join(
|
||||||
latest,
|
latest,
|
||||||
and_(
|
and_(
|
||||||
OHLCVRecord.ticker_id == latest.c.ticker_id,
|
OHLCVRecord.ticker_id == latest.c.ticker_id,
|
||||||
@@ -364,7 +664,23 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
return {tid: float(close) for tid, close in result.all()}
|
return {
|
||||||
|
tid: {
|
||||||
|
"current_price": float(close),
|
||||||
|
"price_date": price_date,
|
||||||
|
"price_updated_at": created_at,
|
||||||
|
}
|
||||||
|
for tid, close, price_date, created_at in result.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
|
||||||
|
"""Most recent close per ticker, kept for callers that only need price."""
|
||||||
|
price_context = await _latest_price_context(db, ticker_ids)
|
||||||
|
return {
|
||||||
|
ticker_id: context["current_price"]
|
||||||
|
for ticker_id, context in price_context.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_trade_setup_history(
|
async def get_trade_setup_history(
|
||||||
@@ -381,16 +697,28 @@ async def get_trade_setup_history(
|
|||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
prices = await _latest_closes(db, {s.ticker_id for s, _ in rows})
|
prices = await _latest_price_context(db, {s.ticker_id for s, _ in rows})
|
||||||
return [
|
return [
|
||||||
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
|
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
|
||||||
for setup, ticker_symbol in rows
|
for setup, ticker_symbol in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float | None = None) -> dict:
|
def _trade_setup_to_dict(setup: TradeSetup, symbol: str, price_context: dict | None = None) -> dict:
|
||||||
targets: list[dict] = []
|
targets: list[dict] = []
|
||||||
conflicts: list[str] = []
|
conflicts: list[str] = []
|
||||||
|
current_price = (
|
||||||
|
float(price_context["current_price"])
|
||||||
|
if price_context and price_context.get("current_price") is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
context_as_of = {
|
||||||
|
"setup_detected_at": setup.detected_at,
|
||||||
|
"score_computed_at": None,
|
||||||
|
"sentiment_at": None,
|
||||||
|
"price_date": price_context.get("price_date") if price_context else None,
|
||||||
|
"price_updated_at": price_context.get("price_updated_at") if price_context else None,
|
||||||
|
}
|
||||||
|
|
||||||
if setup.targets_json:
|
if setup.targets_json:
|
||||||
try:
|
try:
|
||||||
@@ -429,4 +757,5 @@ def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float |
|
|||||||
"evaluated_at": setup.evaluated_at,
|
"evaluated_at": setup.evaluated_at,
|
||||||
"current_price": current_price,
|
"current_price": current_price,
|
||||||
"momentum_percentile": setup.momentum_percentile,
|
"momentum_percentile": setup.momentum_percentile,
|
||||||
|
"context_as_of": context_as_of,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Computes dimension scores (technical, sr_quality, sentiment, fundamental,
|
Computes dimension scores (technical, sr_quality, sentiment, fundamental,
|
||||||
momentum) each 0-100, composite score as weighted average of available
|
momentum) each 0-100, composite score as weighted average of available
|
||||||
dimensions with re-normalized weights, staleness marking/recomputation
|
dimensions with re-normalized weights, staleness marking, explicit refresh
|
||||||
on demand, and weight update triggers full recomputation.
|
paths, and weight update triggers full recomputation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -765,73 +765,37 @@ async def compute_composite_score(
|
|||||||
async def get_score(
|
async def get_score(
|
||||||
db: AsyncSession, symbol: str
|
db: AsyncSession, symbol: str
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get composite + all dimension scores for a ticker.
|
"""Read composite + dimension scores for a ticker without recomputing.
|
||||||
|
|
||||||
Recomputes stale dimensions on demand, then recomputes composite.
|
GET endpoints use this path, so it must not mutate persisted score context.
|
||||||
Returns a dict suitable for ScoreResponse, including dimension breakdowns
|
Scheduled/manual write paths are responsible for refreshing scores.
|
||||||
and composite breakdown with re-normalization info.
|
|
||||||
"""
|
"""
|
||||||
ticker = await _get_ticker(db, symbol)
|
ticker = await _get_ticker(db, symbol)
|
||||||
weights = await _get_weights(db)
|
weights = await _get_weights(db)
|
||||||
|
|
||||||
# Check for stale dimension scores and recompute them
|
|
||||||
result = await db.execute(
|
|
||||||
select(DimensionScore).where(DimensionScore.ticker_id == ticker.id)
|
|
||||||
)
|
|
||||||
dim_scores = {ds.dimension: ds for ds in result.scalars().all()}
|
|
||||||
|
|
||||||
for dim in DIMENSIONS:
|
|
||||||
ds = dim_scores.get(dim)
|
|
||||||
if ds is None or ds.is_stale:
|
|
||||||
await compute_dimension_score(db, symbol, dim)
|
|
||||||
|
|
||||||
# Check composite staleness
|
|
||||||
comp_result = await db.execute(
|
|
||||||
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
|
|
||||||
)
|
|
||||||
comp = comp_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if comp is None or comp.is_stale:
|
|
||||||
await compute_composite_score(db, symbol, weights)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# Re-fetch everything fresh
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(DimensionScore).where(DimensionScore.ticker_id == ticker.id)
|
select(DimensionScore).where(DimensionScore.ticker_id == ticker.id)
|
||||||
)
|
)
|
||||||
dim_scores_list = list(result.scalars().all())
|
dim_scores_list = list(result.scalars().all())
|
||||||
|
dim_scores = {ds.dimension: ds for ds in dim_scores_list}
|
||||||
|
|
||||||
comp_result = await db.execute(
|
comp_result = await db.execute(
|
||||||
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
|
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
|
||||||
)
|
)
|
||||||
comp = comp_result.scalar_one_or_none()
|
comp = comp_result.scalar_one_or_none()
|
||||||
|
|
||||||
# Compute breakdowns for each dimension by calling the dimension computers
|
|
||||||
breakdowns: dict[str, dict | None] = {}
|
|
||||||
for dim in DIMENSIONS:
|
|
||||||
try:
|
|
||||||
raw_result = await _DIMENSION_COMPUTERS[dim](db, symbol)
|
|
||||||
if isinstance(raw_result, tuple) and len(raw_result) == 2:
|
|
||||||
breakdowns[dim] = raw_result[1]
|
|
||||||
else:
|
|
||||||
breakdowns[dim] = None
|
|
||||||
except Exception:
|
|
||||||
breakdowns[dim] = None
|
|
||||||
|
|
||||||
# Build dimension entries with breakdowns
|
|
||||||
dimensions = []
|
dimensions = []
|
||||||
missing = []
|
missing = []
|
||||||
available_dims: list[str] = []
|
available_dims: list[str] = []
|
||||||
for dim in DIMENSIONS:
|
for dim in DIMENSIONS:
|
||||||
found = next((ds for ds in dim_scores_list if ds.dimension == dim), None)
|
found = dim_scores.get(dim)
|
||||||
if found is not None and not found.is_stale and found.score is not None:
|
if found is not None and not found.is_stale and found.score is not None:
|
||||||
dimensions.append({
|
dimensions.append({
|
||||||
"dimension": found.dimension,
|
"dimension": found.dimension,
|
||||||
"score": found.score,
|
"score": found.score,
|
||||||
"is_stale": found.is_stale,
|
"is_stale": found.is_stale,
|
||||||
"computed_at": found.computed_at,
|
"computed_at": found.computed_at,
|
||||||
"breakdown": breakdowns.get(dim),
|
"breakdown": None,
|
||||||
})
|
})
|
||||||
w = weights.get(dim, 0.0)
|
w = weights.get(dim, 0.0)
|
||||||
if w > 0:
|
if w > 0:
|
||||||
@@ -845,7 +809,7 @@ async def get_score(
|
|||||||
"score": found.score,
|
"score": found.score,
|
||||||
"is_stale": found.is_stale,
|
"is_stale": found.is_stale,
|
||||||
"computed_at": found.computed_at,
|
"computed_at": found.computed_at,
|
||||||
"breakdown": breakdowns.get(dim),
|
"breakdown": None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Build composite breakdown: the non-sentiment base (re-normalized weighted
|
# 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
|
dims[ds.ticker_id][ds.dimension] = ds
|
||||||
return comps, dims
|
return comps, dims
|
||||||
|
|
||||||
# Two bulk reads instead of ~4 queries per ticker.
|
|
||||||
comps, dims_by_ticker = await _load_scores()
|
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 = [
|
rankings = [
|
||||||
{
|
{
|
||||||
"symbol": ticker.symbol,
|
"symbol": ticker.symbol,
|
||||||
"composite_score": comp.score,
|
"composite_score": comp.score,
|
||||||
|
"composite_stale": comp.is_stale,
|
||||||
"dimensions": [
|
"dimensions": [
|
||||||
{
|
{
|
||||||
"dimension": ds.dimension,
|
"dimension": ds.dimension,
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ async def _enrich_entry(
|
|||||||
"dimensions": dims,
|
"dimensions": dims,
|
||||||
"rr_ratio": setup.rr_ratio if setup else None,
|
"rr_ratio": setup.rr_ratio if setup else None,
|
||||||
"rr_direction": setup.direction if setup else None,
|
"rr_direction": setup.direction if setup else None,
|
||||||
# 12-1 cross-sectional momentum percentile (the top-pick selector); ticker-
|
# Residual 12-1 activation percentile (the top-pick selector); ticker-level,
|
||||||
# level, so any of the ticker's setups carries the same value.
|
# so any of the ticker's setups carries the same value.
|
||||||
"momentum_percentile": setup.momentum_percentile if setup else None,
|
"momentum_percentile": setup.momentum_percentile if setup else None,
|
||||||
"sr_levels": sr_levels,
|
"sr_levels": sr_levels,
|
||||||
"last_close": last_close,
|
"last_close": last_close,
|
||||||
|
|||||||
@@ -41,16 +41,16 @@ export function ActivationSettings() {
|
|||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the
|
What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the
|
||||||
Signals "Qualified only" view, and the Track Record's qualified stats. The core selection is
|
Signals "Qualified only" view, and the Track Record's qualified stats. The core selection is
|
||||||
<span className="text-gray-300"> cross-sectional momentum</span> — the ticker must rank in the
|
<span className="text-gray-300"> residual cross-sectional momentum</span> — the ticker must rank in the
|
||||||
top slice of the universe by 12-1 month momentum, the one signal the backtest showed predicts
|
top slice of the universe by beta-adjusted 12-1 month momentum, the production signal promoted
|
||||||
forward returns. R:R and confidence stay as floors. Tune the cutoff against the Track Record's
|
from the backtest. R:R and confidence stay as floors. Tune the cutoff against the Track Record's
|
||||||
momentum sweep to see what actually wins.
|
momentum sweep to see what actually wins.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs text-gray-400">Min Momentum Percentile</span>
|
<span className="text-xs text-gray-400">Min Residual Momentum Percentile</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -60,7 +60,7 @@ export function ActivationSettings() {
|
|||||||
onChange={(e) => setForm((prev) => ({ ...prev, min_momentum_percentile: Number(e.target.value) }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, min_momentum_percentile: Number(e.target.value) }))}
|
||||||
className="w-full input-glass px-3 py-2 text-sm"
|
className="w-full input-glass px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
<span className="text-[11px] text-gray-600">Ticker's 12-1 momentum rank. 80 = top 20% of the universe. 0 disables. The core gate.</span>
|
<span className="text-[11px] text-gray-600">Ticker's residual 12-1 momentum rank. 80 = top 20% of the universe. 0 disables. The core gate.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
|
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
|
||||||
@@ -100,7 +100,7 @@ export function ActivationSettings() {
|
|||||||
Require a directional call (exclude NEUTRAL)
|
Require a directional call (exclude NEUTRAL)
|
||||||
<span className="mt-0.5 block text-[11px] text-gray-500">
|
<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
|
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>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import { SkeletonCard } from '../ui/Skeleton';
|
|||||||
export function ExitPolicySettings() {
|
export function ExitPolicySettings() {
|
||||||
const { data, isLoading } = useExitPolicy();
|
const { data, isLoading } = useExitPolicy();
|
||||||
const update = useUpdateExitPolicy();
|
const update = useUpdateExitPolicy();
|
||||||
const [mode, setMode] = useState<ExitPolicy['mode']>('trailing');
|
const [mode, setMode] = useState<ExitPolicy['mode']>('time');
|
||||||
const [pct, setPct] = useState(12);
|
const [pct, setPct] = useState(12);
|
||||||
|
const [holdDays, setHoldDays] = useState(30);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setMode(data.mode);
|
setMode(data.mode);
|
||||||
setPct(data.trailing_pct);
|
setPct(data.trailing_pct);
|
||||||
|
setHoldDays(data.hold_days ?? 30);
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -24,12 +26,14 @@ export function ExitPolicySettings() {
|
|||||||
<h3 className="text-sm font-semibold text-gray-200">Paper-Trade Exit</h3>
|
<h3 className="text-sm font-semibold text-gray-200">Paper-Trade Exit</h3>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
How open paper trades auto-close (in the nightly/intraday outcome job).{' '}
|
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,
|
<span className="text-gray-300">Hold</span> keeps the initial stop and exits at the Nth trading
|
||||||
it lets winners run; <span className="text-gray-300">Target / stop</span> closes at the setup's
|
day's close — the backtest-validated exit (classic momentum: hold ~a month, re-rank);{' '}
|
||||||
target or stop. The setup's initial stop is always the floor.
|
<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>
|
</p>
|
||||||
</div>
|
</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">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs text-gray-400">Exit mode</span>
|
<span className="text-xs text-gray-400">Exit mode</span>
|
||||||
<select
|
<select
|
||||||
@@ -37,10 +41,25 @@ export function ExitPolicySettings() {
|
|||||||
onChange={(e) => setMode(e.target.value as ExitPolicy['mode'])}
|
onChange={(e) => setMode(e.target.value as ExitPolicy['mode'])}
|
||||||
className="w-full input-glass px-3 py-2 text-sm"
|
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="trailing">Trailing stop</option>
|
||||||
<option value="target">Target / stop</option>
|
<option value="target">Target / stop</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs text-gray-400">Trailing width (%)</span>
|
<span className="text-xs text-gray-400">Trailing width (%)</span>
|
||||||
<input
|
<input
|
||||||
@@ -53,13 +72,13 @@ export function ExitPolicySettings() {
|
|||||||
disabled={mode !== 'trailing'}
|
disabled={mode !== 'trailing'}
|
||||||
className="w-full input-glass px-3 py-2 text-sm disabled:opacity-50"
|
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 ~12–15%.</span>
|
<span className="text-[11px] text-gray-600">Give-back from the peak. ≥15% ≈ the hold exit.</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn-primary px-4 py-2 text-sm disabled:opacity-50"
|
className="btn-primary px-4 py-2 text-sm disabled:opacity-50"
|
||||||
disabled={update.isPending}
|
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'}
|
{update.isPending ? 'Saving…' : 'Save Exit Policy'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||||
import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types';
|
import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types';
|
||||||
import { formatPrice, formatDate } from '../../lib/format';
|
import { formatPrice, formatDate, formatLargeNumber } from '../../lib/format';
|
||||||
|
|
||||||
interface CandlestickChartProps {
|
interface CandlestickChartProps {
|
||||||
data: OHLCVBar[];
|
data: OHLCVBar[];
|
||||||
@@ -50,6 +50,9 @@ interface TooltipState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MIN_VISIBLE_BARS = 10;
|
const MIN_VISIBLE_BARS = 10;
|
||||||
|
const CHART_HEIGHT = 440;
|
||||||
|
const VOLUME_PANE_HEIGHT = 72;
|
||||||
|
const PANE_GAP = 18;
|
||||||
|
|
||||||
type RangePreset = '1M' | '3M' | '6M' | 'YTD' | '1Y' | '3Y' | '5Y' | 'All';
|
type RangePreset = '1M' | '3M' | '6M' | 'YTD' | '1Y' | '3Y' | '5Y' | 'All';
|
||||||
const RANGE_PRESETS: RangePreset[] = ['1M', '3M', '6M', 'YTD', '1Y', '3Y', '5Y', 'All'];
|
const RANGE_PRESETS: RangePreset[] = ['1M', '3M', '6M', 'YTD', '1Y', '3Y', '5Y', 'All'];
|
||||||
@@ -109,7 +112,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
|||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
const W = rect.width;
|
const W = rect.width;
|
||||||
const H = 400;
|
const H = CHART_HEIGHT;
|
||||||
|
|
||||||
canvas.width = W * dpr;
|
canvas.width = W * dpr;
|
||||||
canvas.height = H * dpr;
|
canvas.height = H * dpr;
|
||||||
@@ -124,7 +127,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
|||||||
// Margins
|
// Margins
|
||||||
const ml = 12, mr = 70, mt = 12, mb = 32;
|
const ml = 12, mr = 70, mt = 12, mb = 32;
|
||||||
const cw = W - ml - mr;
|
const cw = W - ml - mr;
|
||||||
const ch = H - mt - mb;
|
const volumeH = VOLUME_PANE_HEIGHT;
|
||||||
|
const ch = H - mt - mb - volumeH - PANE_GAP;
|
||||||
|
const priceBottom = mt + ch;
|
||||||
|
const volumeTop = priceBottom + PANE_GAP;
|
||||||
|
const volumeBottom = volumeTop + volumeH;
|
||||||
|
|
||||||
// Current price = explicit prop, else latest close
|
// Current price = explicit prop, else latest close
|
||||||
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
|
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
|
||||||
@@ -145,6 +152,9 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
|||||||
const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
|
const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
|
||||||
const barW = cw / visibleData.length;
|
const barW = cw / visibleData.length;
|
||||||
const candleW = Math.max(barW * 0.65, 1);
|
const candleW = Math.max(barW * 0.65, 1);
|
||||||
|
const volumeW = Math.max(barW * 0.65, 1);
|
||||||
|
const maxVolume = Math.max(...visibleData.map((b) => Math.max(0, b.volume)), 1);
|
||||||
|
const volumeScale = (v: number) => volumeTop + volumeH - (Math.max(0, v) / maxVolume) * volumeH;
|
||||||
|
|
||||||
// Grid lines (horizontal)
|
// Grid lines (horizontal)
|
||||||
const nTicks = 6;
|
const nTicks = 6;
|
||||||
@@ -172,6 +182,34 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
|||||||
ctx.fillText(formatDate(visibleData[i].date), x, H - 6);
|
ctx.fillText(formatDate(visibleData[i].date), x, H - 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Volume pane
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ml, volumeTop - 9);
|
||||||
|
ctx.lineTo(ml + cw, volumeTop - 9);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ml, volumeBottom);
|
||||||
|
ctx.lineTo(ml + cw, volumeBottom);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||||
|
ctx.fillStyle = '#6b7280';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('Volume', ml, volumeTop - 13);
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(formatLargeNumber(maxVolume), W - 8, volumeTop + 4);
|
||||||
|
|
||||||
|
visibleData.forEach((bar, i) => {
|
||||||
|
const x = ml + i * barW + barW / 2;
|
||||||
|
const bullish = bar.close >= bar.open;
|
||||||
|
const yVolume = volumeScale(bar.volume);
|
||||||
|
const hVolume = Math.max(volumeBottom - yVolume, bar.volume > 0 ? 1 : 0);
|
||||||
|
ctx.fillStyle = bullish ? 'rgba(16, 185, 129, 0.32)' : 'rgba(239, 68, 68, 0.28)';
|
||||||
|
ctx.fillRect(x - volumeW / 2, yVolume, volumeW, hVolume);
|
||||||
|
});
|
||||||
|
|
||||||
// Nearest support/resistance only (band if it came from a zone)
|
// Nearest support/resistance only (band if it came from a zone)
|
||||||
markers.forEach((m) => {
|
markers.forEach((m) => {
|
||||||
const isSupport = m.role === 'support';
|
const isSupport = m.role === 'support';
|
||||||
@@ -312,7 +350,22 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Store geometry for hit testing (includes visibleRange offset)
|
// Store geometry for hit testing (includes visibleRange offset)
|
||||||
(canvas as any).__chartMeta = { ml, mr, mt, mb, cw, ch, barW, lo, hi, yScale, visibleStart: start };
|
(canvas as any).__chartMeta = {
|
||||||
|
ml,
|
||||||
|
mr,
|
||||||
|
mt,
|
||||||
|
mb,
|
||||||
|
cw,
|
||||||
|
ch,
|
||||||
|
barW,
|
||||||
|
lo,
|
||||||
|
hi,
|
||||||
|
yScale,
|
||||||
|
visibleStart: start,
|
||||||
|
volumeTop,
|
||||||
|
volumeH,
|
||||||
|
volumeBottom,
|
||||||
|
};
|
||||||
|
|
||||||
// Size the overlay canvas to match
|
// Size the overlay canvas to match
|
||||||
const overlay = overlayCanvasRef.current;
|
const overlay = overlayCanvasRef.current;
|
||||||
@@ -342,12 +395,14 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
|||||||
const meta = (canvas as any).__chartMeta;
|
const meta = (canvas as any).__chartMeta;
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
|
|
||||||
const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart } = meta;
|
const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart, volumeBottom } = meta;
|
||||||
const H = overlay.height / dpr;
|
const H = overlay.height / dpr;
|
||||||
|
const priceBottom = mt + ch;
|
||||||
|
const chartBottom = volumeBottom ?? priceBottom;
|
||||||
|
|
||||||
// Clamp crosshair to chart area
|
// Clamp crosshair to chart area
|
||||||
const cx = Math.max(ml, Math.min(ml + cw, pos.x));
|
const cx = Math.max(ml, Math.min(ml + cw, pos.x));
|
||||||
const cy = Math.max(mt, Math.min(mt + ch, pos.y));
|
const cy = Math.max(mt, Math.min(chartBottom, pos.y));
|
||||||
|
|
||||||
// Dashed crosshair lines
|
// Dashed crosshair lines
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
||||||
@@ -357,37 +412,44 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
|||||||
// Vertical line
|
// Vertical line
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(cx, mt);
|
ctx.moveTo(cx, mt);
|
||||||
ctx.lineTo(cx, mt + ch);
|
ctx.lineTo(cx, chartBottom);
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Horizontal line
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(ml, cy);
|
|
||||||
ctx.lineTo(ml + cw, cy);
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.setLineDash([]);
|
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';
|
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
|
||||||
const priceMetrics = ctx.measureText(priceText);
|
|
||||||
const labelPadX = 5;
|
const labelPadX = 5;
|
||||||
const labelPadY = 3;
|
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)';
|
if (cy <= priceBottom) {
|
||||||
ctx.beginPath();
|
// Horizontal price crosshair only belongs in the price pane.
|
||||||
ctx.roundRect(labelX, labelY, labelW, labelH, 3);
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
||||||
ctx.fill();
|
ctx.lineWidth = 0.75;
|
||||||
ctx.fillStyle = '#e5e7eb';
|
ctx.setLineDash([4, 3]);
|
||||||
ctx.textAlign = 'left';
|
ctx.beginPath();
|
||||||
ctx.textBaseline = 'middle';
|
ctx.moveTo(ml, cy);
|
||||||
ctx.fillText(priceText, labelX + labelPadX, 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)
|
// Date label on x-axis (bottom)
|
||||||
const localIdx = Math.floor((cx - ml) / barW);
|
const localIdx = Math.floor((cx - ml) / barW);
|
||||||
@@ -619,7 +681,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
|||||||
<span>High</span><span class="text-right text-gray-200">${formatPrice(bar.high)}</span>
|
<span>High</span><span class="text-right text-gray-200">${formatPrice(bar.high)}</span>
|
||||||
<span>Low</span><span class="text-right text-gray-200">${formatPrice(bar.low)}</span>
|
<span>Low</span><span class="text-right text-gray-200">${formatPrice(bar.low)}</span>
|
||||||
<span>Close</span><span class="text-right text-gray-200">${formatPrice(bar.close)}</span>
|
<span>Close</span><span class="text-right text-gray-200">${formatPrice(bar.close)}</span>
|
||||||
<span>Vol</span><span class="text-right text-gray-200">${bar.volume.toLocaleString()}</span>
|
<span>Vol</span><span class="text-right text-gray-200" title="${bar.volume.toLocaleString()}">${formatLargeNumber(bar.volume)}</span>
|
||||||
</div>${tradeTooltipHtml}`;
|
</div>${tradeTooltipHtml}`;
|
||||||
} else {
|
} else {
|
||||||
tip.style.display = 'none';
|
tip.style.display = 'none';
|
||||||
@@ -670,16 +732,16 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
|||||||
))}
|
))}
|
||||||
<span className="ml-1 text-[10px] text-gray-600">scroll to zoom · drag to pan</span>
|
<span className="ml-1 text-[10px] text-gray-600">scroll to zoom · drag to pan</span>
|
||||||
</div>
|
</div>
|
||||||
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}>
|
<div ref={containerRef} className="relative w-full" style={{ height: CHART_HEIGHT }}>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ height: 400 }}
|
style={{ height: CHART_HEIGHT }}
|
||||||
/>
|
/>
|
||||||
<canvas
|
<canvas
|
||||||
ref={overlayCanvasRef}
|
ref={overlayCanvasRef}
|
||||||
className="absolute top-0 left-0 w-full cursor-crosshair"
|
className="absolute top-0 left-0 w-full cursor-crosshair"
|
||||||
style={{ height: 400 }}
|
style={{ height: CHART_HEIGHT }}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
|
|||||||
@@ -6,15 +6,30 @@ import { Callout } from '../ui/Callout';
|
|||||||
import { Disclosure } from '../ui/Disclosure';
|
import { Disclosure } from '../ui/Disclosure';
|
||||||
import { Section } from '../ui/Section';
|
import { Section } from '../ui/Section';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import type { BacktestBucket } from '../../lib/types';
|
import type { BacktestBucket, BacktestPortfolioPolicy, BacktestStrategyVariant } from '../../lib/types';
|
||||||
|
|
||||||
function fmtR(v: number | null): string {
|
function fmtR(v: number | null | undefined): string {
|
||||||
if (v === null) return '—';
|
if (v === null || v === undefined) return '—';
|
||||||
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
|
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
|
||||||
}
|
}
|
||||||
function fmtPct(v: number | null): string {
|
function fmtPct(v: number | null): string {
|
||||||
return v === null ? '—' : `${v.toFixed(1)}%`;
|
return v === null ? '—' : `${v.toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
function fmtMoney(v: number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
return v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
function fmtSignedPct(v: number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
return `${v > 0 ? '+' : ''}${v.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
function fmtDays(v: number | null | undefined): string {
|
||||||
|
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
|
||||||
|
}
|
||||||
|
function fmtRPerDay(v: number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
return `${v > 0 ? '+' : ''}${v.toFixed(3)}R`;
|
||||||
|
}
|
||||||
function rColor(v: number | null): string {
|
function rColor(v: number | null): string {
|
||||||
if (v === null) return 'text-gray-400';
|
if (v === null) return 'text-gray-400';
|
||||||
if (v > 0) return 'text-emerald-400';
|
if (v > 0) return 'text-emerald-400';
|
||||||
@@ -24,6 +39,7 @@ function rColor(v: number | null): string {
|
|||||||
|
|
||||||
const SIGNAL_LABELS: Record<string, string> = {
|
const SIGNAL_LABELS: Record<string, string> = {
|
||||||
mom_12_1: '12–1 month momentum',
|
mom_12_1: '12–1 month momentum',
|
||||||
|
mom_12_1_resid: '12–1 residual momentum',
|
||||||
mom_6_1: '6–1 month momentum',
|
mom_6_1: '6–1 month momentum',
|
||||||
mom_3_1: '3–1 month momentum',
|
mom_3_1: '3–1 month momentum',
|
||||||
reversal_1m: '1-month reversal',
|
reversal_1m: '1-month reversal',
|
||||||
@@ -40,6 +56,11 @@ const ABLATION_LABELS: Record<string, string> = {
|
|||||||
momentum_only: 'Momentum only (no floors)',
|
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
|
// Prefer the net-of-costs number when the report carries it; older cached
|
||||||
// reports (pre-cost model) fall back to gross.
|
// reports (pre-cost model) fall back to gross.
|
||||||
function netOrGross(r: { avg_r: number | null; net_avg_r?: number | null }): number | null {
|
function netOrGross(r: { avg_r: number | null; net_avg_r?: number | null }): number | null {
|
||||||
@@ -91,6 +112,10 @@ function BucketRow({ label, b }: { label: string; b: BacktestBucket }) {
|
|||||||
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(b.hit_rate)}</td>
|
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(b.hit_rate)}</td>
|
||||||
<td className={`num px-4 py-2.5 text-right ${rColor(b.avg_r)}`}>{fmtR(b.avg_r)}</td>
|
<td className={`num px-4 py-2.5 text-right ${rColor(b.avg_r)}`}>{fmtR(b.avg_r)}</td>
|
||||||
<td className={`num px-4 py-2.5 text-right ${rColor(b.net_avg_r ?? null)}`}>{fmtR(b.net_avg_r ?? null)}</td>
|
<td className={`num px-4 py-2.5 text-right ${rColor(b.net_avg_r ?? null)}`}>{fmtR(b.net_avg_r ?? null)}</td>
|
||||||
|
<td className="num px-4 py-2.5 text-right text-emerald-400">{fmtR(b.best_r)}</td>
|
||||||
|
<td className="num px-4 py-2.5 text-right text-red-400">{fmtR(b.worst_r)}</td>
|
||||||
|
<td className="num px-4 py-2.5 text-right text-gray-400">{fmtDays(b.avg_hold_days)}</td>
|
||||||
|
<td className={`num px-4 py-2.5 text-right ${rColor(b.net_r_per_day ?? null)}`}>{fmtRPerDay(b.net_r_per_day)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -100,18 +125,11 @@ export function BacktestPanel() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const bestTpAvgR =
|
|
||||||
report?.take_profit_sweep && report.take_profit_sweep.length > 0
|
|
||||||
? Math.max(...report.take_profit_sweep.map((r) => netOrGross(r) ?? -Infinity))
|
|
||||||
: null;
|
|
||||||
const bestTrailAvgR =
|
|
||||||
report?.trailing_sweep && report.trailing_sweep.length > 0
|
|
||||||
? Math.max(...report.trailing_sweep.map((r) => netOrGross(r) ?? -Infinity))
|
|
||||||
: null;
|
|
||||||
const bestTimeAvgR =
|
const bestTimeAvgR =
|
||||||
report?.time_exit_sweep && report.time_exit_sweep.length > 0
|
report?.time_exit_sweep && report.time_exit_sweep.length > 0
|
||||||
? Math.max(...report.time_exit_sweep.map((r) => netOrGross(r) ?? -Infinity))
|
? Math.max(...report.time_exit_sweep.map((r) => netOrGross(r) ?? -Infinity))
|
||||||
: null;
|
: null;
|
||||||
|
const sim = report?.portfolio_sim ?? null;
|
||||||
|
|
||||||
const run = useMutation({
|
const run = useMutation({
|
||||||
mutationFn: () => triggerJob('backtest'),
|
mutationFn: () => triggerJob('backtest'),
|
||||||
@@ -164,6 +182,49 @@ export function BacktestPanel() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{report.recommendation && report.recommendation.items.length > 0 && (
|
||||||
|
<div className="glass border border-blue-400/20 p-4">
|
||||||
|
<p className="section-index">What this backtest recommends</p>
|
||||||
|
{report.recommendation.headline && (
|
||||||
|
<p className="mt-1.5 text-sm font-semibold text-gray-100">
|
||||||
|
{report.recommendation.headline}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{report.recommendation.items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.topic + item.text}
|
||||||
|
className={`text-xs ${item.text.includes('WARNING') || item.text.includes('LAGS') ? 'text-amber-400' : 'text-gray-400'}`}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{report.recommendation.note && (
|
||||||
|
<p className="mt-2 text-[11px] text-gray-600">{report.recommendation.note}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.research_recommendation && report.research_recommendation.items.length > 0 && (
|
||||||
|
<div className="glass border border-emerald-400/15 p-4">
|
||||||
|
<p className="section-index">Research candidates</p>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{report.research_recommendation.items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.topic + item.text}
|
||||||
|
className={`text-xs ${item.candidate ? 'text-emerald-400' : 'text-gray-400'}`}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{report.research_recommendation.note && (
|
||||||
|
<p className="mt-2 text-[11px] text-gray-600">{report.research_recommendation.note}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Stat
|
<Stat
|
||||||
label="Qualified Hit Rate"
|
label="Qualified Hit Rate"
|
||||||
@@ -188,6 +249,30 @@ export function BacktestPanel() {
|
|||||||
valueClass={rColor(report.overall_qualified.total_r)}
|
valueClass={rColor(report.overall_qualified.total_r)}
|
||||||
sub="cumulative, risk-adjusted"
|
sub="cumulative, risk-adjusted"
|
||||||
/>
|
/>
|
||||||
|
{report.overall_qualified.median_net_r != null && (
|
||||||
|
<Stat
|
||||||
|
label="Median Net R"
|
||||||
|
value={fmtR(report.overall_qualified.median_net_r)}
|
||||||
|
valueClass={rColor(report.overall_qualified.median_net_r)}
|
||||||
|
sub="qualified · the typical trade"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{report.overall_qualified.profit_factor != null && (
|
||||||
|
<Stat
|
||||||
|
label="Profit Factor"
|
||||||
|
value={report.overall_qualified.profit_factor.toFixed(2)}
|
||||||
|
valueClass={report.overall_qualified.profit_factor > 1 ? 'text-emerald-400' : 'text-red-400'}
|
||||||
|
sub="qualified · net wins / net losses"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{report.overall_qualified.net_avg_r_ex_top5 != null && (
|
||||||
|
<Stat
|
||||||
|
label="Ex-Top-5% Net R"
|
||||||
|
value={fmtR(report.overall_qualified.net_avg_r_ex_top5)}
|
||||||
|
valueClass={rColor(report.overall_qualified.net_avg_r_ex_top5)}
|
||||||
|
sub="expectancy without the biggest winners"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass overflow-x-auto">
|
<div className="glass overflow-x-auto">
|
||||||
@@ -202,6 +287,10 @@ export function BacktestPanel() {
|
|||||||
<th className="px-4 py-2.5 text-right">Hit Rate</th>
|
<th className="px-4 py-2.5 text-right">Hit Rate</th>
|
||||||
<th className="px-4 py-2.5 text-right">Avg R</th>
|
<th className="px-4 py-2.5 text-right">Avg R</th>
|
||||||
<th className="px-4 py-2.5 text-right">Net Avg R</th>
|
<th className="px-4 py-2.5 text-right">Net Avg R</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">Best R</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">Worst R</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">Avg Hold</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">Net R/d</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -219,11 +308,11 @@ export function BacktestPanel() {
|
|||||||
{report.sweep && report.sweep.length > 0 && report.sweep[0].min_momentum_percentile != null && (
|
{report.sweep && report.sweep.length > 0 && report.sweep[0].min_momentum_percentile != null && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||||
Momentum-percentile sweep
|
Residual-momentum percentile sweep
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-2 text-[11px] text-gray-500">
|
<p className="mb-2 text-[11px] text-gray-500">
|
||||||
How many setups qualify — and how they perform — at each momentum-rank cutoff (floors
|
How many setups qualify — and how they perform — at each production-rank cutoff (floors
|
||||||
held fixed). 80 = only the top 20% of the universe by 12-1 momentum each week; 0 =
|
held fixed). 80 = only the top 20% of the universe by residual 12-1 momentum each week; 0 =
|
||||||
floors only. Lower = more trades, watch that expectancy holds. Your current setting is
|
floors only. Lower = more trades, watch that expectancy holds. Your current setting is
|
||||||
highlighted; set it in Admin → Settings → Activation.
|
highlighted; set it in Admin → Settings → Activation.
|
||||||
</p>
|
</p>
|
||||||
@@ -231,7 +320,7 @@ export function BacktestPanel() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||||
<th className="px-4 py-2.5">Min momentum %ile</th>
|
<th className="px-4 py-2.5">Min residual %ile</th>
|
||||||
<th className="px-4 py-2.5 text-right">Qualified</th>
|
<th className="px-4 py-2.5 text-right">Qualified</th>
|
||||||
<th className="px-4 py-2.5 text-right">Wins</th>
|
<th className="px-4 py-2.5 text-right">Wins</th>
|
||||||
<th className="px-4 py-2.5 text-right">Losses</th>
|
<th className="px-4 py-2.5 text-right">Losses</th>
|
||||||
@@ -285,6 +374,8 @@ export function BacktestPanel() {
|
|||||||
<th className="px-4 py-2.5 text-right">Avg R</th>
|
<th className="px-4 py-2.5 text-right">Avg R</th>
|
||||||
<th className="px-4 py-2.5 text-right">Net Avg R</th>
|
<th className="px-4 py-2.5 text-right">Net Avg R</th>
|
||||||
<th className="px-4 py-2.5 text-right">Total R</th>
|
<th className="px-4 py-2.5 text-right">Total R</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">Hold Net Avg R</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">Hold Total R</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -303,6 +394,12 @@ export function BacktestPanel() {
|
|||||||
{fmtR(row.net_avg_r ?? null)}
|
{fmtR(row.net_avg_r ?? null)}
|
||||||
</td>
|
</td>
|
||||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
|
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
|
||||||
|
<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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -311,105 +408,6 @@ export function BacktestPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{report.take_profit_sweep && report.take_profit_sweep.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)
|
|
||||||
</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 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">Take-profit</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>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{report.take_profit_sweep.map((row) => {
|
|
||||||
const best = netOrGross(row) != null && netOrGross(row) === 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 ${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>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{report.trailing_sweep && report.trailing_sweep.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
|
||||||
Trailing-stop 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 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 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>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{report.trailing_sweep.map((row) => {
|
|
||||||
const best = netOrGross(row) != null && netOrGross(row) === bestTrailAvgR;
|
|
||||||
return (
|
|
||||||
<tr key={row.trail_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.trail_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.win_rate)}</td>
|
|
||||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
|
|
||||||
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.net_avg_r ?? null)}`}>{fmtR(row.net_avg_r ?? null)}</td>
|
|
||||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{report.time_exit_sweep && report.time_exit_sweep.length > 0 && (
|
{report.time_exit_sweep && report.time_exit_sweep.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||||
@@ -432,6 +430,12 @@ export function BacktestPanel() {
|
|||||||
<th className="px-4 py-2.5 text-right">Avg R</th>
|
<th className="px-4 py-2.5 text-right">Avg R</th>
|
||||||
<th className="px-4 py-2.5 text-right">Net Avg R</th>
|
<th className="px-4 py-2.5 text-right">Net Avg R</th>
|
||||||
<th className="px-4 py-2.5 text-right">Total R</th>
|
<th className="px-4 py-2.5 text-right">Total R</th>
|
||||||
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -449,6 +453,12 @@ export function BacktestPanel() {
|
|||||||
<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 ${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 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 ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
|
||||||
|
<td className="num px-4 py-2.5 text-right text-emerald-400">{fmtR(row.best_r)}</td>
|
||||||
|
<td className="num px-4 py-2.5 text-right text-red-400">{fmtR(row.worst_r)}</td>
|
||||||
|
<td className="num px-4 py-2.5 text-right text-gray-400">{fmtDays(row.avg_hold_days)}</td>
|
||||||
|
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_r_per_day ?? null)}`}>{fmtRPerDay(row.net_r_per_day)}</td>
|
||||||
|
<td className={`num px-4 py-2.5 text-right ${rColor(row.median_net_r ?? null)}`}>{fmtR(row.median_net_r)}</td>
|
||||||
|
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_avg_r_ex_top5 ?? null)}`}>{fmtR(row.net_avg_r_ex_top5)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -458,46 +468,123 @@ export function BacktestPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
{sim && sim.policies.length > 0 && (
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
<div>
|
||||||
Probability calibration
|
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||||
</p>
|
Portfolio simulation
|
||||||
<p className="mb-2 text-[11px] text-gray-500">
|
</p>
|
||||||
Do targets we call “X% likely” actually hit that often? Realized below predicted =
|
<p className="mb-2 text-[11px] text-gray-500">
|
||||||
the model is over-confident.
|
{sim.note ?? 'One capital-constrained book over the qualified setups.'}{' '}
|
||||||
</p>
|
<span className="text-gray-300">
|
||||||
{report.calibration.length === 0 ? (
|
Start {fmtMoney(sim.params.starting_capital)} · max {sim.params.max_positions} positions ·{' '}
|
||||||
<Callout variant="empty">Not enough resolved setups to calibrate.</Callout>
|
{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">
|
<div className="glass overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||||
<th className="px-4 py-2.5">Predicted Bucket</th>
|
<th className="px-4 py-2.5">Metric</th>
|
||||||
<th className="px-4 py-2.5 text-right">Setups</th>
|
{sim.policies.map((p) => (
|
||||||
<th className="px-4 py-2.5 text-right">Avg Predicted</th>
|
<th key={p.policy} className="px-4 py-2.5 text-right">
|
||||||
<th className="px-4 py-2.5 text-right">Realized Hit Rate</th>
|
{POLICY_LABELS[p.policy] ?? p.policy}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{report.calibration.map((row) => {
|
{(
|
||||||
const over = row.realized_hit_rate < row.predicted_avg;
|
[
|
||||||
return (
|
['Final equity', (p) => fmtMoney(p.final_equity), (p) => rColor(p.final_equity - p.starting_capital)],
|
||||||
<tr key={row.bucket} className="border-b border-white/[0.04]">
|
['Total return', (p) => fmtSignedPct(p.total_return_pct), (p) => rColor(p.total_return_pct)],
|
||||||
<td className="px-4 py-2.5 text-gray-200">{row.bucket}</td>
|
['SPY return (same window)', (p) => fmtSignedPct(p.spy_return_pct), () => 'text-gray-300'],
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-300">{row.n}</td>
|
['CAGR', (p) => fmtSignedPct(p.cagr_pct), (p) => rColor(p.cagr_pct)],
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-400">{row.predicted_avg.toFixed(0)}%</td>
|
['Max drawdown', (p) => `−${p.max_drawdown_pct.toFixed(1)}%`, () => 'text-amber-400'],
|
||||||
<td className={`num px-4 py-2.5 text-right font-semibold ${over ? 'text-amber-400' : 'text-emerald-400'}`}>
|
['Sharpe (daily, annualized)', (p) => (p.sharpe === null ? '—' : p.sharpe.toFixed(2)), () => 'text-gray-200'],
|
||||||
{row.realized_hit_rate.toFixed(0)}%
|
['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>
|
</td>
|
||||||
</tr>
|
))}
|
||||||
);
|
</tr>
|
||||||
})}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 && (
|
{report.signal_eval && report.signal_eval.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface FieldPoint {
|
|||||||
interface StandingMatrixProps {
|
interface StandingMatrixProps {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
composite: number | null; // X for the highlighted dot (authoritative, from the scores endpoint)
|
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
|
field: FieldPoint[]; // every tracked ticker, for the background cloud
|
||||||
gateMomentum: number; // Y divider = the activation gate's momentum percentile
|
gateMomentum: number; // Y divider = the activation gate's momentum percentile
|
||||||
status: 'top-pick' | 'qualified' | 'none';
|
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>
|
<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">
|
<div className="mt-3 space-y-1 text-xs text-gray-500">
|
||||||
<StatRow label="Quality (composite)" value={`${Math.round(here.composite)}`} />
|
<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)}%`} />}
|
{confidence != null && <StatRow label="Long confidence" value={`${Math.round(confidence)}%`} />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -206,7 +206,7 @@ export default function StandingMatrix({
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
|
<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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { DimensionScoreDetail, CompositeBreakdown } from '../../lib/types';
|
|||||||
interface ScoreCardProps {
|
interface ScoreCardProps {
|
||||||
compositeScore: number | null;
|
compositeScore: number | null;
|
||||||
dimensions: DimensionScoreDetail[];
|
dimensions: DimensionScoreDetail[];
|
||||||
compositeBreakdown?: CompositeBreakdown;
|
compositeBreakdown?: CompositeBreakdown | null;
|
||||||
/** Hide the composite ring/header when the composite is shown elsewhere
|
/** Hide the composite ring/header when the composite is shown elsewhere
|
||||||
* (e.g. the Standing matrix) and this card only carries the dimension detail. */
|
* (e.g. the Standing matrix) and this card only carries the dimension detail. */
|
||||||
showComposite?: boolean;
|
showComposite?: boolean;
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import type { ActivationConfig, TradeSetup } from './types';
|
|||||||
|
|
||||||
const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
|
const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
|
||||||
|
|
||||||
|
function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'short' | 'neutral' {
|
||||||
|
if (!action || action === 'NEUTRAL') return 'neutral';
|
||||||
|
if (action.startsWith('LONG')) return 'long';
|
||||||
|
if (action.startsWith('SHORT')) return 'short';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
export function bestTargetProbability(setup: TradeSetup): number {
|
export function bestTargetProbability(setup: TradeSetup): number {
|
||||||
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0;
|
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0;
|
||||||
}
|
}
|
||||||
@@ -33,17 +40,20 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
|
if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
|
||||||
// Cross-sectional momentum is the core selection (long-only). While the gate is
|
// Residual cross-sectional momentum is the core selection (long-only). While
|
||||||
// active, shorts never qualify; the percentile floor is enforced only when a
|
// the gate is active, shorts never qualify; the percentile floor is enforced
|
||||||
// percentile is attached, otherwise defer to the floors.
|
// only when a percentile is attached, otherwise defer to the floors.
|
||||||
if (config.min_momentum_percentile > 0) {
|
if (config.min_momentum_percentile > 0) {
|
||||||
if (setup.direction === 'short') return false;
|
if (setup.direction === 'short') return false;
|
||||||
if (setup.momentum_percentile != null && setup.momentum_percentile < config.min_momentum_percentile) {
|
if (setup.momentum_percentile != null && setup.momentum_percentile < config.min_momentum_percentile) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// NEUTRAL = "no clear setup" — not actionable, so by default it doesn't qualify.
|
// NEUTRAL = "no clear setup"; an opposite action means this setup is counter-bias.
|
||||||
if (config.exclude_neutral && (setup.recommended_action ?? 'NEUTRAL') === 'NEUTRAL') return false;
|
if (config.exclude_neutral) {
|
||||||
|
const actionDir = actionDirection(setup.recommended_action);
|
||||||
|
if (actionDir === 'neutral' || actionDir !== setup.direction) return false;
|
||||||
|
}
|
||||||
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
|
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -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:
|
* 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
|
* the highest residual 12-1 momentum percentile among qualified setups. Returns
|
||||||
* setups when none qualify). Returns null when there are no setups. Keep in step
|
* null when there are no actionable setups. Keep in step with the Top Setups
|
||||||
* with the Top Setups ranking in DashboardPage.
|
* ranking in DashboardPage.
|
||||||
*/
|
*/
|
||||||
export function topPickSymbol(
|
export function topPickSymbol(
|
||||||
trades: TradeSetup[] | undefined,
|
trades: TradeSetup[] | undefined,
|
||||||
@@ -64,8 +74,7 @@ export function topPickSymbol(
|
|||||||
const all = trades ?? [];
|
const all = trades ?? [];
|
||||||
if (all.length === 0) return null;
|
if (all.length === 0) return null;
|
||||||
const qualified = activation ? all.filter((t) => qualifiesSetup(t, activation)) : [];
|
const qualified = activation ? all.filter((t) => qualifiesSetup(t, activation)) : [];
|
||||||
const pool = qualified.length > 0 ? qualified : all;
|
const top = [...qualified].sort(
|
||||||
const top = [...pool].sort(
|
|
||||||
(a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity),
|
(a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity),
|
||||||
)[0];
|
)[0];
|
||||||
return top?.symbol ?? null;
|
return top?.symbol ?? null;
|
||||||
@@ -74,7 +83,7 @@ export function topPickSymbol(
|
|||||||
/** Short human summary of the active gate, e.g. for tooltips/labels. */
|
/** Short human summary of the active gate, e.g. for tooltips/labels. */
|
||||||
export function activationSummary(config: ActivationConfig): string {
|
export function activationSummary(config: ActivationConfig): string {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (config.min_momentum_percentile > 0) parts.push(`top ${(100 - config.min_momentum_percentile).toFixed(0)}% momentum`);
|
if (config.min_momentum_percentile > 0) parts.push(`top ${(100 - config.min_momentum_percentile).toFixed(0)}% residual momentum`);
|
||||||
parts.push(`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`);
|
parts.push(`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`);
|
||||||
if (config.exclude_neutral) parts.push('directional');
|
if (config.exclude_neutral) parts.push('directional');
|
||||||
if (config.require_high_conviction) parts.push('high-conviction');
|
if (config.require_high_conviction) parts.push('high-conviction');
|
||||||
|
|||||||
+101
-36
@@ -96,7 +96,7 @@ export interface ScoreResponse {
|
|||||||
dimensions: DimensionScoreDetail[];
|
dimensions: DimensionScoreDetail[];
|
||||||
missing_dimensions: string[];
|
missing_dimensions: string[];
|
||||||
computed_at: string | null;
|
computed_at: string | null;
|
||||||
composite_breakdown?: CompositeBreakdown;
|
composite_breakdown?: CompositeBreakdown | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DimensionScoreDetail {
|
export interface DimensionScoreDetail {
|
||||||
@@ -104,12 +104,13 @@ export interface DimensionScoreDetail {
|
|||||||
score: number;
|
score: number;
|
||||||
is_stale: boolean;
|
is_stale: boolean;
|
||||||
computed_at: string | null;
|
computed_at: string | null;
|
||||||
breakdown?: ScoreBreakdown;
|
breakdown?: ScoreBreakdown | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RankingEntry {
|
export interface RankingEntry {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
composite_score: number;
|
composite_score: number;
|
||||||
|
composite_stale: boolean;
|
||||||
dimensions: DimensionScoreDetail[];
|
dimensions: DimensionScoreDetail[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,9 +141,18 @@ export interface TradeSetup {
|
|||||||
evaluated_at: string | null;
|
evaluated_at: string | null;
|
||||||
current_price: number | null;
|
current_price: number | null;
|
||||||
momentum_percentile?: number | null;
|
momentum_percentile?: number | null;
|
||||||
|
context_as_of?: TradeSetupContextAsOf | null;
|
||||||
recommendation_summary?: RecommendationSummary;
|
recommendation_summary?: RecommendationSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TradeSetupContextAsOf {
|
||||||
|
setup_detected_at: string;
|
||||||
|
score_computed_at: string | null;
|
||||||
|
sentiment_at: string | null;
|
||||||
|
price_date: string | null;
|
||||||
|
price_updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// Performance / outcome statistics
|
// Performance / outcome statistics
|
||||||
export interface OutcomeBucketStats {
|
export interface OutcomeBucketStats {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -211,14 +221,15 @@ export interface PaperTrade {
|
|||||||
benchmark_return_pct: number | null;
|
benchmark_return_pct: number | null;
|
||||||
alpha_pct: number | null;
|
alpha_pct: number | null;
|
||||||
alpha_usd: 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_stop: number | null;
|
||||||
trailing_distance_pct: number | null;
|
trailing_distance_pct: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExitPolicy {
|
export interface ExitPolicy {
|
||||||
mode: 'trailing' | 'target';
|
mode: 'time' | 'trailing' | 'target';
|
||||||
trailing_pct: number;
|
trailing_pct: number;
|
||||||
|
hold_days: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BacktestBucket {
|
export interface BacktestBucket {
|
||||||
@@ -232,41 +243,20 @@ export interface BacktestBucket {
|
|||||||
// Net of transaction costs — optional so a stale cached report still renders.
|
// Net of transaction costs — optional so a stale cached report still renders.
|
||||||
net_avg_r?: number | null;
|
net_avg_r?: number | null;
|
||||||
net_total_r?: number | null;
|
net_total_r?: number | null;
|
||||||
}
|
best_r?: number | null;
|
||||||
|
worst_r?: number | null;
|
||||||
export interface BacktestCalibrationRow {
|
avg_hold_days?: number | null;
|
||||||
bucket: string;
|
net_r_per_day?: number | null;
|
||||||
n: number;
|
// Robustness: distribution shape, and expectancy without the top winners.
|
||||||
predicted_avg: number;
|
median_net_r?: number | null;
|
||||||
realized_hit_rate: number;
|
profit_factor?: number | null;
|
||||||
|
net_avg_r_ex_top5?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BacktestSweepRow extends BacktestBucket {
|
export interface BacktestSweepRow extends BacktestBucket {
|
||||||
min_momentum_percentile: number;
|
min_momentum_percentile: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BacktestTakeProfitRow {
|
|
||||||
tp_pct: number;
|
|
||||||
total: number;
|
|
||||||
wins: number;
|
|
||||||
hit_rate: number | null;
|
|
||||||
avg_r: number | null;
|
|
||||||
total_r: number | null;
|
|
||||||
net_avg_r?: number | null;
|
|
||||||
net_total_r?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BacktestTrailingRow {
|
|
||||||
trail_pct: 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BacktestTimeExitRow {
|
export interface BacktestTimeExitRow {
|
||||||
hold_days: number;
|
hold_days: number;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -276,10 +266,84 @@ export interface BacktestTimeExitRow {
|
|||||||
total_r: number | null;
|
total_r: number | null;
|
||||||
net_avg_r?: number | null;
|
net_avg_r?: number | null;
|
||||||
net_total_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 {
|
export interface BacktestGateAblationRow extends BacktestBucket {
|
||||||
variant: string;
|
variant: string;
|
||||||
|
// The same variant graded under the hold-to-horizon time exit.
|
||||||
|
hold_days?: number;
|
||||||
|
hold_avg_r?: number | null;
|
||||||
|
hold_net_avg_r?: number | null;
|
||||||
|
hold_total_r?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BacktestSignalEvalRow {
|
export interface BacktestSignalEvalRow {
|
||||||
@@ -311,10 +375,11 @@ export interface BacktestReport {
|
|||||||
sweep: BacktestSweepRow[];
|
sweep: BacktestSweepRow[];
|
||||||
gate_ablation?: BacktestGateAblationRow[];
|
gate_ablation?: BacktestGateAblationRow[];
|
||||||
gate_ablation_note?: string;
|
gate_ablation_note?: string;
|
||||||
take_profit_sweep?: BacktestTakeProfitRow[];
|
|
||||||
trailing_sweep?: BacktestTrailingRow[];
|
|
||||||
time_exit_sweep?: BacktestTimeExitRow[];
|
time_exit_sweep?: BacktestTimeExitRow[];
|
||||||
calibration: BacktestCalibrationRow[];
|
portfolio_sim?: BacktestPortfolioSim;
|
||||||
|
strategy_variants?: BacktestStrategyVariants;
|
||||||
|
recommendation?: BacktestRecommendation;
|
||||||
|
research_recommendation?: BacktestResearchRecommendation;
|
||||||
signal_eval?: BacktestSignalEvalRow[];
|
signal_eval?: BacktestSignalEvalRow[];
|
||||||
signal_eval_note?: string;
|
signal_eval_note?: string;
|
||||||
note: string;
|
note: string;
|
||||||
|
|||||||
@@ -76,15 +76,12 @@ export default function DashboardPage() {
|
|||||||
[trades.data, activation.data],
|
[trades.data, activation.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show qualified setups first; fall back to the full list when none qualify.
|
// Rank only actionable/qualified setups by residual 12-1 momentum percentile.
|
||||||
// Rank by 12-1 momentum percentile so the strongest names sit at the top.
|
|
||||||
const showingQualified = qualifiedSetups.length > 0;
|
|
||||||
const topSetups: TradeSetup[] = useMemo(() => {
|
const topSetups: TradeSetup[] = useMemo(() => {
|
||||||
const pool = showingQualified ? qualifiedSetups : trades.data ?? [];
|
return [...qualifiedSetups]
|
||||||
return [...pool]
|
|
||||||
.sort((a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity))
|
.sort((a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity))
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
}, [showingQualified, qualifiedSetups, trades.data]);
|
}, [qualifiedSetups]);
|
||||||
|
|
||||||
const topWatchlist = useMemo(
|
const topWatchlist = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -197,12 +194,12 @@ export default function DashboardPage() {
|
|||||||
<div className="xl:col-span-3">
|
<div className="xl:col-span-3">
|
||||||
<Section
|
<Section
|
||||||
title="Top Setups"
|
title="Top Setups"
|
||||||
hint={showingQualified ? 'ranked by expected value' : 'none qualified — showing all'}
|
hint="qualified and ranked by residual momentum"
|
||||||
>
|
>
|
||||||
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
|
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
|
||||||
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
|
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
|
||||||
{trades.data && topSetups.length === 0 && (
|
{trades.data && topSetups.length === 0 && (
|
||||||
<Callout variant="empty">No active setups. Run the scanner from the Signals page.</Callout>
|
<Callout variant="empty">No qualified actionable setups right now.</Callout>
|
||||||
)}
|
)}
|
||||||
{topSetups.length > 0 && (
|
{topSetups.length > 0 && (
|
||||||
<div className="glass overflow-x-auto">
|
<div className="glass overflow-x-auto">
|
||||||
@@ -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">Entry</th>
|
||||||
<th className="px-4 py-3 text-right">R:R</th>
|
<th className="px-4 py-3 text-right">R:R</th>
|
||||||
<th className="px-4 py-3 text-right">Target Prob</th>
|
<th className="px-4 py-3 text-right">Target Prob</th>
|
||||||
<th className="px-4 py-3 text-right">Momentum</th>
|
<th className="px-4 py-3 text-right">Residual Mom.</th>
|
||||||
<th className="hidden px-4 py-3 md:table-cell">Action</th>
|
<th className="hidden px-4 py-3 md:table-cell">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export default function TickerDetailPage() {
|
|||||||
[setupsForSymbol],
|
[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
|
// setup), the field (every ticker's composite × momentum) for the cloud, and
|
||||||
// whether it qualifies / is the top pick.
|
// whether it qualifies / is the top pick.
|
||||||
const myMomentum = longSetup?.momentum_percentile ?? shortSetup?.momentum_percentile ?? null;
|
const myMomentum = longSetup?.momentum_percentile ?? shortSetup?.momentum_percentile ?? null;
|
||||||
@@ -296,7 +296,7 @@ export default function TickerDetailPage() {
|
|||||||
<StatusPill
|
<StatusPill
|
||||||
tone="blue"
|
tone="blue"
|
||||||
label="★ Top Pick"
|
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 && (
|
{hasOpenTrade && (
|
||||||
@@ -346,8 +346,8 @@ export default function TickerDetailPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Chart — always visible */}
|
{/* Chart — always visible */}
|
||||||
<Section title="Price Chart">
|
<Section title="Price & Volume">
|
||||||
{ohlcv.isLoading && <SkeletonCard className="h-[400px]" />}
|
{ohlcv.isLoading && <SkeletonCard className="h-[440px]" />}
|
||||||
{ohlcv.isError && (
|
{ohlcv.isError && (
|
||||||
<SectionError
|
<SectionError
|
||||||
message={ohlcv.error instanceof Error ? ohlcv.error.message : 'Failed to load OHLCV data'}
|
message={ohlcv.error instanceof Error ? ohlcv.error.message : 'Failed to load OHLCV data'}
|
||||||
|
|||||||
@@ -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 **0–100** 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 0–100** (0 = Regime stabil, 100 = Bruch im Gange) mit Label-Band:
|
|
||||||
- 0–30 stabil · 30–60 beobachten · 60–80 erhöht · 80–100 Bruch sichtbar
|
|
||||||
- **Aufschlüsselung pro Signal** (Sub-Score 0–100 + 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 0–100 (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.
|
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Create a minimal local SQLite snapshot for offline backtest research.
|
||||||
|
|
||||||
|
Copies only the data required by app.services.backtest_service.run_backtest:
|
||||||
|
tickers, OHLCV bars, SPY benchmark closes, and activation/recommendation
|
||||||
|
settings. Other system settings are intentionally skipped to avoid copying
|
||||||
|
secrets into local snapshot files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import func, insert, or_, select
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_postgres_url(url: str) -> str:
|
||||||
|
if url.startswith("postgresql+asyncpg://"):
|
||||||
|
return url
|
||||||
|
if url.startswith("postgresql://"):
|
||||||
|
return "postgresql+asyncpg://" + url[len("postgresql://") :]
|
||||||
|
if url.startswith("postgres://"):
|
||||||
|
return "postgresql+asyncpg://" + url[len("postgres://") :]
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_url(path: Path) -> str:
|
||||||
|
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _hide_password(url: str) -> str:
|
||||||
|
return make_url(url).render_as_string(hide_password=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--database-url",
|
||||||
|
default=os.getenv("DATABASE_URL"),
|
||||||
|
help="Source Postgres URL. Defaults to DATABASE_URL, then app .env database_url.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default="backtest_snapshots/prod-backtest.sqlite",
|
||||||
|
help="SQLite snapshot path to create.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--batch-size", type=int, default=5000)
|
||||||
|
parser.add_argument("--force", action="store_true", help="Overwrite an existing snapshot file.")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
async def _copy_table(
|
||||||
|
source: AsyncSession,
|
||||||
|
dest: AsyncSession,
|
||||||
|
model: type,
|
||||||
|
*,
|
||||||
|
batch_size: int,
|
||||||
|
where=None,
|
||||||
|
) -> int:
|
||||||
|
table = model.__table__
|
||||||
|
columns = list(table.columns)
|
||||||
|
|
||||||
|
count_stmt = select(func.count()).select_from(table)
|
||||||
|
stmt = select(*columns)
|
||||||
|
if where is not None:
|
||||||
|
count_stmt = count_stmt.where(where)
|
||||||
|
stmt = stmt.where(where)
|
||||||
|
primary_key_columns = list(table.primary_key.columns)
|
||||||
|
if primary_key_columns:
|
||||||
|
stmt = stmt.order_by(*primary_key_columns)
|
||||||
|
|
||||||
|
expected = int((await source.execute(count_stmt)).scalar_one())
|
||||||
|
if expected == 0:
|
||||||
|
print(f"{table.name}: 0 rows")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
copied = 0
|
||||||
|
stream = await source.stream(stmt.execution_options(yield_per=batch_size))
|
||||||
|
async for partition in stream.partitions(batch_size):
|
||||||
|
rows = [dict(row._mapping) for row in partition]
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
await dest.execute(insert(table), rows)
|
||||||
|
await dest.commit()
|
||||||
|
copied += len(rows)
|
||||||
|
print(f"{table.name}: {copied}/{expected}", end="\r")
|
||||||
|
|
||||||
|
print(f"{table.name}: {copied} rows")
|
||||||
|
return copied
|
||||||
|
|
||||||
|
|
||||||
|
async def _main() -> None:
|
||||||
|
args = _parse_args()
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import Base
|
||||||
|
import app.models # noqa: F401 - registers all metadata tables
|
||||||
|
from app.models.benchmark_price import BenchmarkPrice
|
||||||
|
from app.models.ohlcv import OHLCVRecord
|
||||||
|
from app.models.settings import SystemSetting
|
||||||
|
from app.models.ticker import Ticker
|
||||||
|
|
||||||
|
source_url = _normalize_postgres_url(args.database_url or settings.database_url)
|
||||||
|
output = Path(args.output)
|
||||||
|
if output.exists():
|
||||||
|
if not args.force:
|
||||||
|
raise SystemExit(f"{output} already exists. Pass --force to overwrite it.")
|
||||||
|
output.unlink()
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
source_engine = create_async_engine(
|
||||||
|
source_url,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
connect_args={"server_settings": {"default_transaction_read_only": "on"}},
|
||||||
|
)
|
||||||
|
dest_engine = create_async_engine(_sqlite_url(output))
|
||||||
|
SourceSession = async_sessionmaker(source_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
DestSession = async_sessionmaker(dest_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
print(f"Source: {_hide_password(source_url)}")
|
||||||
|
print(f"Snapshot: {output}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with dest_engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async with SourceSession() as source, DestSession() as dest:
|
||||||
|
counts = {
|
||||||
|
"tickers": await _copy_table(source, dest, Ticker, batch_size=args.batch_size),
|
||||||
|
"system_settings": await _copy_table(
|
||||||
|
source,
|
||||||
|
dest,
|
||||||
|
SystemSetting,
|
||||||
|
batch_size=args.batch_size,
|
||||||
|
where=or_(
|
||||||
|
SystemSetting.key.like("activation_%"),
|
||||||
|
SystemSetting.key.like("recommendation_%"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"benchmark_prices": await _copy_table(source, dest, BenchmarkPrice, batch_size=args.batch_size),
|
||||||
|
"ohlcv_records": await _copy_table(source, dest, OHLCVRecord, batch_size=args.batch_size),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await source_engine.dispose()
|
||||||
|
await dest_engine.dispose()
|
||||||
|
|
||||||
|
print("Done:")
|
||||||
|
for name, count in counts.items():
|
||||||
|
print(f" {name}: {count}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(_main())
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Run the existing backtest service against a local SQLite snapshot.
|
||||||
|
|
||||||
|
The runner is offline/read-only: it does not refresh benchmark prices and does
|
||||||
|
not cache the report back to any database. It writes a local JSON report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_url(path: Path) -> str:
|
||||||
|
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("snapshot", help="SQLite snapshot created by create_backtest_snapshot.py.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--out",
|
||||||
|
default=None,
|
||||||
|
help="JSON report path. Defaults to reports/backtest-<timestamp>.json.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--workers", type=int, default=None, help="Override backtest worker count.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--allow-spawn",
|
||||||
|
action="store_true",
|
||||||
|
help="Allow spawn multiprocessing for offline CLI runs, useful on Windows.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--quiet", action="store_true", help="Hide progress output.")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_output_path() -> Path:
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
return Path("reports") / f"backtest-{stamp}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _pct(value: Any) -> str:
|
||||||
|
return "-" if value is None else f"{float(value):+.1f}%"
|
||||||
|
|
||||||
|
|
||||||
|
def _r(value: Any) -> str:
|
||||||
|
return "-" if value is None else f"{float(value):+.2f}R"
|
||||||
|
|
||||||
|
|
||||||
|
def _print_summary(report: dict) -> None:
|
||||||
|
qualified = report.get("overall_qualified") or {}
|
||||||
|
all_setups = report.get("overall_all") or {}
|
||||||
|
time_exit = {row.get("hold_days"): row for row in report.get("time_exit_sweep") or []}
|
||||||
|
hold_30 = time_exit.get(30) or {}
|
||||||
|
policies = {
|
||||||
|
row.get("policy"): row
|
||||||
|
for row in ((report.get("portfolio_sim") or {}).get("policies") or [])
|
||||||
|
}
|
||||||
|
hold_policy = policies.get("hold") or {}
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("Backtest summary")
|
||||||
|
print(f" candidates: {report.get('candidates')}")
|
||||||
|
print(f" qualified: {report.get('qualified')}")
|
||||||
|
print(f" all setups net avg R: {_r(all_setups.get('net_avg_r'))}")
|
||||||
|
print(f" qualified net avg R: {_r(qualified.get('net_avg_r'))}")
|
||||||
|
print(f" qualified total R: {_r(qualified.get('total_r'))}")
|
||||||
|
print(f" 30d hold net avg R: {_r(hold_30.get('net_avg_r'))}")
|
||||||
|
print(f" 30d hold total R: {_r(hold_30.get('total_r'))}")
|
||||||
|
if hold_policy:
|
||||||
|
print(f" hold CAGR: {_pct(hold_policy.get('cagr_pct'))}")
|
||||||
|
print(f" hold max drawdown: {_pct(hold_policy.get('max_drawdown_pct'))}")
|
||||||
|
print(f" hold Sharpe: {hold_policy.get('sharpe')}")
|
||||||
|
print(f" hold trades: {hold_policy.get('trades')}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _main() -> None:
|
||||||
|
args = _parse_args()
|
||||||
|
snapshot = Path(args.snapshot)
|
||||||
|
if not snapshot.exists():
|
||||||
|
raise SystemExit(f"Snapshot not found: {snapshot}")
|
||||||
|
|
||||||
|
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
||||||
|
if args.allow_spawn:
|
||||||
|
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.backtest_service import run_backtest
|
||||||
|
|
||||||
|
if args.workers is not None:
|
||||||
|
settings.backtest_workers = args.workers
|
||||||
|
|
||||||
|
output = Path(args.out) if args.out else _default_output_path()
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
||||||
|
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
last_progress: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
def progress(done: int, total: int, symbol: str) -> None:
|
||||||
|
nonlocal last_progress
|
||||||
|
if args.quiet:
|
||||||
|
return
|
||||||
|
marker = (done, total)
|
||||||
|
if marker == last_progress:
|
||||||
|
return
|
||||||
|
last_progress = marker
|
||||||
|
label = f" {symbol}" if symbol else ""
|
||||||
|
print(f"progress: {done}/{total}{label}", end="\r")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with Session() as db:
|
||||||
|
report = await run_backtest(db, progress_cb=progress)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
if not args.quiet:
|
||||||
|
print("")
|
||||||
|
with output.open("w", encoding="utf-8") as fh:
|
||||||
|
json.dump(report, fh, indent=2)
|
||||||
|
fh.write("\n")
|
||||||
|
|
||||||
|
print(f"Report written: {output}")
|
||||||
|
_print_summary(report)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(_main())
|
||||||
@@ -27,7 +27,7 @@ class TestActivationConfig:
|
|||||||
assert config == {
|
assert config == {
|
||||||
"min_momentum_percentile": 80.0,
|
"min_momentum_percentile": 80.0,
|
||||||
"min_rr": 1.2,
|
"min_rr": 1.2,
|
||||||
"min_confidence": 55.0,
|
"min_confidence": 0.0, # off — the July 2026 ablation showed it adds nothing
|
||||||
"require_high_conviction": False,
|
"require_high_conviction": False,
|
||||||
"exclude_conflicts": False,
|
"exclude_conflicts": False,
|
||||||
"exclude_neutral": True,
|
"exclude_neutral": True,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from datetime import date, datetime, timedelta, timezone
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.models.alert import AlertLog
|
from app.models.alert import AlertLog
|
||||||
from app.models.ohlcv import OHLCVRecord
|
from app.models.ohlcv import OHLCVRecord
|
||||||
@@ -113,6 +114,26 @@ async def test_score_drop_seeds_then_alerts(session):
|
|||||||
assert await svc._watermark(session, "AAA") == 60.0
|
assert await svc._watermark(session, "AAA") == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_qualified_includes_current_price_and_target_move():
|
||||||
|
text = svc._format_qualified({
|
||||||
|
"symbol": "AAPL",
|
||||||
|
"direction": "long",
|
||||||
|
"current_price": 196.42,
|
||||||
|
"entry_price": 195.80,
|
||||||
|
"target": 207.50,
|
||||||
|
"stop_loss": 190.20,
|
||||||
|
"rr_ratio": 2.1,
|
||||||
|
"confidence_score": 76.0,
|
||||||
|
"targets": [{"probability": 63.0}],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert "now 196.42" in text
|
||||||
|
assert "entry 195.80" in text
|
||||||
|
assert "target 207.50 (+5.6%)" in text
|
||||||
|
assert "stop 190.20" in text
|
||||||
|
assert "P(target) 63%" in text
|
||||||
|
|
||||||
|
|
||||||
async def _add_ticker(session, symbol: str, *, watchlisted: bool, close: float,
|
async def _add_ticker(session, symbol: str, *, watchlisted: bool, close: float,
|
||||||
levels: list[tuple[float, str, int]]) -> int:
|
levels: list[tuple[float, str, int]]) -> int:
|
||||||
user = await session.get(User, 1)
|
user = await session.get(User, 1)
|
||||||
@@ -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]]
|
cvx = [m for m in msgs if "CVX" in m[1]]
|
||||||
assert len(cvx) == 1
|
assert len(cvx) == 1
|
||||||
assert "183.00–185.00" in cvx[0][1]
|
assert "183.00–185.00" in cvx[0][1]
|
||||||
|
assert "now 182.00 -> 183.00–185.00 (+0.5%)" in cvx[0][1]
|
||||||
|
assert "strength 100" in cvx[0][1]
|
||||||
|
|
||||||
|
|
||||||
async def test_sr_proximity_skips_non_watchlist_unqualified(session):
|
async def test_sr_proximity_skips_non_watchlist_unqualified(session):
|
||||||
@@ -172,6 +195,61 @@ async def test_dispatch_no_credentials(session):
|
|||||||
assert res["status"] == "no_credentials"
|
assert res["status"] == "no_credentials"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dispatch_bundles_discovery_alerts_and_logs_each_item(session, monkeypatch):
|
||||||
|
async def fake_collect_qualified(_db):
|
||||||
|
return [
|
||||||
|
("qualified:AAPL:long", "🟢 <b>AAPL LONG</b> | now 196.42 | target 207.50 (+5.6%)"),
|
||||||
|
("qualified:TSLA:short", "🔴 <b>TSLA SHORT</b> | now 292.10 | target 276.50 (-5.3%)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def fake_collect_sr(_db):
|
||||||
|
return [
|
||||||
|
("sr:MSFT:resistance", "📍 <b>MSFT</b> resistance | now 508.20 -> 512.00 (+0.7%)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
sent: list[str] = []
|
||||||
|
|
||||||
|
async def fake_send(_client, _token, _chat_id, text):
|
||||||
|
sent.append(text)
|
||||||
|
|
||||||
|
monkeypatch.setattr(svc, "_collect_qualified", fake_collect_qualified)
|
||||||
|
monkeypatch.setattr(svc, "_collect_sr_proximity", fake_collect_sr)
|
||||||
|
monkeypatch.setattr(svc, "_send", fake_send)
|
||||||
|
|
||||||
|
await svc.update_alert_config(
|
||||||
|
session,
|
||||||
|
enabled=True,
|
||||||
|
bot_token="token",
|
||||||
|
telegram_chat_id="chat",
|
||||||
|
score_drop_enabled=False,
|
||||||
|
digest_enabled=False,
|
||||||
|
regime_quadrant_enabled=False,
|
||||||
|
trade_closed_enabled=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
res = await svc.dispatch_alerts(session)
|
||||||
|
|
||||||
|
assert res == {"status": "ok", "sent": 1, "candidates": 3}
|
||||||
|
assert len(sent) == 1
|
||||||
|
assert "<b>Signal run</b> — 3 new alert(s)" in sent[0]
|
||||||
|
assert "<b>Qualified setups</b>" in sent[0]
|
||||||
|
assert "<b>Near support/resistance</b>" in sent[0]
|
||||||
|
assert "AAPL LONG" in sent[0]
|
||||||
|
assert "MSFT" in sent[0]
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(AlertLog.alert_type, AlertLog.dedup_key)
|
||||||
|
.order_by(AlertLog.alert_type, AlertLog.dedup_key)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
assert rows == [
|
||||||
|
("qualified", "qualified:AAPL:long"),
|
||||||
|
("qualified", "qualified:TSLA:short"),
|
||||||
|
("sr_proximity", "sr:MSFT:resistance"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def _add_closed_trade(session, symbol: str, reason: str, *,
|
async def _add_closed_trade(session, symbol: str, reason: str, *,
|
||||||
close: float = 110.0, closed_hours_ago: float = 1.0) -> None:
|
close: float = 110.0, closed_hours_ago: float = 1.0) -> None:
|
||||||
if await session.get(User, 1) is None:
|
if await session.get(User, 1) is None:
|
||||||
|
|||||||
+385
-113
@@ -32,6 +32,7 @@ def _cand(
|
|||||||
qualified: bool = True,
|
qualified: bool = True,
|
||||||
direction: str = "long",
|
direction: str = "long",
|
||||||
risk_pct: float = 0.05,
|
risk_pct: float = 0.05,
|
||||||
|
hold_days: int = 10,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
target_hit = outcome == OUTCOME_TARGET_HIT
|
target_hit = outcome == OUTCOME_TARGET_HIT
|
||||||
realized = rr if target_hit else (0.0 if outcome == OUTCOME_EXPIRED else -1.0)
|
realized = rr if target_hit else (0.0 if outcome == OUTCOME_EXPIRED else -1.0)
|
||||||
@@ -44,6 +45,7 @@ def _cand(
|
|||||||
"qualified": qualified,
|
"qualified": qualified,
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"risk_pct": risk_pct,
|
"risk_pct": risk_pct,
|
||||||
|
"hold_days": hold_days,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -51,103 +53,193 @@ def _cand(
|
|||||||
_COST_R_005 = 2 * bt.COST_PER_SIDE / 0.05
|
_COST_R_005 = 2 * bt.COST_PER_SIDE / 0.05
|
||||||
|
|
||||||
|
|
||||||
def _bar(high: float, low: float, close: float) -> SimpleNamespace:
|
def _bar(high: float, low: float, close: float, open_: float | None = None) -> SimpleNamespace:
|
||||||
return SimpleNamespace(high=high, low=low, close=close)
|
"""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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestTakeProfitPrimitives:
|
def _signal_test_series(extra_return: float = 0.0) -> tuple[list[date], list[float], list[float], dict[date, float]]:
|
||||||
def test_long_tp_reachable_before_stop(self):
|
base = date(2024, 1, 1)
|
||||||
risk, stopped, mfe, close_pct = bt._tp_primitives("long", 100.0, 95.0, [_bar(109, 101, 108)], 30)
|
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 risk == pytest.approx(0.05)
|
||||||
assert stopped is False
|
assert stop_day is None
|
||||||
assert mfe == pytest.approx(0.09)
|
|
||||||
assert close_pct == pytest.approx(0.08)
|
|
||||||
|
|
||||||
def test_long_stop_zeroes_mfe(self):
|
def test_stop_day_is_one_based(self):
|
||||||
# Low pierces the stop on the only bar → loss, nothing banked before it.
|
bars = [_bar(102, 99, 101), _bar(101, 94, 96)]
|
||||||
risk, stopped, mfe, close_pct = bt._tp_primitives("long", 100.0, 95.0, [_bar(101, 94, 96)], 30)
|
risk, stop_day = bt._risk_and_stop_day("long", 100.0, 95.0, bars, 30)
|
||||||
assert stopped is True
|
assert risk == pytest.approx(0.05)
|
||||||
assert mfe == pytest.approx(0.0)
|
assert stop_day == 2
|
||||||
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_short_direction(self):
|
def test_short_direction(self):
|
||||||
# short entry 100, stop 105; price falls → favourable = (entry - low)/entry
|
_, stop_day = bt._risk_and_stop_day("short", 100.0, 105.0, [_bar(106, 101, 104)], 30)
|
||||||
risk, stopped, mfe, close_pct = bt._tp_primitives("short", 100.0, 105.0, [_bar(101, 92, 93)], 30)
|
assert stop_day == 1
|
||||||
assert risk == pytest.approx(0.05)
|
|
||||||
assert stopped is False
|
|
||||||
assert mfe == pytest.approx(0.08)
|
|
||||||
assert close_pct == pytest.approx(0.07)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTakeProfitBucket:
|
|
||||||
def test_bucket_mix(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
|
|
||||||
]
|
|
||||||
b = bt._take_profit_bucket(cands, 0.08)
|
|
||||||
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)
|
|
||||||
# net: minus a 0.04R round trip per candidate (risk_pct 0.05)
|
|
||||||
assert b["net_total_r"] == pytest.approx(0.8 - 3 * _COST_R_005, abs=0.01)
|
|
||||||
assert b["net_avg_r"] == pytest.approx((0.8 - 3 * _COST_R_005) / 3, abs=0.01)
|
|
||||||
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTrailingBucket:
|
|
||||||
def test_bucket(self):
|
|
||||||
cands = [
|
|
||||||
{"trail_r": {5: 1.4, 10: 0.8}, "risk_pct": 0.10},
|
|
||||||
{"trail_r": {5: -1.0, 10: -1.0}, "risk_pct": 0.10},
|
|
||||||
{"trail_r": {5: 0.5, 10: 0.5}, "risk_pct": 0.10},
|
|
||||||
]
|
|
||||||
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)
|
|
||||||
# net: 0.02R round trip per candidate (risk_pct 0.10)
|
|
||||||
assert b["net_total_r"] == pytest.approx(0.9 - 3 * 0.02, abs=0.01)
|
|
||||||
assert b["net_avg_r"] == pytest.approx(0.28, abs=0.01)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTimeExits:
|
class TestTimeExits:
|
||||||
@@ -177,6 +269,10 @@ class TestTimeExits:
|
|||||||
res = bt._time_exits("long", 100.0, 100.0, [_bar(103, 99, 102)], (5,))
|
res = bt._time_exits("long", 100.0, 100.0, [_bar(103, 99, 102)], (5,))
|
||||||
assert res[5] == 0.0
|
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:
|
class TestTimeExitBucket:
|
||||||
def test_bucket(self):
|
def test_bucket(self):
|
||||||
@@ -192,6 +288,15 @@ class TestTimeExitBucket:
|
|||||||
assert b["win_rate"] == pytest.approx(66.7, abs=0.1)
|
assert b["win_rate"] == pytest.approx(66.7, abs=0.1)
|
||||||
assert b["avg_r"] == pytest.approx(0.3, abs=0.01)
|
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["net_avg_r"] == pytest.approx(0.28, abs=0.01)
|
||||||
|
assert b["best_r"] == pytest.approx(1.4)
|
||||||
|
assert b["worst_r"] == pytest.approx(-1.0)
|
||||||
|
# No stop_day on any candidate → every hold runs the full 5 days.
|
||||||
|
assert b["avg_hold_days"] == 5.0
|
||||||
|
assert b["net_r_per_day"] == pytest.approx(0.28 / 5.0, abs=0.001)
|
||||||
|
# robustness on net rs [1.38, -1.02, 0.48]
|
||||||
|
assert b["median_net_r"] == pytest.approx(0.48, abs=0.001)
|
||||||
|
assert b["profit_factor"] == pytest.approx(1.86 / 1.02, abs=0.01)
|
||||||
|
assert b["net_avg_r_ex_top5"] == pytest.approx((0.48 - 1.02) / 2, abs=0.001)
|
||||||
|
|
||||||
def test_missing_hold_skipped(self):
|
def test_missing_hold_skipped(self):
|
||||||
b = bt._time_exit_bucket([{"time_r": {5: 1.0}}], 21)
|
b = bt._time_exit_bucket([{"time_r": {5: 1.0}}], 21)
|
||||||
@@ -208,12 +313,14 @@ def _acand(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Ablation candidate: meets_core mirrors the default floors (min_rr 1.2,
|
"""Ablation candidate: meets_core mirrors the default floors (min_rr 1.2,
|
||||||
min_confidence 55, exclude_neutral on)."""
|
min_confidence 55, exclude_neutral on)."""
|
||||||
meets = rr >= 1.2 and conf >= 55.0 and action != "NEUTRAL"
|
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 {
|
return {
|
||||||
"rr": rr,
|
"rr": rr,
|
||||||
"confidence": conf,
|
"confidence": conf,
|
||||||
"action": action,
|
"action": action,
|
||||||
"momentum_percentile": mp,
|
"momentum_percentile": mp,
|
||||||
|
"activation_momentum_percentile": mp,
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"meets_core": meets,
|
"meets_core": meets,
|
||||||
"risk_level": "Low",
|
"risk_level": "Low",
|
||||||
@@ -221,6 +328,7 @@ def _acand(
|
|||||||
"outcome": OUTCOME_TARGET_HIT,
|
"outcome": OUTCOME_TARGET_HIT,
|
||||||
"realized_r": rr,
|
"realized_r": rr,
|
||||||
"risk_pct": 0.05,
|
"risk_pct": 0.05,
|
||||||
|
"time_r": {d: 0.5 for d in bt.TIME_EXIT_DAYS},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -240,7 +348,7 @@ class TestGateAblation:
|
|||||||
_acand(rr=1.0), # fails R:R floor
|
_acand(rr=1.0), # fails R:R floor
|
||||||
_acand(action="NEUTRAL"), # fails NEUTRAL exclusion
|
_acand(action="NEUTRAL"), # fails NEUTRAL exclusion
|
||||||
_acand(mp=50.0), # fails the momentum cutoff
|
_acand(mp=50.0), # fails the momentum cutoff
|
||||||
_acand(direction="short", mp=95.0), # short — gated out
|
_acand(direction="short", action="SHORT_MODERATE", mp=95.0), # short — gated out
|
||||||
]
|
]
|
||||||
rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 80.0)}
|
rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 80.0)}
|
||||||
assert rows["all_floors"]["total"] == 1
|
assert rows["all_floors"]["total"] == 1
|
||||||
@@ -249,14 +357,95 @@ class TestGateAblation:
|
|||||||
assert rows["no_neutral_exclusion"]["total"] == 2
|
assert rows["no_neutral_exclusion"]["total"] == 2
|
||||||
assert rows["momentum_only"]["total"] == 4
|
assert rows["momentum_only"]["total"] == 4
|
||||||
assert rows["all_floors"]["net_avg_r"] is not None
|
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):
|
def test_threshold_zero_disables_momentum_gate(self):
|
||||||
# Floors only: the short and the low-momentum long both pass all_floors.
|
# Floors only: the short and the low-momentum long both pass all_floors.
|
||||||
cands = [_acand(mp=50.0), _acand(direction="short", mp=None)]
|
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)}
|
rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 0.0)}
|
||||||
assert rows["all_floors"]["total"] == 2
|
assert rows["all_floors"]["total"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def _sim_prices(start_ord: int, closes: list[float]) -> tuple:
|
||||||
|
"""Column arrays for consecutive daily bars: open = close (no gaps),
|
||||||
|
high/low = close ± 1."""
|
||||||
|
ords = list(range(start_ord, start_ord + len(closes)))
|
||||||
|
return (
|
||||||
|
ords,
|
||||||
|
list(closes),
|
||||||
|
[c + 1.0 for c in closes],
|
||||||
|
[c - 1.0 for c in closes],
|
||||||
|
list(closes),
|
||||||
|
[1_000_000] * len(closes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sim_cand(
|
||||||
|
sym: str, day_ord: int, entry: float, stop: float, target: float, mp: float = 90.0
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"qualified": True,
|
||||||
|
"direction": "long",
|
||||||
|
"symbol": sym,
|
||||||
|
"date": date.fromordinal(day_ord).isoformat(),
|
||||||
|
"entry": entry,
|
||||||
|
"stop": stop,
|
||||||
|
"target": target,
|
||||||
|
"momentum_percentile": mp,
|
||||||
|
"activation_momentum_percentile": mp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSimulatePortfolio:
|
||||||
|
ORD = date(2025, 1, 6).toordinal()
|
||||||
|
|
||||||
|
def test_hold_policy_accounting(self):
|
||||||
|
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
|
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0)
|
||||||
|
sim = bt._simulate_portfolio([cand], prices, None, "hold", 3)
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["trades"] == 1
|
||||||
|
# 20 shares (1% risk / $5 stop distance), exit at the day-3 close 106:
|
||||||
|
# pnl = 2120 − 2000 − 2.00 entry cost − 2.12 exit cost = 115.88
|
||||||
|
assert sim["final_equity"] == pytest.approx(10_115.88, abs=0.01)
|
||||||
|
assert sim["win_rate"] == 100.0
|
||||||
|
assert sim["best_trade_r"] == pytest.approx(1.2)
|
||||||
|
assert sim["avg_hold_days"] == 3.0
|
||||||
|
assert sim["max_drawdown_pct"] == 0.0
|
||||||
|
assert sim["cagr_pct"] is None # window far too short to annualize
|
||||||
|
assert sim["spy_return_pct"] is None
|
||||||
|
assert sim["yearly_returns"] == [
|
||||||
|
{"year": 2025, "return_pct": pytest.approx(1.2, abs=0.05)}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_target_policy_exits_at_target(self):
|
||||||
|
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
|
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=105.0)
|
||||||
|
sim = bt._simulate_portfolio([cand], prices, None, "target", 30)
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["trades"] == 1
|
||||||
|
assert sim["best_trade_r"] == pytest.approx(1.0) # filled exactly at 105
|
||||||
|
|
||||||
|
def test_stop_gap_fills_at_open(self):
|
||||||
|
# Day-1 bar gaps to a 90 open, below the 95 stop → fill at the open.
|
||||||
|
ords = list(range(self.ORD, self.ORD + 2))
|
||||||
|
prices = {"AAA": (ords, [100.0, 90.0], [101.0, 92.0], [99.0, 88.0], [100.0, 91.0], [1, 1])}
|
||||||
|
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=120.0)
|
||||||
|
sim = bt._simulate_portfolio([cand], prices, None, "hold", 30)
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["trades"] == 1
|
||||||
|
assert sim["worst_trade_r"] == pytest.approx(-2.0) # (90 − 100) / 5
|
||||||
|
|
||||||
|
def test_nothing_qualified_returns_none(self):
|
||||||
|
assert bt._simulate_portfolio([], {}, None, "hold", 30) is None
|
||||||
|
|
||||||
|
|
||||||
def test_bucket_stats_counts_and_expectancy():
|
def test_bucket_stats_counts_and_expectancy():
|
||||||
cands = [
|
cands = [
|
||||||
_cand(70, OUTCOME_TARGET_HIT, 3.0), # +3R win
|
_cand(70, OUTCOME_TARGET_HIT, 3.0), # +3R win
|
||||||
@@ -277,6 +466,15 @@ def test_bucket_stats_counts_and_expectancy():
|
|||||||
# net = gross minus a 0.04R round trip per candidate (risk_pct 0.05)
|
# 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_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["net_total_r"] == pytest.approx(4.0 - 4 * _COST_R_005, abs=0.01)
|
||||||
|
assert s["best_r"] == 3.0
|
||||||
|
assert s["worst_r"] == -1.0
|
||||||
|
assert s["avg_hold_days"] == 10.0
|
||||||
|
assert s["net_r_per_day"] == pytest.approx((1.0 - _COST_R_005) / 10.0, abs=0.001)
|
||||||
|
# robustness: net rs are [2.96, 1.96, -1.04, -0.04]
|
||||||
|
assert s["median_net_r"] == pytest.approx(0.96, abs=0.001)
|
||||||
|
assert s["profit_factor"] == pytest.approx(4.92 / 1.08, abs=0.01)
|
||||||
|
# ex-top-5%: ceil(4 * 0.05) = 1 winner trimmed → mean of the remaining three
|
||||||
|
assert s["net_avg_r_ex_top5"] == pytest.approx((1.96 - 1.04 - 0.04) / 3, abs=0.001)
|
||||||
|
|
||||||
|
|
||||||
def test_bucket_stats_empty():
|
def test_bucket_stats_empty():
|
||||||
@@ -295,24 +493,91 @@ def test_bucket_stats_no_risk_pct_means_no_cost():
|
|||||||
assert s["net_total_r"] == s["total_r"]
|
assert s["net_total_r"] == s["total_r"]
|
||||||
|
|
||||||
|
|
||||||
def test_calibration_buckets():
|
def test_build_recommendation_reads_the_report():
|
||||||
cands = [
|
report = {
|
||||||
_cand(65, OUTCOME_TARGET_HIT, 2.0),
|
"overall_qualified": {"net_avg_r": 0.13, "net_avg_r_ex_top5": 0.05},
|
||||||
_cand(62, OUTCOME_STOP_HIT, 2.0),
|
"time_exit_sweep": [
|
||||||
_cand(15, OUTCOME_STOP_HIT, 2.0),
|
{"hold_days": 21, "net_avg_r": 0.38},
|
||||||
]
|
{"hold_days": 30, "net_avg_r": 0.50, "net_avg_r_ex_top5": 0.21},
|
||||||
rows = bt._calibration(cands)
|
],
|
||||||
by_bucket = {r["bucket"]: r for r in rows}
|
"gate_ablation": [
|
||||||
assert by_bucket["60-80%"]["n"] == 2
|
{"variant": "all_floors", "total": 100, "hold_net_avg_r": 0.50},
|
||||||
assert by_bucket["60-80%"]["realized_hit_rate"] == 50.0 # 1 of 2 hit
|
{"variant": "no_confidence_floor", "total": 130, "hold_net_avg_r": 0.49},
|
||||||
assert by_bucket["0-20%"]["n"] == 1
|
{"variant": "no_rr_floor", "total": 400, "hold_net_avg_r": 0.34},
|
||||||
assert by_bucket["0-20%"]["realized_hit_rate"] == 0.0
|
{"variant": "no_neutral_exclusion", "total": 120, "hold_net_avg_r": 0.46},
|
||||||
|
],
|
||||||
|
"sweep": [
|
||||||
|
{"min_momentum_percentile": 80.0, "net_avg_r": 0.13, "total": 100},
|
||||||
|
{"min_momentum_percentile": 60.0, "net_avg_r": 0.05, "total": 300},
|
||||||
|
{"min_momentum_percentile": 0.0, "net_avg_r": -0.12, "total": 1000},
|
||||||
|
],
|
||||||
|
"portfolio_sim": {"policies": [
|
||||||
|
{"policy": "target", "cagr_pct": 23.7, "total_return_pct": 134.8,
|
||||||
|
"spy_return_pct": 95.9, "max_drawdown_pct": 20.7},
|
||||||
|
{"policy": "hold", "cagr_pct": 31.9, "total_return_pct": 203.6,
|
||||||
|
"spy_return_pct": 95.9, "max_drawdown_pct": 21.2},
|
||||||
|
]},
|
||||||
|
}
|
||||||
|
rec = bt._build_recommendation(report)
|
||||||
|
by_topic: dict[str, list[str]] = {}
|
||||||
|
for item in rec["items"]:
|
||||||
|
by_topic.setdefault(item["topic"], []).append(item["text"])
|
||||||
|
|
||||||
|
assert rec["headline"] is not None and "hold 30" in rec["headline"]
|
||||||
|
assert any("hold 30 trading days" in t for t in by_topic["exit"])
|
||||||
|
gate_texts = " | ".join(by_topic["gate"])
|
||||||
|
assert "confidence floor adds nothing" in gate_texts
|
||||||
|
assert "keep the R:R floor" in gate_texts
|
||||||
|
assert "keep the NEUTRAL exclusion" in gate_texts
|
||||||
|
assert "80" in by_topic["cutoff"][0]
|
||||||
|
assert "beats" in by_topic["benchmark"][0]
|
||||||
|
# robustness is judged under the RECOMMENDED exit (the 30d hold), not the
|
||||||
|
# target model the recommendation advises abandoning
|
||||||
|
assert any(
|
||||||
|
"not a handful of outliers" in t and "under the recommended 30d hold" in t
|
||||||
|
for t in by_topic["robustness"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_recommendation_flags_outlier_dependence():
|
||||||
|
rec = bt._build_recommendation({
|
||||||
|
"overall_qualified": {"net_avg_r": 0.13, "net_avg_r_ex_top5": -0.02},
|
||||||
|
})
|
||||||
|
robustness = [i["text"] for i in rec["items"] if i["topic"] == "robustness"]
|
||||||
|
assert robustness and "WARNING" in robustness[0]
|
||||||
|
|
||||||
|
|
||||||
def test_window_setups_too_short_returns_empty():
|
def test_window_setups_too_short_returns_empty():
|
||||||
assert bt._window_setups([], {}, {}) == []
|
assert bt._window_setups([], {}, {}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_ticker_candidates_carry_gate_fields():
|
||||||
|
"""The ablation recomputes floors from candidate fields — a candidate missing
|
||||||
|
action/risk_level silently zeroes the ablation rows (July 2026 regression)."""
|
||||||
|
from app.services.admin_service import ACTIVATION_DEFAULTS
|
||||||
|
from app.services.recommendation_service import DEFAULT_RECOMMENDATION_CONFIG
|
||||||
|
|
||||||
|
base = date(2025, 1, 1)
|
||||||
|
bars = []
|
||||||
|
for i in range(160):
|
||||||
|
close = 100.0 + 8.0 * math.sin(i / 6.0)
|
||||||
|
bars.append(SimpleNamespace(
|
||||||
|
date=base + timedelta(days=i),
|
||||||
|
open=close,
|
||||||
|
high=close + 1.5,
|
||||||
|
low=close - 1.5,
|
||||||
|
close=close,
|
||||||
|
volume=1_000_000 + (i % 5) * 1000,
|
||||||
|
))
|
||||||
|
cands = bt._replay_ticker(
|
||||||
|
"OSC", bars, dict(DEFAULT_RECOMMENDATION_CONFIG), dict(ACTIVATION_DEFAULTS)
|
||||||
|
)
|
||||||
|
assert cands, "expected the oscillating series to produce candidates"
|
||||||
|
for c in cands:
|
||||||
|
assert c.get("action") is not None
|
||||||
|
assert "risk_level" in c
|
||||||
|
|
||||||
|
|
||||||
async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None:
|
async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None:
|
||||||
t = Ticker(symbol=symbol)
|
t = Ticker(symbol=symbol)
|
||||||
session.add(t)
|
session.add(t)
|
||||||
@@ -340,8 +605,9 @@ async def test_run_backtest_smoke(session):
|
|||||||
assert report["tickers"] == 1
|
assert report["tickers"] == 1
|
||||||
assert isinstance(report["candidates"], int)
|
assert isinstance(report["candidates"], int)
|
||||||
for key in (
|
for key in (
|
||||||
"overall_qualified", "overall_all", "by_direction", "calibration", "sweep",
|
"overall_qualified", "overall_all", "by_direction", "sweep",
|
||||||
"gate_ablation", "time_exit_sweep",
|
"gate_ablation", "time_exit_sweep", "portfolio_sim", "strategy_variants",
|
||||||
|
"recommendation", "research_recommendation",
|
||||||
):
|
):
|
||||||
assert key in report
|
assert key in report
|
||||||
# the oscillating series should yield at least some resolved setups
|
# the oscillating series should yield at least some resolved setups
|
||||||
@@ -351,18 +617,24 @@ async def test_run_backtest_smoke(session):
|
|||||||
assert report["params"]["cost_per_side_pct"] == pytest.approx(bt.COST_PER_SIDE * 100)
|
assert report["params"]["cost_per_side_pct"] == pytest.approx(bt.COST_PER_SIDE * 100)
|
||||||
assert "net_avg_r" in report["overall_all"]
|
assert "net_avg_r" in report["overall_all"]
|
||||||
|
|
||||||
# ablation baseline reproduces the qualified set exactly
|
# 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"]}
|
ablation = {r["variant"]: r for r in report["gate_ablation"]}
|
||||||
assert ablation["all_floors"]["total"] == report["overall_qualified"]["total"]
|
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
|
# time-exit sweep covers the configured hold lengths
|
||||||
assert [r["hold_days"] for r in report["time_exit_sweep"]] == list(bt.TIME_EXIT_DAYS)
|
assert [r["hold_days"] for r in report["time_exit_sweep"]] == list(bt.TIME_EXIT_DAYS)
|
||||||
|
|
||||||
|
# portfolio simulation section is always present (policies may be empty
|
||||||
|
# when nothing qualifies)
|
||||||
|
assert "portfolio_sim" in report
|
||||||
|
assert isinstance(report["portfolio_sim"]["policies"], list)
|
||||||
|
assert report["portfolio_sim"]["params"]["max_positions"] == bt.SIM_MAX_POSITIONS
|
||||||
|
assert isinstance(report["strategy_variants"]["variants"], list)
|
||||||
|
|
||||||
# sweep: lowering the momentum-percentile cutoff can only add qualifiers
|
# sweep: lowering the momentum-percentile cutoff can only add qualifiers
|
||||||
sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True)
|
sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True)
|
||||||
counts = [r["total"] for r in sweep]
|
counts = [r["total"] for r in sweep]
|
||||||
assert counts == sorted(counts) # ascending as threshold descends
|
assert counts == sorted(counts) # ascending as threshold descends
|
||||||
# every calibration row is internally consistent
|
|
||||||
for row in report["calibration"]:
|
|
||||||
assert 0 <= row["realized_hit_rate"] <= 100
|
|
||||||
assert row["n"] >= 1
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Unit tests for the cross-sectional 12-1 momentum ranking."""
|
"""Unit tests for the cross-sectional activation momentum ranking."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -35,6 +35,21 @@ async def _seed(session, symbol: str, rate: float, n: int = 280) -> None:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_closes(session, symbol: str, closes: list[float]) -> None:
|
||||||
|
t = Ticker(symbol=symbol)
|
||||||
|
session.add(t)
|
||||||
|
await session.flush()
|
||||||
|
base = date(2024, 1, 1)
|
||||||
|
for i, close in enumerate(closes):
|
||||||
|
session.add(OHLCVRecord(
|
||||||
|
ticker_id=t.id,
|
||||||
|
date=base + timedelta(days=i),
|
||||||
|
open=close, high=close, low=close, close=close,
|
||||||
|
volume=1_000_000,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
def test_compute_momentum_insufficient_history():
|
def test_compute_momentum_insufficient_history():
|
||||||
assert ms.compute_12_1_momentum([100.0] * 100) is None
|
assert ms.compute_12_1_momentum([100.0] * 100) is None
|
||||||
|
|
||||||
@@ -47,7 +62,11 @@ def test_compute_momentum_value():
|
|||||||
assert m > 0
|
assert m > 0
|
||||||
|
|
||||||
|
|
||||||
async def test_ranks_universe_into_percentiles(session):
|
async def test_ranks_universe_into_raw_percentiles_when_benchmark_missing(session, monkeypatch):
|
||||||
|
async def no_benchmark(_db):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
|
||||||
await _seed(session, "HIGH", rate=1.010) # strong uptrend → top momentum
|
await _seed(session, "HIGH", rate=1.010) # strong uptrend → top momentum
|
||||||
await _seed(session, "MID", rate=1.002)
|
await _seed(session, "MID", rate=1.002)
|
||||||
await _seed(session, "LOW", rate=0.999) # declining → bottom momentum
|
await _seed(session, "LOW", rate=0.999) # declining → bottom momentum
|
||||||
@@ -58,7 +77,31 @@ async def test_ranks_universe_into_percentiles(session):
|
|||||||
assert pct["LOW"] == 0.0
|
assert pct["LOW"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
async def test_short_history_ticker_is_unranked(session):
|
async def test_ranks_universe_into_residual_percentiles_when_benchmark_available(session, monkeypatch):
|
||||||
|
base = date(2024, 1, 1)
|
||||||
|
n = 280
|
||||||
|
benchmark = {base + timedelta(days=i): 100.0 * (1.001 ** i) for i in range(n)}
|
||||||
|
|
||||||
|
async def with_benchmark(_db):
|
||||||
|
return benchmark
|
||||||
|
|
||||||
|
monkeypatch.setattr(ms, "_load_activation_benchmark", with_benchmark)
|
||||||
|
market = [benchmark[base + timedelta(days=i)] for i in range(n)]
|
||||||
|
await _seed_closes(session, "DRIFT", [market[i] * (1.0008 ** i) for i in range(n)])
|
||||||
|
await _seed_closes(session, "BETA", market)
|
||||||
|
await _seed_closes(session, "LAG", [market[i] * (0.9992 ** i) for i in range(n)])
|
||||||
|
|
||||||
|
pct = await ms.compute_momentum_percentiles(session)
|
||||||
|
assert pct["DRIFT"] == 100.0
|
||||||
|
assert pct["BETA"] == 50.0
|
||||||
|
assert pct["LAG"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_short_history_ticker_is_unranked(session, monkeypatch):
|
||||||
|
async def no_benchmark(_db):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
|
||||||
await _seed(session, "LONG", rate=1.005)
|
await _seed(session, "LONG", rate=1.005)
|
||||||
await _seed(session, "SHORTHX", rate=1.005, n=100) # < 1y → no momentum
|
await _seed(session, "SHORTHX", rate=1.005, n=100) # < 1y → no momentum
|
||||||
|
|
||||||
@@ -67,5 +110,9 @@ async def test_short_history_ticker_is_unranked(session):
|
|||||||
assert "SHORTHX" not in pct
|
assert "SHORTHX" not in pct
|
||||||
|
|
||||||
|
|
||||||
async def test_empty_universe_returns_empty(session):
|
async def test_empty_universe_returns_empty(session, monkeypatch):
|
||||||
|
async def no_benchmark(_db):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
|
||||||
assert await ms.compute_momentum_percentiles(session) == {}
|
assert await ms.compute_momentum_percentiles(session) == {}
|
||||||
|
|||||||
@@ -205,9 +205,12 @@ class TestTrailingClose:
|
|||||||
|
|
||||||
|
|
||||||
async def test_exit_policy_defaults_and_round_trip(session):
|
async def test_exit_policy_defaults_and_round_trip(session):
|
||||||
assert await svc.get_exit_policy(session) == {"mode": "trailing", "trailing_pct": 12.0}
|
# Default: the backtest-validated hold-to-horizon exit.
|
||||||
updated = await svc.set_exit_policy(session, mode="target", trailing_pct=15.0)
|
assert await svc.get_exit_policy(session) == {
|
||||||
assert updated == {"mode": "target", "trailing_pct": 15.0}
|
"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"
|
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")
|
await svc.set_exit_policy(session, mode="bogus")
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
await svc.set_exit_policy(session, trailing_pct=200.0)
|
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):
|
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_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
|
await _add_bars(session, tid, [(120, 110), (130, 100)], start=date.today()) # run up, pull back
|
||||||
assert await svc.resolve_open_trades(session) == 1
|
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):
|
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)
|
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_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
|
await _add_bars(session, tid, [(125, 118)], start=date.today()) # peak 125
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ STRICT_GATE = {
|
|||||||
|
|
||||||
def _setup(**kwargs):
|
def _setup(**kwargs):
|
||||||
base = dict(
|
base = dict(
|
||||||
|
direction="long",
|
||||||
rr_ratio=3.0,
|
rr_ratio=3.0,
|
||||||
confidence_score=80.0,
|
confidence_score=80.0,
|
||||||
recommended_action="LONG_HIGH",
|
recommended_action="LONG_HIGH",
|
||||||
@@ -124,6 +125,15 @@ class TestExcludeNeutral:
|
|||||||
def test_directional_passes_when_on(self):
|
def test_directional_passes_when_on(self):
|
||||||
assert setup_qualifies(_setup(recommended_action="LONG_MODERATE"), NEUTRAL_GATE) is True
|
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):
|
def test_neutral_allowed_when_off(self):
|
||||||
# Flag absent from the config → NEUTRAL still qualifies (backward compatible).
|
# Flag absent from the config → NEUTRAL still qualifies (backward compatible).
|
||||||
assert setup_qualifies(_setup(recommended_action="NEUTRAL"), DEFAULT_GATE) is True
|
assert setup_qualifies(_setup(recommended_action="NEUTRAL"), DEFAULT_GATE) is True
|
||||||
|
|||||||
@@ -11,20 +11,29 @@ Zero-candidate and single-candidate scenarios must produce identical results.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from hypothesis import given, settings, HealthCheck, strategies as st
|
from hypothesis import given, settings, HealthCheck, strategies as st
|
||||||
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.ohlcv import OHLCVRecord
|
from app.models.ohlcv import OHLCVRecord
|
||||||
|
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||||
from app.models.sr_level import SRLevel
|
from app.models.sr_level import SRLevel
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.models.trade_setup import TradeSetup
|
from app.models.trade_setup import TradeSetup
|
||||||
from app.models.score import CompositeScore
|
from app.models.score import CompositeScore, DimensionScore
|
||||||
|
from app.models.sentiment import SentimentScore
|
||||||
from app.services.rr_scanner_service import scan_ticker, get_trade_setups
|
from app.services.rr_scanner_service import scan_ticker, get_trade_setups
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(value: datetime) -> datetime:
|
||||||
|
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Session fixtures
|
# Session fixtures
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -431,3 +440,389 @@ async def test_get_trade_setups_sorting_rr_desc_composite_desc(db_session: Async
|
|||||||
f"Expected symbol order ['SORTD', 'SORTC', 'SORTB', 'SORTA'], "
|
f"Expected symbol order ['SORTD', 'SORTC', 'SORTB', 'SORTA'], "
|
||||||
f"got {symbols}"
|
f"got {symbols}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
|
||||||
|
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context
|
||||||
|
(bullish sentiment, composite 96) that yields live confidence 97."""
|
||||||
|
old_scan = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||||
|
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||||
|
old_reasoning = (
|
||||||
|
"LONG (high confidence): 82% with aligned signals "
|
||||||
|
"(technical=88, momentum=60, sentiment=neutral)."
|
||||||
|
)
|
||||||
|
|
||||||
|
ticker = Ticker(symbol="TTWO")
|
||||||
|
db_session.add(ticker)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
stale_setup = TradeSetup(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=235.0,
|
||||||
|
stop_loss=220.0,
|
||||||
|
target=265.0,
|
||||||
|
rr_ratio=2.0,
|
||||||
|
composite_score=71.8,
|
||||||
|
detected_at=old_scan,
|
||||||
|
confidence_score=82.0,
|
||||||
|
recommended_action="LONG_HIGH",
|
||||||
|
reasoning=old_reasoning,
|
||||||
|
risk_level="High",
|
||||||
|
)
|
||||||
|
db_session.add(stale_setup)
|
||||||
|
|
||||||
|
db_session.add_all([
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="technical",
|
||||||
|
score=88.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="momentum",
|
||||||
|
score=60.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="fundamental",
|
||||||
|
score=95.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="sentiment",
|
||||||
|
score=85.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
CompositeScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
score=96.0,
|
||||||
|
is_stale=False,
|
||||||
|
weights_json="{}",
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
SentimentScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
classification="bullish",
|
||||||
|
confidence=85,
|
||||||
|
source="test",
|
||||||
|
timestamp=current,
|
||||||
|
reasoning="",
|
||||||
|
citations_json="[]",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
await db_session.flush()
|
||||||
|
return stale_setup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_recommendation_payload_uses_current_score_and_sentiment(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""Latest setup payload should not show stale scan text when score context moved."""
|
||||||
|
stale_setup = await _seed_stale_setup_with_current_scores(db_session)
|
||||||
|
old_reasoning = stale_setup.reasoning
|
||||||
|
|
||||||
|
rows = await get_trade_setups(
|
||||||
|
db_session,
|
||||||
|
symbol="TTWO",
|
||||||
|
live_recommendation=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(rows) == 1
|
||||||
|
row = rows[0]
|
||||||
|
assert row["composite_score"] == pytest.approx(96.0)
|
||||||
|
assert row["confidence_score"] == pytest.approx(97.0)
|
||||||
|
assert row["recommended_action"] == "LONG_HIGH"
|
||||||
|
assert "sentiment=bullish" in row["reasoning"]
|
||||||
|
assert "sentiment=neutral" not in row["reasoning"]
|
||||||
|
|
||||||
|
persisted = await db_session.get(TradeSetup, stale_setup.id)
|
||||||
|
assert persisted is not None
|
||||||
|
assert persisted.composite_score == pytest.approx(71.8)
|
||||||
|
assert persisted.reasoning == old_reasoning
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_recommendation_filters_apply_to_live_values(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""min_confidence must judge the overlaid live confidence, not the stored one."""
|
||||||
|
await _seed_stale_setup_with_current_scores(db_session)
|
||||||
|
|
||||||
|
# Stored confidence is 82 — a stored-column filter would drop this row.
|
||||||
|
# Live confidence is 97, so it must pass.
|
||||||
|
rows = await get_trade_setups(
|
||||||
|
db_session,
|
||||||
|
symbol="TTWO",
|
||||||
|
min_confidence=90.0,
|
||||||
|
live_recommendation=True,
|
||||||
|
)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["confidence_score"] == pytest.approx(97.0)
|
||||||
|
|
||||||
|
# And a floor above the live value must drop it.
|
||||||
|
rows = await get_trade_setups(
|
||||||
|
db_session,
|
||||||
|
symbol="TTWO",
|
||||||
|
min_confidence=98.0,
|
||||||
|
live_recommendation=True,
|
||||||
|
)
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_two_direction_setup(db_session: AsyncSession) -> None:
|
||||||
|
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||||
|
ticker = Ticker(symbol="BOTH")
|
||||||
|
db_session.add(ticker)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
db_session.add_all([
|
||||||
|
TradeSetup(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=112.0,
|
||||||
|
rr_ratio=2.4,
|
||||||
|
composite_score=30.0,
|
||||||
|
detected_at=current,
|
||||||
|
confidence_score=25.0,
|
||||||
|
recommended_action="NEUTRAL",
|
||||||
|
risk_level="Low",
|
||||||
|
),
|
||||||
|
TradeSetup(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
direction="short",
|
||||||
|
entry_price=100.0,
|
||||||
|
stop_loss=105.0,
|
||||||
|
target=88.0,
|
||||||
|
rr_ratio=2.4,
|
||||||
|
composite_score=30.0,
|
||||||
|
detected_at=current,
|
||||||
|
confidence_score=90.0,
|
||||||
|
recommended_action="SHORT_HIGH",
|
||||||
|
risk_level="Low",
|
||||||
|
),
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="technical",
|
||||||
|
score=10.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="momentum",
|
||||||
|
score=10.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="fundamental",
|
||||||
|
score=10.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
CompositeScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
score=30.0,
|
||||||
|
is_stale=False,
|
||||||
|
weights_json="{}",
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
SentimentScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
classification="bearish",
|
||||||
|
confidence=90,
|
||||||
|
source="test",
|
||||||
|
timestamp=current,
|
||||||
|
reasoning="",
|
||||||
|
citations_json="[]",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_recommendation_action_independent_of_direction_filter(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
await _seed_two_direction_setup(db_session)
|
||||||
|
|
||||||
|
all_rows = await get_trade_setups(
|
||||||
|
db_session,
|
||||||
|
symbol="BOTH",
|
||||||
|
live_recommendation=True,
|
||||||
|
)
|
||||||
|
filtered_rows = await get_trade_setups(
|
||||||
|
db_session,
|
||||||
|
symbol="BOTH",
|
||||||
|
direction="long",
|
||||||
|
live_recommendation=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
long_from_all = next(row for row in all_rows if row["direction"] == "long")
|
||||||
|
assert len(filtered_rows) == 1
|
||||||
|
assert long_from_all["recommended_action"] == "SHORT_HIGH"
|
||||||
|
assert filtered_rows[0]["recommended_action"] == "SHORT_HIGH"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_overlay_preserves_setup_specific_risk_and_context(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||||
|
ticker = Ticker(symbol="RISK")
|
||||||
|
db_session.add(ticker)
|
||||||
|
await db_session.flush()
|
||||||
|
db_session.add_all([
|
||||||
|
TradeSetup(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=112.0,
|
||||||
|
rr_ratio=2.4,
|
||||||
|
composite_score=50.0,
|
||||||
|
detected_at=current,
|
||||||
|
confidence_score=50.0,
|
||||||
|
recommended_action="NEUTRAL",
|
||||||
|
risk_level="Medium",
|
||||||
|
conflict_flags_json=json.dumps([
|
||||||
|
"target-availability: Fewer than 3 valid S/R targets available"
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="technical",
|
||||||
|
score=50.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="momentum",
|
||||||
|
score=50.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
CompositeScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
score=50.0,
|
||||||
|
is_stale=False,
|
||||||
|
weights_json="{}",
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
SentimentScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
classification="neutral",
|
||||||
|
confidence=50,
|
||||||
|
source="test",
|
||||||
|
timestamp=current,
|
||||||
|
reasoning="",
|
||||||
|
citations_json="[]",
|
||||||
|
),
|
||||||
|
OHLCVRecord(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
date=date(2026, 7, 3),
|
||||||
|
open=101.0,
|
||||||
|
high=102.0,
|
||||||
|
low=100.0,
|
||||||
|
close=101.0,
|
||||||
|
volume=1000,
|
||||||
|
created_at=current,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
rows = await get_trade_setups(
|
||||||
|
db_session,
|
||||||
|
symbol="RISK",
|
||||||
|
live_recommendation=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(rows) == 1
|
||||||
|
row = rows[0]
|
||||||
|
assert row["risk_level"] == "Medium"
|
||||||
|
assert row["conflict_flags"] == [
|
||||||
|
"target-availability: Fewer than 3 valid S/R targets available"
|
||||||
|
]
|
||||||
|
assert row["current_price"] == pytest.approx(101.0)
|
||||||
|
assert _as_utc(row["context_as_of"]["score_computed_at"]) == current
|
||||||
|
assert _as_utc(row["context_as_of"]["sentiment_at"]) == current
|
||||||
|
assert row["context_as_of"]["price_date"] == date(2026, 7, 3)
|
||||||
|
assert _as_utc(row["context_as_of"]["price_updated_at"]) == current
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_trade_setup_read_does_not_recompute_scores(db_session: AsyncSession):
|
||||||
|
await _seed_stale_setup_with_current_scores(db_session)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.scoring_service.compute_all_dimensions",
|
||||||
|
new=AsyncMock(side_effect=AssertionError("GET must not recompute dimensions")),
|
||||||
|
), patch(
|
||||||
|
"app.services.scoring_service.compute_composite_score",
|
||||||
|
new=AsyncMock(side_effect=AssertionError("GET must not recompute composite")),
|
||||||
|
):
|
||||||
|
rows = await get_trade_setups(
|
||||||
|
db_session,
|
||||||
|
symbol="TTWO",
|
||||||
|
live_recommendation=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_intraday_price_update_changes_live_price_without_new_signal_rows(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||||
|
ticker = Ticker(symbol="LIVEP")
|
||||||
|
db_session.add(ticker)
|
||||||
|
await db_session.flush()
|
||||||
|
setup = TradeSetup(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=112.0,
|
||||||
|
rr_ratio=2.4,
|
||||||
|
composite_score=50.0,
|
||||||
|
detected_at=current,
|
||||||
|
)
|
||||||
|
price = OHLCVRecord(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
date=date(2026, 7, 3),
|
||||||
|
open=100.0,
|
||||||
|
high=101.0,
|
||||||
|
low=99.0,
|
||||||
|
close=100.0,
|
||||||
|
volume=1000,
|
||||||
|
created_at=current,
|
||||||
|
)
|
||||||
|
db_session.add_all([setup, price])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
rows = await get_trade_setups(db_session, symbol="LIVEP", live_recommendation=True)
|
||||||
|
assert rows[0]["current_price"] == pytest.approx(100.0)
|
||||||
|
|
||||||
|
price.close = 102.0
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
rows = await get_trade_setups(db_session, symbol="LIVEP", live_recommendation=True)
|
||||||
|
assert rows[0]["current_price"] == pytest.approx(102.0)
|
||||||
|
setup_count = await db_session.scalar(select(func.count()).select_from(TradeSetup))
|
||||||
|
snapshot_count = await db_session.scalar(select(func.count()).select_from(SignalContextSnapshot))
|
||||||
|
assert setup_count == 1
|
||||||
|
assert snapshot_count == 0
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
"""Unit tests for get_score composite breakdown and dimension breakdown wiring."""
|
"""Unit tests for read-only get_score composite breakdown wiring."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import datetime, timezone
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
from app.models.score import CompositeScore, DimensionScore
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.services.scoring_service import get_score, _DIMENSION_COMPUTERS
|
from app.services.scoring_service import get_score
|
||||||
|
|
||||||
TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def fresh_db():
|
async def fresh_db():
|
||||||
"""Provide a non-transactional session so get_score can commit."""
|
"""Provide a non-transactional session for persisted score reads."""
|
||||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
@@ -30,176 +30,101 @@ async def fresh_db():
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def _make_ohlcv_records(n: int, base_close: float = 100.0) -> list:
|
|
||||||
"""Create n mock OHLCV records with realistic price data."""
|
|
||||||
records = []
|
|
||||||
for i in range(n):
|
|
||||||
price = base_close + (i * 0.5)
|
|
||||||
records.append(
|
|
||||||
SimpleNamespace(
|
|
||||||
date=date(2024, 1, 1),
|
|
||||||
open=price - 0.5,
|
|
||||||
high=price + 1.0,
|
|
||||||
low=price - 1.0,
|
|
||||||
close=price,
|
|
||||||
volume=1000000,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return records
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_none_computer():
|
|
||||||
"""Return an AsyncMock that returns (None, None) — simulates missing dimension data."""
|
|
||||||
return AsyncMock(return_value=(None, None))
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_score_computer(score: float, breakdown: dict | None = None):
|
|
||||||
"""Return an AsyncMock that returns a fixed (score, breakdown) tuple."""
|
|
||||||
bd = breakdown or {
|
|
||||||
"sub_scores": [{"name": "mock", "score": score, "weight": 1.0, "raw_value": score, "description": "mock"}],
|
|
||||||
"formula": "mock formula",
|
|
||||||
"unavailable": [],
|
|
||||||
}
|
|
||||||
return AsyncMock(return_value=(score, bd))
|
|
||||||
|
|
||||||
|
|
||||||
async def _seed_ticker(session: AsyncSession, symbol: str = "AAPL") -> Ticker:
|
async def _seed_ticker(session: AsyncSession, symbol: str = "AAPL") -> Ticker:
|
||||||
"""Insert a ticker row and return it."""
|
|
||||||
ticker = Ticker(symbol=symbol)
|
ticker = Ticker(symbol=symbol)
|
||||||
session.add(ticker)
|
session.add(ticker)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return ticker
|
return ticker
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_scores(session: AsyncSession, ticker: Ticker, *, stale: bool = False) -> None:
|
||||||
|
now = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||||
|
session.add_all([
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="technical",
|
||||||
|
score=70.0,
|
||||||
|
is_stale=stale,
|
||||||
|
computed_at=now,
|
||||||
|
),
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="momentum",
|
||||||
|
score=60.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=now,
|
||||||
|
),
|
||||||
|
CompositeScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
score=66.0,
|
||||||
|
is_stale=stale,
|
||||||
|
weights_json="{}",
|
||||||
|
computed_at=now,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_score_returns_composite_breakdown(fresh_db):
|
async def test_get_score_returns_composite_breakdown_without_recomputing(fresh_db):
|
||||||
"""get_score should include a composite_breakdown dict with weights and re-normalization info."""
|
ticker = await _seed_ticker(fresh_db, "AAPL")
|
||||||
await _seed_ticker(fresh_db, "AAPL")
|
await _seed_scores(fresh_db, ticker)
|
||||||
|
|
||||||
original = dict(_DIMENSION_COMPUTERS)
|
|
||||||
try:
|
|
||||||
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0)
|
|
||||||
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0)
|
|
||||||
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
|
|
||||||
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
|
|
||||||
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
|
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.scoring_service.compute_dimension_score",
|
||||||
|
new=AsyncMock(side_effect=AssertionError("GET must not recompute dimensions")),
|
||||||
|
), patch(
|
||||||
|
"app.services.scoring_service.compute_composite_score",
|
||||||
|
new=AsyncMock(side_effect=AssertionError("GET must not recompute composite")),
|
||||||
|
):
|
||||||
result = await get_score(fresh_db, "AAPL")
|
result = await get_score(fresh_db, "AAPL")
|
||||||
finally:
|
|
||||||
_DIMENSION_COMPUTERS.update(original)
|
|
||||||
|
|
||||||
assert "composite_breakdown" in result
|
assert result["composite_score"] == 66.0
|
||||||
cb = result["composite_breakdown"]
|
cb = result["composite_breakdown"]
|
||||||
assert cb is not None
|
assert cb is not None
|
||||||
assert "weights" in cb
|
|
||||||
assert "available_dimensions" in cb
|
|
||||||
assert "missing_dimensions" in cb
|
|
||||||
assert "renormalized_weights" in cb
|
|
||||||
assert "formula" in cb
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_score_composite_breakdown_has_correct_available_missing(fresh_db):
|
|
||||||
"""Composite breakdown should correctly list available and missing dimensions."""
|
|
||||||
await _seed_ticker(fresh_db, "AAPL")
|
|
||||||
|
|
||||||
original = dict(_DIMENSION_COMPUTERS)
|
|
||||||
try:
|
|
||||||
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0)
|
|
||||||
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0)
|
|
||||||
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
|
|
||||||
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
|
|
||||||
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
|
|
||||||
|
|
||||||
result = await get_score(fresh_db, "AAPL")
|
|
||||||
finally:
|
|
||||||
_DIMENSION_COMPUTERS.update(original)
|
|
||||||
|
|
||||||
cb = result["composite_breakdown"]
|
|
||||||
assert "technical" in cb["available_dimensions"]
|
assert "technical" in cb["available_dimensions"]
|
||||||
assert "momentum" in cb["available_dimensions"]
|
assert "momentum" in cb["available_dimensions"]
|
||||||
assert "sentiment" in cb["missing_dimensions"]
|
assert "sentiment" in cb["missing_dimensions"]
|
||||||
assert "fundamental" in cb["missing_dimensions"]
|
assert "fundamental" in cb["missing_dimensions"]
|
||||||
assert "sr_quality" in cb["missing_dimensions"]
|
assert "sr_quality" in cb["missing_dimensions"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_score_renormalized_weights_sum_to_one(fresh_db):
|
|
||||||
"""Re-normalized weights should sum to 1.0 when at least one dimension is available."""
|
|
||||||
await _seed_ticker(fresh_db, "AAPL")
|
|
||||||
|
|
||||||
original = dict(_DIMENSION_COMPUTERS)
|
|
||||||
try:
|
|
||||||
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0)
|
|
||||||
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0)
|
|
||||||
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
|
|
||||||
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
|
|
||||||
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
|
|
||||||
|
|
||||||
result = await get_score(fresh_db, "AAPL")
|
|
||||||
finally:
|
|
||||||
_DIMENSION_COMPUTERS.update(original)
|
|
||||||
|
|
||||||
cb = result["composite_breakdown"]
|
|
||||||
assert cb["renormalized_weights"]
|
assert cb["renormalized_weights"]
|
||||||
total = sum(cb["renormalized_weights"].values())
|
assert abs(sum(cb["renormalized_weights"].values()) - 1.0) < 1e-9
|
||||||
assert abs(total - 1.0) < 1e-9
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_score_dimensions_include_breakdowns(fresh_db):
|
async def test_get_score_dimensions_do_not_recompute_breakdowns(fresh_db):
|
||||||
"""Each available dimension entry should include a breakdown dict."""
|
ticker = await _seed_ticker(fresh_db, "AAPL")
|
||||||
await _seed_ticker(fresh_db, "AAPL")
|
await _seed_scores(fresh_db, ticker)
|
||||||
|
|
||||||
tech_breakdown = {
|
result = await get_score(fresh_db, "AAPL")
|
||||||
"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)
|
|
||||||
|
|
||||||
tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None)
|
tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None)
|
||||||
assert tech_dim is not None
|
assert tech_dim is not None
|
||||||
assert "breakdown" in tech_dim
|
assert tech_dim["breakdown"] is None
|
||||||
assert tech_dim["breakdown"] is not None
|
|
||||||
assert len(tech_dim["breakdown"]["sub_scores"]) == 3
|
|
||||||
names = [s["name"] for s in tech_dim["breakdown"]["sub_scores"]]
|
|
||||||
assert "ADX" in names
|
|
||||||
assert "EMA" in names
|
|
||||||
assert "RSI" in names
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_score_all_dimensions_missing(fresh_db):
|
async def test_get_score_all_dimensions_missing(fresh_db):
|
||||||
"""When all dimensions return None, composite_breakdown should list all as missing."""
|
|
||||||
await _seed_ticker(fresh_db, "AAPL")
|
await _seed_ticker(fresh_db, "AAPL")
|
||||||
|
|
||||||
original = dict(_DIMENSION_COMPUTERS)
|
result = await get_score(fresh_db, "AAPL")
|
||||||
try:
|
|
||||||
for dim in _DIMENSION_COMPUTERS:
|
|
||||||
_DIMENSION_COMPUTERS[dim] = _mock_none_computer()
|
|
||||||
|
|
||||||
result = await get_score(fresh_db, "AAPL")
|
|
||||||
finally:
|
|
||||||
_DIMENSION_COMPUTERS.update(original)
|
|
||||||
|
|
||||||
cb = result["composite_breakdown"]
|
cb = result["composite_breakdown"]
|
||||||
assert cb["available_dimensions"] == []
|
assert cb["available_dimensions"] == []
|
||||||
assert len(cb["missing_dimensions"]) == 5
|
assert len(cb["missing_dimensions"]) == 5
|
||||||
assert cb["renormalized_weights"] == {}
|
assert cb["renormalized_weights"] == {}
|
||||||
assert result["composite_score"] is None
|
assert result["composite_score"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_score_reports_stale_without_refreshing(fresh_db):
|
||||||
|
ticker = await _seed_ticker(fresh_db, "AAPL")
|
||||||
|
await _seed_scores(fresh_db, ticker, stale=True)
|
||||||
|
|
||||||
|
result = await get_score(fresh_db, "AAPL")
|
||||||
|
|
||||||
|
assert result["composite_stale"] is True
|
||||||
|
assert "technical" in result["missing_dimensions"]
|
||||||
|
tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None)
|
||||||
|
assert tech_dim is not None
|
||||||
|
assert tech_dim["is_stale"] is True
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""Unit tests for get_rankings: bulk-load fast path, sorting, exclusion, and
|
"""Unit tests for read-only get_rankings: bulk-load, sorting, and staleness."""
|
||||||
lazy recompute of stale scores."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,7 +6,6 @@ from datetime import datetime, timezone
|
|||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -20,7 +18,7 @@ TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def fresh_db():
|
async def fresh_db():
|
||||||
"""Non-transactional session so get_rankings can commit recomputes."""
|
"""Non-transactional session for persisted ranking reads."""
|
||||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
@@ -84,46 +82,34 @@ async def test_fast_path_sorts_and_does_not_recompute(fresh_db: AsyncSession):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ticker_without_computable_composite_is_excluded(fresh_db: AsyncSession):
|
async def test_ticker_without_computable_composite_is_excluded(fresh_db: AsyncSession):
|
||||||
"""A ticker whose composite can't be computed (recompute yields no row) is
|
"""A ticker without a persisted composite is omitted from rankings."""
|
||||||
omitted from the rankings rather than appearing with a null score."""
|
|
||||||
fresh = await _seed_ticker(fresh_db, "OK")
|
fresh = await _seed_ticker(fresh_db, "OK")
|
||||||
await _seed_ticker(fresh_db, "NONE") # no composite; recompute can't make one
|
await _seed_ticker(fresh_db, "NONE")
|
||||||
fresh_db.add_all([_composite(fresh.id, 50.0), _dimension(fresh.id, "technical", 50.0)])
|
fresh_db.add_all([_composite(fresh.id, 50.0), _dimension(fresh.id, "technical", 50.0)])
|
||||||
await fresh_db.commit()
|
await fresh_db.commit()
|
||||||
|
|
||||||
# Recompute is a no-op that produces no composite row for NONE.
|
|
||||||
with patch("app.services.scoring_service.compute_dimension_score",
|
with patch("app.services.scoring_service.compute_dimension_score",
|
||||||
new=AsyncMock(return_value=None)), \
|
new=AsyncMock(side_effect=AssertionError("should not recompute"))), \
|
||||||
patch("app.services.scoring_service.compute_composite_score",
|
patch("app.services.scoring_service.compute_composite_score",
|
||||||
new=AsyncMock(return_value=(None, ["technical"]))):
|
new=AsyncMock(side_effect=AssertionError("should not recompute"))):
|
||||||
result = await get_rankings(fresh_db)
|
result = await get_rankings(fresh_db)
|
||||||
|
|
||||||
assert [r["symbol"] for r in result["rankings"]] == ["OK"]
|
assert [r["symbol"] for r in result["rankings"]] == ["OK"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stale_composite_is_recomputed(fresh_db: AsyncSession):
|
async def test_stale_composite_is_reported_without_recompute(fresh_db: AsyncSession):
|
||||||
"""A stale composite triggers a recompute and then appears in the rankings."""
|
"""A stale composite appears with its stale flag and is not recomputed."""
|
||||||
ticker = await _seed_ticker(fresh_db, "STALE")
|
ticker = await _seed_ticker(fresh_db, "STALE")
|
||||||
fresh_db.add(_composite(ticker.id, 10.0, stale=True))
|
fresh_db.add(_composite(ticker.id, 10.0, stale=True))
|
||||||
await fresh_db.commit()
|
await fresh_db.commit()
|
||||||
|
|
||||||
async def _fake_recompute(db, symbol, weights=None):
|
|
||||||
# Mirror the real upsert: refresh the existing row in place.
|
|
||||||
existing = (await db.execute(
|
|
||||||
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
|
|
||||||
)).scalar_one()
|
|
||||||
existing.score = 77.0
|
|
||||||
existing.is_stale = False
|
|
||||||
return 77.0, []
|
|
||||||
|
|
||||||
# Dimension recompute is a no-op; composite recompute refreshes the score.
|
|
||||||
with patch("app.services.scoring_service.compute_dimension_score",
|
with patch("app.services.scoring_service.compute_dimension_score",
|
||||||
new=AsyncMock(return_value=55.0)), \
|
new=AsyncMock(side_effect=AssertionError("should not recompute"))), \
|
||||||
patch("app.services.scoring_service.compute_composite_score",
|
patch("app.services.scoring_service.compute_composite_score",
|
||||||
new=AsyncMock(side_effect=_fake_recompute)) as comp_mock:
|
new=AsyncMock(side_effect=AssertionError("should not recompute"))):
|
||||||
result = await get_rankings(fresh_db)
|
result = await get_rankings(fresh_db)
|
||||||
|
|
||||||
comp_mock.assert_awaited() # recompute path was taken
|
|
||||||
assert [r["symbol"] for r in result["rankings"]] == ["STALE"]
|
assert [r["symbol"] for r in result["rankings"]] == ["STALE"]
|
||||||
assert result["rankings"][0]["composite_score"] == 77.0 # reflects the recompute
|
assert result["rankings"][0]["composite_score"] == 10.0
|
||||||
|
assert result["rankings"][0]["composite_stale"] is True
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Tests for sentiment-collection scoping (``_get_sentiment_priority_tickers``).
|
"""Tests for sentiment-collection scoping (``_get_sentiment_priority_tickers``).
|
||||||
|
|
||||||
A dashboard 'top pick' is the highest-momentum *qualified* long setup. Sentiment
|
A dashboard 'top pick' is the highest residual-momentum *qualified* long setup. Sentiment
|
||||||
can never move a ticker's momentum percentile (the gate's core axis) — only its
|
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
|
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
|
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.
|
(always refreshed, cap-exempt) and the capped filler tier behind it.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -60,8 +60,9 @@ def test_quintile_spread_none_when_too_few():
|
|||||||
def test_signal_values_momentum_and_trend():
|
def test_signal_values_momentum_and_trend():
|
||||||
# Steadily rising series so every lookback is positive and trend is above SMA.
|
# Steadily rising series so every lookback is positive and trend is above SMA.
|
||||||
closes = [100.0 * (1.01 ** k) for k in range(300)]
|
closes = [100.0 * (1.01 ** k) for k in range(300)]
|
||||||
|
dates = [date(2024, 1, 1) + timedelta(days=k) for k in range(300)]
|
||||||
i = 299
|
i = 299
|
||||||
vals = bt._signal_values(closes, closes, i)
|
vals = bt._signal_values(dates, closes, closes, i)
|
||||||
assert vals["mom_12_1"] > 0 # up over the 12→1 month window
|
assert vals["mom_12_1"] > 0 # up over the 12→1 month window
|
||||||
assert vals["trend_200"] > 0 # price above its 200-bar SMA in an uptrend
|
assert vals["trend_200"] > 0 # price above its 200-bar SMA in an uptrend
|
||||||
# 12-1 momentum skips the last month: close[i-21] / close[i-252] - 1
|
# 12-1 momentum skips the last month: close[i-21] / close[i-252] - 1
|
||||||
@@ -73,7 +74,8 @@ def test_signal_values_momentum_and_trend():
|
|||||||
|
|
||||||
def test_signal_values_drops_signals_without_enough_history():
|
def test_signal_values_drops_signals_without_enough_history():
|
||||||
closes = [100.0 + k for k in range(80)] # only 80 bars
|
closes = [100.0 + k for k in range(80)] # only 80 bars
|
||||||
vals = bt._signal_values(closes, closes, 79)
|
dates = [date(2024, 1, 1) + timedelta(days=k) for k in range(80)]
|
||||||
|
vals = bt._signal_values(dates, closes, closes, 79)
|
||||||
assert "mom_3_1" in vals # needs 63 bars of lookback — present
|
assert "mom_3_1" in vals # needs 63 bars of lookback — present
|
||||||
assert "mom_6_1" not in vals # needs 126 — absent
|
assert "mom_6_1" not in vals # needs 126 — absent
|
||||||
assert "mom_12_1" not in vals # needs 252 — absent
|
assert "mom_12_1" not in vals # needs 252 — absent
|
||||||
|
|||||||
Reference in New Issue
Block a user