Compare commits
50 Commits
f8d62e4074
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 66ef0564c1 | |||
| 14327ab25a | |||
| eaad935a2a | |||
| d4ccea2d69 | |||
| 8c36cfcef1 | |||
| ac51e23949 | |||
| 2b0068ae08 | |||
| 7fd34d6de8 | |||
| 8d5863bac4 | |||
| be4d6a05ca | |||
| aadec7d403 | |||
| 849489a4b5 | |||
| 80b4113280 | |||
| 13374087db | |||
| 1e82dfad7f | |||
| 29a61cb2ca | |||
| 243e369e9a | |||
| 0f43e755f4 | |||
| 942a22ce65 | |||
| 8750aac6d9 | |||
| 29b1a9a28c | |||
| 84ce7c5c26 | |||
| da0bb3367e | |||
| a9f4686157 | |||
| 94ed3207d7 | |||
| 5442b62495 | |||
| f61e11adea | |||
| 1566b84379 | |||
| ab9ce18809 | |||
| c5f6b07a3e | |||
| c63951ca02 | |||
| 6511a1020b | |||
| 20a1c143f3 | |||
| 6c2e45377c | |||
| 7e9a6cd7ec | |||
| 8bcbbfcfd0 | |||
| 0627787bfc | |||
| 30effa89b7 | |||
| 4a96f85cd9 | |||
| 146dadf06f | |||
| d15acb8741 | |||
| 2f21c685e8 | |||
| 65dd53baa3 | |||
| e683513857 | |||
| a07bfee6e6 | |||
| 66444af65c | |||
| 60def1155b | |||
| 02b8df58f0 | |||
| 613fc756ec | |||
| 7c5fb1138d |
+57
-32
@@ -22,6 +22,12 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Serialize deploys so two quick pushes to main can't rsync/restart on top of
|
||||
# each other. Don't cancel an in-flight deploy mid-restart.
|
||||
concurrency:
|
||||
group: deploy-main
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -29,7 +35,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
- run: pip install ruff
|
||||
- run: ruff check app/
|
||||
|
||||
@@ -52,17 +59,21 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: pip install -e ".[dev]"
|
||||
# The Postgres service exists only to validate the migrations against real
|
||||
# Postgres (what prod runs). The test suite itself uses an in-memory SQLite
|
||||
# engine (tests/conftest.py), so pytest doesn't touch this service.
|
||||
- run: alembic upgrade head
|
||||
env:
|
||||
DATABASE_URL: postgresql+asyncpg://test_user:test_pass@postgres:5432/test_db
|
||||
- run: pytest --tb=short
|
||||
env:
|
||||
DATABASE_URL: postgresql+asyncpg://test_user:test_pass@postgres:5432/test_db
|
||||
- run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
@@ -76,17 +87,6 @@ jobs:
|
||||
deploy:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
- name: Deploy to server
|
||||
env:
|
||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
||||
@@ -94,19 +94,36 @@ jobs:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ vars.SSH_KNOWN_HOSTS }}
|
||||
SSH_PORT: ${{ vars.SSH_PORT || '22' }}
|
||||
run: |
|
||||
# Install tools missing from runner image
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq rsync openssh-client > /dev/null 2>&1 || true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
# Write SSH credentials
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Install deploy tools
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq rsync openssh-client > /dev/null 2>&1 || true
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||
# known_hosts is supplied, so verify the host key instead of blindly
|
||||
# trusting it (StrictHostKeyChecking=no would defeat the fingerprint).
|
||||
echo "SSH_OPTS=-i $HOME/.ssh/deploy_key -o StrictHostKeyChecking=yes -p ${SSH_PORT}" >> "$GITHUB_ENV"
|
||||
|
||||
SSH_OPTS="-i ~/.ssh/deploy_key -o StrictHostKeyChecking=no -p $SSH_PORT"
|
||||
|
||||
# Sync application files
|
||||
- name: Sync files to server
|
||||
run: |
|
||||
rsync -avz --delete \
|
||||
--exclude '.git/' \
|
||||
--exclude '.gitea/' \
|
||||
@@ -118,10 +135,11 @@ jobs:
|
||||
--exclude '*.pyc' \
|
||||
--exclude 'frontend/node_modules/' \
|
||||
-e "ssh $SSH_OPTS" \
|
||||
./ ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/
|
||||
./ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/"
|
||||
|
||||
# Install deps & restart on server
|
||||
ssh $SSH_OPTS ${DEPLOY_USER}@${DEPLOY_HOST} << REMOTE_SCRIPT
|
||||
- name: Install deps & run migrations
|
||||
run: |
|
||||
ssh $SSH_OPTS "${DEPLOY_USER}@${DEPLOY_HOST}" << REMOTE_SCRIPT
|
||||
set -e
|
||||
cd ${DEPLOY_PATH}
|
||||
|
||||
@@ -141,12 +159,19 @@ jobs:
|
||||
else
|
||||
alembic upgrade head
|
||||
fi
|
||||
|
||||
# Restart service
|
||||
sudo systemctl restart signalplatform.service
|
||||
echo "✓ signalplatform deployed"
|
||||
|
||||
REMOTE_SCRIPT
|
||||
|
||||
# Cleanup
|
||||
rm -f ~/.ssh/deploy_key
|
||||
- name: Restart service & health check
|
||||
run: |
|
||||
ssh $SSH_OPTS "${DEPLOY_USER}@${DEPLOY_HOST}" << REMOTE_SCRIPT
|
||||
set -e
|
||||
sudo systemctl restart signalplatform.service
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:8998/api/v1/health > /dev/null \
|
||||
|| { echo "✗ health check failed after restart"; exit 1; }
|
||||
echo "✓ signalplatform deployed"
|
||||
REMOTE_SCRIPT
|
||||
|
||||
- name: Clean up SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
|
||||
@@ -33,3 +33,7 @@ alembic/versions/__pycache__/
|
||||
|
||||
# Generated SSL bundle
|
||||
combined-ca-bundle.pem
|
||||
|
||||
# Local research artifacts
|
||||
backtest_snapshots/
|
||||
reports/backtest-*.json
|
||||
|
||||
@@ -4,20 +4,110 @@ Investing-signal platform for NASDAQ stocks. Surfaces the best trading opportuni
|
||||
|
||||
**Philosophy:** Don't predict price. Find the path of least resistance, key S/R zones, and asymmetric R:R setups.
|
||||
|
||||
## How It Works
|
||||
|
||||
Scheduled pipelines turn raw prices into a ranked, gated list of tradeable setups. Everything downstream of OHLCV is recomputed from stored data, so each refresh is cheap and idempotent. Job timing is cron-based and configurable in **Admin → Jobs** (default timezone Europe/Berlin).
|
||||
|
||||
### Daily Load — the full refresh
|
||||
|
||||
Once a day (default 07:00). Steps run **in dependency order**, each consuming the previous step's fresh output:
|
||||
|
||||
1. **OHLCV** — fetch the latest daily bars for every tracked ticker (Alpaca); new tickers backfill ~5 years.
|
||||
2. **Sentiment** — fetch sentiment for the names that matter and are stale (> 5 days): top-pick feeders (residual-momentum leaders with a tradeable long setup), the watchlist, and open paper trades, plus a top-N-by-composite discovery net. Runs *before* the scan so the scan sees fresh sentiment.
|
||||
3. **R:R Scan** — recompute S/R zones, the 5-dimension scores and long/short setups (ATR stops, S/R targets) for every ticker, and attach each ticker's residual 12‑1 momentum activation percentile.
|
||||
4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and auto-close paper trades per the exit policy (default: hold 30 trading days with the initial stop — the backtest-validated exit).
|
||||
5. **Market Regime** — recompute the regime index (breadth/trend).
|
||||
6. **Regime Monitor** — observational early-warning snapshot (VIX, credit spreads via FRED); feeds nothing else.
|
||||
|
||||
A failing step is logged; the pipeline continues with the next.
|
||||
|
||||
### Intraday — light refresh
|
||||
|
||||
Hourly across the US session (Mon–Fri): only **OHLCV → Outcome Eval**, to keep prices current and close paper trades intraday. No scan/sentiment — the dashboard recomputes live R:R from the latest price, so fresh prices are enough.
|
||||
|
||||
### Other jobs
|
||||
|
||||
Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (weekly) · Ticker-universe sync (daily). Deep history backfill and event study are manual-only (Admin → Jobs).
|
||||
|
||||
### From score to "top pick"
|
||||
|
||||
1. **Composite score** — technical, S/R-quality, sentiment, fundamental and momentum sub-scores (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.
|
||||
3. **Activation gate** — a setup *qualifies* only if it clears the R:R floor **and** ranks in the top residual-momentum percentile of the universe (the validated edge is long-only; the confidence floor was ablated to zero effect and defaults off).
|
||||
4. **Top pick** — the highest residual-momentum qualified setup; highlighted on the Dashboard and labelled on the ticker page.
|
||||
|
||||
## Strategy Status — What's Validated and What Isn't
|
||||
|
||||
**Read this before touching scoring, gating, or setup logic.** The platform measures itself — a weekly-replay backtest plus a factor rank-IC harness (`app/services/backtest_service.py`) — and the verdicts below come from those reports (June 2026, ~5 years of OHLCV), not from opinion.
|
||||
|
||||
| Component | Verdict | Evidence |
|
||||
|---|---|---|
|
||||
| **Residual 12-1 cross-sectional momentum** (the activation gate, long-only) | **Production ranking — in-sample edge** | Promoted July 2026 after the portfolio variant beat raw 80 on CAGR, Sharpe and drawdown. Raw 12-1 remains a fallback only when benchmark data is unavailable |
|
||||
| S/R setup engine (ATR stops, S/R targets, reach-probability) | **Filter/execution context, not the exit** | R:R/room-to-run still earns its keep as a filter, but S/R targets underperform the time exit. The probability model is display-only |
|
||||
| Composite score + 5 dimensions | **Display/ranking only** | Sub-scores are hand-built heuristics; none has a measured IC. Note: the "momentum" *dimension* is 5/20-day ROC — NOT the validated 12-1 factor (that lives in `momentum_service`) |
|
||||
| LLM sentiment | Display + a bounded composite adjustment (± weight × 100 pts around neutral 50) | Deliberately kept out of the setup engine; no point-in-time history to validate against yet |
|
||||
| Fundamentals | Feeds composite + confidence only | Latest values only, no history — same limitation |
|
||||
| Short setups | **Excluded while the momentum gate is active** | Backtest showed shorts fight the trend and drag expectancy |
|
||||
| Expected-value gate (removed June 2026) | Degenerate — do not resurrect | Structurally favored distant lottery targets; selected *worse*-than-random setups |
|
||||
|
||||
Caveats on the momentum result: in-sample, roughly one market regime, costs/slippage approximated at 0.1% per side, and residual momentum still needs SPY benchmark history to compute. The **out-of-sample proof is the forward paper-trade record**: Signals → Track Record compares live qualified expectancy against the backtest.
|
||||
|
||||
### Current production baseline
|
||||
|
||||
Use this as a regression guardrail for future strategy changes, not as a return promise. Backtest run: 506 tickers, weekly cadence, 30-trading-day horizon, 2022-06-28 → 2026-07-01, 0.1% per-side costs, price-only SPY benchmark.
|
||||
|
||||
| Item | Current baseline |
|
||||
|---|---|
|
||||
| Strategy version | `residual_momentum_12_1_rr_time_v2` |
|
||||
| Production gate | Long-only, residual 12-1 momentum percentile >= 80, R:R floor on, NEUTRAL excluded, confidence floor effectively off |
|
||||
| Exit | Hold 30 trading days with the initial ATR stop |
|
||||
| Qualified setups | 1,810 |
|
||||
| Qualified net expectancy | +0.16R per setup |
|
||||
| Profit factor | 1.27 |
|
||||
| Portfolio CAGR | +40.4% |
|
||||
| Portfolio total return | +289.4% vs SPY +95.9% |
|
||||
| Max drawdown | -26.1% |
|
||||
| Sharpe | 1.52 daily, annualized |
|
||||
| Robustness | 30d hold remains +0.16R net/trade after removing the top 5% winners |
|
||||
|
||||
Nearest challengers from the same run: legacy raw 80 was weaker (+33.8% CAGR, -28.8% max drawdown, Sharpe 1.32); raw 90 was close but had lower Sharpe and worse drawdown (+40.4% CAGR, -27.6% max drawdown, Sharpe 1.49); residual 80 / max 15 removed book-full skips but did not improve CAGR, drawdown, Sharpe or closed trades.
|
||||
|
||||
### The iron rule for strategy changes
|
||||
|
||||
A signal earns its way into selection **only** through the factor harness:
|
||||
|
||||
1. Add it as a point-in-time function of past bars in `_signal_values()` (`backtest_service.py`).
|
||||
2. Run the backtest (Admin → Jobs, or the weekly run) and read the **Signal edge** table (Signals → Track Record).
|
||||
3. Wire it into the gate or ranking **only if** |mean IC| ≳ 0.03 with a consistent sign and `reliable: true` (≥ 12 non-overlapping windows).
|
||||
|
||||
Corollaries: never let an unvalidated score gate setups; the outcome evaluator must keep scoring **all** setups (unqualified ones are the control group); LLM output stays display-only in the quant path.
|
||||
|
||||
### Highest-value next experiments (in order)
|
||||
|
||||
1. **Raw 90 challenger** — keep comparing raw 12-1 momentum at cutoff 90 against production residual 80; promote only if it beats residual production on Sharpe and drawdown without a meaningful CAGR hit.
|
||||
2. **Capacity check** — keep only the residual 80 / max 15 portfolio row as a guardrail; max 20 and raw max 15 added no information in the July 2026 run.
|
||||
3. **Signal context snapshots** — accumulate point-in-time composite/sentiment/fundamental context for every new setup so the discretionary overlay can be tested forward-only.
|
||||
4. **More breadth, not more history** — widening the ranked universe (e.g. `nasdaq_all`) strengthens each week's cross-section and the IC t-stat, even if only the top slice is traded. (Deeper history was considered and declined.)
|
||||
|
||||
## Key Use Cases
|
||||
|
||||
- **Find today's best long setup.** On the **Dashboard**, the *Top Setups* table lists qualified setups ranked by residual momentum with the #1 flagged "Top pick". Each row opens the ticker page for the chart, scores, S/R targets and entry/stop.
|
||||
- **Track a trade you took.** Mark a setup as a **paper trade**: it's marked-to-market against the latest close, auto-closed by the active exit policy (default: 30 trading days with the initial stop), and its sentiment stays fresh while open. *Signals → Track Record* shows the realized edge.
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Tech |
|
||||
|---|---|
|
||||
| Backend | Python 3.12+, FastAPI, Uvicorn, async SQLAlchemy, Alembic |
|
||||
| Database | PostgreSQL (asyncpg) |
|
||||
| Scheduler | APScheduler — OHLCV, sentiment, fundamentals, R:R scan |
|
||||
| Scheduler | APScheduler — daily & intraday pipelines, fundamentals, alerts, regime, backtest |
|
||||
| Frontend | React 18, TypeScript, Vite 5 |
|
||||
| Styling | Tailwind CSS 3 with custom glassmorphism design system |
|
||||
| State | TanStack React Query v5 (server), Zustand (client/auth) |
|
||||
| Charts | Canvas 2D candlestick chart with S/R overlays |
|
||||
| Routing | React Router v6 (SPA) |
|
||||
| HTTP | Axios with JWT interceptor |
|
||||
| Data providers | Alpaca (OHLCV), OpenAI (sentiment, optional micro-batch), Fundamentals chain: FMP → Finnhub → Alpha Vantage |
|
||||
| Data providers | Alpaca (OHLCV); OpenAI / Gemini / DeepSeek / xAI (sentiment, pluggable); Fundamentals chain: FMP → Finnhub → Alpha Vantage; FRED (regime); Telegram (alerts) |
|
||||
|
||||
## Features
|
||||
|
||||
@@ -30,10 +120,15 @@ Investing-signal platform for NASDAQ stocks. Surfaces the best trading opportuni
|
||||
- Sentiment analysis with time-decay weighted scoring
|
||||
- Fundamental data tracking (P/E, revenue growth, earnings surprise, market cap)
|
||||
- 5-dimension scoring engine (technical, S/R quality, sentiment, fundamental, momentum) with configurable weights
|
||||
- Risk:Reward scanner — long and short setups, ATR-based stops, configurable R:R threshold (default 1.5:1)
|
||||
- Auto-populated watchlist (top-10 by composite score) + manual entries (cap: 20)
|
||||
- Risk:Reward scanner — long and short setups, ATR-based stops, S/R-based targets, configurable R:R threshold (default 1.5:1)
|
||||
- Activation gate — qualifies setups on a residual-momentum percentile floor plus an R:R floor (validated long-only edge)
|
||||
- Recommendation layer — directional confidence, conflict detection, per-target reach-probability
|
||||
- Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: hold 30 trading days with the initial stop; trailing / target-stop selectable), realized track record + outcome evaluation
|
||||
- Market-regime index + FRED early-warning monitor (VIX, credit spreads); weekly backtest + manual event study
|
||||
- Telegram alerts (e.g. regime-quadrant changes)
|
||||
- User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary
|
||||
- JWT auth with admin role, configurable registration, user access control
|
||||
- Scheduled jobs with enable/disable control and status monitoring
|
||||
- Cron-scheduled pipelines (admin-configurable) with per-job enable/disable and live status monitoring
|
||||
- Admin panel: user management, data cleanup, job control, system settings
|
||||
|
||||
### Frontend
|
||||
@@ -56,12 +151,15 @@ Investing-signal platform for NASDAQ stocks. Surfaces the best trading opportuni
|
||||
|---|---|---|
|
||||
| `/login` | Login | Public |
|
||||
| `/register` | Register | Public (when enabled) |
|
||||
| `/watchlist` | Watchlist (default) | Authenticated |
|
||||
| `/` | Dashboard — top setups, open trades, regime (default) | Authenticated |
|
||||
| `/market` | Market — watchlist + rankings tabs | Authenticated |
|
||||
| `/signals` | Signals — scanner + track record tabs | Authenticated |
|
||||
| `/regime` | Market Regime | Authenticated |
|
||||
| `/ticker/:symbol` | Ticker Detail | Authenticated |
|
||||
| `/scanner` | Trade Scanner | Authenticated |
|
||||
| `/rankings` | Rankings | Authenticated |
|
||||
| `/admin` | Admin Panel | Admin only |
|
||||
|
||||
Legacy routes redirect: `/watchlist` → `/market`, `/rankings` → `/market?tab=rankings`, `/scanner` → `/signals`, `/performance` → `/signals?tab=track`.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All under `/api/v1/`. Interactive docs at `/docs` (Swagger) and `/redoc`.
|
||||
@@ -78,7 +176,10 @@ All under `/api/v1/`. Interactive docs at `/docs` (Swagger) and `/redoc`.
|
||||
| Sentiment | `GET /sentiment/{symbol}` |
|
||||
| Fundamentals | `GET /fundamentals/{symbol}` |
|
||||
| Scores | `GET /scores/{symbol}`, `GET /rankings`, `PUT /scores/weights` |
|
||||
| Trades | `GET /trades` |
|
||||
| Trades | `GET /trades`, `GET /trades/{symbol}`, `GET /trades/{symbol}/history`, `GET /trades/activation`, `GET /trades/performance` |
|
||||
| Paper Trades | `GET /paper-trades`, `POST /paper-trades`, `POST /paper-trades/{id}/close` |
|
||||
| Market / Regime | `GET /market/regime`, `GET /regime/monitor`, `GET/PUT /regime/config`, `GET /regime/history`, `GET /regime/event-study`, `GET/PUT /regime/fundamentals`, `GET /backtest/report` |
|
||||
| Jobs | `GET /jobs/running` |
|
||||
| Watchlist | `GET /watchlist`, `POST /watchlist/{symbol}`, `DELETE /watchlist/{symbol}` |
|
||||
| Admin | `GET /admin/users`, `POST /admin/users`, `PUT /admin/users/{id}/access`, `PUT /admin/users/{id}/password`, `PUT /admin/settings/registration`, `GET /admin/settings`, `PUT /admin/settings/{key}`, `GET/PUT /admin/settings/recommendations`, `GET/PUT /admin/settings/ticker-universe`, `POST /admin/tickers/bootstrap`, `POST /admin/data/cleanup`, `GET /admin/jobs`, `POST /admin/jobs/{name}/trigger`, `PUT /admin/jobs/{name}/toggle`, `GET /admin/pipeline/readiness` |
|
||||
|
||||
@@ -140,11 +241,56 @@ npm run preview # Preview the production build locally
|
||||
# Backend tests (in-memory SQLite — no PostgreSQL needed)
|
||||
pytest tests/ -v
|
||||
|
||||
# Frontend tests
|
||||
# Frontend: there is no test suite — `npm test` calls vitest, which is not
|
||||
# installed. The frontend check is the full TypeScript build:
|
||||
cd frontend
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Local Backtest Snapshots
|
||||
|
||||
For research loops, run the production backtest locally from a SQLite snapshot
|
||||
instead of deploying and clicking the Admin job. The snapshot contains only the
|
||||
tables needed by `run_backtest`: tickers, OHLCV bars, SPY benchmark closes, and
|
||||
activation/recommendation settings. Secrets and cached reports are not copied.
|
||||
|
||||
1. Open an SSH tunnel to the production Postgres instance:
|
||||
|
||||
```bash
|
||||
ssh -N -L 15432:127.0.0.1:5432 deploy@your-server
|
||||
```
|
||||
|
||||
2. In another terminal, create or refresh the snapshot:
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
python scripts/create_backtest_snapshot.py \
|
||||
--database-url "postgresql+asyncpg://stock_backend:PASSWORD@127.0.0.1:15432/stock_data_backend" \
|
||||
--output backtest_snapshots/prod.sqlite \
|
||||
--force
|
||||
|
||||
# Windows PowerShell
|
||||
.venv\Scripts\python.exe scripts\create_backtest_snapshot.py `
|
||||
--database-url "postgresql+asyncpg://stock_backend:PASSWORD@127.0.0.1:15432/stock_data_backend" `
|
||||
--output backtest_snapshots\prod.sqlite `
|
||||
--force
|
||||
```
|
||||
|
||||
3. Run the backtest fully offline from the snapshot:
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
python scripts/run_backtest_snapshot.py backtest_snapshots/prod.sqlite --workers 8
|
||||
|
||||
# Windows PowerShell
|
||||
.venv\Scripts\python.exe scripts\run_backtest_snapshot.py backtest_snapshots\prod.sqlite --workers 12 --allow-spawn
|
||||
```
|
||||
|
||||
The runner writes `reports/backtest-<timestamp>.json` and prints the headline
|
||||
metrics. Keep the SSH tunnel open only while creating the snapshot; the backtest
|
||||
run itself is local/offline. `backtest_snapshots/` and generated backtest reports
|
||||
are git-ignored.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Configure in `.env` (copy from `.env.example`):
|
||||
@@ -164,83 +310,86 @@ Configure in `.env` (copy from `.env.example`):
|
||||
| `FMP_API_KEY` | Optional (fundamentals) | — | Financial Modeling Prep API key (first provider in chain) |
|
||||
| `FINNHUB_API_KEY` | Optional (fundamentals) | — | Finnhub API key (fallback provider) |
|
||||
| `ALPHA_VANTAGE_API_KEY` | Optional (fundamentals) | — | Alpha Vantage API key (fallback provider) |
|
||||
| `DATA_COLLECTOR_FREQUENCY` | No | `daily` | OHLCV collection schedule |
|
||||
| `FRED_API_KEY` | Optional (regime) | — | FRED key for the regime monitor (VIX, credit spreads) |
|
||||
| `TELEGRAM_BOT_TOKEN` | Optional (alerts) | — | Telegram bot token for alerts (can also be set in Admin) |
|
||||
| `TELEGRAM_CHAT_ID` | Optional (alerts) | — | Telegram chat id for alerts |
|
||||
| `DATA_COLLECTOR_FREQUENCY` | No | `daily` | OHLCV collection schedule (legacy — see note below) |
|
||||
| `SENTIMENT_POLL_INTERVAL_MINUTES` | No | `30` | Sentiment polling interval |
|
||||
| `FUNDAMENTAL_FETCH_FREQUENCY` | No | `daily` | Fundamentals fetch schedule |
|
||||
| `FUNDAMENTAL_FETCH_FREQUENCY` | No | `weekly` | Fundamentals fetch cadence |
|
||||
| `RR_SCAN_FREQUENCY` | No | `daily` | R:R scanner schedule |
|
||||
| `FUNDAMENTAL_RATE_LIMIT_RETRIES` | No | `3` | Retries per ticker on fundamentals rate-limit |
|
||||
| `FUNDAMENTAL_RATE_LIMIT_BACKOFF_SECONDS` | No | `15` | Base backoff seconds for fundamentals retry (exponential) |
|
||||
| `DEFAULT_WATCHLIST_AUTO_SIZE` | No | `10` | Auto-watchlist size |
|
||||
| `DEFAULT_RR_THRESHOLD` | No | `3.0` | Minimum R:R ratio for setups |
|
||||
| `DEFAULT_RR_THRESHOLD` | No | `1.5` | Minimum R:R ratio for setups |
|
||||
| `DB_POOL_SIZE` | No | `5` | Database connection pool size |
|
||||
| `LOG_LEVEL` | No | `INFO` | Logging level |
|
||||
|
||||
> **Note:** Pipeline timing (daily / intraday / fundamentals cron, timezone) is configured at runtime in **Admin → Jobs** and stored in the DB — the `*_FREQUENCY` env vars are legacy fallbacks for the few jobs still on interval triggers (alerts, universe sync).
|
||||
|
||||
## Production Deployment (Debian 12)
|
||||
|
||||
**Ongoing deploys are automated.** Every push to `main` triggers the Gitea Actions pipeline (`.gitea/workflows/deploy.yml`): lint → test → rsync to the server → `pip install` → `alembic upgrade head` → restart `signalplatform.service` → health check. There is no manual deploy step; the steps below are only for provisioning a new server.
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt install -y python3.12 python3.12-venv postgresql nginx nodejs npm
|
||||
sudo apt update && sudo apt install -y python3.12 python3.12-venv postgresql nginx rsync
|
||||
```
|
||||
|
||||
### 2. Create service user
|
||||
### 2. Create the deploy user
|
||||
|
||||
The pipeline connects over SSH as this user; it owns the app directory and needs passwordless permission to restart the service:
|
||||
|
||||
```bash
|
||||
sudo useradd -r -s /usr/sbin/nologin stockdata
|
||||
sudo useradd -m deploy
|
||||
sudo mkdir -p /opt/signalplatform
|
||||
sudo chown deploy:deploy /opt/signalplatform
|
||||
echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart signalplatform.service' | sudo tee /etc/sudoers.d/deploy-restart
|
||||
```
|
||||
|
||||
### 3. Deploy application
|
||||
### 3. Configure the pipeline (Gitea repo settings)
|
||||
|
||||
Variables: `DEPLOY_HOST`, `DEPLOY_USER` (`deploy`), `DEPLOY_PATH` (`/opt/signalplatform`), `SSH_KNOWN_HOSTS` (host fingerprint), `SSH_PORT`. Secret: `SSH_PRIVATE_KEY` (matching the deploy user's authorized key).
|
||||
|
||||
### 4. Configure the app
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/stock-data-backend
|
||||
# Copy project files to /opt/stock-data-backend
|
||||
cd /opt/stock-data-backend
|
||||
python3.12 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 4. Configure
|
||||
|
||||
```bash
|
||||
sudo cp .env.example /opt/stock-data-backend/.env
|
||||
sudo chown stockdata:stockdata /opt/stock-data-backend/.env
|
||||
# After the first pipeline run has synced the files:
|
||||
cp /opt/signalplatform/.env.example /opt/signalplatform/.env
|
||||
# Edit .env with production values (strong JWT_SECRET, real API keys, etc.)
|
||||
```
|
||||
|
||||
`.env` is excluded from the rsync, so it survives every deploy.
|
||||
|
||||
### 5. Database
|
||||
|
||||
Either trigger the workflow manually (workflow_dispatch) with `run_setup_db: true` — the deploy then runs `deploy/setup_db.sh` instead of plain migrations — or run it once by hand:
|
||||
|
||||
```bash
|
||||
DB_NAME=stock_data_backend DB_USER=stock_backend DB_PASS=strong_password ./deploy/setup_db.sh
|
||||
```
|
||||
|
||||
### 6. Build frontend
|
||||
### 6. Systemd service
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 7. Systemd service
|
||||
|
||||
```bash
|
||||
sudo cp deploy/stock-data-backend.service /etc/systemd/system/
|
||||
sudo cp deploy/signalplatform.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now stock-data-backend
|
||||
sudo systemctl enable --now signalplatform
|
||||
```
|
||||
|
||||
### 8. Nginx reverse proxy
|
||||
The unit runs uvicorn on `127.0.0.1:8998` as the `deploy` user, with `WorkingDirectory=/opt/signalplatform`.
|
||||
|
||||
### 7. Nginx reverse proxy
|
||||
|
||||
```bash
|
||||
sudo cp deploy/nginx.conf /etc/nginx/sites-available/stock-data-backend
|
||||
sudo ln -s /etc/nginx/sites-available/stock-data-backend /etc/nginx/sites-enabled/
|
||||
sudo cp deploy/nginx.conf /etc/nginx/sites-available/signalplatform
|
||||
sudo ln -s /etc/nginx/sites-available/signalplatform /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Nginx serves the frontend static files from `frontend/dist/` and proxies `/api/v1/` to the backend.
|
||||
Nginx serves the frontend static files from `frontend/dist/` (built on the CI runner and rsynced) and proxies `/api/v1/` to the backend.
|
||||
|
||||
### 9. SSL (recommended)
|
||||
### 8. SSL (recommended)
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
@@ -292,7 +441,7 @@ frontend/
|
||||
│ └── watchlist/ # Watchlist table, add ticker form
|
||||
├── hooks/ # React Query hooks (one per resource)
|
||||
├── lib/ # Types, formatting utilities
|
||||
├── pages/ # Page components (7 pages)
|
||||
├── pages/ # Page components (Login, Register, Dashboard, Market, Signals, Regime, Ticker, Admin)
|
||||
├── stores/ # Zustand auth store
|
||||
└── styles/ # Global CSS with glassmorphism classes
|
||||
|
||||
@@ -306,3 +455,64 @@ tests/
|
||||
├── unit/ # Unit tests
|
||||
└── property/ # Property-based tests (Hypothesis)
|
||||
```
|
||||
|
||||
## Maintainer Guide
|
||||
|
||||
Context for whoever — human or AI — continues this work. The owner pushes straight to `main` on a self-hosted Gitea remote (no PRs); deployment is automated by the Gitea Actions workflow at `.gitea/workflows/deploy.yml`.
|
||||
|
||||
### Invariants — do not break these
|
||||
|
||||
- **`app/services/qualification.py` is mirrored in `frontend/src/lib/qualification.ts`.** Any gate change must land in both, or the UI's "qualified" flags silently disagree with the server.
|
||||
- **Live scan and backtest share the same pure functions.** The backtest replays production logic through DB-free functions (`compute_technical_from_arrays`, `compute_momentum_from_closes`, `detect_sr_levels`, the recommendation helpers). New strategy logic must stay in pure functions consumed by both paths, or the backtest stops measuring what production actually does.
|
||||
- **One S/R model app-wide:** `sr_service.detect_sr_levels` + `cluster_sr_zones` (2% tolerance) feed the chart, alerts, and target generation identically.
|
||||
- **The outcome evaluator evaluates ALL setups**, not just qualified ones — unqualified setups are the control group that makes the Track Record meaningful.
|
||||
- **`SystemSetting` access goes through `app/services/settings_store.py`** — don't query the model directly.
|
||||
- **Time-series data gets a real table** (see `benchmark_prices`, `regime_snapshots`); `SystemSetting` JSON is only for config and cached reports.
|
||||
- **Discretionary overlay data is forward-only.** `signal_context_snapshots` captures composite/dimension/sentiment/fundamental context for new setups. Do not approximate historical sentiment/fundamental snapshots from today's data.
|
||||
- Style: surgical changes, minimal new files; extend existing services rather than adding parallel ones.
|
||||
|
||||
### Where the strategy lives
|
||||
|
||||
| Concern | File |
|
||||
|---|---|
|
||||
| Composite + 5 dimension scores, weights | `app/services/scoring_service.py` |
|
||||
| Residual 12-1 momentum ranking (the validated activation factor) | `app/services/momentum_service.py` |
|
||||
| Setup construction (ATR stop, S/R targets) | `app/services/rr_scanner_service.py` |
|
||||
| Confidence, targets, reach-probability, action | `app/services/recommendation_service.py` |
|
||||
| Activation gate predicate (mirrored in TS) | `app/services/qualification.py` |
|
||||
| Gate defaults / admin config | `app/services/admin_service.py` (`ACTIVATION_DEFAULTS`) |
|
||||
| Backtest + factor rank-IC harness ("Signal edge") | `app/services/backtest_service.py` |
|
||||
| Outcome resolution (target/stop/expired/ambiguous) | `app/services/outcome_service.py` |
|
||||
| Paper trades + time/trailing/target auto-exit | `app/services/paper_trade_service.py` |
|
||||
| Point-in-time setup context snapshots | `app/models/signal_context_snapshot.py` + `app/services/rr_scanner_service.py` |
|
||||
| S/R detection & zone clustering | `app/services/sr_service.py` |
|
||||
| SPY benchmark for residual momentum + paper-trade alpha | `app/services/benchmark_service.py` |
|
||||
| Pipelines & job registration | `app/scheduler.py` |
|
||||
|
||||
### Verifying changes
|
||||
|
||||
```bash
|
||||
pytest tests/ -q # backend; in-memory SQLite, no Postgres needed
|
||||
cd frontend && npm run build # full tsc check — this IS the frontend "test"
|
||||
```
|
||||
|
||||
- `npm test` in `frontend/` is dead (vitest isn't installed; there are no frontend test files). Use `npm run build`.
|
||||
- Backend tests that exercise services which `commit()` need a plain session fixture, not the rolling-back `db_session` — copy the pattern in `tests/unit/test_rr_scanner_integration.py`.
|
||||
- `ruff` reports ~11 pre-existing errors in old test files; those are not regressions.
|
||||
|
||||
### Deploying
|
||||
|
||||
Automated by Gitea Actions (`.gitea/workflows/deploy.yml`) on every push to `main`: **lint** (`ruff check app/`) → **test** (pytest; `alembic upgrade head` validated against a real Postgres 16 service; frontend `npm ci && npm run build`) → **deploy** (frontend built on the runner, rsync to the server excluding `.env`, `pip install -e .`, `alembic upgrade head`, restart `signalplatform.service`, health check on `127.0.0.1:8998`). Deploys are serialized by a concurrency group so overlapping pushes can't race.
|
||||
|
||||
Practical consequences:
|
||||
|
||||
- **A `ruff` error in `app/` or any failing backend test blocks the deploy.** (CI lints only `app/`, so the pre-existing ruff noise in old test files doesn't.)
|
||||
- **Migrations run automatically on deploy** — no manual `alembic` step. A migration that only works on SQLite will fail CI against Postgres, by design.
|
||||
- Pushing to `main` **is** deploying to production — there is no separate release step.
|
||||
- After a gate or scanner change ships, trigger an R:R scan (Admin → Jobs) so live setups pick up new fields.
|
||||
|
||||
### Roadmap (agreed June 2026)
|
||||
|
||||
1. **Forward paper-test the momentum book** — the out-of-sample proof the backtest can't give. Watch Signals → Track Record (live vs backtest).
|
||||
2. **Full IBKR integration** — read real positions, overlay entries/stops on charts, alert on holdings' score deterioration. (Paper trading, the lighter alternative, is done.)
|
||||
3. Strategy experiments in the order listed under **Strategy Status** above — each one goes through the factor harness first.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add benchmark_prices
|
||||
|
||||
Stores daily closes for a benchmark index (SPY) so paper-trade alpha — trade
|
||||
return minus the benchmark's return over the same holding period — can be
|
||||
computed. Kept separate from the tradeable universe: the benchmark is not a
|
||||
Ticker, so it never enters the scanner, momentum ranking, or rankings.
|
||||
|
||||
Revision ID: 012
|
||||
Revises: 011
|
||||
Create Date: 2026-06-28 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "012"
|
||||
down_revision: Union[str, None] = "011"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"benchmark_prices",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("symbol", sa.String(length=20), nullable=False),
|
||||
sa.Column("date", sa.Date(), nullable=False),
|
||||
sa.Column("close", sa.Float(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("symbol", "date", name="uq_benchmark_symbol_date"),
|
||||
)
|
||||
op.create_index("ix_benchmark_prices_symbol", "benchmark_prices", ["symbol"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_benchmark_prices_symbol", table_name="benchmark_prices")
|
||||
op.drop_table("benchmark_prices")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add close_reason to paper_trades
|
||||
|
||||
Records how an open paper trade was closed (trailing | stop | target | manual) so
|
||||
the close alert can summarise it and the UI can show why a position exited.
|
||||
|
||||
Revision ID: 013
|
||||
Revises: 012
|
||||
Create Date: 2026-06-30 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "013"
|
||||
down_revision: Union[str, None] = "012"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("paper_trades", sa.Column("close_reason", sa.String(length=10), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("paper_trades", "close_reason")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add name to tickers
|
||||
|
||||
Company name (e.g. "Biogen Inc."), backfilled from Alpaca so the UI can show which
|
||||
company is behind a symbol. Nullable — symbols Alpaca doesn't cover stay name-less.
|
||||
|
||||
Revision ID: 014
|
||||
Revises: 013
|
||||
Create Date: 2026-07-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "014"
|
||||
down_revision: Union[str, None] = "013"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("tickers", sa.Column("name", sa.String(length=120), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tickers", "name")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Phase 3 strategy adoption: time-based exit + confidence floor removed.
|
||||
|
||||
The July 2026 backtest (gate ablation graded under both exit models, plus the
|
||||
capital-constrained portfolio simulation) concluded:
|
||||
|
||||
- The best exit is hold-to-horizon: keep the initial ATR stop and exit at the
|
||||
30th trading day's close (+0.50R net/trade vs +0.13R for the S/R target
|
||||
exit; simulated book +31.9% vs +23.7% CAGR at the same drawdown). The paper
|
||||
trade exit-policy default is now ``time`` (30 trading days).
|
||||
- The confidence floor adds nothing (identical net/trade with it removed,
|
||||
under both exit models) while cutting ~25% of qualified trades. Its default
|
||||
is now 0 (off).
|
||||
|
||||
Stored rows for these two settings were written under the old semantics, so
|
||||
they are cleared here and the new code defaults take effect. Re-tune in
|
||||
Admin -> Activation / Paper-Trade Exit if desired. Note: this changes which
|
||||
setups qualify and how paper trades close, so Track Record comparability
|
||||
resets from this deploy.
|
||||
|
||||
Revision ID: 015
|
||||
Revises: 014
|
||||
Create Date: 2026-07-02 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "015"
|
||||
down_revision: Union[str, None] = "014"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DELETE FROM system_settings "
|
||||
"WHERE key IN ('activation_min_confidence', 'paper_exit_mode')"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# One-way data reset: the old per-key values aren't recoverable. Code
|
||||
# defaults apply until re-tuned, so there is nothing to restore.
|
||||
pass
|
||||
@@ -0,0 +1,55 @@
|
||||
"""add signal context snapshots
|
||||
|
||||
Revision ID: 016
|
||||
Revises: 015
|
||||
Create Date: 2026-07-02 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "016"
|
||||
down_revision: Union[str, None] = "015"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"signal_context_snapshots",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("trade_setup_id", sa.Integer(), nullable=False),
|
||||
sa.Column("ticker_id", sa.Integer(), nullable=False),
|
||||
sa.Column("detected_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("strategy_version", sa.String(length=80), nullable=False),
|
||||
sa.Column("direction", sa.String(length=10), nullable=False),
|
||||
sa.Column("entry_price", sa.Float(), nullable=False),
|
||||
sa.Column("stop_loss", sa.Float(), nullable=False),
|
||||
sa.Column("target", sa.Float(), nullable=False),
|
||||
sa.Column("rr_ratio", sa.Float(), nullable=False),
|
||||
sa.Column("confidence_score", sa.Float(), nullable=True),
|
||||
sa.Column("recommended_action", sa.String(length=20), nullable=True),
|
||||
sa.Column("risk_level", sa.String(length=10), nullable=True),
|
||||
sa.Column("momentum_percentile", sa.Float(), nullable=True),
|
||||
sa.Column("score_context_json", sa.Text(), nullable=False),
|
||||
sa.Column("sentiment_context_json", sa.Text(), nullable=False),
|
||||
sa.Column("fundamental_context_json", sa.Text(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["ticker_id"], ["tickers.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["trade_setup_id"], ["trade_setups.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("trade_setup_id", name="uq_signal_context_trade_setup"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_signal_context_ticker_detected",
|
||||
"signal_context_snapshots",
|
||||
["ticker_id", "detected_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_signal_context_ticker_detected", table_name="signal_context_snapshots")
|
||||
op.drop_table("signal_context_snapshots")
|
||||
+9
-5
@@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
# Database
|
||||
database_url: str = "postgresql+asyncpg://stock_backend:changeme@localhost:5432/stock_data_backend"
|
||||
@@ -49,10 +49,14 @@ class Settings(BaseSettings):
|
||||
data_collector_frequency: str = "daily"
|
||||
sentiment_poll_interval_minutes: int = 30
|
||||
# Sentiment search-budget controls (Gemini grounding free tier = 5000/month).
|
||||
# Only fetch sentiment for relevant tickers (watchlist + open trades + top-N by
|
||||
# composite), skip ones refreshed within fresh_hours, and cap per run.
|
||||
sentiment_fresh_hours: int = 72
|
||||
sentiment_max_per_run: int = 25
|
||||
# Scope (see _get_sentiment_priority_tickers): everything that matters is always
|
||||
# refreshed in full — open paper trades + the curated watchlist + top-pick
|
||||
# feeders (residual-momentum leaders with a tradeable long setup) — plus a top-N composite
|
||||
# discovery net. No per-run cap: the set is naturally bounded (watchlist <= 20,
|
||||
# composite <= top_composite), so a full refresh stays well inside the free tier.
|
||||
# Skip anything refreshed within fresh_hours (5 days: sentiment shifts slowly and
|
||||
# the score window is 7 days).
|
||||
sentiment_fresh_hours: int = 120
|
||||
sentiment_top_composite: int = 30
|
||||
fundamental_fetch_frequency: str = "weekly" # quarterly-ish data; weekly conserves API quota
|
||||
rr_scan_frequency: str = "daily"
|
||||
|
||||
@@ -11,6 +11,8 @@ from app.models.settings import SystemSetting, IngestionProgress
|
||||
from app.models.alert import AlertLog
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.regime_snapshot import RegimeSnapshot
|
||||
from app.models.benchmark_price import BenchmarkPrice
|
||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||
|
||||
__all__ = [
|
||||
"Ticker",
|
||||
@@ -28,4 +30,6 @@ __all__ = [
|
||||
"AlertLog",
|
||||
"PaperTrade",
|
||||
"RegimeSnapshot",
|
||||
"BenchmarkPrice",
|
||||
"SignalContextSnapshot",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from datetime import date as date_type
|
||||
|
||||
from sqlalchemy import Date, Float, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class BenchmarkPrice(Base):
|
||||
"""Daily close for a benchmark index (e.g. SPY), used to compute trade alpha.
|
||||
|
||||
A standalone price series, deliberately NOT a tracked ``Ticker`` — so the
|
||||
benchmark never becomes a trade candidate or rankings-table row. Its closes
|
||||
are used for residual momentum and trade alpha. One row per (symbol, date).
|
||||
"""
|
||||
|
||||
__tablename__ = "benchmark_prices"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("symbol", "date", name="uq_benchmark_symbol_date"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
symbol: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
date: Mapped[date_type] = mapped_column(Date, nullable=False)
|
||||
close: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
@@ -34,3 +34,5 @@ class PaperTrade(Base):
|
||||
)
|
||||
close_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# How the trade was closed: "trailing" | "stop" | "target" | "manual".
|
||||
close_reason: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
|
||||
@@ -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")
|
||||
@@ -11,6 +11,9 @@ class Ticker(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
symbol: Mapped[str] = mapped_column(String(10), unique=True, nullable=False)
|
||||
# Company name (e.g. "Biogen Inc."); backfilled from Alpaca, nullable for
|
||||
# symbols Alpaca doesn't know.
|
||||
name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
@@ -26,8 +26,9 @@ class TradeSetup(Base):
|
||||
)
|
||||
|
||||
confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# Ticker's 12-1 momentum percentile across the universe at detection time
|
||||
# (0–100, 100 = strongest). Drives the activation gate's core selection.
|
||||
# Ticker's activation momentum percentile across the universe at detection
|
||||
# time. Since July 2026 this is residual 12-1 momentum when benchmark data is
|
||||
# available, with raw 12-1 as a fallback.
|
||||
momentum_percentile: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
targets_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
conflict_flags_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -315,6 +315,16 @@ async def bootstrap_tickers(
|
||||
return APIEnvelope(status="success", data=result)
|
||||
|
||||
|
||||
@router.post("/admin/tickers/backfill-names", response_model=APIEnvelope)
|
||||
async def backfill_ticker_names(
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Fill in company names for tracked tickers (one Alpaca call)."""
|
||||
result = await ticker_universe_service.backfill_ticker_names(db)
|
||||
return APIEnvelope(status="success", data=result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -114,8 +114,9 @@ async def fetch_symbol(
|
||||
result = await ingestion_service.fetch_and_ingest(
|
||||
db, provider, symbol_upper, start_date, end_date
|
||||
)
|
||||
status_map = {"complete": "ok", "partial": "ok", "no_data": "warning"}
|
||||
sources_out["ohlcv"] = {
|
||||
"status": "ok" if result.status in ("complete", "partial") else "error",
|
||||
"status": status_map.get(result.status, "error"),
|
||||
"records": result.records_ingested,
|
||||
"message": result.message,
|
||||
}
|
||||
|
||||
+12
-1
@@ -1,6 +1,6 @@
|
||||
"""Market-level endpoints (benchmark regime + AI/Tech regime-change monitor)."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -129,3 +129,14 @@ async def regime_event_study(
|
||||
None until the manual "Event Study" job has run (Admin → Jobs)."""
|
||||
data = await event_study_service.get_event_study_report(db)
|
||||
return APIEnvelope(status="success", data=data)
|
||||
|
||||
|
||||
@router.get("/regime/history", response_model=APIEnvelope)
|
||||
async def regime_history(
|
||||
days: int = Query(default=400, ge=7, le=2000),
|
||||
_user: User = Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Daily history of the index / early-warning / combined scores (for the chart)."""
|
||||
data = await regime_monitor_service.get_regime_history(db, days=days)
|
||||
return APIEnvelope(status="success", data=data)
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, require_access
|
||||
from app.dependencies import get_db, require_access, require_admin
|
||||
from app.models.user import User
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.schemas.paper_trade import PaperTradeClose, PaperTradeCreate, PaperTradeResponse
|
||||
from app.schemas.paper_trade import (
|
||||
ExitPolicyUpdate,
|
||||
PaperTradeClose,
|
||||
PaperTradeCreate,
|
||||
PaperTradeResponse,
|
||||
)
|
||||
from app.services import paper_trade_service
|
||||
|
||||
router = APIRouter(tags=["paper-trades"])
|
||||
@@ -40,6 +45,28 @@ async def list_paper_trades(
|
||||
return APIEnvelope(status="success", data=data)
|
||||
|
||||
|
||||
@router.get("/paper-trades/exit-policy", response_model=APIEnvelope)
|
||||
async def read_exit_policy(
|
||||
_user: User = Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""The active auto-exit policy for open paper trades (shown in the UI)."""
|
||||
return APIEnvelope(status="success", data=await paper_trade_service.get_exit_policy(db))
|
||||
|
||||
|
||||
@router.put("/paper-trades/exit-policy", response_model=APIEnvelope)
|
||||
async def write_exit_policy(
|
||||
body: ExitPolicyUpdate,
|
||||
_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Change the auto-exit policy (admin)."""
|
||||
data = await paper_trade_service.set_exit_policy(
|
||||
db, mode=body.mode, trailing_pct=body.trailing_pct, hold_days=body.hold_days
|
||||
)
|
||||
return APIEnvelope(status="success", data=data)
|
||||
|
||||
|
||||
@router.post("/paper-trades", response_model=APIEnvelope, status_code=201)
|
||||
async def create_paper_trade(
|
||||
body: PaperTradeCreate,
|
||||
|
||||
@@ -39,6 +39,10 @@ def _map_composite_breakdown(raw: dict | None) -> CompositeBreakdownResponse | N
|
||||
missing_dimensions=raw["missing_dimensions"],
|
||||
renormalized_weights=raw["renormalized_weights"],
|
||||
formula=raw["formula"],
|
||||
base_score=raw.get("base_score"),
|
||||
sentiment_score=raw.get("sentiment_score"),
|
||||
sentiment_adjustment=raw.get("sentiment_adjustment"),
|
||||
max_sentiment_adjustment=raw.get("max_sentiment_adjustment"),
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["scores"])
|
||||
@@ -50,7 +54,7 @@ async def read_score(
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Get composite + dimension scores for a symbol. Recomputes stale scores."""
|
||||
"""Get the latest persisted composite + dimension scores for a symbol."""
|
||||
result = await get_score(db, symbol)
|
||||
|
||||
data = ScoreResponse(
|
||||
@@ -90,6 +94,7 @@ async def read_rankings(
|
||||
RankingEntry(
|
||||
symbol=r["symbol"],
|
||||
composite_score=r["composite_score"],
|
||||
composite_stale=r.get("composite_stale", False),
|
||||
dimensions=[
|
||||
DimensionScoreResponse(**d) for d in r["dimensions"]
|
||||
],
|
||||
|
||||
@@ -34,6 +34,7 @@ async def list_trade_setups(
|
||||
direction=direction,
|
||||
min_confidence=min_confidence,
|
||||
recommended_action=recommended_action,
|
||||
live_recommendation=True,
|
||||
)
|
||||
|
||||
data = []
|
||||
@@ -92,7 +93,11 @@ async def get_ticker_trade_setups(
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
rows = await get_trade_setups(db, symbol=symbol)
|
||||
rows = await get_trade_setups(
|
||||
db,
|
||||
symbol=symbol,
|
||||
live_recommendation=True,
|
||||
)
|
||||
data = []
|
||||
for row in rows:
|
||||
summary = RecommendationSummaryResponse(
|
||||
|
||||
+147
-53
@@ -20,7 +20,7 @@ from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy import and_, case, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -36,6 +36,7 @@ from app.providers.protocol import SentimentData
|
||||
from app.services import fundamental_service, ingestion_service, sentiment_service, settings_store
|
||||
from app.services.alert_service import dispatch_alerts
|
||||
from app.services.backtest_service import run_and_store as run_backtest_and_store
|
||||
from app.services.benchmark_service import refresh_benchmark_prices
|
||||
from app.services.market_regime_service import update_market_regime
|
||||
from app.services.regime_monitor_service import update_regime_monitor
|
||||
from app.services.event_study_service import run_and_store as run_event_study_and_store
|
||||
@@ -218,78 +219,141 @@ async def _get_ohlcv_priority_tickers(db: AsyncSession) -> list[str]:
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _get_sentiment_priority_tickers(db: AsyncSession) -> list[str]:
|
||||
"""Symbols to fetch sentiment for, budgeted to stay in the free search tier.
|
||||
async def _get_top_pick_feeder_ids(db: AsyncSession) -> set[int]:
|
||||
"""Ticker ids whose latest LONG setup makes them a top-pick feeder.
|
||||
|
||||
Scope: only tickers that matter — watchlist + open paper trades + top-N by
|
||||
composite score + the momentum leaders the activation gate qualifies on. Skip
|
||||
any refreshed within ``sentiment_fresh_hours``. Cap the run at
|
||||
``sentiment_max_per_run``, oldest/missing first. Once the relevant set is
|
||||
fresh, runs make zero grounded searches until it ages out.
|
||||
A dashboard 'top pick' is the highest residual-momentum *qualified* setup.
|
||||
Sentiment can never move a ticker's activation percentile (the gate's core
|
||||
axis) — only its confidence and EV ranking. So the only tickers that are, or
|
||||
could become with positive sentiment, a top pick are residual-momentum leaders
|
||||
that already have a tradeable long setup clearing the R:R floor. That set is exactly:
|
||||
|
||||
latest long setup with momentum_percentile >= gate AND rr_ratio >= floor.
|
||||
|
||||
It contains both the currently-qualified setups and the near-miss ones held
|
||||
back only by a neutral/missing sentiment — the cases the user saw surface as
|
||||
top picks with no sentiment. Only meaningful with the momentum gate on
|
||||
(min_momentum_percentile > 0); off, there is no leader axis to anchor on and we
|
||||
defer to the filler set. Best-effort: a config failure must not stop collection.
|
||||
"""
|
||||
from app.models.trade_setup import TradeSetup
|
||||
|
||||
try:
|
||||
from app.services.admin_service import get_activation_config
|
||||
|
||||
activation = await get_activation_config(db)
|
||||
min_pct = float(activation.get("min_momentum_percentile", 0.0))
|
||||
min_rr = float(activation.get("min_rr", 0.0))
|
||||
except Exception:
|
||||
logger.exception("Sentiment top-pick scoping failed; using filler set only")
|
||||
return set()
|
||||
|
||||
if min_pct <= 0:
|
||||
return set()
|
||||
|
||||
# Latest long setup per ticker, then keep those clearing the gate's momentum
|
||||
# percentile and R:R floor. (Sentiment runs before the day's scan, so this
|
||||
# reads the previous scan's setups — momentum is a slow, cross-sectional signal,
|
||||
# so yesterday's leaders are the right anchor.)
|
||||
latest_long = (
|
||||
select(TradeSetup.ticker_id, func.max(TradeSetup.detected_at).label("md"))
|
||||
.where(TradeSetup.direction == "long")
|
||||
.group_by(TradeSetup.ticker_id)
|
||||
.subquery()
|
||||
)
|
||||
rows = await db.execute(
|
||||
select(TradeSetup.ticker_id)
|
||||
.join(
|
||||
latest_long,
|
||||
and_(
|
||||
TradeSetup.ticker_id == latest_long.c.ticker_id,
|
||||
TradeSetup.detected_at == latest_long.c.md,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
TradeSetup.direction == "long",
|
||||
TradeSetup.rr_ratio >= min_rr,
|
||||
TradeSetup.momentum_percentile.is_not(None),
|
||||
TradeSetup.momentum_percentile >= min_pct,
|
||||
)
|
||||
)
|
||||
return {r[0] for r in rows.all()}
|
||||
|
||||
|
||||
async def _stale_sentiment_symbols(
|
||||
db: AsyncSession, ticker_ids: set[int], cutoff: datetime
|
||||
) -> list[str]:
|
||||
"""Symbols among ``ticker_ids`` whose newest sentiment is missing or older than
|
||||
``cutoff``, ordered missing-first → oldest → alphabetical."""
|
||||
if not ticker_ids:
|
||||
return []
|
||||
latest_ts = func.max(SentimentScore.timestamp)
|
||||
missing_first = case((latest_ts.is_(None), 0), else_=1)
|
||||
stmt = (
|
||||
select(Ticker.symbol)
|
||||
.outerjoin(SentimentScore, SentimentScore.ticker_id == Ticker.id)
|
||||
.where(Ticker.id.in_(ticker_ids))
|
||||
.group_by(Ticker.id, Ticker.symbol)
|
||||
.having(or_(latest_ts.is_(None), latest_ts < cutoff))
|
||||
.order_by(missing_first.asc(), latest_ts.asc(), Ticker.symbol.asc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _get_sentiment_priority_tickers(db: AsyncSession) -> list[str]:
|
||||
"""Symbols to fetch sentiment for, skipping anything refreshed within
|
||||
``sentiment_fresh_hours``.
|
||||
|
||||
No per-run cap: the relevant set is naturally bounded (curated watchlist <= 20,
|
||||
a handful of open trades and top-pick feeders, top-N composite), so refreshing
|
||||
all of it stays well inside the free search tier — and everything that matters
|
||||
is always fully covered. The two tiers only affect ORDER, so a mid-run provider
|
||||
rate limit still lands the names we care about first:
|
||||
|
||||
Priority: top-pick feeders (residual-momentum leaders with a tradeable long setup, see
|
||||
``_get_top_pick_feeder_ids``) + the curated watchlist + open paper trades —
|
||||
the set we never want shown without sentiment.
|
||||
Filler: top-N by composite — a cheap discovery net for names not yet covered.
|
||||
|
||||
Once the set is fresh, runs make zero grounded searches until it ages out.
|
||||
"""
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.score import CompositeScore
|
||||
from app.models.watchlist import WatchlistEntry
|
||||
|
||||
relevant: set[int] = set()
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=settings.sentiment_fresh_hours)
|
||||
|
||||
# Priority: the set we always want fresh — top-pick feeders, the curated
|
||||
# watchlist, and open positions.
|
||||
priority_ids = await _get_top_pick_feeder_ids(db)
|
||||
wl = await db.execute(
|
||||
select(WatchlistEntry.ticker_id)
|
||||
.where(WatchlistEntry.entry_type != "dismissed")
|
||||
.distinct()
|
||||
)
|
||||
relevant.update(r[0] for r in wl.all())
|
||||
priority_ids.update(r[0] for r in wl.all())
|
||||
pt = await db.execute(
|
||||
select(PaperTrade.ticker_id).where(PaperTrade.status == "open").distinct()
|
||||
)
|
||||
relevant.update(r[0] for r in pt.all())
|
||||
priority_ids.update(r[0] for r in pt.all())
|
||||
|
||||
# Filler: top-N by composite, a discovery net for names not already covered.
|
||||
top = await db.execute(
|
||||
select(CompositeScore.ticker_id)
|
||||
.order_by(CompositeScore.score.desc())
|
||||
.limit(settings.sentiment_top_composite)
|
||||
)
|
||||
relevant.update(r[0] for r in top.all())
|
||||
filler_ids = {r[0] for r in top.all()} - priority_ids
|
||||
|
||||
# Momentum leaders: the tickers that can clear the activation gate, which
|
||||
# selects the top ``min_momentum_percentile`` slice by 12-1 momentum — a
|
||||
# different axis than composite score. The gate qualifies setups on this
|
||||
# percentile, so without including them a freshly-qualifying ticker carries no
|
||||
# sentiment and gets enhanced as neutral. Pre-fetching their sentiment here (in
|
||||
# the daily pipeline, sentiment runs right after the OHLCV refresh) means the
|
||||
# following R:R scan reads real sentiment for the setups it qualifies.
|
||||
# Best-effort: a momentum/config failure must not stop sentiment collection.
|
||||
try:
|
||||
from app.services import momentum_service
|
||||
from app.services.admin_service import get_activation_config
|
||||
|
||||
activation = await get_activation_config(db)
|
||||
min_pct = float(activation.get("min_momentum_percentile", 0.0))
|
||||
if min_pct > 0:
|
||||
percentiles = await momentum_service.compute_momentum_percentiles(db)
|
||||
leaders = [sym for sym, pct in percentiles.items() if pct >= min_pct]
|
||||
if leaders:
|
||||
rows = await db.execute(
|
||||
select(Ticker.id).where(Ticker.symbol.in_(leaders))
|
||||
)
|
||||
relevant.update(r[0] for r in rows.all())
|
||||
except Exception:
|
||||
logger.exception("Sentiment momentum-leader scoping failed; using base relevant set")
|
||||
|
||||
if not relevant:
|
||||
if not priority_ids and not filler_ids:
|
||||
return []
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=settings.sentiment_fresh_hours)
|
||||
latest_ts = func.max(SentimentScore.timestamp)
|
||||
missing_first = case((latest_ts.is_(None), 0), else_=1)
|
||||
result = await db.execute(
|
||||
select(Ticker.symbol)
|
||||
.outerjoin(SentimentScore, SentimentScore.ticker_id == Ticker.id)
|
||||
.where(Ticker.id.in_(relevant))
|
||||
.group_by(Ticker.id, Ticker.symbol)
|
||||
.having(or_(latest_ts.is_(None), latest_ts < cutoff))
|
||||
.order_by(missing_first.asc(), latest_ts.asc(), Ticker.symbol.asc())
|
||||
.limit(settings.sentiment_max_per_run)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
# No cap — fetch every stale name. Priority first so a rate limit mid-run still
|
||||
# covers the curated/at-risk set before the discovery net.
|
||||
priority_syms = await _stale_sentiment_symbols(db, priority_ids, cutoff)
|
||||
filler_syms = await _stale_sentiment_symbols(db, filler_ids, cutoff)
|
||||
return priority_syms + filler_syms
|
||||
|
||||
|
||||
async def _get_fundamental_priority_tickers(db: AsyncSession) -> list[str]:
|
||||
@@ -803,6 +867,34 @@ async def compute_market_regime() -> None:
|
||||
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job: Benchmark Collector (SPY closes for paper-trade alpha)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def collect_benchmark() -> None:
|
||||
"""Refresh the stored benchmark (SPY) daily closes used for paper-trade alpha."""
|
||||
job_name = "benchmark_collector"
|
||||
_log_event(logging.INFO, "job_start", job=job_name)
|
||||
_runtime_start(job_name, total=1)
|
||||
|
||||
try:
|
||||
async with async_session_factory() as db:
|
||||
if not await _is_job_enabled(db, job_name):
|
||||
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
|
||||
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
|
||||
return
|
||||
|
||||
written = await refresh_benchmark_prices(db)
|
||||
|
||||
_runtime_progress(job_name, processed=1, total=1)
|
||||
_runtime_finish(job_name, "completed", processed=1, total=1, message=f"{written} rows")
|
||||
_log_event(logging.INFO, "job_complete", job=job_name, rows=written)
|
||||
except Exception as exc:
|
||||
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
|
||||
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job: Regime Monitor
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -953,6 +1045,7 @@ async def sync_ticker_universe() -> None:
|
||||
# Daily (full): the complete data→signal refresh, once a day.
|
||||
_DAILY_PIPELINE_STEPS = [
|
||||
("data_collector", "collect_ohlcv"),
|
||||
("benchmark_collector", "collect_benchmark"),
|
||||
("sentiment_collector", "collect_sentiment"),
|
||||
("rr_scanner", "scan_rr"),
|
||||
("outcome_evaluator", "evaluate_outcomes"),
|
||||
@@ -1005,8 +1098,8 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
|
||||
|
||||
|
||||
async def run_daily_pipeline() -> None:
|
||||
"""Full daily flow: OHLCV → sentiment → R:R scan → outcome eval (+paper
|
||||
close) → market regime."""
|
||||
"""Full daily flow: OHLCV → benchmark → sentiment → R:R scan → outcome eval
|
||||
(+paper close) → market regime."""
|
||||
await _run_pipeline("daily_pipeline", _DAILY_PIPELINE_STEPS)
|
||||
|
||||
|
||||
@@ -1113,6 +1206,7 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
||||
# interval job). They stay manually triggerable from Admin → Jobs.
|
||||
_members = [
|
||||
(collect_ohlcv, "data_collector", "Data Collector (OHLCV)"),
|
||||
(collect_benchmark, "benchmark_collector", "Benchmark Collector"),
|
||||
(collect_sentiment, "sentiment_collector", "Sentiment Collector"),
|
||||
(scan_rr, "rr_scanner", "R:R Scanner"),
|
||||
(evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"),
|
||||
|
||||
@@ -64,6 +64,7 @@ class ActivationConfigUpdate(BaseModel):
|
||||
min_confidence: float | None = Field(default=None, ge=0, le=100)
|
||||
require_high_conviction: bool | None = None
|
||||
exclude_conflicts: bool | None = None
|
||||
exclude_neutral: bool | None = None
|
||||
|
||||
|
||||
class ScheduleConfigUpdate(BaseModel):
|
||||
@@ -98,3 +99,5 @@ class AlertConfigUpdate(BaseModel):
|
||||
sr_proximity_enabled: bool | None = None
|
||||
score_drop_enabled: bool | None = None
|
||||
digest_enabled: bool | None = None
|
||||
regime_quadrant_enabled: bool | None = None
|
||||
trade_closed_enabled: bool | None = None
|
||||
|
||||
@@ -20,6 +20,13 @@ class PaperTradeClose(BaseModel):
|
||||
close_price: float | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class ExitPolicyUpdate(BaseModel):
|
||||
"""Auto-exit policy for open paper trades."""
|
||||
mode: str | None = Field(default=None, pattern=r"^(time|trailing|target)$")
|
||||
trailing_pct: float | None = Field(default=None, ge=0.5, le=90)
|
||||
hold_days: int | None = Field(default=None, ge=2, le=250)
|
||||
|
||||
|
||||
class PaperTradeResponse(BaseModel):
|
||||
id: int
|
||||
symbol: str
|
||||
@@ -33,3 +40,13 @@ class PaperTradeResponse(BaseModel):
|
||||
close_price: float | None = None
|
||||
closed_at: datetime | None = None
|
||||
current_price: float | None = None
|
||||
# Alpha vs the S&P 500 (SPY) over the trade's holding period. None when the
|
||||
# benchmark series doesn't cover the trade's open date yet.
|
||||
benchmark_return_pct: float | None = None
|
||||
alpha_pct: float | None = None
|
||||
alpha_usd: float | None = None
|
||||
close_reason: str | None = None
|
||||
# Live trailing-stop level + how far price sits above it (% ), for open trades
|
||||
# when the trailing exit policy is active.
|
||||
trailing_stop: float | None = None
|
||||
trailing_distance_pct: float | None = None
|
||||
|
||||
@@ -33,6 +33,12 @@ class CompositeBreakdownResponse(BaseModel):
|
||||
missing_dimensions: list[str]
|
||||
renormalized_weights: dict[str, float]
|
||||
formula: str
|
||||
# Sentiment is applied as a signed adjustment on top of the non-sentiment base
|
||||
# rather than averaged in.
|
||||
base_score: float | None = None
|
||||
sentiment_score: float | None = None
|
||||
sentiment_adjustment: float | None = None
|
||||
max_sentiment_adjustment: float | None = None
|
||||
|
||||
|
||||
class DimensionScoreResponse(BaseModel):
|
||||
@@ -72,6 +78,7 @@ class RankingEntry(BaseModel):
|
||||
|
||||
symbol: str
|
||||
composite_score: float
|
||||
composite_stale: bool = False
|
||||
dimensions: list[DimensionScoreResponse] = []
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class TickerCreate(BaseModel):
|
||||
class TickerResponse(BaseModel):
|
||||
id: int
|
||||
symbol: str
|
||||
name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -26,6 +26,14 @@ class RecommendationSummaryResponse(BaseModel):
|
||||
composite_score: float
|
||||
|
||||
|
||||
class TradeSetupContextAsOfResponse(BaseModel):
|
||||
setup_detected_at: datetime
|
||||
score_computed_at: datetime | None = None
|
||||
sentiment_at: datetime | None = None
|
||||
price_date: date | None = None
|
||||
price_updated_at: datetime | None = None
|
||||
|
||||
|
||||
class TradeSetupResponse(BaseModel):
|
||||
"""A single trade setup detected by the R:R scanner."""
|
||||
|
||||
@@ -49,4 +57,5 @@ class TradeSetupResponse(BaseModel):
|
||||
evaluated_at: datetime | None = None
|
||||
current_price: float | None = None
|
||||
momentum_percentile: float | None = None
|
||||
context_as_of: TradeSetupContextAsOfResponse | None = None
|
||||
recommendation_summary: RecommendationSummaryResponse | None = None
|
||||
|
||||
@@ -32,6 +32,7 @@ class WatchlistEntryResponse(BaseModel):
|
||||
dimensions: list[DimensionScoreSummary] = []
|
||||
rr_ratio: float | None = None
|
||||
rr_direction: str | None = None
|
||||
momentum_percentile: float | None = None
|
||||
sr_levels: list[SRLevelSummary] = []
|
||||
last_close: float | None = None
|
||||
change_pct: float | None = None
|
||||
|
||||
@@ -39,10 +39,9 @@ SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
|
||||
# Track Record's qualified stats. The outcome evaluator deliberately ignores
|
||||
# these — every setup is evaluated so the gate itself can be validated.
|
||||
#
|
||||
# The core test is expected value (in R): probability-weighted asymmetry, so a
|
||||
# fat-but-improbable target and a likely-but-thin one are both rejected. R:R and
|
||||
# confidence are floors; high-conviction / clean-read / target-probability are
|
||||
# optional tighteners (off by default — turn on to be more selective).
|
||||
# The core selection is residual cross-sectional 12-1 momentum (top percentile
|
||||
# of the universe, long-only). R:R and confidence are floors; high-conviction /
|
||||
# clean-read are optional tighteners (off by default).
|
||||
_ACTIVATION_FLOAT_KEYS: dict[str, str] = {
|
||||
"min_momentum_percentile": "activation_min_momentum_percentile",
|
||||
"min_rr": "activation_min_rr",
|
||||
@@ -51,13 +50,20 @@ _ACTIVATION_FLOAT_KEYS: dict[str, str] = {
|
||||
_ACTIVATION_BOOL_KEYS: dict[str, str] = {
|
||||
"require_high_conviction": "activation_require_high_conviction",
|
||||
"exclude_conflicts": "activation_exclude_conflicts",
|
||||
"exclude_neutral": "activation_exclude_neutral",
|
||||
}
|
||||
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
|
||||
"min_momentum_percentile": 80.0,
|
||||
"min_rr": 1.2,
|
||||
"min_confidence": 55.0,
|
||||
# 0 = off. The July 2026 gate ablation showed the confidence floor added
|
||||
# nothing (identical net/trade with it removed, under both exit models)
|
||||
# while cutting ~25% of qualified trades.
|
||||
"min_confidence": 0.0,
|
||||
"require_high_conviction": False,
|
||||
"exclude_conflicts": False,
|
||||
# On by default: a NEUTRAL ("no clear setup") recommendation isn't an
|
||||
# actionable signal, so it shouldn't qualify or be crowned a top pick.
|
||||
"exclude_neutral": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -512,6 +518,7 @@ async def get_pipeline_readiness(db: AsyncSession) -> list[dict]:
|
||||
VALID_JOB_NAMES = {
|
||||
"data_collector",
|
||||
"data_backfill",
|
||||
"benchmark_collector",
|
||||
"sentiment_collector",
|
||||
"fundamental_collector",
|
||||
"rr_scanner",
|
||||
@@ -529,6 +536,7 @@ VALID_JOB_NAMES = {
|
||||
JOB_LABELS = {
|
||||
"data_collector": "Data Collector (OHLCV)",
|
||||
"data_backfill": "Data Backfill (deep history)",
|
||||
"benchmark_collector": "Benchmark Collector",
|
||||
"sentiment_collector": "Sentiment Collector",
|
||||
"fundamental_collector": "Fundamental Collector",
|
||||
"rr_scanner": "R:R Scanner",
|
||||
@@ -546,6 +554,7 @@ JOB_LABELS = {
|
||||
# Jobs driven by the daily_pipeline (in order) rather than their own timer.
|
||||
PIPELINE_MEMBERS = {
|
||||
"data_collector",
|
||||
"benchmark_collector",
|
||||
"sentiment_collector",
|
||||
"rr_scanner",
|
||||
"outcome_evaluator",
|
||||
|
||||
+322
-23
@@ -26,6 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.models.alert import AlertLog
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.score import CompositeScore
|
||||
from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
@@ -46,6 +47,8 @@ KEY_QUALIFIED = "alerts_qualified_enabled"
|
||||
KEY_SR = "alerts_sr_proximity_enabled"
|
||||
KEY_SCORE_DROP = "alerts_score_drop_enabled"
|
||||
KEY_DIGEST = "alerts_digest_enabled"
|
||||
KEY_REGIME_QUADRANT = "alerts_regime_quadrant_enabled"
|
||||
KEY_TRADE_CLOSED = "alerts_trade_closed_enabled"
|
||||
|
||||
_BOOL_DEFAULTS = {
|
||||
KEY_ENABLED: False,
|
||||
@@ -53,8 +56,16 @@ _BOOL_DEFAULTS = {
|
||||
KEY_SR: True,
|
||||
KEY_SCORE_DROP: True,
|
||||
KEY_DIGEST: True,
|
||||
KEY_REGIME_QUADRANT: True,
|
||||
KEY_TRADE_CLOSED: True,
|
||||
}
|
||||
|
||||
# Paper-trade auto-close alert: catch every close at least once (the job runs
|
||||
# hourly), then never re-send the same trade (a huge cooldown ≈ once-per-trade).
|
||||
CLOSED_LOOKBACK_HOURS = 26
|
||||
CLOSED_ALERT_COOLDOWN_HOURS = 24 * 365 * 5
|
||||
TRADE_CLOSED_TYPE = "trade_closed"
|
||||
|
||||
# Tunables (kept as constants for now; promote to settings if needed)
|
||||
SR_PROXIMITY_PCT = 2.0 # within this % of a strong zone → alert
|
||||
SR_MIN_STRENGTH = 60 # only strong zones are alert-worthy
|
||||
@@ -64,6 +75,31 @@ COOLDOWN_HOURS = 72 # don't re-send the same key within this window
|
||||
DIGEST_HOUR_UTC = 22 # send the daily digest on the first run at/after this hour
|
||||
|
||||
WATERMARK_TYPE = "score_watermark"
|
||||
SIGNAL_BUNDLE_ALERT_TYPES = ("qualified", "sr_proximity", "score_drop")
|
||||
SIGNAL_BUNDLE_SECTIONS = (
|
||||
("qualified", "Qualified setups"),
|
||||
("sr_proximity", "Near support/resistance"),
|
||||
("score_drop", "Score drops"),
|
||||
)
|
||||
SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML parsing
|
||||
|
||||
# Regime quadrant-change alert: (regime index x early-warning) quadrant.
|
||||
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary
|
||||
# from flip-flopping; the cooldown caps how often a genuine change can re-alert.
|
||||
QUAD_TYPE = "regime_quadrant"
|
||||
QUAD_X_DIV = 40.0 # regime index divider (matches the frontend quadrant)
|
||||
QUAD_Y_DIV = 60.0 # early-warning divider
|
||||
QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider
|
||||
QUAD_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts
|
||||
QUAD_LABELS = {
|
||||
"1": "① Hot & brittle",
|
||||
"2": "② Transition",
|
||||
"3": "③ Healthy & broad",
|
||||
"4": "④ Real downturn",
|
||||
}
|
||||
|
||||
AlertItem = tuple[str, str, str] # alert_type, dedup_key, text
|
||||
AlertLogRef = tuple[str, str] # alert_type, dedup_key
|
||||
|
||||
|
||||
def _as_bool(value: str | None, default: bool) -> bool:
|
||||
@@ -73,7 +109,7 @@ def _as_bool(value: str | None, default: bool) -> bool:
|
||||
|
||||
|
||||
async def _resolve(db: AsyncSession) -> dict:
|
||||
keys = [KEY_ENABLED, KEY_TOKEN, KEY_CHAT_ID, KEY_QUALIFIED, KEY_SR, KEY_SCORE_DROP, KEY_DIGEST]
|
||||
keys = [KEY_ENABLED, KEY_TOKEN, KEY_CHAT_ID, KEY_QUALIFIED, KEY_SR, KEY_SCORE_DROP, KEY_DIGEST, KEY_REGIME_QUADRANT, KEY_TRADE_CLOSED]
|
||||
stored = await settings_store.get_map(db, keys)
|
||||
|
||||
db_token = (stored.get(KEY_TOKEN) or "").strip()
|
||||
@@ -95,6 +131,8 @@ async def _resolve(db: AsyncSession) -> dict:
|
||||
"sr": _as_bool(stored.get(KEY_SR), _BOOL_DEFAULTS[KEY_SR]),
|
||||
"score_drop": _as_bool(stored.get(KEY_SCORE_DROP), _BOOL_DEFAULTS[KEY_SCORE_DROP]),
|
||||
"digest": _as_bool(stored.get(KEY_DIGEST), _BOOL_DEFAULTS[KEY_DIGEST]),
|
||||
"regime_quadrant": _as_bool(stored.get(KEY_REGIME_QUADRANT), _BOOL_DEFAULTS[KEY_REGIME_QUADRANT]),
|
||||
"trade_closed": _as_bool(stored.get(KEY_TRADE_CLOSED), _BOOL_DEFAULTS[KEY_TRADE_CLOSED]),
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +148,8 @@ async def get_alert_config(db: AsyncSession) -> dict:
|
||||
"sr_proximity_enabled": r["sr"],
|
||||
"score_drop_enabled": r["score_drop"],
|
||||
"digest_enabled": r["digest"],
|
||||
"regime_quadrant_enabled": r["regime_quadrant"],
|
||||
"trade_closed_enabled": r["trade_closed"],
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +163,8 @@ async def update_alert_config(
|
||||
sr_proximity_enabled: bool | None = None,
|
||||
score_drop_enabled: bool | None = None,
|
||||
digest_enabled: bool | None = None,
|
||||
regime_quadrant_enabled: bool | None = None,
|
||||
trade_closed_enabled: bool | None = None,
|
||||
) -> dict:
|
||||
"""Persist config. An empty/omitted bot_token leaves the stored token intact."""
|
||||
bool_updates = {
|
||||
@@ -131,6 +173,8 @@ async def update_alert_config(
|
||||
KEY_SR: sr_proximity_enabled,
|
||||
KEY_SCORE_DROP: score_drop_enabled,
|
||||
KEY_DIGEST: digest_enabled,
|
||||
KEY_REGIME_QUADRANT: regime_quadrant_enabled,
|
||||
KEY_TRADE_CLOSED: trade_closed_enabled,
|
||||
}
|
||||
for key, val in bool_updates.items():
|
||||
if val is not None:
|
||||
@@ -220,19 +264,37 @@ async def _watchlist_tickers(db: AsyncSession) -> list[tuple[int, str]]:
|
||||
|
||||
|
||||
async def _qualified_setups(db: AsyncSession) -> list[dict]:
|
||||
setups = await get_trade_setups(db)
|
||||
# live_recommendation: gate and format on current score/sentiment context,
|
||||
# not the values frozen into the setup at scan time.
|
||||
setups = await get_trade_setups(db, live_recommendation=True)
|
||||
config = await get_activation_config(db)
|
||||
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
|
||||
|
||||
|
||||
def _fmt_price(value: float | int | None) -> str:
|
||||
return "n/a" if value is None else f"{float(value):.2f}"
|
||||
|
||||
|
||||
def _fmt_signed_move(from_price: float | int | None, to_price: float | int | None) -> str:
|
||||
if from_price is None or to_price is None:
|
||||
return "n/a"
|
||||
from_float = float(from_price)
|
||||
if from_float == 0:
|
||||
return "n/a"
|
||||
pct = (float(to_price) - from_float) / from_float * 100.0
|
||||
return f"{pct:+.1f}%"
|
||||
|
||||
|
||||
def _format_qualified(s: dict) -> str:
|
||||
prob = best_target_probability(SimpleNamespace(**s))
|
||||
arrow = "🟢" if s["direction"] == "long" else "🔴"
|
||||
current = s.get("current_price") or s.get("entry_price")
|
||||
return (
|
||||
f"{arrow} <b>{s['symbol']} {s['direction'].upper()}</b> — qualified setup\n"
|
||||
f"entry {s['entry_price']:.2f} → target {s['target']:.2f} "
|
||||
f"(R:R {s['rr_ratio']:.1f}:1)\n"
|
||||
f"confidence {(s.get('confidence_score') or 0):.0f}% · P(target) {prob:.0f}%"
|
||||
f"{arrow} <b>{s['symbol']} {s['direction'].upper()}</b> | "
|
||||
f"now {_fmt_price(current)} | entry {_fmt_price(s['entry_price'])} | "
|
||||
f"target {_fmt_price(s['target'])} ({_fmt_signed_move(current, s['target'])}) | "
|
||||
f"stop {_fmt_price(s['stop_loss'])} | R:R {s['rr_ratio']:.1f} | "
|
||||
f"conf {(s.get('confidence_score') or 0):.0f}% | P(target) {prob:.0f}%"
|
||||
)
|
||||
|
||||
|
||||
@@ -255,6 +317,34 @@ async def _latest_close(db: AsyncSession, ticker_id: int) -> float | None:
|
||||
return float(row[0]) if row else None
|
||||
|
||||
|
||||
def _sr_zone_label(zone: dict) -> str:
|
||||
return (
|
||||
f"{zone['low']:.2f}–{zone['high']:.2f}"
|
||||
if zone["level_count"] > 1
|
||||
else f"{zone['midpoint']:.2f}"
|
||||
)
|
||||
|
||||
|
||||
def _sr_touch_price(zone: dict, current_price: float) -> float:
|
||||
low = float(zone["low"])
|
||||
high = float(zone["high"])
|
||||
if current_price < low:
|
||||
return low
|
||||
if current_price > high:
|
||||
return high
|
||||
return float(zone["midpoint"])
|
||||
|
||||
|
||||
def _format_sr_proximity(symbol: str, zone: dict, current_price: float) -> str:
|
||||
touch_price = _sr_touch_price(zone, current_price)
|
||||
return (
|
||||
f"📍 <b>{symbol}</b> {zone['type']} | "
|
||||
f"now {_fmt_price(current_price)} -> {_sr_zone_label(zone)} "
|
||||
f"({_fmt_signed_move(current_price, touch_price)}) | "
|
||||
f"strength {float(zone['strength']):.0f}"
|
||||
)
|
||||
|
||||
|
||||
async def _collect_sr_proximity(db: AsyncSession) -> list[tuple[str, str]]:
|
||||
"""One alert per watchlist ticker for the NEAREST strong S/R zone within range.
|
||||
|
||||
@@ -284,21 +374,12 @@ async def _collect_sr_proximity(db: AsyncSession) -> list[tuple[str, str]]:
|
||||
|
||||
# Nearest strong zone only.
|
||||
nearest = min(strong, key=lambda z: abs(price - z["midpoint"]))
|
||||
dist_pct = abs(price - nearest["midpoint"]) / price * 100
|
||||
dist_pct = abs(price - _sr_touch_price(nearest, price)) / price * 100
|
||||
if dist_pct > SR_PROXIMITY_PCT:
|
||||
continue
|
||||
|
||||
label = (
|
||||
f"{nearest['low']:.2f}–{nearest['high']:.2f}"
|
||||
if nearest["level_count"] > 1
|
||||
else f"{nearest['midpoint']:.2f}"
|
||||
)
|
||||
key = f"sr:{symbol}:{nearest['type']}" # one per side per ticker per cooldown
|
||||
out.append((
|
||||
key,
|
||||
f"📍 <b>{symbol}</b> approaching {nearest['type']} {label} "
|
||||
f"(now {price:.2f}, {dist_pct:.1f}% away)",
|
||||
))
|
||||
out.append((key, _format_sr_proximity(symbol, nearest, price)))
|
||||
return out
|
||||
|
||||
|
||||
@@ -355,13 +436,210 @@ async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None:
|
||||
)
|
||||
else:
|
||||
lines.append("No qualified setups today.")
|
||||
|
||||
# Open paper trades: unrealized gain + the live trailing stop and how far away.
|
||||
from app.services import paper_trade_service
|
||||
|
||||
open_trades = await paper_trade_service.list_trades(db, status="open")
|
||||
if open_trades:
|
||||
lines.append("")
|
||||
lines.append(f"💼 <b>{len(open_trades)} open trade(s):</b>")
|
||||
for t in open_trades:
|
||||
entry = t["entry_price"]
|
||||
cur = t.get("current_price")
|
||||
sign = 1.0 if t["direction"] == "long" else -1.0
|
||||
if cur and entry:
|
||||
gain_pct = (cur - entry) / entry * 100.0 * sign
|
||||
gain_usd = (cur - entry) * t["shares"] * sign
|
||||
gain = f"{gain_pct:+.1f}% ({'+' if gain_usd >= 0 else '−'}${abs(gain_usd):.0f})"
|
||||
else:
|
||||
gain = "n/a"
|
||||
ts = t.get("trailing_stop")
|
||||
if ts is not None:
|
||||
dist = t.get("trailing_distance_pct")
|
||||
stop_txt = f"trail {ts:.2f}" + (f" ({dist:.1f}% away)" if dist is not None else "")
|
||||
else:
|
||||
stop_txt = f"stop {t['stop_loss']:.2f}"
|
||||
lines.append(f"• {t['symbol']} {t['direction'].upper()} {gain} · {stop_txt}")
|
||||
|
||||
return key, "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paper-trade close trigger (one summary per auto-closed trade)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_closed_trade(trade: PaperTrade, symbol: str) -> str:
|
||||
sign = 1.0 if trade.direction == "long" else -1.0
|
||||
entry = trade.entry_price
|
||||
exit_price = trade.close_price if trade.close_price is not None else entry
|
||||
per_share = (exit_price - entry) * sign
|
||||
pnl_pct = (per_share / entry * 100.0) if entry else 0.0
|
||||
pnl_usd = per_share * trade.shares
|
||||
risk = abs(entry - trade.stop_loss)
|
||||
r_mult = (per_share / risk) if risk > 0 else None
|
||||
win = per_share > 0
|
||||
money = f"{'+' if pnl_usd >= 0 else '−'}${abs(pnl_usd):.2f}"
|
||||
r_txt = f" · {r_mult:+.2f}R" if r_mult is not None else ""
|
||||
days = (trade.closed_at - trade.opened_at).days if (trade.closed_at and trade.opened_at) else None
|
||||
held = f" · held {days}d" if days is not None else ""
|
||||
reason = {"trailing": "trailing stop", "stop": "stop-loss", "target": "target"}.get(
|
||||
trade.close_reason or "", trade.close_reason or "closed"
|
||||
)
|
||||
return (
|
||||
f"{'✅' if win else '🔴'} <b>{symbol} {trade.direction.upper()} closed</b> ({reason})\n"
|
||||
f"{pnl_pct:+.1f}% · {money}{r_txt}{held}\n"
|
||||
f"{entry:.2f} → {exit_price:.2f}"
|
||||
)
|
||||
|
||||
|
||||
async def _collect_closed_trades(db: AsyncSession) -> list[tuple[str, str]]:
|
||||
"""One alert per auto-closed paper trade (trailing / stop / target). Manual
|
||||
closes are skipped — you already know about those. Dedup is by trade id."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=CLOSED_LOOKBACK_HOURS)
|
||||
result = await db.execute(
|
||||
select(PaperTrade, Ticker.symbol)
|
||||
.join(Ticker, PaperTrade.ticker_id == Ticker.id)
|
||||
.where(
|
||||
PaperTrade.status == "closed",
|
||||
PaperTrade.closed_at.is_not(None),
|
||||
PaperTrade.closed_at > cutoff,
|
||||
PaperTrade.close_reason.in_(("trailing", "stop", "target")),
|
||||
)
|
||||
.order_by(PaperTrade.closed_at.desc())
|
||||
)
|
||||
return [(str(trade.id), _format_closed_trade(trade, symbol)) for trade, symbol in result.all()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regime quadrant-change trigger (hysteresis + cooldown)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _bools_to_quadrant(x_high: bool, y_high: bool) -> str:
|
||||
if y_high:
|
||||
return "2" if x_high else "1" # ② Transition / ① Hot & brittle
|
||||
return "4" if x_high else "3" # ④ Real downturn / ③ Healthy & broad
|
||||
|
||||
|
||||
def _quadrant_to_bools(q: str) -> tuple[bool, bool]:
|
||||
return {"1": (False, True), "2": (True, True), "3": (False, False), "4": (True, False)}[q]
|
||||
|
||||
|
||||
def _classify_quadrant(x: float, y: float, prev: str | None, margin: float = QUAD_MARGIN) -> str:
|
||||
"""Quadrant of (regime index x, early warning y), with per-axis hysteresis.
|
||||
|
||||
Each axis only flips once the value crosses its divider by ``margin`` in the
|
||||
new direction, so a point parked on a divider keeps its current quadrant
|
||||
instead of flip-flopping. ``prev`` None means a fresh (no-hysteresis) classify.
|
||||
"""
|
||||
if prev is None:
|
||||
return _bools_to_quadrant(x >= QUAD_X_DIV, y >= QUAD_Y_DIV)
|
||||
px, py = _quadrant_to_bools(prev)
|
||||
x_high = (x >= QUAD_X_DIV - margin) if px else (x >= QUAD_X_DIV + margin)
|
||||
y_high = (y >= QUAD_Y_DIV - margin) if py else (y >= QUAD_Y_DIV + margin)
|
||||
return _bools_to_quadrant(x_high, y_high)
|
||||
|
||||
|
||||
async def _last_quadrant(db: AsyncSession) -> tuple[str | None, datetime | None]:
|
||||
"""Most recently logged quadrant (and when), our baseline for change + cooldown."""
|
||||
result = await db.execute(
|
||||
select(AlertLog.dedup_key, AlertLog.created_at)
|
||||
.where(AlertLog.alert_type == QUAD_TYPE)
|
||||
.order_by(AlertLog.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
row = result.first()
|
||||
return (row[0], row[1]) if row else (None, None)
|
||||
|
||||
|
||||
async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
|
||||
"""Alert once when the regime quadrant changes (hysteresis + cooldown).
|
||||
|
||||
Seeds silently on first run. Thereafter alerts only when the
|
||||
hysteresis-confirmed quadrant differs from the last logged one AND the
|
||||
cooldown has elapsed. The dispatch loop logs the new quadrant on send, which
|
||||
becomes the next baseline and resets the cooldown clock.
|
||||
"""
|
||||
from app.services.regime_monitor_service import get_regime_monitor
|
||||
|
||||
data = await get_regime_monitor(db)
|
||||
if not data.get("available"):
|
||||
return []
|
||||
x = data.get("total_score")
|
||||
y = (data.get("early_warning") or {}).get("score")
|
||||
if x is None or y is None:
|
||||
return []
|
||||
|
||||
prev, prev_time = await _last_quadrant(db)
|
||||
if prev is None:
|
||||
_log_alert(db, QUAD_TYPE, _classify_quadrant(x, y, None)) # seed, no alert
|
||||
return []
|
||||
|
||||
new_q = _classify_quadrant(x, y, prev)
|
||||
if new_q == prev:
|
||||
return []
|
||||
|
||||
if prev_time is not None:
|
||||
if prev_time.tzinfo is None:
|
||||
prev_time = prev_time.replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) - prev_time < timedelta(days=QUAD_COOLDOWN_DAYS):
|
||||
return [] # genuine change, but inside the cooldown — stay quiet
|
||||
|
||||
text = (
|
||||
f"🧭 <b>Regime quadrant change</b>\n"
|
||||
f"{QUAD_LABELS.get(prev, prev)} → {QUAD_LABELS.get(new_q, new_q)}\n"
|
||||
f"regime {x:.0f} · early-warning {y:.0f}"
|
||||
)
|
||||
return [(new_q, text)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _signal_bundle_messages(items: list[AlertItem]) -> list[tuple[list[AlertLogRef], str]]:
|
||||
if not items:
|
||||
return []
|
||||
|
||||
by_type: dict[str, list[AlertItem]] = {key: [] for key in SIGNAL_BUNDLE_ALERT_TYPES}
|
||||
for item in items:
|
||||
by_type.setdefault(item[0], []).append(item)
|
||||
|
||||
total = sum(len(group) for group in by_type.values())
|
||||
header = f"📣 <b>Signal run</b> — {total} new alert(s)"
|
||||
bundles: list[tuple[list[AlertLogRef], str]] = []
|
||||
lines = [header]
|
||||
logs: list[AlertLogRef] = []
|
||||
current_section: str | None = None
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal lines, logs, current_section
|
||||
if logs:
|
||||
bundles.append((logs.copy(), "\n".join(lines)))
|
||||
lines = [f"{header} (continued)"]
|
||||
logs = []
|
||||
current_section = None
|
||||
|
||||
for alert_type, section_title in SIGNAL_BUNDLE_SECTIONS:
|
||||
for item_type, key, text in by_type.get(alert_type, []):
|
||||
block: list[str] = []
|
||||
if current_section != alert_type:
|
||||
block.extend(["", f"<b>{section_title}</b>"])
|
||||
block.append(text)
|
||||
|
||||
if logs and len("\n".join(lines + block)) > SIGNAL_BUNDLE_MAX_CHARS:
|
||||
flush()
|
||||
block = ["", f"<b>{section_title}</b>", text]
|
||||
|
||||
lines.extend(block)
|
||||
logs.append((item_type, key))
|
||||
current_section = alert_type
|
||||
|
||||
if logs:
|
||||
bundles.append((logs.copy(), "\n".join(lines)))
|
||||
return bundles
|
||||
|
||||
|
||||
async def dispatch_alerts(db: AsyncSession) -> dict:
|
||||
"""Gather all enabled triggers, dedup, and push to Telegram. Job entrypoint."""
|
||||
cfg = await _resolve(db)
|
||||
@@ -370,31 +648,52 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
|
||||
if not cfg["token"] or not cfg["chat_id"]:
|
||||
return {"status": "no_credentials", "sent": 0}
|
||||
|
||||
outgoing: list[tuple[str, str, str]] = [] # (alert_type, key, text)
|
||||
signal_outgoing: list[AlertItem] = []
|
||||
outgoing: list[AlertItem] = []
|
||||
|
||||
if cfg["qualified"]:
|
||||
for key, text in await _collect_qualified(db):
|
||||
if not await _recently_alerted(db, "qualified", key):
|
||||
outgoing.append(("qualified", key, text))
|
||||
signal_outgoing.append(("qualified", key, text))
|
||||
|
||||
if cfg["sr"]:
|
||||
for key, text in await _collect_sr_proximity(db):
|
||||
if not await _recently_alerted(db, "sr_proximity", key):
|
||||
outgoing.append(("sr_proximity", key, text))
|
||||
signal_outgoing.append(("sr_proximity", key, text))
|
||||
|
||||
if cfg["score_drop"]:
|
||||
# also seeds/advances watermarks as a side effect
|
||||
for key, text in await _collect_score_drops(db):
|
||||
outgoing.append(("score_drop", key, text))
|
||||
signal_outgoing.append(("score_drop", key, text))
|
||||
|
||||
if cfg["digest"]:
|
||||
digest = await _collect_digest(db)
|
||||
if digest is not None:
|
||||
outgoing.append(("digest", digest[0], digest[1]))
|
||||
|
||||
if cfg["regime_quadrant"]:
|
||||
# cooldown/hysteresis handled in the collector (like score drops)
|
||||
for key, text in await _collect_regime_quadrant(db):
|
||||
outgoing.append((QUAD_TYPE, key, text))
|
||||
|
||||
if cfg["trade_closed"]:
|
||||
for key, text in await _collect_closed_trades(db):
|
||||
if not await _recently_alerted(db, TRADE_CLOSED_TYPE, key, cooldown_hours=CLOSED_ALERT_COOLDOWN_HOURS):
|
||||
outgoing.append((TRADE_CLOSED_TYPE, key, text))
|
||||
|
||||
sent = 0
|
||||
if outgoing:
|
||||
candidates = len(signal_outgoing) + len(outgoing)
|
||||
if signal_outgoing or outgoing:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
for log_refs, text in _signal_bundle_messages(signal_outgoing):
|
||||
try:
|
||||
await _send(client, cfg["token"], cfg["chat_id"], text)
|
||||
for alert_type, key in log_refs:
|
||||
_log_alert(db, alert_type, key)
|
||||
sent += 1
|
||||
except Exception:
|
||||
logger.exception("Failed to send signal alert bundle")
|
||||
|
||||
for alert_type, key, text in outgoing:
|
||||
try:
|
||||
await _send(client, cfg["token"], cfg["chat_id"], text)
|
||||
@@ -404,7 +703,7 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
|
||||
logger.exception("Failed to send alert %s", key)
|
||||
|
||||
await db.commit() # persist watermark seeds/advances and sent-logs
|
||||
return {"status": "ok", "sent": sent, "candidates": len(outgoing)}
|
||||
return {"status": "ok", "sent": sent, "candidates": candidates}
|
||||
|
||||
|
||||
async def send_test_alert(db: AsyncSession) -> dict:
|
||||
|
||||
+1139
-46
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
"""Benchmark price store + alpha helpers.
|
||||
|
||||
Fetches the S&P 500 proxy (SPY) daily closes via Alpaca and persists them, so
|
||||
paper-trade alpha — a trade's return minus the benchmark's return over the same
|
||||
holding period — can be computed. The benchmark is a standalone series, NOT a
|
||||
tracked ``Ticker``; its closes feed residual momentum and alpha, but it never
|
||||
becomes a trade candidate or rankings-table row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.benchmark_price import BenchmarkPrice
|
||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BENCHMARK_SYMBOL = "SPY"
|
||||
# ~800 calendar days ≈ 550 trading days — comfortably covers any realistic paper
|
||||
# holding period plus a margin for the nearest-prior-trading-day lookup.
|
||||
_HISTORY_DAYS = 800
|
||||
|
||||
|
||||
async def refresh_benchmark_prices(
|
||||
db: AsyncSession, symbol: str = BENCHMARK_SYMBOL, days: int = _HISTORY_DAYS
|
||||
) -> int:
|
||||
"""Fetch the benchmark's daily closes and upsert them. Returns rows written.
|
||||
|
||||
Idempotent: inserts new dates, updates a close only if it changed (e.g. after
|
||||
a split adjustment). Best-effort — returns 0 when Alpaca keys are unset.
|
||||
"""
|
||||
if not settings.alpaca_api_key or not settings.alpaca_api_secret:
|
||||
logger.warning("Benchmark refresh skipped: Alpaca keys not configured")
|
||||
return 0
|
||||
|
||||
provider = AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret)
|
||||
end = date.today()
|
||||
start = end - timedelta(days=days)
|
||||
bars = await provider.fetch_ohlcv(symbol, start, end)
|
||||
|
||||
existing = {
|
||||
row.date: row
|
||||
for row in (
|
||||
await db.execute(select(BenchmarkPrice).where(BenchmarkPrice.symbol == symbol))
|
||||
).scalars()
|
||||
}
|
||||
|
||||
written = 0
|
||||
for bar in bars:
|
||||
current = existing.get(bar.date)
|
||||
if current is None:
|
||||
db.add(BenchmarkPrice(symbol=symbol, date=bar.date, close=float(bar.close)))
|
||||
written += 1
|
||||
elif abs(current.close - float(bar.close)) > 1e-9:
|
||||
current.close = float(bar.close)
|
||||
written += 1
|
||||
|
||||
if written:
|
||||
await db.commit()
|
||||
logger.info("Benchmark %s refreshed: %d rows written", symbol, written)
|
||||
return written
|
||||
|
||||
|
||||
async def load_benchmark_closes(
|
||||
db: AsyncSession, symbol: str = BENCHMARK_SYMBOL
|
||||
) -> dict[date, float]:
|
||||
"""Return ``{date: close}`` for the benchmark (empty if none stored yet)."""
|
||||
rows = await db.execute(
|
||||
select(BenchmarkPrice.date, BenchmarkPrice.close).where(BenchmarkPrice.symbol == symbol)
|
||||
)
|
||||
return {d: float(c) for d, c in rows.all()}
|
||||
|
||||
|
||||
def benchmark_return_pct(
|
||||
closes: dict[date, float], open_date: date, as_of_date: date
|
||||
) -> float | None:
|
||||
"""Benchmark % return between two dates, using the nearest close on/before each.
|
||||
|
||||
Returns ``None`` when there's no benchmark data at or before either endpoint
|
||||
(e.g. a trade opened before the stored history, or the table is empty).
|
||||
"""
|
||||
if not closes:
|
||||
return None
|
||||
dates = sorted(closes)
|
||||
|
||||
def _close_on_or_before(target: date) -> float | None:
|
||||
idx = bisect.bisect_right(dates, target) - 1
|
||||
return closes[dates[idx]] if idx >= 0 else None
|
||||
|
||||
start = _close_on_or_before(open_date)
|
||||
end = _close_on_or_before(as_of_date)
|
||||
if start is None or end is None or start == 0:
|
||||
return None
|
||||
return (end - start) / start * 100.0
|
||||
@@ -34,12 +34,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
KEY_REPORT = "regime_event_study"
|
||||
|
||||
# Defaults — admin-tunable later if needed.
|
||||
EVENT_THRESHOLD_PCT = 15.0 # drawdown from the 52w high that counts as a "break"
|
||||
RECOVER_PCT = 5.0 # must recover to within this of the high before a new event
|
||||
# Defaults. The 15% threshold gave only 2 events in 5y (statistically useless),
|
||||
# so the default is lower with a cooldown-based dedup to surface more, cleaner
|
||||
# events. Each indicator "warns" at its OWN 80th percentile rather than a shared
|
||||
# absolute level, so the leading vs. coincident comparison is fair across scales.
|
||||
EVENT_THRESHOLD_PCT = 10.0 # drawdown from the 52w high that counts as a "break"
|
||||
COOLDOWN_DAYS = 40 # min trading days between event onsets (dedup)
|
||||
DRAWDOWN_LOOKBACK = 252 # 52-week trailing high
|
||||
HORIZON_DAYS = 20 # signal-centered prediction horizon
|
||||
WARN_THRESHOLD = 60.0 # indicator level treated as "warning on"
|
||||
WARN_PERCENTILE = 80.0 # each indicator warns at its own Nth percentile
|
||||
PRE, POST = 60, 20 # event-centered window (trading days)
|
||||
|
||||
|
||||
@@ -52,6 +55,17 @@ def _median(values: list[float]) -> float | None:
|
||||
return float(s[mid]) if n % 2 else (s[mid - 1] + s[mid]) / 2.0
|
||||
|
||||
|
||||
def _percentile(values: list[float], pct: float) -> float | None:
|
||||
"""Linear-interpolated percentile of the non-None values."""
|
||||
vals = sorted(v for v in values if v is not None)
|
||||
if not vals:
|
||||
return None
|
||||
k = (len(vals) - 1) * (pct / 100.0)
|
||||
lo = int(k)
|
||||
hi = min(lo + 1, len(vals) - 1)
|
||||
return vals[lo] + (vals[hi] - vals[lo]) * (k - lo)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event detection
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -61,22 +75,23 @@ def detect_events(
|
||||
dates: list[date],
|
||||
threshold_pct: float = EVENT_THRESHOLD_PCT,
|
||||
lookback: int = DRAWDOWN_LOOKBACK,
|
||||
recover_pct: float = RECOVER_PCT,
|
||||
cooldown: int = COOLDOWN_DAYS,
|
||||
) -> list[dict]:
|
||||
"""Drawdown events: ``t0`` = first day the drawdown from the trailing 52w high
|
||||
crosses ``threshold_pct``. De-duplicated — a new event needs a recovery back to
|
||||
within ``recover_pct`` of the high first (so one decline = one event)."""
|
||||
"""Drawdown events: ``t0`` = a day the drawdown from the trailing 52w high
|
||||
crosses up through ``threshold_pct`` (rising edge). De-duplicated by a
|
||||
``cooldown`` of trading days, so a continuous decline counts once but distinct
|
||||
drawdowns separated by a recovery each register."""
|
||||
events: list[dict] = []
|
||||
in_event = False
|
||||
prev_dd = 0.0
|
||||
last_event = -10**9
|
||||
for i in range(len(closes)):
|
||||
window = closes[max(0, i - lookback + 1): i + 1]
|
||||
hi = max(window)
|
||||
dd = (hi - closes[i]) / hi * 100.0 if hi > 0 else 0.0
|
||||
if not in_event and dd >= threshold_pct:
|
||||
if dd >= threshold_pct and prev_dd < threshold_pct and (i - last_event) >= cooldown:
|
||||
events.append({"date": dates[i].isoformat(), "index": i, "depth_pct": round(dd, 1)})
|
||||
in_event = True
|
||||
elif in_event and dd <= recover_pct:
|
||||
in_event = False
|
||||
last_event = i
|
||||
prev_dd = dd
|
||||
return events
|
||||
|
||||
|
||||
@@ -84,23 +99,9 @@ def detect_events(
|
||||
# Event-centered: lead time + mean path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def event_centered(
|
||||
indicator: dict[date, float],
|
||||
events_idx: list[int],
|
||||
dates: list[date],
|
||||
pre: int = PRE,
|
||||
post: int = POST,
|
||||
threshold: float = WARN_THRESHOLD,
|
||||
) -> dict:
|
||||
"""Align the indicator at each event's ``t0`` and measure how early it warned.
|
||||
|
||||
Lead = the earliest day within ``[t0-pre, t0]`` at which the indicator first
|
||||
crosses ``threshold``. Also returns the cross-event mean path.
|
||||
"""
|
||||
leads: list[float] = []
|
||||
sums: dict[int, float] = {}
|
||||
counts: dict[int, int] = {}
|
||||
for t0 in events_idx:
|
||||
def _lead(indicator: dict[date, float], t0: int, dates: list[date], pre: int, threshold: float) -> int | None:
|
||||
"""Earliest day within ``[t0-pre, t0]`` at which the indicator crosses
|
||||
``threshold`` — i.e. how many days of warning before the event, or None."""
|
||||
lead: int | None = None
|
||||
for k in range(0, pre + 1):
|
||||
idx = t0 - k
|
||||
@@ -109,6 +110,27 @@ def event_centered(
|
||||
v = indicator.get(dates[idx])
|
||||
if v is not None and v >= threshold:
|
||||
lead = k # keep going: the largest k = earliest warning in the window
|
||||
return lead
|
||||
|
||||
|
||||
def event_centered(
|
||||
indicator: dict[date, float],
|
||||
events_idx: list[int],
|
||||
dates: list[date],
|
||||
pre: int = PRE,
|
||||
post: int = POST,
|
||||
threshold: float = 60.0,
|
||||
) -> dict:
|
||||
"""Align the indicator at each event's ``t0`` and measure how early it warned.
|
||||
|
||||
Lead time is measured against ``threshold`` (each indicator gets its own,
|
||||
derived from its distribution). Also returns the cross-event mean path.
|
||||
"""
|
||||
leads: list[float] = []
|
||||
sums: dict[int, float] = {}
|
||||
counts: dict[int, int] = {}
|
||||
for t0 in events_idx:
|
||||
lead = _lead(indicator, t0, dates, pre, threshold)
|
||||
if lead is not None:
|
||||
leads.append(lead)
|
||||
for rel in range(-pre, post + 1):
|
||||
@@ -125,6 +147,7 @@ def event_centered(
|
||||
"median_lead_days": _median(leads),
|
||||
"events_with_signal": len(leads),
|
||||
"events_total": len(events_idx),
|
||||
"warn_threshold": round(threshold, 1),
|
||||
"mean_path": mean_path,
|
||||
}
|
||||
|
||||
@@ -211,7 +234,8 @@ async def run_event_study(
|
||||
db: AsyncSession,
|
||||
threshold_pct: float = EVENT_THRESHOLD_PCT,
|
||||
horizon: int = HORIZON_DAYS,
|
||||
warn_threshold: float = WARN_THRESHOLD,
|
||||
cooldown: int = COOLDOWN_DAYS,
|
||||
warn_percentile: float = WARN_PERCENTILE,
|
||||
) -> dict:
|
||||
"""Run the study: detect events on the benchmark, then measure breadth-divergence
|
||||
vs. the coincident price composite. Best-effort; returns available=False on no data."""
|
||||
@@ -227,23 +251,40 @@ async def run_event_study(
|
||||
|
||||
dates = [d for d, _ in bench]
|
||||
closes = [c for _, c in bench]
|
||||
events = detect_events(closes, dates, threshold_pct)
|
||||
events = detect_events(closes, dates, threshold_pct, cooldown=cooldown)
|
||||
events_idx = [e["index"] for e in events]
|
||||
|
||||
breadth = await breadth_service.compute_breadth_series(db)
|
||||
divergence = breadth_service.compute_divergence_series(breadth, bench)
|
||||
coincident = _coincident_series(prices, dates, config)
|
||||
|
||||
def _evaluate(series: dict[date, float]) -> dict:
|
||||
# Each indicator warns at its OWN distribution's percentile, so a leading
|
||||
# indicator isn't penalised for living on a different scale than the baseline.
|
||||
warn = {
|
||||
"breadth_divergence": _percentile(list(divergence.values()), warn_percentile) or 60.0,
|
||||
"coincident_price": _percentile(list(coincident.values()), warn_percentile) or 60.0,
|
||||
}
|
||||
series_by_key = {"breadth_divergence": divergence, "coincident_price": coincident}
|
||||
|
||||
def _evaluate(series: dict[date, float], threshold: float) -> dict:
|
||||
return {
|
||||
**event_centered(series, events_idx, dates, threshold=warn_threshold),
|
||||
**event_centered(series, events_idx, dates, threshold=threshold),
|
||||
"signal": signal_centered(series, events_idx, dates, horizon),
|
||||
}
|
||||
|
||||
indicators = {
|
||||
"breadth_divergence": _evaluate(divergence),
|
||||
"coincident_price": _evaluate(coincident),
|
||||
indicators = {key: _evaluate(series_by_key[key], warn[key]) for key in series_by_key}
|
||||
|
||||
# Per-event comparison: which event, and each indicator's lead on THAT event —
|
||||
# so a median over a tiny sample can't hide an apples-to-oranges comparison.
|
||||
per_event = [
|
||||
{
|
||||
"date": e["date"],
|
||||
"depth_pct": e["depth_pct"],
|
||||
"breadth_lead": _lead(divergence, e["index"], dates, PRE, warn["breadth_divergence"]),
|
||||
"coincident_lead": _lead(coincident, e["index"], dates, PRE, warn["coincident_price"]),
|
||||
}
|
||||
for e in events
|
||||
]
|
||||
|
||||
bd = indicators["breadth_divergence"]["median_lead_days"]
|
||||
cd = indicators["coincident_price"]["median_lead_days"]
|
||||
@@ -261,11 +302,13 @@ async def run_event_study(
|
||||
"params": {
|
||||
"benchmark": leader,
|
||||
"event_threshold_pct": threshold_pct,
|
||||
"cooldown_days": cooldown,
|
||||
"horizon_days": horizon,
|
||||
"warn_threshold": warn_threshold,
|
||||
"warn_percentile": warn_percentile,
|
||||
},
|
||||
"events": events,
|
||||
"indicators": indicators,
|
||||
"per_event": per_event,
|
||||
"lead_delta_days": lead_delta,
|
||||
"recent_breadth": recent_breadth,
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class IngestionResult:
|
||||
symbol: str
|
||||
records_ingested: int
|
||||
last_date: date | None
|
||||
status: str # "complete" | "partial" | "error"
|
||||
status: str # "complete" | "partial" | "error" | "no_data"
|
||||
message: str | None = None
|
||||
|
||||
|
||||
@@ -143,6 +143,31 @@ async def fetch_and_ingest(
|
||||
message=str(exc),
|
||||
)
|
||||
|
||||
# Provider returned nothing. With no history at all this almost always means
|
||||
# the provider doesn't cover this symbol (Alpaca = US listings only) — surface
|
||||
# that instead of a misleading "success". With existing bars it just means
|
||||
# there were no new bars in the requested window.
|
||||
if not records:
|
||||
existing = await _get_ohlcv_bar_count(db, ticker.id)
|
||||
if existing == 0:
|
||||
return IngestionResult(
|
||||
symbol=ticker.symbol,
|
||||
records_ingested=0,
|
||||
last_date=None,
|
||||
status="no_data",
|
||||
message=(
|
||||
"No data returned by the provider — it may not cover this symbol "
|
||||
"(Alpaca serves US-listed securities only)."
|
||||
),
|
||||
)
|
||||
return IngestionResult(
|
||||
symbol=ticker.symbol,
|
||||
records_ingested=0,
|
||||
last_date=None,
|
||||
status="complete",
|
||||
message="Already up to date — no new bars.",
|
||||
)
|
||||
|
||||
# Sort records by date to ensure ordered ingestion
|
||||
records.sort(key=lambda r: r.date)
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""Cross-sectional 12-1 momentum ranking for the universe.
|
||||
"""Cross-sectional residual 12-1 momentum ranking for the universe.
|
||||
|
||||
The activation gate selects the top ``min_momentum_percentile`` of the universe
|
||||
by 12-1 month momentum (return from ~12 months ago to ~1 month ago — the one
|
||||
price signal the backtest showed sorts forward returns). The daily scan ranks
|
||||
every ticker and stores each setup's percentile (see ``rr_scanner_service``), so
|
||||
the live list, the Track Record's qualified stats, and outcome evaluation all gate
|
||||
on the same value.
|
||||
by residual 12-1 month momentum: the stock's 12-1 return after subtracting its
|
||||
estimated benchmark beta contribution over the same formation window. The daily
|
||||
scan ranks every ticker and stores each setup's percentile (see
|
||||
``rr_scanner_service``), so the live list, the Track Record's qualified stats,
|
||||
and outcome evaluation all gate on the same value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -35,29 +36,101 @@ def compute_12_1_momentum(closes: list[float]) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def compute_residual_12_1_momentum(
|
||||
dates: list[date],
|
||||
closes: list[float],
|
||||
benchmark_closes: dict[date, float],
|
||||
) -> float | None:
|
||||
"""12-1 momentum after removing linear benchmark exposure.
|
||||
|
||||
Estimate beta from daily stock/benchmark returns over the standard 12-1
|
||||
formation window, then sum stock return minus beta * benchmark return. No
|
||||
intercept is subtracted: fitting an intercept over the same window would make
|
||||
residuals sum to roughly zero and destroy the ranking signal.
|
||||
"""
|
||||
i = len(closes) - 1
|
||||
if not benchmark_closes or len(dates) != len(closes) or i - _MOM_LOOKBACK < 0:
|
||||
return None
|
||||
|
||||
stock_rets: list[float] = []
|
||||
market_rets: list[float] = []
|
||||
for k in range(i - _MOM_LOOKBACK + 1, i - _MOM_SKIP + 1):
|
||||
prev_close = closes[k - 1]
|
||||
bench_prev = benchmark_closes.get(dates[k - 1])
|
||||
bench_cur = benchmark_closes.get(dates[k])
|
||||
if prev_close <= 0 or bench_prev is None or bench_cur is None or bench_prev <= 0:
|
||||
continue
|
||||
stock_rets.append(closes[k] / prev_close - 1.0)
|
||||
market_rets.append(bench_cur / bench_prev - 1.0)
|
||||
|
||||
if len(stock_rets) < 100:
|
||||
return None
|
||||
mean_market = sum(market_rets) / len(market_rets)
|
||||
mean_stock = sum(stock_rets) / len(stock_rets)
|
||||
var_market = sum((x - mean_market) ** 2 for x in market_rets)
|
||||
if var_market <= 0:
|
||||
return None
|
||||
cov = sum(
|
||||
(stock_rets[k] - mean_stock) * (market_rets[k] - mean_market)
|
||||
for k in range(len(stock_rets))
|
||||
)
|
||||
beta = cov / var_market
|
||||
return sum(stock_rets[k] - beta * market_rets[k] for k in range(len(stock_rets)))
|
||||
|
||||
|
||||
async def _load_activation_benchmark(db: AsyncSession) -> dict[date, float]:
|
||||
"""Load SPY closes for residual momentum; refresh once if the table is empty."""
|
||||
try:
|
||||
from app.services.benchmark_service import load_benchmark_closes, refresh_benchmark_prices
|
||||
|
||||
closes = await load_benchmark_closes(db)
|
||||
if closes:
|
||||
return closes
|
||||
await refresh_benchmark_prices(db)
|
||||
return await load_benchmark_closes(db)
|
||||
except Exception:
|
||||
logger.exception("Residual momentum benchmark load failed; falling back to raw momentum")
|
||||
return {}
|
||||
|
||||
|
||||
async def compute_momentum_percentiles(db: AsyncSession) -> dict[str, float]:
|
||||
"""Compute each ticker's 12-1 momentum and rank the universe into a
|
||||
``{symbol: percentile}`` map (0–100, 100 = strongest momentum). Tickers
|
||||
without a full year of history are absent (can't be ranked)."""
|
||||
"""Compute each ticker's activation momentum rank.
|
||||
|
||||
Production uses residual 12-1 momentum when benchmark data is available. If
|
||||
SPY data is absent, fall back to raw 12-1 momentum rather than disabling the
|
||||
scanner. Tickers without enough stock/benchmark history are absent.
|
||||
"""
|
||||
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
|
||||
tickers = list(result.scalars().all())
|
||||
|
||||
momentum: dict[str, float] = {}
|
||||
benchmark_closes = await _load_activation_benchmark(db)
|
||||
using_residual = len(benchmark_closes) >= _MOM_LOOKBACK
|
||||
|
||||
values: dict[str, float] = {}
|
||||
for ticker in tickers:
|
||||
try:
|
||||
records = await query_ohlcv(db, ticker.symbol)
|
||||
except Exception:
|
||||
logger.exception("Momentum fetch failed for %s", ticker.symbol)
|
||||
continue
|
||||
m = compute_12_1_momentum([float(r.close) for r in records])
|
||||
if m is not None:
|
||||
momentum[ticker.symbol] = m
|
||||
closes = [float(r.close) for r in records]
|
||||
value = (
|
||||
compute_residual_12_1_momentum([r.date for r in records], closes, benchmark_closes)
|
||||
if using_residual
|
||||
else compute_12_1_momentum(closes)
|
||||
)
|
||||
if value is not None:
|
||||
values[ticker.symbol] = value
|
||||
|
||||
ranked = sorted(momentum, key=lambda s: momentum[s])
|
||||
ranked = sorted(values, key=lambda s: values[s])
|
||||
n = len(ranked)
|
||||
percentiles = {
|
||||
sym: round((rank / (n - 1) * 100.0) if n > 1 else 100.0, 2)
|
||||
for rank, sym in enumerate(ranked)
|
||||
}
|
||||
logger.info(json.dumps({"event": "momentum_ranked", "tickers": n}))
|
||||
logger.info(json.dumps({
|
||||
"event": "momentum_ranked",
|
||||
"signal": "residual_12_1" if using_residual else "raw_12_1_fallback",
|
||||
"tickers": n,
|
||||
}))
|
||||
return percentiles
|
||||
|
||||
@@ -16,7 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -34,6 +34,13 @@ OUTCOME_EXPIRED = "expired"
|
||||
|
||||
DEFAULT_MAX_BARS = 30
|
||||
|
||||
# A setup's outcome is only unbiased once its full evaluation window has elapsed:
|
||||
# until then, near stops resolve as losses within days while far targets are still
|
||||
# pending, so a young sample skews sharply negative. Only count setups detected at
|
||||
# least this many CALENDAR days ago (~max_bars trading days, ×1.5 to cover
|
||||
# weekends/holidays). Younger setups are reported separately as "maturing".
|
||||
_MATURITY_DAYS = int(DEFAULT_MAX_BARS * 1.5)
|
||||
|
||||
# Confidence buckets for the performance breakdown
|
||||
_CONFIDENCE_BUCKETS = [
|
||||
("<50%", 0.0, 50.0),
|
||||
@@ -183,7 +190,12 @@ async def get_performance_stats(
|
||||
db: AsyncSession,
|
||||
config: dict | None = None,
|
||||
) -> dict:
|
||||
"""Aggregate outcome statistics over all evaluated trade setups.
|
||||
"""Aggregate outcome statistics over the *matured* evaluated trade setups.
|
||||
|
||||
Only setups whose full evaluation window has elapsed (see ``_MATURITY_DAYS``)
|
||||
are counted, so the headline isn't dominated by quick stop-outs while slower
|
||||
winners are still in flight. ``maturing`` reports how many are excluded for
|
||||
being too young.
|
||||
|
||||
avg_r is the expectancy per trade in R-multiples (win = +rr_ratio,
|
||||
loss = -1R, expired = 0R). A positive avg_r means the signals have
|
||||
@@ -197,13 +209,23 @@ async def get_performance_stats(
|
||||
result = await db.execute(
|
||||
select(TradeSetup).where(TradeSetup.actual_outcome.is_not(None))
|
||||
)
|
||||
evaluated = list(result.scalars().all())
|
||||
evaluated_all = list(result.scalars().all())
|
||||
|
||||
# Matured cohort only — see _MATURITY_DAYS. Setups whose window hasn't fully
|
||||
# elapsed are excluded so quick stop-outs can't drag the headline negative
|
||||
# while their slower-to-resolve winners are still in flight.
|
||||
cutoff_date = (datetime.now(timezone.utc) - timedelta(days=_MATURITY_DAYS)).date()
|
||||
evaluated = [s for s in evaluated_all if s.detected_at.date() <= cutoff_date]
|
||||
|
||||
pending_result = await db.execute(
|
||||
select(TradeSetup.id).where(TradeSetup.actual_outcome.is_(None))
|
||||
)
|
||||
pending_count = len(pending_result.scalars().all())
|
||||
|
||||
# Still inside their measurement window (excluded above so they can't bias the
|
||||
# stats): young setups that already resolved + everything still pending.
|
||||
maturing_count = (len(evaluated_all) - len(evaluated)) + pending_count
|
||||
|
||||
if config is not None:
|
||||
qualified = [s for s in evaluated if setup_qualifies(s, config)]
|
||||
else:
|
||||
@@ -229,6 +251,7 @@ async def get_performance_stats(
|
||||
return {
|
||||
"overall": _bucket_stats(qualified),
|
||||
"pending": pending_count,
|
||||
"maturing": maturing_count,
|
||||
"by_direction": {k: _bucket_stats(v) for k, v in sorted(by_direction.items())},
|
||||
"by_action": {k: _bucket_stats(v) for k, v in sorted(by_action.items())},
|
||||
"by_confidence": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -11,6 +11,7 @@ from app.exceptions import NotFoundError, ValidationError
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import benchmark_service, settings_store
|
||||
from app.services.outcome_service import (
|
||||
OUTCOME_AMBIGUOUS,
|
||||
OUTCOME_STOP_HIT,
|
||||
@@ -19,6 +20,66 @@ from app.services.outcome_service import (
|
||||
evaluate_setup_against_bars,
|
||||
)
|
||||
|
||||
# Exit policy for OPEN paper trades (auto-close). "time" holds a fixed number of
|
||||
# trading days with the initial stop and exits at that day's close — the exit the
|
||||
# July 2026 backtest validated (the classic momentum hold-and-re-rank); "trailing"
|
||||
# rides a trailing stop; "target" closes at the setup's stop/target. Stored in
|
||||
# SystemSetting so it's tunable + transparent in the UI.
|
||||
KEY_EXIT_MODE = "paper_exit_mode"
|
||||
KEY_TRAILING_PCT = "paper_trailing_pct"
|
||||
KEY_HOLD_DAYS = "paper_hold_days"
|
||||
DEFAULT_EXIT_MODE = "time"
|
||||
DEFAULT_TRAILING_PCT = 12.0
|
||||
DEFAULT_HOLD_DAYS = 30
|
||||
|
||||
_VALID_EXIT_MODES = ("time", "trailing", "target")
|
||||
|
||||
|
||||
async def get_exit_policy(db: AsyncSession) -> dict:
|
||||
"""Active auto-exit policy:
|
||||
{'mode': 'time'|'trailing'|'target', 'trailing_pct': float, 'hold_days': int}."""
|
||||
mode = (await settings_store.get_value(db, KEY_EXIT_MODE, DEFAULT_EXIT_MODE)).strip().lower()
|
||||
if mode not in _VALID_EXIT_MODES:
|
||||
mode = DEFAULT_EXIT_MODE
|
||||
raw = await settings_store.get_value(db, KEY_TRAILING_PCT, str(DEFAULT_TRAILING_PCT))
|
||||
try:
|
||||
pct = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
pct = DEFAULT_TRAILING_PCT
|
||||
pct = max(0.5, min(90.0, pct))
|
||||
raw_days = await settings_store.get_value(db, KEY_HOLD_DAYS, str(DEFAULT_HOLD_DAYS))
|
||||
try:
|
||||
hold_days = int(float(raw_days))
|
||||
except (TypeError, ValueError):
|
||||
hold_days = DEFAULT_HOLD_DAYS
|
||||
hold_days = max(2, min(250, hold_days))
|
||||
return {"mode": mode, "trailing_pct": pct, "hold_days": hold_days}
|
||||
|
||||
|
||||
async def set_exit_policy(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
mode: str | None = None,
|
||||
trailing_pct: float | None = None,
|
||||
hold_days: int | None = None,
|
||||
) -> dict:
|
||||
"""Persist the auto-exit policy (admin). Validates inputs."""
|
||||
if mode is not None:
|
||||
mode = mode.strip().lower()
|
||||
if mode not in _VALID_EXIT_MODES:
|
||||
raise ValidationError("mode must be 'time', 'trailing' or 'target'")
|
||||
await settings_store.upsert_setting(db, KEY_EXIT_MODE, mode)
|
||||
if trailing_pct is not None:
|
||||
if not 0.5 <= float(trailing_pct) <= 90.0:
|
||||
raise ValidationError("trailing_pct must be between 0.5 and 90")
|
||||
await settings_store.upsert_setting(db, KEY_TRAILING_PCT, str(float(trailing_pct)))
|
||||
if hold_days is not None:
|
||||
if not 2 <= int(hold_days) <= 250:
|
||||
raise ValidationError("hold_days must be between 2 and 250")
|
||||
await settings_store.upsert_setting(db, KEY_HOLD_DAYS, str(int(hold_days)))
|
||||
await db.commit()
|
||||
return await get_exit_policy(db)
|
||||
|
||||
|
||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||
normalised = symbol.strip().upper()
|
||||
@@ -50,6 +111,58 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
|
||||
return {tid: float(close) for tid, close in result.all()}
|
||||
|
||||
|
||||
async def _max_high_after(db: AsyncSession, ticker_id: int, since: date) -> float | None:
|
||||
"""Highest high strictly after ``since`` — the running peak for a trailing stop."""
|
||||
result = await db.execute(
|
||||
select(func.max(OHLCVRecord.high)).where(
|
||||
OHLCVRecord.ticker_id == ticker_id, OHLCVRecord.date > since
|
||||
)
|
||||
)
|
||||
v = result.scalar()
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
def _time_close(
|
||||
direction: str, init_stop: float, hold_days: int, rows: list[tuple]
|
||||
) -> tuple[float, date, str] | None:
|
||||
"""Walk post-entry ``rows`` of (date, open, high, low, close); close at the
|
||||
initial stop if hit (a gap through it fills at the open, matching the
|
||||
backtest's fill model), else at the ``hold_days``-th bar's close ('time').
|
||||
None while neither has happened."""
|
||||
long = direction == "long"
|
||||
for i, (d, open_, high, low, close) in enumerate(rows):
|
||||
if (low <= init_stop) if long else (high >= init_stop):
|
||||
fill = min(init_stop, open_) if long else max(init_stop, open_)
|
||||
return float(fill), d, "stop"
|
||||
if i + 1 >= hold_days:
|
||||
return float(close), d, "time"
|
||||
return None
|
||||
|
||||
|
||||
def _trailing_close(
|
||||
direction: str, entry: float, init_stop: float, trail_frac: float, bars: list[Bar]
|
||||
) -> tuple[float, date, str] | None:
|
||||
"""Walk post-entry bars; return (price, date, reason) when the trailing or initial
|
||||
stop is hit, else None. The stop only ratchets up: max(init_stop, peak*(1-trail))
|
||||
for a long. reason = 'trailing' once it's above the initial stop, else 'stop'."""
|
||||
long = direction == "long"
|
||||
peak = entry
|
||||
for b in bars:
|
||||
if long:
|
||||
level = max(init_stop, peak * (1 - trail_frac))
|
||||
if b.low <= level:
|
||||
return level, b.date, ("trailing" if level > init_stop else "stop")
|
||||
if b.high > peak:
|
||||
peak = b.high
|
||||
else:
|
||||
level = min(init_stop, peak * (1 + trail_frac))
|
||||
if b.high >= level:
|
||||
return level, b.date, ("trailing" if level < init_stop else "stop")
|
||||
if b.low < peak:
|
||||
peak = b.low
|
||||
return None
|
||||
|
||||
|
||||
async def create_trade(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
@@ -85,7 +198,35 @@ async def create_trade(
|
||||
return trade
|
||||
|
||||
|
||||
def _to_dict(trade: PaperTrade, symbol: str, current_price: float | None) -> dict:
|
||||
def _to_dict(
|
||||
trade: PaperTrade,
|
||||
symbol: str,
|
||||
current_price: float | None,
|
||||
benchmark_closes: dict[date, float] | None = None,
|
||||
trailing: tuple[float, float | None] | None = None,
|
||||
) -> dict:
|
||||
# For open trades, mark to market; for closed, the realized exit price.
|
||||
ref = current_price if trade.status == "open" else trade.close_price
|
||||
|
||||
# Alpha = trade return − benchmark (SPY) return over the same holding period.
|
||||
benchmark_return = None
|
||||
alpha_pct = None
|
||||
alpha_usd = None
|
||||
if ref is not None and trade.entry_price and benchmark_closes:
|
||||
sign = 1.0 if trade.direction == "long" else -1.0
|
||||
trade_return = (ref - trade.entry_price) / trade.entry_price * 100.0 * sign
|
||||
as_of = (
|
||||
trade.closed_at.date()
|
||||
if trade.status == "closed" and trade.closed_at is not None
|
||||
else date.today()
|
||||
)
|
||||
benchmark_return = benchmark_service.benchmark_return_pct(
|
||||
benchmark_closes, trade.opened_at.date(), as_of
|
||||
)
|
||||
if benchmark_return is not None:
|
||||
alpha_pct = trade_return - benchmark_return
|
||||
alpha_usd = alpha_pct / 100.0 * trade.entry_price * trade.shares
|
||||
|
||||
return {
|
||||
"id": trade.id,
|
||||
"symbol": symbol,
|
||||
@@ -98,21 +239,27 @@ def _to_dict(trade: PaperTrade, symbol: str, current_price: float | None) -> dic
|
||||
"opened_at": trade.opened_at,
|
||||
"close_price": trade.close_price,
|
||||
"closed_at": trade.closed_at,
|
||||
# For open trades, mark to market; for closed, the realized exit price.
|
||||
"current_price": current_price if trade.status == "open" else trade.close_price,
|
||||
"current_price": ref,
|
||||
"benchmark_return_pct": benchmark_return,
|
||||
"alpha_pct": alpha_pct,
|
||||
"alpha_usd": alpha_usd,
|
||||
"close_reason": trade.close_reason,
|
||||
"trailing_stop": trailing[0] if trailing else None,
|
||||
"trailing_distance_pct": trailing[1] if trailing else None,
|
||||
}
|
||||
|
||||
|
||||
async def list_trades(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
user_id: int | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[dict]:
|
||||
stmt = (
|
||||
select(PaperTrade, Ticker.symbol)
|
||||
.join(Ticker, PaperTrade.ticker_id == Ticker.id)
|
||||
.where(PaperTrade.user_id == user_id)
|
||||
)
|
||||
if user_id is not None: # None → all users (single-user app; used by the digest)
|
||||
stmt = stmt.where(PaperTrade.user_id == user_id)
|
||||
if status is not None:
|
||||
stmt = stmt.where(PaperTrade.status == status)
|
||||
stmt = stmt.order_by(PaperTrade.opened_at.desc())
|
||||
@@ -120,7 +267,38 @@ async def list_trades(
|
||||
rows = (await db.execute(stmt)).all()
|
||||
open_ids = {t.ticker_id for t, _ in rows if t.status == "open"}
|
||||
prices = await _latest_closes(db, open_ids)
|
||||
return [_to_dict(t, sym, prices.get(t.ticker_id)) for t, sym in rows]
|
||||
|
||||
# Benchmark closes for alpha — populated by the daily/benchmark job. Empty until
|
||||
# that runs once, in which case alpha is simply left unset (a read path never
|
||||
# makes a provider call).
|
||||
benchmark_closes = await benchmark_service.load_benchmark_closes(db)
|
||||
|
||||
# Current trailing-stop level + distance for open trades (when trailing is active).
|
||||
policy = await get_exit_policy(db)
|
||||
trailing_info: dict[int, tuple[float, float | None]] = {}
|
||||
if policy["mode"] == "trailing":
|
||||
trail_frac = policy["trailing_pct"] / 100.0
|
||||
for t, _ in rows:
|
||||
if t.status != "open":
|
||||
continue
|
||||
max_high = await _max_high_after(db, t.ticker_id, t.opened_at.date())
|
||||
peak = max(t.entry_price, max_high) if max_high is not None else t.entry_price
|
||||
long = t.direction == "long"
|
||||
level = (
|
||||
max(t.stop_loss, peak * (1 - trail_frac))
|
||||
if long
|
||||
else min(t.stop_loss, peak * (1 + trail_frac))
|
||||
)
|
||||
cur = prices.get(t.ticker_id)
|
||||
dist = None
|
||||
if cur:
|
||||
dist = ((cur - level) / cur * 100.0) if long else ((level - cur) / cur * 100.0)
|
||||
trailing_info[t.id] = (level, dist)
|
||||
|
||||
return [
|
||||
_to_dict(t, sym, prices.get(t.ticker_id), benchmark_closes, trailing_info.get(t.id))
|
||||
for t, sym in rows
|
||||
]
|
||||
|
||||
|
||||
async def close_trade(
|
||||
@@ -149,6 +327,7 @@ async def close_trade(
|
||||
|
||||
trade.status = "closed"
|
||||
trade.close_price = float(close_price)
|
||||
trade.close_reason = "manual"
|
||||
trade.closed_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(trade)
|
||||
@@ -156,47 +335,67 @@ async def close_trade(
|
||||
|
||||
|
||||
async def resolve_open_trades(db: AsyncSession) -> int:
|
||||
"""Auto-close open trades whose stop or target was hit in the daily bars.
|
||||
"""Auto-close open trades per the active exit policy, from the daily bars.
|
||||
|
||||
Walks the bars after each trade's open (same logic as the outcome evaluator).
|
||||
Target hit → close at the target; stop (or an ambiguous same-bar touch) →
|
||||
close at the stop. Trades that have hit neither stay open. Returns the count
|
||||
closed.
|
||||
Walks the bars after each trade's open. 'time' closes at the initial stop or
|
||||
the hold_days-th close; 'trailing' at the trailing/initial stop; 'target' at
|
||||
the setup's target or stop (same logic as the outcome evaluator). Trades that
|
||||
have hit nothing stay open. Returns the count closed.
|
||||
"""
|
||||
result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open"))
|
||||
open_trades = list(result.scalars().all())
|
||||
if not open_trades:
|
||||
return 0
|
||||
|
||||
policy = await get_exit_policy(db)
|
||||
mode = policy["mode"]
|
||||
trail_frac = policy["trailing_pct"] / 100.0
|
||||
hold_days = policy["hold_days"]
|
||||
|
||||
closed = 0
|
||||
for trade in open_trades:
|
||||
bars_result = await db.execute(
|
||||
select(OHLCVRecord.date, OHLCVRecord.high, OHLCVRecord.low)
|
||||
select(
|
||||
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
|
||||
OHLCVRecord.low, OHLCVRecord.close,
|
||||
)
|
||||
.where(
|
||||
OHLCVRecord.ticker_id == trade.ticker_id,
|
||||
OHLCVRecord.date > trade.opened_at.date(),
|
||||
)
|
||||
.order_by(OHLCVRecord.date.asc())
|
||||
)
|
||||
bars = [Bar(date=d, high=h, low=lo) for d, h, lo in bars_result.all()]
|
||||
rows = bars_result.all()
|
||||
bars = [Bar(date=d, high=h, low=lo) for d, _, h, lo, _ in rows]
|
||||
if not bars:
|
||||
continue
|
||||
|
||||
if mode == "time":
|
||||
hit = _time_close(trade.direction, trade.stop_loss, hold_days, rows)
|
||||
if hit is None:
|
||||
continue # neither the stop nor the hold horizon reached yet
|
||||
close_price, close_date, reason = hit
|
||||
elif mode == "trailing":
|
||||
hit = _trailing_close(trade.direction, trade.entry_price, trade.stop_loss, trail_frac, bars)
|
||||
if hit is None:
|
||||
continue # neither the trailing nor the initial stop reached yet
|
||||
close_price, close_date, reason = hit
|
||||
else:
|
||||
# max_bars beyond the data so a still-open trade returns undecided (not "expired").
|
||||
outcome, outcome_date = evaluate_setup_against_bars(
|
||||
trade.direction, trade.stop_loss, trade.target, bars, max_bars=len(bars) + 1
|
||||
)
|
||||
if outcome == OUTCOME_TARGET_HIT:
|
||||
trade.close_price = trade.target
|
||||
close_price, close_date, reason = trade.target, outcome_date, "target"
|
||||
elif outcome in (OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS):
|
||||
trade.close_price = trade.stop_loss
|
||||
close_price, close_date, reason = trade.stop_loss, outcome_date, "stop"
|
||||
else:
|
||||
continue
|
||||
|
||||
trade.status = "closed"
|
||||
trade.closed_at = datetime.combine(
|
||||
outcome_date, datetime.min.time(), tzinfo=timezone.utc
|
||||
)
|
||||
trade.close_price = float(close_price)
|
||||
trade.close_reason = reason
|
||||
trade.closed_at = datetime.combine(close_date, datetime.min.time(), tzinfo=timezone.utc)
|
||||
closed += 1
|
||||
|
||||
if closed:
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
A single predicate, driven by the admin activation config, used by the
|
||||
performance stats (server) and mirrored on the frontend. The core selection is
|
||||
cross-sectional momentum: a setup's ticker must rank in the top
|
||||
``min_momentum_percentile`` of the universe by 12-1 month momentum — the one
|
||||
signal the backtest showed actually sorts forward returns. R:R and confidence
|
||||
remain as floors, and conviction/conflict survive as optional tighteners (off by
|
||||
default). The momentum percentile is computed across the universe and attached to
|
||||
each setup upstream; when it's absent the gate falls back to the floors.
|
||||
residual cross-sectional momentum: a setup's ticker must rank in the top
|
||||
``min_momentum_percentile`` of the universe by beta-adjusted 12-1 month momentum.
|
||||
R:R and confidence remain as floors, and conviction/conflict survive as optional
|
||||
tighteners (off by default). The activation percentile is computed across the
|
||||
universe and attached to each setup upstream; when it's absent the gate falls
|
||||
back to the floors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,6 +17,16 @@ from typing import Any
|
||||
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
|
||||
|
||||
|
||||
def _action_direction(action: str | None) -> str:
|
||||
if not action or action == "NEUTRAL":
|
||||
return "neutral"
|
||||
if action.startswith("LONG"):
|
||||
return "long"
|
||||
if action.startswith("SHORT"):
|
||||
return "short"
|
||||
return "neutral"
|
||||
|
||||
|
||||
def best_target_probability(setup: Any) -> float:
|
||||
"""Highest probability among a setup's targets, 0 if none."""
|
||||
targets = getattr(setup, "targets", None) or []
|
||||
@@ -65,12 +75,12 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
|
||||
return False
|
||||
if (setup.confidence_score or 0.0) < config["min_confidence"]:
|
||||
return False
|
||||
# Cross-sectional momentum: the core selection. A setup's ticker must rank in
|
||||
# the top ``min_momentum_percentile`` of the universe by 12-1 momentum. The
|
||||
# validated edge is long-only, so while the gate is active shorts (which fight
|
||||
# the trend) never qualify. The percentile floor is only enforced when a
|
||||
# percentile is attached (live setups / backtest); callers that don't attach
|
||||
# it defer to the floors above.
|
||||
# Residual cross-sectional momentum: the core selection. A setup's ticker
|
||||
# must rank in the top ``min_momentum_percentile`` of the universe by
|
||||
# beta-adjusted 12-1 momentum. The validated edge is long-only, so while the
|
||||
# gate is active shorts (which fight the trend) never qualify. The percentile
|
||||
# floor is only enforced when a percentile is attached (live setups /
|
||||
# backtest); callers that don't attach it defer to the floors above.
|
||||
min_pct = float(config.get("min_momentum_percentile", 0.0))
|
||||
if min_pct > 0:
|
||||
if (getattr(setup, "direction", "long") or "long") == "short":
|
||||
@@ -78,6 +88,15 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
|
||||
momentum_percentile = getattr(setup, "momentum_percentile", None)
|
||||
if momentum_percentile is not None and momentum_percentile < min_pct:
|
||||
return False
|
||||
# A setup is actionable only when the live ticker action points in the same
|
||||
# direction. NEUTRAL means no clear signal; an opposite action means the
|
||||
# setup is counter-bias. ``exclude_neutral`` defaults on; callers that omit
|
||||
# it keep legacy floor-only behavior.
|
||||
if config.get("exclude_neutral"):
|
||||
action_direction = _action_direction(getattr(setup, "recommended_action", None))
|
||||
setup_direction = (getattr(setup, "direction", "long") or "long").lower()
|
||||
if action_direction == "neutral" or action_direction != setup_direction:
|
||||
return False
|
||||
if config.get("require_high_conviction"):
|
||||
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
|
||||
return False
|
||||
|
||||
@@ -524,6 +524,56 @@ def _build_reasoning(
|
||||
)
|
||||
|
||||
|
||||
def build_recommendation_snapshot(
|
||||
dimension_scores: dict[str, float],
|
||||
sentiment_classification: str | None,
|
||||
config: dict[str, float],
|
||||
available_directions: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the ticker-level recommendation from the supplied live context."""
|
||||
conflicts = signal_conflict_detector.detect_conflicts(
|
||||
dimension_scores=dimension_scores,
|
||||
sentiment_classification=sentiment_classification,
|
||||
config=config,
|
||||
)
|
||||
|
||||
long_confidence = direction_analyzer.calculate_confidence(
|
||||
direction="long",
|
||||
dimension_scores=dimension_scores,
|
||||
sentiment_classification=sentiment_classification,
|
||||
conflicts=conflicts,
|
||||
)
|
||||
short_confidence = direction_analyzer.calculate_confidence(
|
||||
direction="short",
|
||||
dimension_scores=dimension_scores,
|
||||
sentiment_classification=sentiment_classification,
|
||||
conflicts=conflicts,
|
||||
)
|
||||
|
||||
action = _choose_recommended_action(
|
||||
long_confidence, short_confidence, config, available_directions
|
||||
)
|
||||
reasoning = _build_reasoning(
|
||||
action=action,
|
||||
long_confidence=long_confidence,
|
||||
short_confidence=short_confidence,
|
||||
conflicts=conflicts,
|
||||
dimension_scores=dimension_scores,
|
||||
sentiment_classification=sentiment_classification,
|
||||
config=config,
|
||||
available_directions=available_directions,
|
||||
)
|
||||
|
||||
return {
|
||||
"action": action,
|
||||
"reasoning": reasoning,
|
||||
"risk_level": _risk_level_from_conflicts(conflicts),
|
||||
"long_confidence": long_confidence,
|
||||
"short_confidence": short_confidence,
|
||||
"conflicts": conflicts,
|
||||
}
|
||||
|
||||
|
||||
PRIMARY_TARGET_MIN_RR = 1.5
|
||||
|
||||
|
||||
@@ -559,24 +609,15 @@ async def enhance_trade_setup(
|
||||
) -> TradeSetup:
|
||||
config = await get_recommendation_config(db)
|
||||
|
||||
conflicts = signal_conflict_detector.detect_conflicts(
|
||||
snapshot = build_recommendation_snapshot(
|
||||
dimension_scores=dimension_scores,
|
||||
sentiment_classification=sentiment_classification,
|
||||
config=config,
|
||||
available_directions=available_directions,
|
||||
)
|
||||
|
||||
long_confidence = direction_analyzer.calculate_confidence(
|
||||
direction="long",
|
||||
dimension_scores=dimension_scores,
|
||||
sentiment_classification=sentiment_classification,
|
||||
conflicts=conflicts,
|
||||
)
|
||||
short_confidence = direction_analyzer.calculate_confidence(
|
||||
direction="short",
|
||||
dimension_scores=dimension_scores,
|
||||
sentiment_classification=sentiment_classification,
|
||||
conflicts=conflicts,
|
||||
)
|
||||
conflicts = list(snapshot["conflicts"])
|
||||
long_confidence = float(snapshot["long_confidence"])
|
||||
short_confidence = float(snapshot["short_confidence"])
|
||||
|
||||
direction = setup.direction.lower()
|
||||
confidence = long_confidence if direction == "long" else short_confidence
|
||||
@@ -622,19 +663,8 @@ async def enhance_trade_setup(
|
||||
|
||||
# Action and reasoning are ticker-level: they consider both directions and
|
||||
# which directions are actually tradeable, and are identical on every setup.
|
||||
action = _choose_recommended_action(
|
||||
long_confidence, short_confidence, config, available_directions
|
||||
)
|
||||
reasoning = _build_reasoning(
|
||||
action=action,
|
||||
long_confidence=long_confidence,
|
||||
short_confidence=short_confidence,
|
||||
conflicts=conflicts,
|
||||
dimension_scores=dimension_scores,
|
||||
sentiment_classification=sentiment_classification,
|
||||
config=config,
|
||||
available_directions=available_directions,
|
||||
)
|
||||
action = str(snapshot["action"])
|
||||
reasoning = str(snapshot["reasoning"])
|
||||
|
||||
setup.confidence_score = round(confidence, 2)
|
||||
setup.targets_json = json.dumps(targets)
|
||||
|
||||
@@ -38,7 +38,7 @@ from app.config import settings
|
||||
from app.exceptions import ProviderError
|
||||
from app.models.regime_snapshot import RegimeSnapshot
|
||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||
from app.services import settings_store
|
||||
from app.services import breadth_service, settings_store
|
||||
from app.services.admin_service import update_setting
|
||||
from app.services.sentiment_provider_service import _resolve as resolve_llm_config
|
||||
|
||||
@@ -65,6 +65,11 @@ DEFAULT_CONFIG: dict = {
|
||||
"F1": 25, "F2": 15, "F3": 8, "F4": 7,
|
||||
},
|
||||
"alert_threshold": 65,
|
||||
# Observational early-warning blend: a small Combined score = weighted mean of
|
||||
# the coincident index and the breadth-divergence early-warning score. Kept
|
||||
# separate from the index weights above so the early-warning side stays
|
||||
# decoupled until proven. Tunable; need not sum to 1 (normalised).
|
||||
"combined_weights": {"coincident": 0.6, "early_warning": 0.4},
|
||||
"leader_weight": 2.0, # SMH counts 2x vs QQQ where both feed a signal
|
||||
"rs_lookback": 60, # trading days for relative-strength / breadth trend
|
||||
"fundamental_staleness_days": 80,
|
||||
@@ -530,6 +535,21 @@ async def update_regime_monitor(db: AsyncSession, backfill_days: int = 90) -> di
|
||||
leader_series = prices.get(leader or "", [])
|
||||
latest_date = leader_series[-1][0] if leader_series else end
|
||||
|
||||
# Early-warning signal: breadth-divergence over the stored universe (leads but
|
||||
# noisy). Computed once here so the daily job carries it live, as a SEPARATE
|
||||
# score next to the coincident index — not folded into the index weights.
|
||||
# Best-effort: a breadth failure must not stop the index update.
|
||||
try:
|
||||
breadth = await breadth_service.compute_breadth_series(db)
|
||||
divergence = breadth_service.compute_divergence_series(breadth, sorted(leader_series))
|
||||
except Exception as exc:
|
||||
logger.warning("Regime monitor: breadth/divergence skipped: %s", exc)
|
||||
divergence = {}
|
||||
# As-of lookup: the stored universe (breadth) can lag the live benchmark date
|
||||
# by a day or two, so an exact-date match would blank the newest snapshot.
|
||||
div_items = sorted(divergence.items())
|
||||
cw = config.get("combined_weights") or {"coincident": 0.6, "early_warning": 0.4}
|
||||
|
||||
dates = {latest_date}
|
||||
if await _snapshot_count(db) < 5 and leader_series:
|
||||
cutoff = end - timedelta(days=backfill_days)
|
||||
@@ -538,8 +558,26 @@ async def update_regime_monitor(db: AsyncSession, backfill_days: int = 90) -> di
|
||||
latest_result: dict | None = None
|
||||
for d in sorted(dates):
|
||||
result = _compute_index(prices, vix_series, oas_series, overrides, config, d)
|
||||
_attach_early_warning(result, _divergence_asof(div_items, d), cw)
|
||||
await _upsert_snapshot(db, result)
|
||||
latest_result = result
|
||||
|
||||
# Backfill early-warning + combined onto recent existing snapshots (e.g. the
|
||||
# index history written before this signal existed) so their 7/30-day trends
|
||||
# populate immediately rather than only filling in over the coming weeks.
|
||||
if div_items:
|
||||
recent = await db.execute(
|
||||
select(RegimeSnapshot).where(RegimeSnapshot.date >= end - timedelta(days=120))
|
||||
)
|
||||
for row in recent.scalars().all():
|
||||
try:
|
||||
res = json.loads(row.breakdown_json)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if (res.get("early_warning") or {}).get("score") is not None:
|
||||
continue
|
||||
_attach_early_warning(res, _divergence_asof(div_items, row.date), cw)
|
||||
row.breakdown_json = json.dumps(res)
|
||||
await db.commit()
|
||||
|
||||
logger.info(json.dumps({
|
||||
@@ -551,19 +589,65 @@ async def update_regime_monitor(db: AsyncSession, backfill_days: int = 90) -> di
|
||||
return latest_result or {"available": False, "reason": "no data"}
|
||||
|
||||
|
||||
async def _score_at_or_before(db: AsyncSession, target: date) -> float | None:
|
||||
def _divergence_asof(div_items: list[tuple[date, float]], as_of: date, max_lag_days: int = 7) -> float | None:
|
||||
"""Latest divergence value on/before ``as_of``, tolerating a small data lag
|
||||
between the live benchmark and the stored universe. None if too stale/absent."""
|
||||
chosen: tuple[date, float] | None = None
|
||||
for d, v in div_items:
|
||||
if d <= as_of:
|
||||
chosen = (d, v)
|
||||
else:
|
||||
break
|
||||
if chosen is None or (as_of - chosen[0]).days > max_lag_days:
|
||||
return None
|
||||
return chosen[1]
|
||||
|
||||
|
||||
def _attach_early_warning(result: dict, ew: float | None, weights: dict) -> None:
|
||||
"""Attach the separate early-warning score and a combined blend to a snapshot.
|
||||
|
||||
``ew`` is the breadth-divergence value as-of this date (or None). The combined
|
||||
score is a normalised weighted mean of the coincident index and the early
|
||||
warning — observational, kept apart from the index itself.
|
||||
"""
|
||||
result["early_warning"] = {
|
||||
"score": round(ew, 1) if ew is not None else None,
|
||||
"band": band_for(ew) if ew is not None else None,
|
||||
}
|
||||
if ew is None:
|
||||
combined = result["total_score"]
|
||||
else:
|
||||
wc = float(weights.get("coincident", 0.6))
|
||||
we = float(weights.get("early_warning", 0.4))
|
||||
wsum = (wc + we) or 1.0
|
||||
combined = (result["total_score"] * wc + ew * we) / wsum
|
||||
result["combined"] = {"score": round(combined, 1), "band": band_for(combined)}
|
||||
|
||||
|
||||
async def _result_at_or_before(db: AsyncSession, target: date) -> dict | None:
|
||||
"""Parsed snapshot result for the latest date on/before ``target``."""
|
||||
res = await db.execute(
|
||||
select(RegimeSnapshot.total_score)
|
||||
select(RegimeSnapshot.breakdown_json)
|
||||
.where(RegimeSnapshot.date <= target)
|
||||
.order_by(RegimeSnapshot.date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
val = res.scalar_one_or_none()
|
||||
return float(val) if val is not None else None
|
||||
raw = res.scalar_one_or_none()
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _delta(curr: float | None, prev: float | None) -> float | None:
|
||||
return round(curr - prev, 1) if (curr is not None and prev is not None) else None
|
||||
|
||||
|
||||
async def get_regime_monitor(db: AsyncSession) -> dict:
|
||||
"""Latest snapshot result + 7/30-day trend deltas. Cheap (one+ row reads)."""
|
||||
"""Latest snapshot + 7/30-day trend deltas for the index, early-warning, and
|
||||
combined scores. Cheap (a few row reads)."""
|
||||
res = await db.execute(
|
||||
select(RegimeSnapshot).order_by(RegimeSnapshot.date.desc()).limit(1)
|
||||
)
|
||||
@@ -577,16 +661,50 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
|
||||
result = {"date": latest.date.isoformat(), "total_score": latest.total_score,
|
||||
"band": latest.band, "breakdown": []}
|
||||
|
||||
score_7 = await _score_at_or_before(db, latest.date - timedelta(days=7))
|
||||
score_30 = await _score_at_or_before(db, latest.date - timedelta(days=30))
|
||||
r7 = await _result_at_or_before(db, latest.date - timedelta(days=7))
|
||||
r30 = await _result_at_or_before(db, latest.date - timedelta(days=30))
|
||||
|
||||
def _nested(r: dict | None, key: str) -> float | None:
|
||||
return (r.get(key) or {}).get("score") if r else None
|
||||
|
||||
result["available"] = True
|
||||
cur_total = result.get("total_score")
|
||||
result["trend"] = {
|
||||
"delta_7": round(latest.total_score - score_7, 1) if score_7 is not None else None,
|
||||
"delta_30": round(latest.total_score - score_30, 1) if score_30 is not None else None,
|
||||
"delta_7": _delta(cur_total, (r7 or {}).get("total_score")),
|
||||
"delta_30": _delta(cur_total, (r30 or {}).get("total_score")),
|
||||
}
|
||||
for key in ("early_warning", "combined"):
|
||||
block = result.get(key) or {"score": None, "band": None}
|
||||
block["delta_7"] = _delta(block.get("score"), _nested(r7, key))
|
||||
block["delta_30"] = _delta(block.get("score"), _nested(r30, key))
|
||||
result[key] = block
|
||||
return result
|
||||
|
||||
|
||||
async def get_regime_history(db: AsyncSession, days: int = 400) -> list[dict]:
|
||||
"""Daily history of the index, early-warning, and combined scores for the
|
||||
score-over-time chart. One point per snapshot date, ascending."""
|
||||
cutoff = date.today() - timedelta(days=days)
|
||||
res = await db.execute(
|
||||
select(RegimeSnapshot)
|
||||
.where(RegimeSnapshot.date >= cutoff)
|
||||
.order_by(RegimeSnapshot.date.asc())
|
||||
)
|
||||
out: list[dict] = []
|
||||
for row in res.scalars().all():
|
||||
try:
|
||||
data = json.loads(row.breakdown_json)
|
||||
except (TypeError, ValueError):
|
||||
data = {}
|
||||
out.append({
|
||||
"date": row.date.isoformat(),
|
||||
"index": row.total_score,
|
||||
"early_warning": (data.get("early_warning") or {}).get("score"),
|
||||
"combined": (data.get("combined") or {}).get("score"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F1/F3 via grounded LLM (reuses the configured sentiment provider)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,24 +11,33 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.exceptions import NotFoundError
|
||||
from app.models.fundamental import FundamentalData
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.score import CompositeScore, DimensionScore
|
||||
from app.models.sentiment import SentimentScore
|
||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||
from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.trade_setup import TradeSetup
|
||||
from app.services.indicator_service import _extract_ohlcv, compute_atr
|
||||
from app.services.price_service import query_ohlcv
|
||||
from app.services.recommendation_service import enhance_trade_setup
|
||||
from app.services.recommendation_service import (
|
||||
_risk_level_from_conflicts,
|
||||
build_recommendation_snapshot,
|
||||
enhance_trade_setup,
|
||||
get_recommendation_config,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STRATEGY_VERSION = "residual_momentum_12_1_rr_time_v2"
|
||||
|
||||
|
||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||
normalised = symbol.strip().upper()
|
||||
@@ -76,6 +85,263 @@ async def _get_latest_sentiment(db: AsyncSession, ticker_id: int) -> str | None:
|
||||
return row.classification if row else None
|
||||
|
||||
|
||||
async def _apply_live_recommendation_context(
|
||||
db: AsyncSession,
|
||||
setup_rows: list[tuple[TradeSetup, str]],
|
||||
rows: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Decorate latest setup rows with current score/sentiment recommendation data.
|
||||
|
||||
This intentionally updates only the API payload. Stored trade setups and
|
||||
history remain point-in-time records for outcome analysis.
|
||||
"""
|
||||
if not rows or not setup_rows:
|
||||
return rows
|
||||
|
||||
ticker_ids = {setup.ticker_id for setup, _ in setup_rows}
|
||||
setups_by_id = {setup.id: setup for setup, _ in setup_rows}
|
||||
directions_by_ticker = await _latest_available_directions_by_ticker(db, ticker_ids)
|
||||
|
||||
dim_result = await db.execute(
|
||||
select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids))
|
||||
)
|
||||
dims_by_ticker: dict[int, dict[str, float]] = {}
|
||||
for ds in dim_result.scalars().all():
|
||||
dims_by_ticker.setdefault(ds.ticker_id, {})[ds.dimension] = float(ds.score)
|
||||
|
||||
comp_result = await db.execute(
|
||||
select(CompositeScore)
|
||||
.where(CompositeScore.ticker_id.in_(ticker_ids))
|
||||
.order_by(CompositeScore.ticker_id, CompositeScore.computed_at.desc())
|
||||
)
|
||||
composites: dict[int, CompositeScore] = {}
|
||||
for comp in comp_result.scalars().all():
|
||||
composites.setdefault(comp.ticker_id, comp)
|
||||
|
||||
sent_result = await db.execute(
|
||||
select(SentimentScore)
|
||||
.where(SentimentScore.ticker_id.in_(ticker_ids))
|
||||
.order_by(SentimentScore.ticker_id, SentimentScore.timestamp.desc())
|
||||
)
|
||||
sentiments: dict[int, SentimentScore] = {}
|
||||
for sent in sent_result.scalars().all():
|
||||
sentiments.setdefault(sent.ticker_id, sent)
|
||||
|
||||
config = await get_recommendation_config(db)
|
||||
live_rows: list[dict] = []
|
||||
for row in rows:
|
||||
setup = setups_by_id.get(row["id"])
|
||||
if setup is None:
|
||||
live_rows.append(row)
|
||||
continue
|
||||
|
||||
ticker_id = setup.ticker_id
|
||||
live_row = dict(row)
|
||||
|
||||
comp = composites.get(ticker_id)
|
||||
if comp is not None:
|
||||
live_row["composite_score"] = float(comp.score)
|
||||
live_row["context_as_of"]["score_computed_at"] = comp.computed_at
|
||||
|
||||
dimension_scores = dims_by_ticker.get(ticker_id)
|
||||
sentiment = sentiments.get(ticker_id)
|
||||
if sentiment is not None:
|
||||
live_row["context_as_of"]["sentiment_at"] = sentiment.timestamp
|
||||
if dimension_scores:
|
||||
snapshot = build_recommendation_snapshot(
|
||||
dimension_scores=dimension_scores,
|
||||
sentiment_classification=sentiment.classification if sentiment else None,
|
||||
config=config,
|
||||
available_directions=directions_by_ticker.get(ticker_id),
|
||||
)
|
||||
direction = setup.direction.lower()
|
||||
confidence_key = "long_confidence" if direction == "long" else "short_confidence"
|
||||
live_row["confidence_score"] = round(float(snapshot[confidence_key]), 2)
|
||||
live_row["recommended_action"] = snapshot["action"]
|
||||
live_row["reasoning"] = snapshot["reasoning"]
|
||||
setup_conflicts = _setup_specific_conflicts(live_row.get("conflict_flags", []))
|
||||
live_conflicts = [str(item) for item in snapshot["conflicts"]]
|
||||
live_row["conflict_flags"] = live_conflicts + setup_conflicts
|
||||
live_row["risk_level"] = _risk_level_from_conflicts(live_row["conflict_flags"])
|
||||
|
||||
live_rows.append(live_row)
|
||||
|
||||
return live_rows
|
||||
|
||||
|
||||
def _setup_specific_conflicts(conflicts: list[str]) -> list[str]:
|
||||
signal_prefixes = (
|
||||
"sentiment-technical:",
|
||||
"sentiment-momentum:",
|
||||
"momentum-technical:",
|
||||
"fundamental-technical:",
|
||||
)
|
||||
return [
|
||||
str(conflict)
|
||||
for conflict in conflicts
|
||||
if not str(conflict).startswith(signal_prefixes)
|
||||
]
|
||||
|
||||
|
||||
async def _latest_available_directions_by_ticker(
|
||||
db: AsyncSession,
|
||||
ticker_ids: set[int],
|
||||
) -> dict[int, set[str]]:
|
||||
if not ticker_ids:
|
||||
return {}
|
||||
|
||||
result = await db.execute(
|
||||
select(TradeSetup)
|
||||
.where(TradeSetup.ticker_id.in_(ticker_ids))
|
||||
.order_by(
|
||||
TradeSetup.ticker_id,
|
||||
TradeSetup.direction,
|
||||
TradeSetup.detected_at.desc(),
|
||||
TradeSetup.id.desc(),
|
||||
)
|
||||
)
|
||||
latest_by_key: set[tuple[int, str]] = set()
|
||||
directions: dict[int, set[str]] = {}
|
||||
for setup in result.scalars().all():
|
||||
direction = setup.direction.lower()
|
||||
key = (setup.ticker_id, direction)
|
||||
if key in latest_by_key:
|
||||
continue
|
||||
latest_by_key.add(key)
|
||||
directions.setdefault(setup.ticker_id, set()).add(direction)
|
||||
return directions
|
||||
|
||||
|
||||
def _json_default(value):
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
async def _create_signal_context_snapshots(
|
||||
db: AsyncSession,
|
||||
setups: list[TradeSetup],
|
||||
*,
|
||||
strategy_version: str = STRATEGY_VERSION,
|
||||
) -> None:
|
||||
"""Capture point-in-time discretionary context for freshly generated setups.
|
||||
|
||||
The scanner stores the setup itself first so each snapshot can be keyed by
|
||||
``trade_setup_id``. This is intentionally forward-only: old sentiment,
|
||||
fundamentals and composite scores are not reconstructed from today's data.
|
||||
"""
|
||||
if not setups:
|
||||
return
|
||||
|
||||
ticker_ids = {s.ticker_id for s in setups}
|
||||
|
||||
dims: dict[int, dict[str, dict]] = {}
|
||||
dim_rows = (
|
||||
await db.execute(select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids)))
|
||||
).scalars().all()
|
||||
for row in dim_rows:
|
||||
dims.setdefault(row.ticker_id, {})[row.dimension] = {
|
||||
"score": float(row.score),
|
||||
"is_stale": bool(row.is_stale),
|
||||
"computed_at": row.computed_at,
|
||||
}
|
||||
|
||||
composites: dict[int, CompositeScore] = {}
|
||||
comp_rows = (
|
||||
await db.execute(
|
||||
select(CompositeScore)
|
||||
.where(CompositeScore.ticker_id.in_(ticker_ids))
|
||||
.order_by(CompositeScore.ticker_id, CompositeScore.computed_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
for row in comp_rows:
|
||||
composites.setdefault(row.ticker_id, row)
|
||||
|
||||
sentiments: dict[int, SentimentScore] = {}
|
||||
sent_rows = (
|
||||
await db.execute(
|
||||
select(SentimentScore)
|
||||
.where(SentimentScore.ticker_id.in_(ticker_ids))
|
||||
.order_by(SentimentScore.ticker_id, SentimentScore.timestamp.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
for row in sent_rows:
|
||||
sentiments.setdefault(row.ticker_id, row)
|
||||
|
||||
fundamentals: dict[int, FundamentalData] = {}
|
||||
fund_rows = (
|
||||
await db.execute(
|
||||
select(FundamentalData)
|
||||
.where(FundamentalData.ticker_id.in_(ticker_ids))
|
||||
.order_by(FundamentalData.ticker_id, FundamentalData.fetched_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
for row in fund_rows:
|
||||
fundamentals.setdefault(row.ticker_id, row)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for setup in setups:
|
||||
comp = composites.get(setup.ticker_id)
|
||||
sent = sentiments.get(setup.ticker_id)
|
||||
fund = fundamentals.get(setup.ticker_id)
|
||||
score_context = {
|
||||
"composite_score": float(comp.score) if comp else float(setup.composite_score),
|
||||
"composite_is_stale": bool(comp.is_stale) if comp else None,
|
||||
"composite_computed_at": comp.computed_at if comp else None,
|
||||
"dimensions": dims.get(setup.ticker_id, {}),
|
||||
}
|
||||
sentiment_context = (
|
||||
{
|
||||
"classification": sent.classification,
|
||||
"confidence": int(sent.confidence),
|
||||
"recommendation": sent.recommendation,
|
||||
"timestamp": sent.timestamp,
|
||||
"source": sent.source,
|
||||
}
|
||||
if sent
|
||||
else {}
|
||||
)
|
||||
fundamental_context = (
|
||||
{
|
||||
"pe_ratio": fund.pe_ratio,
|
||||
"revenue_growth": fund.revenue_growth,
|
||||
"earnings_surprise": fund.earnings_surprise,
|
||||
"market_cap": fund.market_cap,
|
||||
"next_earnings_date": fund.next_earnings_date,
|
||||
"fetched_at": fund.fetched_at,
|
||||
}
|
||||
if fund
|
||||
else {}
|
||||
)
|
||||
db.add(
|
||||
SignalContextSnapshot(
|
||||
trade_setup_id=setup.id,
|
||||
ticker_id=setup.ticker_id,
|
||||
detected_at=setup.detected_at,
|
||||
created_at=now,
|
||||
strategy_version=strategy_version,
|
||||
direction=setup.direction,
|
||||
entry_price=float(setup.entry_price),
|
||||
stop_loss=float(setup.stop_loss),
|
||||
target=float(setup.target),
|
||||
rr_ratio=float(setup.rr_ratio),
|
||||
confidence_score=(
|
||||
float(setup.confidence_score) if setup.confidence_score is not None else None
|
||||
),
|
||||
recommended_action=setup.recommended_action,
|
||||
risk_level=setup.risk_level,
|
||||
momentum_percentile=(
|
||||
float(setup.momentum_percentile)
|
||||
if setup.momentum_percentile is not None
|
||||
else None
|
||||
),
|
||||
score_context_json=json.dumps(score_context, default=_json_default),
|
||||
sentiment_context_json=json.dumps(sentiment_context, default=_json_default),
|
||||
fundamental_context_json=json.dumps(fundamental_context, default=_json_default),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def scan_ticker(
|
||||
db: AsyncSession,
|
||||
symbol: str,
|
||||
@@ -85,9 +351,9 @@ async def scan_ticker(
|
||||
) -> list[TradeSetup]:
|
||||
"""Scan a single ticker for trade setups meeting the R:R threshold.
|
||||
|
||||
``momentum_percentile`` is the ticker's 12-1 momentum rank across the universe
|
||||
(computed by the caller), stored on each setup so the activation gate can
|
||||
select the top slice."""
|
||||
``momentum_percentile`` is the ticker's residual 12-1 momentum activation
|
||||
rank across the universe (computed by the caller), stored on each setup so
|
||||
the activation gate can select the top slice."""
|
||||
ticker = await _get_ticker(db, symbol)
|
||||
|
||||
records = await query_ohlcv(db, symbol)
|
||||
@@ -238,6 +504,9 @@ async def scan_ticker(
|
||||
for s in enhanced_setups:
|
||||
await db.refresh(s)
|
||||
|
||||
await _create_signal_context_snapshots(db, enhanced_setups)
|
||||
await db.commit()
|
||||
|
||||
return enhanced_setups
|
||||
|
||||
|
||||
@@ -256,8 +525,9 @@ async def scan_all_tickers(
|
||||
tickers = list(result.scalars().all())
|
||||
total = len(tickers)
|
||||
|
||||
# Rank the universe by 12-1 momentum up front so each new setup carries its
|
||||
# ticker's percentile (used by the activation gate). Best-effort.
|
||||
# Rank the universe by residual 12-1 momentum up front so each new setup
|
||||
# carries its activation percentile. Best-effort; the ranker falls back to
|
||||
# raw 12-1 momentum only if benchmark data is unavailable.
|
||||
try:
|
||||
from app.services import momentum_service
|
||||
|
||||
@@ -303,6 +573,7 @@ async def get_trade_setups(
|
||||
min_confidence: float | None = None,
|
||||
recommended_action: str | None = None,
|
||||
symbol: str | None = None,
|
||||
live_recommendation: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Get latest stored trade setups, optionally filtered."""
|
||||
stmt = (
|
||||
@@ -313,9 +584,11 @@ async def get_trade_setups(
|
||||
stmt = stmt.where(TradeSetup.direction == direction.lower())
|
||||
if symbol is not None:
|
||||
stmt = stmt.where(Ticker.symbol == symbol.strip().upper())
|
||||
if min_confidence is not None:
|
||||
# With live_recommendation these fields are overlaid with current values
|
||||
# below, so filtering happens there instead of against the stored columns.
|
||||
if min_confidence is not None and not live_recommendation:
|
||||
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
|
||||
if recommended_action is not None:
|
||||
if recommended_action is not None and not live_recommendation:
|
||||
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
|
||||
|
||||
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
|
||||
@@ -339,15 +612,37 @@ async def get_trade_setups(
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
prices = await _latest_closes(db, {s.ticker_id for s, _ in latest_rows})
|
||||
return [
|
||||
prices = await _latest_price_context(db, {s.ticker_id for s, _ in latest_rows})
|
||||
rows_out = [
|
||||
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
|
||||
for setup, ticker_symbol in latest_rows
|
||||
]
|
||||
if live_recommendation:
|
||||
rows_out = await _apply_live_recommendation_context(db, latest_rows, rows_out)
|
||||
if min_confidence is not None:
|
||||
rows_out = [
|
||||
row for row in rows_out
|
||||
if row["confidence_score"] is not None
|
||||
and row["confidence_score"] >= min_confidence
|
||||
]
|
||||
if recommended_action is not None:
|
||||
rows_out = [
|
||||
row for row in rows_out
|
||||
if row["recommended_action"] == recommended_action
|
||||
]
|
||||
rows_out.sort(
|
||||
key=lambda row: (
|
||||
row["confidence_score"] if row["confidence_score"] is not None else -1.0,
|
||||
row["rr_ratio"],
|
||||
row["composite_score"],
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return rows_out
|
||||
|
||||
|
||||
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
|
||||
"""Most recent close per ticker — used to judge a setup's current relevance."""
|
||||
async def _latest_price_context(db: AsyncSession, ticker_ids: set[int]) -> dict[int, dict]:
|
||||
"""Most recent daily OHLCV row per ticker for live price context."""
|
||||
if not ticker_ids:
|
||||
return {}
|
||||
latest = (
|
||||
@@ -356,7 +651,12 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
|
||||
.group_by(OHLCVRecord.ticker_id)
|
||||
.subquery()
|
||||
)
|
||||
stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join(
|
||||
stmt = select(
|
||||
OHLCVRecord.ticker_id,
|
||||
OHLCVRecord.close,
|
||||
OHLCVRecord.date,
|
||||
OHLCVRecord.created_at,
|
||||
).join(
|
||||
latest,
|
||||
and_(
|
||||
OHLCVRecord.ticker_id == latest.c.ticker_id,
|
||||
@@ -364,7 +664,23 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
|
||||
),
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return {tid: float(close) for tid, close in result.all()}
|
||||
return {
|
||||
tid: {
|
||||
"current_price": float(close),
|
||||
"price_date": price_date,
|
||||
"price_updated_at": created_at,
|
||||
}
|
||||
for tid, close, price_date, created_at in result.all()
|
||||
}
|
||||
|
||||
|
||||
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
|
||||
"""Most recent close per ticker, kept for callers that only need price."""
|
||||
price_context = await _latest_price_context(db, ticker_ids)
|
||||
return {
|
||||
ticker_id: context["current_price"]
|
||||
for ticker_id, context in price_context.items()
|
||||
}
|
||||
|
||||
|
||||
async def get_trade_setup_history(
|
||||
@@ -381,16 +697,28 @@ async def get_trade_setup_history(
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
prices = await _latest_closes(db, {s.ticker_id for s, _ in rows})
|
||||
prices = await _latest_price_context(db, {s.ticker_id for s, _ in rows})
|
||||
return [
|
||||
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
|
||||
for setup, ticker_symbol in rows
|
||||
]
|
||||
|
||||
|
||||
def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float | None = None) -> dict:
|
||||
def _trade_setup_to_dict(setup: TradeSetup, symbol: str, price_context: dict | None = None) -> dict:
|
||||
targets: list[dict] = []
|
||||
conflicts: list[str] = []
|
||||
current_price = (
|
||||
float(price_context["current_price"])
|
||||
if price_context and price_context.get("current_price") is not None
|
||||
else None
|
||||
)
|
||||
context_as_of = {
|
||||
"setup_detected_at": setup.detected_at,
|
||||
"score_computed_at": None,
|
||||
"sentiment_at": None,
|
||||
"price_date": price_context.get("price_date") if price_context else None,
|
||||
"price_updated_at": price_context.get("price_updated_at") if price_context else None,
|
||||
}
|
||||
|
||||
if setup.targets_json:
|
||||
try:
|
||||
@@ -429,4 +757,5 @@ def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float |
|
||||
"evaluated_at": setup.evaluated_at,
|
||||
"current_price": current_price,
|
||||
"momentum_percentile": setup.momentum_percentile,
|
||||
"context_as_of": context_as_of,
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Computes dimension scores (technical, sr_quality, sentiment, fundamental,
|
||||
momentum) each 0-100, composite score as weighted average of available
|
||||
dimensions with re-normalized weights, staleness marking/recomputation
|
||||
on demand, and weight update triggers full recomputation.
|
||||
dimensions with re-normalized weights, staleness marking, explicit refresh
|
||||
paths, and weight update triggers full recomputation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,13 +28,31 @@ DIMENSIONS = ["technical", "sr_quality", "sentiment", "fundamental", "momentum"]
|
||||
DEFAULT_WEIGHTS: dict[str, float] = {
|
||||
"technical": 0.25,
|
||||
"sr_quality": 0.20,
|
||||
"sentiment": 0.15,
|
||||
"sentiment": 0.10,
|
||||
"fundamental": 0.20,
|
||||
"momentum": 0.20,
|
||||
}
|
||||
|
||||
SCORING_WEIGHTS_KEY = "scoring_weights"
|
||||
|
||||
# Sentiment enters the composite as a signed adjustment around this neutral point,
|
||||
# not as an averaged-in level (see _sentiment_adjustment / compute_composite_score).
|
||||
NEUTRAL_SENTIMENT = 50.0
|
||||
|
||||
|
||||
def _sentiment_adjustment(sentiment_score: float | None, sentiment_weight: float) -> float:
|
||||
"""Signed points sentiment contributes to the base composite.
|
||||
|
||||
+MAX_ADJ at max-confidence bullish (score 100), 0 at neutral (50), -MAX_ADJ at
|
||||
max-confidence bearish (score 0), where MAX_ADJ = sentiment weight * 100. A
|
||||
50%-confidence call maps to score 50 → no effect (a coin flip carries no info),
|
||||
so going from no sentiment to bullish can only ever help.
|
||||
"""
|
||||
if sentiment_score is None:
|
||||
return 0.0
|
||||
max_adj = sentiment_weight * 100.0
|
||||
return max_adj * (sentiment_score - NEUTRAL_SENTIMENT) / 50.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -670,10 +688,15 @@ async def compute_composite_score(
|
||||
symbol: str,
|
||||
weights: dict[str, float] | None = None,
|
||||
) -> tuple[float | None, list[str]]:
|
||||
"""Compute composite score from available dimension scores.
|
||||
"""Compute the composite score.
|
||||
|
||||
The non-sentiment dimensions form a re-normalized weighted-average *base*.
|
||||
Sentiment is then applied as a signed adjustment around neutral (50), not
|
||||
averaged in: neutral leaves the base unchanged, bullish adds and bearish
|
||||
subtracts (scaled by confidence), so going from no sentiment to bullish can
|
||||
only help. See _sentiment_adjustment.
|
||||
|
||||
Returns (composite_score, missing_dimensions).
|
||||
Missing dimensions are excluded and weights re-normalized.
|
||||
"""
|
||||
ticker = await _get_ticker(db, symbol)
|
||||
|
||||
@@ -686,29 +709,32 @@ async def compute_composite_score(
|
||||
)
|
||||
dim_scores = {ds.dimension: ds for ds in result.scalars().all()}
|
||||
|
||||
available: list[tuple[str, float, float]] = [] # (dim, weight, score)
|
||||
missing: list[str] = []
|
||||
|
||||
for dim in DIMENSIONS:
|
||||
w = weights.get(dim, 0.0)
|
||||
if w <= 0:
|
||||
continue
|
||||
def _live(dim: str) -> float | None:
|
||||
ds = dim_scores.get(dim)
|
||||
if ds is not None and not ds.is_stale and ds.score is not None:
|
||||
available.append((dim, w, ds.score))
|
||||
return ds.score
|
||||
return None
|
||||
|
||||
missing = [dim for dim in DIMENSIONS if _live(dim) is None]
|
||||
|
||||
# Base: re-normalized weighted average of the non-sentiment dimensions.
|
||||
base_available = [
|
||||
(dim, weights.get(dim, 0.0), _live(dim))
|
||||
for dim in DIMENSIONS
|
||||
if dim != "sentiment" and weights.get(dim, 0.0) > 0 and _live(dim) is not None
|
||||
]
|
||||
sentiment_score = _live("sentiment")
|
||||
|
||||
if base_available:
|
||||
total_weight = sum(w for _, w, _ in base_available)
|
||||
base = sum(w * s for _, w, s in base_available) / total_weight
|
||||
elif sentiment_score is not None:
|
||||
base = NEUTRAL_SENTIMENT # only sentiment present → neutral baseline
|
||||
else:
|
||||
missing.append(dim)
|
||||
return None, missing # nothing to score
|
||||
|
||||
if not available:
|
||||
return None, missing
|
||||
|
||||
# Re-normalize weights
|
||||
total_weight = sum(w for _, w, _ in available)
|
||||
if total_weight == 0:
|
||||
return None, missing
|
||||
|
||||
composite = sum(w * s for _, w, s in available) / total_weight
|
||||
composite = max(0.0, min(100.0, composite))
|
||||
delta = _sentiment_adjustment(sentiment_score, weights.get("sentiment", 0.0))
|
||||
composite = max(0.0, min(100.0, base + delta))
|
||||
|
||||
# Persist composite score
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -739,73 +765,37 @@ async def compute_composite_score(
|
||||
async def get_score(
|
||||
db: AsyncSession, symbol: str
|
||||
) -> dict:
|
||||
"""Get composite + all dimension scores for a ticker.
|
||||
"""Read composite + dimension scores for a ticker without recomputing.
|
||||
|
||||
Recomputes stale dimensions on demand, then recomputes composite.
|
||||
Returns a dict suitable for ScoreResponse, including dimension breakdowns
|
||||
and composite breakdown with re-normalization info.
|
||||
GET endpoints use this path, so it must not mutate persisted score context.
|
||||
Scheduled/manual write paths are responsible for refreshing scores.
|
||||
"""
|
||||
ticker = await _get_ticker(db, symbol)
|
||||
weights = await _get_weights(db)
|
||||
|
||||
# Check for stale dimension scores and recompute them
|
||||
result = await db.execute(
|
||||
select(DimensionScore).where(DimensionScore.ticker_id == ticker.id)
|
||||
)
|
||||
dim_scores = {ds.dimension: ds for ds in result.scalars().all()}
|
||||
|
||||
for dim in DIMENSIONS:
|
||||
ds = dim_scores.get(dim)
|
||||
if ds is None or ds.is_stale:
|
||||
await compute_dimension_score(db, symbol, dim)
|
||||
|
||||
# Check composite staleness
|
||||
comp_result = await db.execute(
|
||||
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
|
||||
)
|
||||
comp = comp_result.scalar_one_or_none()
|
||||
|
||||
if comp is None or comp.is_stale:
|
||||
await compute_composite_score(db, symbol, weights)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch everything fresh
|
||||
result = await db.execute(
|
||||
select(DimensionScore).where(DimensionScore.ticker_id == ticker.id)
|
||||
)
|
||||
dim_scores_list = list(result.scalars().all())
|
||||
dim_scores = {ds.dimension: ds for ds in dim_scores_list}
|
||||
|
||||
comp_result = await db.execute(
|
||||
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
|
||||
)
|
||||
comp = comp_result.scalar_one_or_none()
|
||||
|
||||
# Compute breakdowns for each dimension by calling the dimension computers
|
||||
breakdowns: dict[str, dict | None] = {}
|
||||
for dim in DIMENSIONS:
|
||||
try:
|
||||
raw_result = await _DIMENSION_COMPUTERS[dim](db, symbol)
|
||||
if isinstance(raw_result, tuple) and len(raw_result) == 2:
|
||||
breakdowns[dim] = raw_result[1]
|
||||
else:
|
||||
breakdowns[dim] = None
|
||||
except Exception:
|
||||
breakdowns[dim] = None
|
||||
|
||||
# Build dimension entries with breakdowns
|
||||
dimensions = []
|
||||
missing = []
|
||||
available_dims: list[str] = []
|
||||
for dim in DIMENSIONS:
|
||||
found = next((ds for ds in dim_scores_list if ds.dimension == dim), None)
|
||||
found = dim_scores.get(dim)
|
||||
if found is not None and not found.is_stale and found.score is not None:
|
||||
dimensions.append({
|
||||
"dimension": found.dimension,
|
||||
"score": found.score,
|
||||
"is_stale": found.is_stale,
|
||||
"computed_at": found.computed_at,
|
||||
"breakdown": breakdowns.get(dim),
|
||||
"breakdown": None,
|
||||
})
|
||||
w = weights.get(dim, 0.0)
|
||||
if w > 0:
|
||||
@@ -819,25 +809,50 @@ async def get_score(
|
||||
"score": found.score,
|
||||
"is_stale": found.is_stale,
|
||||
"computed_at": found.computed_at,
|
||||
"breakdown": breakdowns.get(dim),
|
||||
"breakdown": None,
|
||||
})
|
||||
|
||||
# Build composite breakdown with re-normalization info
|
||||
composite_breakdown = None
|
||||
available_weight_sum = sum(weights.get(d, 0.0) for d in available_dims)
|
||||
# Build composite breakdown: the non-sentiment base (re-normalized weighted
|
||||
# average) plus sentiment as a signed adjustment around neutral.
|
||||
base_dims = [d for d in available_dims if d != "sentiment"]
|
||||
available_weight_sum = sum(weights.get(d, 0.0) for d in base_dims)
|
||||
if available_weight_sum > 0:
|
||||
renormalized_weights = {
|
||||
d: weights.get(d, 0.0) / available_weight_sum for d in available_dims
|
||||
d: weights.get(d, 0.0) / available_weight_sum for d in base_dims
|
||||
}
|
||||
else:
|
||||
renormalized_weights = {}
|
||||
|
||||
fresh = {
|
||||
ds.dimension: ds.score
|
||||
for ds in dim_scores_list
|
||||
if not ds.is_stale and ds.score is not None
|
||||
}
|
||||
if renormalized_weights:
|
||||
base_score = sum(renormalized_weights[d] * fresh[d] for d in base_dims)
|
||||
elif "sentiment" in fresh:
|
||||
base_score = NEUTRAL_SENTIMENT
|
||||
else:
|
||||
base_score = None
|
||||
|
||||
sentiment_val = fresh.get("sentiment")
|
||||
sentiment_weight = weights.get("sentiment", 0.0)
|
||||
sentiment_adjustment = _sentiment_adjustment(sentiment_val, sentiment_weight)
|
||||
|
||||
composite_breakdown = {
|
||||
"weights": weights,
|
||||
"available_dimensions": available_dims,
|
||||
"available_dimensions": base_dims,
|
||||
"missing_dimensions": missing,
|
||||
"renormalized_weights": renormalized_weights,
|
||||
"formula": "Weighted average of available dimensions with re-normalized weights: sum(weight_i * score_i) / sum(weight_i)",
|
||||
"base_score": base_score,
|
||||
"sentiment_score": sentiment_val,
|
||||
"sentiment_adjustment": sentiment_adjustment,
|
||||
"max_sentiment_adjustment": sentiment_weight * 100.0,
|
||||
"formula": (
|
||||
"Base = re-normalized weighted average of the non-sentiment dimensions. "
|
||||
"Composite = base + sentiment adjustment, where adjustment = "
|
||||
"MAX_ADJ * (sentiment - 50) / 50 and MAX_ADJ = sentiment weight * 100."
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -874,31 +889,13 @@ async def get_rankings(db: AsyncSession) -> dict:
|
||||
dims[ds.ticker_id][ds.dimension] = ds
|
||||
return comps, dims
|
||||
|
||||
# Two bulk reads instead of ~4 queries per ticker.
|
||||
comps, dims_by_ticker = await _load_scores()
|
||||
|
||||
# Lazily recompute any stale/missing scores (kept fresh by the daily scan;
|
||||
# this self-heals tickers that aged out between scans), committing once.
|
||||
recomputed = False
|
||||
for ticker in tickers:
|
||||
comp = comps.get(ticker.id)
|
||||
if comp is None or comp.is_stale:
|
||||
dim_scores = dims_by_ticker.get(ticker.id, {})
|
||||
for dim in DIMENSIONS:
|
||||
ds = dim_scores.get(dim)
|
||||
if ds is None or ds.is_stale:
|
||||
await compute_dimension_score(db, ticker.symbol, dim)
|
||||
await compute_composite_score(db, ticker.symbol, weights)
|
||||
recomputed = True
|
||||
|
||||
if recomputed:
|
||||
await db.commit()
|
||||
comps, dims_by_ticker = await _load_scores()
|
||||
|
||||
rankings = [
|
||||
{
|
||||
"symbol": ticker.symbol,
|
||||
"composite_score": comp.score,
|
||||
"composite_stale": comp.is_stale,
|
||||
"dimensions": [
|
||||
{
|
||||
"dimension": ds.dimension,
|
||||
|
||||
@@ -6,6 +6,7 @@ well-known universes (S&P 500, NASDAQ-100, NASDAQ All).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -357,6 +358,55 @@ async def fetch_universe_symbols(db: AsyncSession, universe: str) -> list[str]:
|
||||
raise ProviderError(f"Universe '{normalised_universe}' returned no valid symbols. Attempts: {reason}")
|
||||
|
||||
|
||||
async def _fetch_alpaca_asset_names() -> dict[str, str]:
|
||||
"""One Alpaca Trading-API call → {internal_symbol: company_name} for all US
|
||||
equities. Tries paper and live endpoints so it works with either key type."""
|
||||
if not settings.alpaca_api_key or not settings.alpaca_api_secret:
|
||||
raise ValidationError("Alpaca API credentials are required to backfill names")
|
||||
|
||||
from alpaca.trading.client import TradingClient
|
||||
from alpaca.trading.enums import AssetClass, AssetStatus
|
||||
from alpaca.trading.requests import GetAssetsRequest
|
||||
|
||||
req = GetAssetsRequest(status=AssetStatus.ACTIVE, asset_class=AssetClass.US_EQUITY)
|
||||
last_err: Exception | None = None
|
||||
for paper in (True, False):
|
||||
try:
|
||||
client = TradingClient(settings.alpaca_api_key, settings.alpaca_api_secret, paper=paper)
|
||||
assets = await asyncio.to_thread(client.get_all_assets, req)
|
||||
names: dict[str, str] = {}
|
||||
for asset in assets:
|
||||
sym = getattr(asset, "symbol", None)
|
||||
nm = getattr(asset, "name", None)
|
||||
if sym and nm:
|
||||
names[sym.replace(".", "-").upper()] = nm # BRK.B → BRK-B
|
||||
if names:
|
||||
return names
|
||||
except Exception as exc: # noqa: BLE001 — try the other endpoint
|
||||
last_err = exc
|
||||
|
||||
raise ProviderError(f"Failed to fetch asset names from Alpaca: {last_err}")
|
||||
|
||||
|
||||
async def backfill_ticker_names(db: AsyncSession, *, only_missing: bool = True) -> dict[str, int]:
|
||||
"""Fill Ticker.name from Alpaca in a single request for the whole universe."""
|
||||
result = await db.execute(select(Ticker))
|
||||
tickers = list(result.scalars().all())
|
||||
targets = [t for t in tickers if not t.name] if only_missing else tickers
|
||||
if not targets:
|
||||
return {"updated": 0, "checked": 0, "unmatched": 0}
|
||||
|
||||
names = await _fetch_alpaca_asset_names()
|
||||
updated = 0
|
||||
for ticker in targets:
|
||||
nm = names.get(ticker.symbol.upper())
|
||||
if nm and nm != ticker.name:
|
||||
ticker.name = nm[:120]
|
||||
updated += 1
|
||||
await db.commit()
|
||||
return {"updated": updated, "checked": len(targets), "unmatched": len(targets) - updated}
|
||||
|
||||
|
||||
async def bootstrap_universe(
|
||||
db: AsyncSession,
|
||||
universe: str,
|
||||
@@ -387,6 +437,13 @@ async def bootstrap_universe(
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Best-effort: fill company names for any tickers still missing one. Never let
|
||||
# a name-fetch hiccup fail the bootstrap itself.
|
||||
try:
|
||||
await backfill_ticker_names(db, only_missing=True)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Ticker name backfill failed during bootstrap", exc_info=True)
|
||||
|
||||
return {
|
||||
"universe": normalised_universe,
|
||||
"total_universe_symbols": len(symbols),
|
||||
|
||||
@@ -173,6 +173,9 @@ async def _enrich_entry(
|
||||
"dimensions": dims,
|
||||
"rr_ratio": setup.rr_ratio if setup else None,
|
||||
"rr_direction": setup.direction if setup else None,
|
||||
# Residual 12-1 activation percentile (the top-pick selector); ticker-level,
|
||||
# so any of the ticker's setups carries the same value.
|
||||
"momentum_percentile": setup.momentum_percentile if setup else None,
|
||||
"sr_levels": sr_levels,
|
||||
"last_close": last_close,
|
||||
"change_pct": change_pct,
|
||||
|
||||
@@ -134,6 +134,8 @@ export function updateAlertSettings(payload: {
|
||||
sr_proximity_enabled?: boolean;
|
||||
score_drop_enabled?: boolean;
|
||||
digest_enabled?: boolean;
|
||||
regime_quadrant_enabled?: boolean;
|
||||
trade_closed_enabled?: boolean;
|
||||
}) {
|
||||
return apiClient
|
||||
.put<AlertConfig>('admin/settings/alerts', payload)
|
||||
@@ -169,6 +171,12 @@ export function bootstrapTickers(universe: TickerUniverse, pruneMissing: boolean
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function backfillTickerNames() {
|
||||
return apiClient
|
||||
.post<{ updated: number; checked: number; unmatched: number }>('admin/tickers/backfill-names')
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
// Jobs
|
||||
export interface JobStatus {
|
||||
name: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface IngestionSourceResult {
|
||||
status: 'ok' | 'error' | 'skipped';
|
||||
status: 'ok' | 'error' | 'skipped' | 'warning';
|
||||
message?: string | null;
|
||||
records?: number;
|
||||
classification?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client';
|
||||
import type { PaperTrade } from '../lib/types';
|
||||
import type { ExitPolicy, PaperTrade } from '../lib/types';
|
||||
|
||||
export function listPaperTrades(status?: 'open' | 'closed') {
|
||||
return apiClient
|
||||
@@ -7,6 +7,14 @@ export function listPaperTrades(status?: 'open' | 'closed') {
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getExitPolicy() {
|
||||
return apiClient.get<ExitPolicy>('paper-trades/exit-policy').then((r) => r.data);
|
||||
}
|
||||
|
||||
export function updateExitPolicy(payload: Partial<ExitPolicy>) {
|
||||
return apiClient.put<ExitPolicy>('paper-trades/exit-policy', payload).then((r) => r.data);
|
||||
}
|
||||
|
||||
export interface CreatePaperTradeBody {
|
||||
symbol: string;
|
||||
direction: 'long' | 'short';
|
||||
|
||||
@@ -4,12 +4,19 @@ import type {
|
||||
RegimeConfig,
|
||||
RegimeFundamentals,
|
||||
EventStudyReport,
|
||||
RegimeHistoryPoint,
|
||||
} from '../lib/types';
|
||||
|
||||
export function getRegimeMonitor() {
|
||||
return apiClient.get<RegimeMonitor>('regime/monitor').then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getRegimeHistory(days = 400) {
|
||||
return apiClient
|
||||
.get<RegimeHistoryPoint[]>('regime/history', { params: { days } })
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getEventStudy() {
|
||||
return apiClient.get<EventStudyReport | null>('regime/event-study').then((r) => r.data);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const DEFAULTS: ActivationConfig = {
|
||||
min_confidence: 55,
|
||||
require_high_conviction: false,
|
||||
exclude_conflicts: false,
|
||||
exclude_neutral: true,
|
||||
};
|
||||
|
||||
export function ActivationSettings() {
|
||||
@@ -40,16 +41,16 @@ export function ActivationSettings() {
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the
|
||||
Signals "Qualified only" view, and the Track Record's qualified stats. The core selection is
|
||||
<span className="text-gray-300"> cross-sectional momentum</span> — the ticker must rank in the
|
||||
top slice of the universe by 12-1 month momentum, the one signal the backtest showed predicts
|
||||
forward returns. R:R and confidence stay as floors. Tune the cutoff against the Track Record's
|
||||
<span className="text-gray-300"> residual cross-sectional momentum</span> — the ticker must rank in the
|
||||
top slice of the universe by beta-adjusted 12-1 month momentum, the production signal promoted
|
||||
from the backtest. R:R and confidence stay as floors. Tune the cutoff against the Track Record's
|
||||
momentum sweep to see what actually wins.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Min Momentum Percentile</span>
|
||||
<span className="text-xs text-gray-400">Min Residual Momentum Percentile</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -59,7 +60,7 @@ export function ActivationSettings() {
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, min_momentum_percentile: Number(e.target.value) }))}
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-600">Ticker's 12-1 momentum rank. 80 = top 20% of the universe. 0 disables. The core gate.</span>
|
||||
<span className="text-[11px] text-gray-600">Ticker's residual 12-1 momentum rank. 80 = top 20% of the universe. 0 disables. The core gate.</span>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
|
||||
@@ -87,6 +88,24 @@ export function ActivationSettings() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/[0.06] pt-4">
|
||||
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.exclude_neutral}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, exclude_neutral: e.target.checked }))}
|
||||
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
|
||||
/>
|
||||
<span>
|
||||
Require a directional call (exclude NEUTRAL)
|
||||
<span className="mt-0.5 block text-[11px] text-gray-500">
|
||||
On by default. A NEUTRAL ("No Clear Setup") recommendation isn't a tradeable signal, so it
|
||||
never qualifies or becomes a top pick. Turn off to also count no-clear-direction residual momentum leaders.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/[0.06] pt-4">
|
||||
<p className="text-xs font-medium uppercase tracking-widest text-gray-500">Optional tighteners</p>
|
||||
<p className="mt-1 text-[11px] text-gray-600">Off by default — turn on to be more selective on top of the momentum gate.</p>
|
||||
|
||||
@@ -12,13 +12,17 @@ type TriggerKey =
|
||||
| 'qualified_enabled'
|
||||
| 'sr_proximity_enabled'
|
||||
| 'score_drop_enabled'
|
||||
| 'digest_enabled';
|
||||
| 'digest_enabled'
|
||||
| 'regime_quadrant_enabled'
|
||||
| 'trade_closed_enabled';
|
||||
|
||||
const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [
|
||||
{ key: 'qualified_enabled', label: 'Qualified setups', hint: 'a setup newly clears the activation gate' },
|
||||
{ key: 'sr_proximity_enabled', label: 'Watchlist S/R proximity', hint: 'a watched ticker nears a strong support/resistance' },
|
||||
{ key: 'score_drop_enabled', label: 'Score deterioration', hint: 'a watched ticker’s composite drops sharply' },
|
||||
{ key: 'digest_enabled', label: 'Daily digest', hint: 'one end-of-day summary of qualified setups' },
|
||||
{ key: 'digest_enabled', label: 'Daily digest', hint: 'end-of-day summary incl. open trades + trailing stops' },
|
||||
{ key: 'regime_quadrant_enabled', label: 'Regime quadrant change', hint: 'the regime monitor shifts quadrant (hysteresis + cooldown)' },
|
||||
{ key: 'trade_closed_enabled', label: 'Trade closed', hint: 'a paper trade auto-closes (trailing/target/stop) — incl. losses' },
|
||||
];
|
||||
|
||||
function Toggle({ checked, onChange, label, hint }: {
|
||||
@@ -56,6 +60,8 @@ export function AlertSettings() {
|
||||
sr_proximity_enabled: true,
|
||||
score_drop_enabled: true,
|
||||
digest_enabled: true,
|
||||
regime_quadrant_enabled: true,
|
||||
trade_closed_enabled: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,6 +73,8 @@ export function AlertSettings() {
|
||||
sr_proximity_enabled: data.sr_proximity_enabled,
|
||||
score_drop_enabled: data.score_drop_enabled,
|
||||
digest_enabled: data.digest_enabled,
|
||||
regime_quadrant_enabled: data.regime_quadrant_enabled,
|
||||
trade_closed_enabled: data.trade_closed_enabled,
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ExitPolicy } from '../../lib/types';
|
||||
import { useExitPolicy, useUpdateExitPolicy } from '../../hooks/usePaperTrades';
|
||||
import { SkeletonCard } from '../ui/Skeleton';
|
||||
|
||||
export function ExitPolicySettings() {
|
||||
const { data, isLoading } = useExitPolicy();
|
||||
const update = useUpdateExitPolicy();
|
||||
const [mode, setMode] = useState<ExitPolicy['mode']>('time');
|
||||
const [pct, setPct] = useState(12);
|
||||
const [holdDays, setHoldDays] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setMode(data.mode);
|
||||
setPct(data.trailing_pct);
|
||||
setHoldDays(data.hold_days ?? 30);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) return <SkeletonCard />;
|
||||
|
||||
return (
|
||||
<div className="glass p-5 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-200">Paper-Trade Exit</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
How open paper trades auto-close (in the nightly/intraday outcome job).{' '}
|
||||
<span className="text-gray-300">Hold</span> keeps the initial stop and exits at the Nth trading
|
||||
day's close — the backtest-validated exit (classic momentum: hold ~a month, re-rank);{' '}
|
||||
<span className="text-gray-300">Trailing</span> rides a trailing stop;{' '}
|
||||
<span className="text-gray-300">Target / stop</span> closes at the setup's target or stop.
|
||||
The setup's initial stop is always the floor.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Exit mode</span>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as ExitPolicy['mode'])}
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="time">Hold N days + stop</option>
|
||||
<option value="trailing">Trailing stop</option>
|
||||
<option value="target">Target / stop</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Hold (trading days)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={250}
|
||||
step={1}
|
||||
value={holdDays}
|
||||
onChange={(e) => setHoldDays(Number(e.target.value))}
|
||||
disabled={mode !== 'time'}
|
||||
className="w-full input-glass px-3 py-2 text-sm disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-600">Backtest optimum: 30 (its evaluation horizon).</span>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Trailing width (%)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={90}
|
||||
step={0.5}
|
||||
value={pct}
|
||||
onChange={(e) => setPct(Number(e.target.value))}
|
||||
disabled={mode !== 'trailing'}
|
||||
className="w-full input-glass px-3 py-2 text-sm disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-600">Give-back from the peak. ≥15% ≈ the hold exit.</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary px-4 py-2 text-sm disabled:opacity-50"
|
||||
disabled={update.isPending}
|
||||
onClick={() => update.mutate({ mode, trailing_pct: pct, hold_days: holdDays })}
|
||||
>
|
||||
{update.isPending ? 'Saving…' : 'Save Exit Policy'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
useBackfillTickerNames,
|
||||
useBootstrapTickers,
|
||||
useTickerUniverseSetting,
|
||||
useUpdateTickerUniverseSetting,
|
||||
@@ -17,6 +18,7 @@ export function TickerUniverseBootstrap() {
|
||||
const { data, isLoading, isError, error } = useTickerUniverseSetting();
|
||||
const updateDefault = useUpdateTickerUniverseSetting();
|
||||
const bootstrap = useBootstrapTickers();
|
||||
const backfillNames = useBackfillTickerNames();
|
||||
|
||||
const [universe, setUniverse] = useState<TickerUniverse>('sp500');
|
||||
const [pruneMissing, setPruneMissing] = useState(false);
|
||||
@@ -85,6 +87,14 @@ export function TickerUniverseBootstrap() {
|
||||
>
|
||||
{bootstrap.isPending ? 'Bootstrapping…' : 'Bootstrap Now'}
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white disabled:opacity-50"
|
||||
onClick={() => backfillNames.mutate()}
|
||||
disabled={backfillNames.isPending}
|
||||
title="Fill in company names from Alpaca (one request for all tickers)"
|
||||
>
|
||||
{backfillNames.isPending ? 'Backfilling…' : 'Backfill Names'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types';
|
||||
import { formatPrice, formatDate } from '../../lib/format';
|
||||
import { formatPrice, formatDate, formatLargeNumber } from '../../lib/format';
|
||||
|
||||
interface CandlestickChartProps {
|
||||
data: OHLCVBar[];
|
||||
@@ -50,6 +50,9 @@ interface TooltipState {
|
||||
}
|
||||
|
||||
const MIN_VISIBLE_BARS = 10;
|
||||
const CHART_HEIGHT = 440;
|
||||
const VOLUME_PANE_HEIGHT = 72;
|
||||
const PANE_GAP = 18;
|
||||
|
||||
type RangePreset = '1M' | '3M' | '6M' | 'YTD' | '1Y' | '3Y' | '5Y' | 'All';
|
||||
const RANGE_PRESETS: RangePreset[] = ['1M', '3M', '6M', 'YTD', '1Y', '3Y', '5Y', 'All'];
|
||||
@@ -109,7 +112,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const W = rect.width;
|
||||
const H = 400;
|
||||
const H = CHART_HEIGHT;
|
||||
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
@@ -124,7 +127,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
// Margins
|
||||
const ml = 12, mr = 70, mt = 12, mb = 32;
|
||||
const cw = W - ml - mr;
|
||||
const ch = H - mt - mb;
|
||||
const volumeH = VOLUME_PANE_HEIGHT;
|
||||
const ch = H - mt - mb - volumeH - PANE_GAP;
|
||||
const priceBottom = mt + ch;
|
||||
const volumeTop = priceBottom + PANE_GAP;
|
||||
const volumeBottom = volumeTop + volumeH;
|
||||
|
||||
// Current price = explicit prop, else latest close
|
||||
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
|
||||
@@ -145,6 +152,9 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
|
||||
const barW = cw / visibleData.length;
|
||||
const candleW = Math.max(barW * 0.65, 1);
|
||||
const volumeW = Math.max(barW * 0.65, 1);
|
||||
const maxVolume = Math.max(...visibleData.map((b) => Math.max(0, b.volume)), 1);
|
||||
const volumeScale = (v: number) => volumeTop + volumeH - (Math.max(0, v) / maxVolume) * volumeH;
|
||||
|
||||
// Grid lines (horizontal)
|
||||
const nTicks = 6;
|
||||
@@ -172,6 +182,34 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
ctx.fillText(formatDate(visibleData[i].date), x, H - 6);
|
||||
}
|
||||
|
||||
// Volume pane
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, volumeTop - 9);
|
||||
ctx.lineTo(ml + cw, volumeTop - 9);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, volumeBottom);
|
||||
ctx.lineTo(ml + cw, volumeBottom);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('Volume', ml, volumeTop - 13);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(formatLargeNumber(maxVolume), W - 8, volumeTop + 4);
|
||||
|
||||
visibleData.forEach((bar, i) => {
|
||||
const x = ml + i * barW + barW / 2;
|
||||
const bullish = bar.close >= bar.open;
|
||||
const yVolume = volumeScale(bar.volume);
|
||||
const hVolume = Math.max(volumeBottom - yVolume, bar.volume > 0 ? 1 : 0);
|
||||
ctx.fillStyle = bullish ? 'rgba(16, 185, 129, 0.32)' : 'rgba(239, 68, 68, 0.28)';
|
||||
ctx.fillRect(x - volumeW / 2, yVolume, volumeW, hVolume);
|
||||
});
|
||||
|
||||
// Nearest support/resistance only (band if it came from a zone)
|
||||
markers.forEach((m) => {
|
||||
const isSupport = m.role === 'support';
|
||||
@@ -312,7 +350,22 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
});
|
||||
|
||||
// Store geometry for hit testing (includes visibleRange offset)
|
||||
(canvas as any).__chartMeta = { ml, mr, mt, mb, cw, ch, barW, lo, hi, yScale, visibleStart: start };
|
||||
(canvas as any).__chartMeta = {
|
||||
ml,
|
||||
mr,
|
||||
mt,
|
||||
mb,
|
||||
cw,
|
||||
ch,
|
||||
barW,
|
||||
lo,
|
||||
hi,
|
||||
yScale,
|
||||
visibleStart: start,
|
||||
volumeTop,
|
||||
volumeH,
|
||||
volumeBottom,
|
||||
};
|
||||
|
||||
// Size the overlay canvas to match
|
||||
const overlay = overlayCanvasRef.current;
|
||||
@@ -342,12 +395,14 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const meta = (canvas as any).__chartMeta;
|
||||
if (!meta) return;
|
||||
|
||||
const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart } = meta;
|
||||
const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart, volumeBottom } = meta;
|
||||
const H = overlay.height / dpr;
|
||||
const priceBottom = mt + ch;
|
||||
const chartBottom = volumeBottom ?? priceBottom;
|
||||
|
||||
// Clamp crosshair to chart area
|
||||
const cx = Math.max(ml, Math.min(ml + cw, pos.x));
|
||||
const cy = Math.max(mt, Math.min(mt + ch, pos.y));
|
||||
const cy = Math.max(mt, Math.min(chartBottom, pos.y));
|
||||
|
||||
// Dashed crosshair lines
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
||||
@@ -357,24 +412,30 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
// Vertical line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, mt);
|
||||
ctx.lineTo(cx, mt + ch);
|
||||
ctx.lineTo(cx, chartBottom);
|
||||
ctx.stroke();
|
||||
|
||||
// Horizontal line
|
||||
ctx.setLineDash([]);
|
||||
|
||||
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
|
||||
const labelPadX = 5;
|
||||
const labelPadY = 3;
|
||||
|
||||
if (cy <= priceBottom) {
|
||||
// Horizontal price crosshair only belongs in the price pane.
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
||||
ctx.lineWidth = 0.75;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, cy);
|
||||
ctx.lineTo(ml + cw, cy);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Price label on y-axis (right side)
|
||||
const price = hi - ((cy - mt) / ch) * (hi - lo);
|
||||
const priceText = formatPrice(price);
|
||||
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
|
||||
const priceMetrics = ctx.measureText(priceText);
|
||||
const labelPadX = 5;
|
||||
const labelPadY = 3;
|
||||
const labelW = priceMetrics.width + labelPadX * 2;
|
||||
const labelH = 16 + labelPadY * 2;
|
||||
const labelX = ml + cw + 2;
|
||||
@@ -388,6 +449,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(priceText, labelX + labelPadX, cy);
|
||||
}
|
||||
|
||||
// Date label on x-axis (bottom)
|
||||
const localIdx = Math.floor((cx - ml) / barW);
|
||||
@@ -619,7 +681,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
<span>High</span><span class="text-right text-gray-200">${formatPrice(bar.high)}</span>
|
||||
<span>Low</span><span class="text-right text-gray-200">${formatPrice(bar.low)}</span>
|
||||
<span>Close</span><span class="text-right text-gray-200">${formatPrice(bar.close)}</span>
|
||||
<span>Vol</span><span class="text-right text-gray-200">${bar.volume.toLocaleString()}</span>
|
||||
<span>Vol</span><span class="text-right text-gray-200" title="${bar.volume.toLocaleString()}">${formatLargeNumber(bar.volume)}</span>
|
||||
</div>${tradeTooltipHtml}`;
|
||||
} else {
|
||||
tip.style.display = 'none';
|
||||
@@ -670,16 +732,16 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
))}
|
||||
<span className="ml-1 text-[10px] text-gray-600">scroll to zoom · drag to pan</span>
|
||||
</div>
|
||||
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}>
|
||||
<div ref={containerRef} className="relative w-full" style={{ height: CHART_HEIGHT }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full"
|
||||
style={{ height: 400 }}
|
||||
style={{ height: CHART_HEIGHT }}
|
||||
/>
|
||||
<canvas
|
||||
ref={overlayCanvasRef}
|
||||
className="absolute top-0 left-0 w-full cursor-crosshair"
|
||||
style={{ height: 400 }}
|
||||
style={{ height: CHART_HEIGHT }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { usePaperTrades, useClosePaperTrade } from '../../hooks/usePaperTrades';
|
||||
import { usePaperTrades, useClosePaperTrade, useExitPolicy } from '../../hooks/usePaperTrades';
|
||||
import { useTickerNames } from '../../hooks/useTickers';
|
||||
import { tradePnl } from '../../lib/paperTrade';
|
||||
import { formatPrice } from '../../lib/format';
|
||||
import { Section } from '../ui/Section';
|
||||
@@ -18,19 +19,32 @@ function pnlColor(v: number): string {
|
||||
|
||||
export function OpenTradesPanel() {
|
||||
const { data: trades, isLoading } = usePaperTrades('open');
|
||||
const { data: policy } = useExitPolicy();
|
||||
const tickerNames = useTickerNames();
|
||||
const close = useClosePaperTrade();
|
||||
|
||||
const exitLabel = policy
|
||||
? policy.mode === 'trailing'
|
||||
? `auto-exit: trailing ${Math.round(policy.trailing_pct)}%`
|
||||
: 'auto-exit: target/stop'
|
||||
: null;
|
||||
|
||||
const totals = useMemo(() => {
|
||||
let pnl = 0, winners = 0, losers = 0, priced = 0;
|
||||
let pnl = 0, winners = 0, losers = 0, priced = 0, alphaUsd = 0, alphaPriced = 0;
|
||||
for (const t of trades ?? []) {
|
||||
const p = tradePnl(t);
|
||||
if (!p) continue;
|
||||
if (p) {
|
||||
priced += 1;
|
||||
pnl += p.pnl;
|
||||
if (p.pnl > 0) winners += 1;
|
||||
else if (p.pnl < 0) losers += 1;
|
||||
}
|
||||
return { pnl, winners, losers, priced };
|
||||
if (t.alpha_usd != null) {
|
||||
alphaUsd += t.alpha_usd;
|
||||
alphaPriced += 1;
|
||||
}
|
||||
}
|
||||
return { pnl, winners, losers, priced, alphaUsd, alphaPriced };
|
||||
}, [trades]);
|
||||
|
||||
if (isLoading) return null;
|
||||
@@ -39,7 +53,7 @@ export function OpenTradesPanel() {
|
||||
return (
|
||||
<Section
|
||||
title="Open Trades"
|
||||
hint={rows.length > 0 ? `${rows.length} open · ${totals.winners}▲ ${totals.losers}▼` : 'paper trading'}
|
||||
hint={rows.length > 0 ? `${rows.length} open · ${totals.winners}▲ ${totals.losers}▼${exitLabel ? ` · ${exitLabel}` : ''}` : 'paper trading'}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<Callout variant="empty">
|
||||
@@ -58,6 +72,8 @@ export function OpenTradesPanel() {
|
||||
<th className="px-4 py-3 text-right">P&L</th>
|
||||
<th className="px-4 py-3 text-right">%</th>
|
||||
<th className="px-4 py-3 text-right">R</th>
|
||||
<th className="px-4 py-3 text-right">Alpha</th>
|
||||
<th className="px-4 py-3 text-right">Trail Stop</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -70,6 +86,11 @@ export function OpenTradesPanel() {
|
||||
<Link to={`/ticker/${t.symbol}`} className="font-medium text-blue-300 hover:text-blue-200">
|
||||
{t.symbol}
|
||||
</Link>
|
||||
{tickerNames.get(t.symbol.toUpperCase()) && (
|
||||
<div className="max-w-[150px] truncate text-[11px] text-gray-500">
|
||||
{tickerNames.get(t.symbol.toUpperCase())}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`num text-[10px] font-semibold uppercase ${t.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
@@ -90,6 +111,23 @@ export function OpenTradesPanel() {
|
||||
<td className={`num px-4 py-3 text-right ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
|
||||
{p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
|
||||
</td>
|
||||
<td className={`num px-4 py-3 text-right ${t.alpha_pct != null ? pnlColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">
|
||||
{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}
|
||||
</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-300" title="Current trailing-stop level · how far below the price">
|
||||
{t.trailing_stop != null ? (
|
||||
<>
|
||||
{formatPrice(t.trailing_stop)}
|
||||
{t.trailing_distance_pct != null && (
|
||||
<span className="ml-1 text-[10px] text-gray-500">
|
||||
{Math.abs(t.trailing_distance_pct).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -110,12 +148,16 @@ export function OpenTradesPanel() {
|
||||
<tfoot>
|
||||
<tr className="border-t border-white/[0.08]">
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500" colSpan={5}>
|
||||
Total unrealized P&L
|
||||
Total unrealized P&L · alpha vs S&P 500
|
||||
</td>
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${pnlColor(totals.pnl)}`}>
|
||||
{money(totals.pnl)}
|
||||
</td>
|
||||
<td colSpan={3} />
|
||||
<td colSpan={2} />
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${totals.alphaPriced > 0 ? pnlColor(totals.alphaUsd) : 'text-gray-500'}`}>
|
||||
{totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'}
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import TickerSearch from './TickerSearch';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Overview', end: true },
|
||||
@@ -46,6 +47,9 @@ export default function MobileNav() {
|
||||
}`}
|
||||
>
|
||||
<nav className="px-3 py-2 space-y-1">
|
||||
<div className="pb-2">
|
||||
<TickerSearch onNavigate={() => setOpen(false)} />
|
||||
</div>
|
||||
{navItems.map(({ to, label, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { check as healthCheck } from '../../api/health';
|
||||
import { getRunningJobs } from '../../api/jobs';
|
||||
import TickerSearch from './TickerSearch';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Overview', index: '01', end: true },
|
||||
@@ -54,6 +55,10 @@ export default function Sidebar() {
|
||||
<p className="text-[10px] text-gray-500 mt-1.5 font-mono uppercase tracking-[0.22em]">Trading Intelligence</p>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pt-4">
|
||||
<TickerSearch />
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 py-5 space-y-1">
|
||||
{navItems.map(({ to, label, index, end }) => (
|
||||
<NavLink key={to} to={to} end={end} className={({ isActive }) => linkClasses(isActive)}>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTickers } from '../../hooks/useTickers';
|
||||
import { Input } from '../ui/Field';
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
|
||||
/** Jump-to-ticker search over the tracked universe. Selecting a match opens its
|
||||
* detail page — it does NOT add the ticker to the watchlist. */
|
||||
export default function TickerSearch({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const tickers = useTickers();
|
||||
const navigate = useNavigate();
|
||||
const [q, setQ] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const blurTimer = useRef<number | null>(null);
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const query = q.trim().toUpperCase();
|
||||
if (!query) return [];
|
||||
const all = tickers.data ?? [];
|
||||
const starts = all.filter((t) => t.symbol.toUpperCase().startsWith(query));
|
||||
const contains = all.filter(
|
||||
(t) => !t.symbol.toUpperCase().startsWith(query) && t.symbol.toUpperCase().includes(query),
|
||||
);
|
||||
return [...starts, ...contains].slice(0, MAX_RESULTS);
|
||||
}, [q, tickers.data]);
|
||||
|
||||
const go = (symbol: string) => {
|
||||
navigate(`/ticker/${symbol}`);
|
||||
setQ('');
|
||||
setOpen(false);
|
||||
setActive(0);
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(a + 1, matches.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(a - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const m = matches[active];
|
||||
if (m) go(m.symbol);
|
||||
} else if (e.key === 'Escape') {
|
||||
setQ('');
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showList = open && q.trim().length > 0 && matches.length > 0;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
setActive(0);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => {
|
||||
blurTimer.current = window.setTimeout(() => setOpen(false), 120);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Search ticker…"
|
||||
aria-label="Search ticker"
|
||||
autoComplete="off"
|
||||
className="w-full"
|
||||
/>
|
||||
{showList && (
|
||||
<ul className="absolute z-20 mt-1 max-h-72 w-full overflow-y-auto rounded-lg glass py-1 shadow-xl">
|
||||
{matches.map((t, i) => (
|
||||
<li key={t.symbol}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => go(t.symbol)}
|
||||
className={`flex w-full items-baseline gap-2 px-3 py-1.5 text-left text-sm transition-colors ${
|
||||
i === active ? 'bg-blue-400/[0.12] text-blue-200' : 'text-gray-300 hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{t.symbol}</span>
|
||||
{t.name && <span className="truncate text-xs text-gray-500">{t.name}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState, useMemo, type FormEvent } from 'react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useUpdateWeights } from '../../hooks/useScores';
|
||||
|
||||
interface WeightsFormProps {
|
||||
weights: Record<string, number>;
|
||||
}
|
||||
|
||||
const SENTIMENT = 'sentiment';
|
||||
|
||||
export function WeightsForm({ weights }: WeightsFormProps) {
|
||||
// Convert API decimal weights (0-1) to 0-100 integer scale on mount
|
||||
// API decimal weights (0-1) → 0-100 integer sliders. For the base dimensions
|
||||
// that's their share of the weighted average; for sentiment it's the ± points
|
||||
// it can move the composite (MAX_ADJ), decoupled from the base mix.
|
||||
const [sliderValues, setSliderValues] = useState<Record<string, number>>(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(weights).map(([key, w]) => [key, Math.round(w * 100)])
|
||||
@@ -14,10 +18,10 @@ export function WeightsForm({ weights }: WeightsFormProps) {
|
||||
);
|
||||
const updateWeights = useUpdateWeights();
|
||||
|
||||
const allZero = useMemo(
|
||||
() => Object.values(sliderValues).every((v) => v === 0),
|
||||
[sliderValues]
|
||||
);
|
||||
const baseKeys = Object.keys(weights).filter((k) => k !== SENTIMENT);
|
||||
const hasSentiment = SENTIMENT in weights;
|
||||
const baseTotal = baseKeys.reduce((sum, k) => sum + (sliderValues[k] ?? 0), 0);
|
||||
const sentimentPts = sliderValues[SENTIMENT] ?? 0;
|
||||
|
||||
const handleChange = (key: string, value: string) => {
|
||||
const num = parseInt(value, 10);
|
||||
@@ -26,24 +30,35 @@ export function WeightsForm({ weights }: WeightsFormProps) {
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (allZero) return;
|
||||
if (baseTotal === 0) return;
|
||||
|
||||
const total = Object.values(sliderValues).reduce((sum, v) => sum + v, 0);
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(sliderValues).map(([key, v]) => [key, v / total])
|
||||
// Base dimensions normalize among themselves; sentiment passes through raw
|
||||
// (slider value / 100) so it stays independent of the base.
|
||||
const payload: Record<string, number> = Object.fromEntries(
|
||||
baseKeys.map((key) => [key, (sliderValues[key] ?? 0) / baseTotal])
|
||||
);
|
||||
updateWeights.mutate(normalized);
|
||||
if (hasSentiment) payload[SENTIMENT] = sentimentPts / 100;
|
||||
updateWeights.mutate(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="glass p-5">
|
||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-widest text-gray-500">
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-widest text-gray-500">
|
||||
Scoring Weights
|
||||
</h3>
|
||||
<p className="mb-4 text-[11px] text-gray-500">
|
||||
The base dimensions are a weighted average (shares normalize to 100%). Sentiment is applied
|
||||
separately as a signed adjustment on top.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Object.keys(weights).map((key) => (
|
||||
{baseKeys.map((key) => (
|
||||
<label key={key} className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-gray-400 capitalize">{key.replace(/_/g, ' ')}</span>
|
||||
<span className="text-xs text-gray-400 capitalize">
|
||||
{key.replace(/_/g, ' ')}
|
||||
<span className="ml-1 text-gray-600">
|
||||
· {baseTotal > 0 ? Math.round(((sliderValues[key] ?? 0) / baseTotal) * 100) : 0}%
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
@@ -61,14 +76,39 @@ export function WeightsForm({ weights }: WeightsFormProps) {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{allZero && (
|
||||
<p className="mt-3 text-xs text-red-400">
|
||||
At least one weight must be greater than zero
|
||||
</p>
|
||||
|
||||
{hasSentiment && (
|
||||
<div className="mt-4 border-t border-white/[0.06] pt-4">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-gray-400">Sentiment influence (± points)</span>
|
||||
<div className="flex items-center gap-2 sm:max-w-sm">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={30}
|
||||
step={1}
|
||||
value={sentimentPts}
|
||||
onChange={(e) => handleChange(SENTIMENT, e.target.value)}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-700 accent-blue-500"
|
||||
/>
|
||||
<span className="min-w-[3ch] text-right text-sm font-medium text-gray-300">
|
||||
±{sentimentPts}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-gray-600">
|
||||
Max points a bullish (or bearish) read moves the composite, scaled by confidence.
|
||||
Doesn’t change the base mix. 0 = ignore sentiment.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{baseTotal === 0 && (
|
||||
<p className="mt-3 text-xs text-red-400">At least one base weight must be greater than zero</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateWeights.isPending || allZero}
|
||||
disabled={updateWeights.isPending || baseTotal === 0}
|
||||
className="mt-4 btn-primary px-4 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
<span>{updateWeights.isPending ? 'Updating…' : 'Update Weights'}</span>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
ZAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
ReferenceArea,
|
||||
} from 'recharts';
|
||||
import { getRegimeHistory } from '../../api/regime';
|
||||
import { Callout } from '../ui/Callout';
|
||||
import { SkeletonCard } from '../ui/Skeleton';
|
||||
|
||||
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
|
||||
|
||||
// Quadrant dividers. Regime < 40 ≈ intact; early-warning > 60 ≈ elevated.
|
||||
const X_DIV = 40; // regime index
|
||||
const Y_DIV = 60; // early warning
|
||||
const TRAIL = 60; // sessions shown
|
||||
|
||||
interface QPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
/** Centered moving average to de-noise the path; today (last) kept exact. */
|
||||
function smoothTrail(points: QPoint[], half = 2): QPoint[] {
|
||||
const n = points.length;
|
||||
return points.map((p, i) => {
|
||||
if (i === n - 1) return { ...p };
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let c = 0;
|
||||
for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
|
||||
sx += points[j].x;
|
||||
sy += points[j].y;
|
||||
c += 1;
|
||||
}
|
||||
return { x: sx / c, y: sy / c, date: p.date };
|
||||
});
|
||||
}
|
||||
|
||||
/** Recency gradient: 0 = oldest (muted slate), 1 = newest (bright blue). */
|
||||
function recencyColor(t: number): string {
|
||||
const lerp = (a: number, b: number) => Math.round(a + (b - a) * t);
|
||||
const r = lerp(71, 96);
|
||||
const g = lerp(85, 165);
|
||||
const b = lerp(105, 250);
|
||||
const alpha = (0.3 + 0.7 * t).toFixed(2);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payload: QPoint }[] }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0].payload;
|
||||
return (
|
||||
<div className="glass px-2.5 py-1.5 text-[11px]">
|
||||
<div className="text-gray-300">{p.date}</div>
|
||||
<div className="text-gray-400">
|
||||
Regime <span className="text-blue-300">{Math.round(p.x)}</span> · Early warning{' '}
|
||||
<span className="text-orange-300">{Math.round(p.y)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegimeQuadrant() {
|
||||
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(400) });
|
||||
|
||||
const points = useMemo<QPoint[]>(() => {
|
||||
const data = history.data ?? [];
|
||||
return data
|
||||
.filter((p) => p.early_warning != null)
|
||||
.slice(-TRAIL)
|
||||
.map((p) => ({ x: p.index, y: p.early_warning as number, date: p.date }));
|
||||
}, [history.data]);
|
||||
|
||||
const trail = useMemo(() => smoothTrail(points), [points]);
|
||||
const latest = points.length ? points[points.length - 1] : null;
|
||||
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||
Regime quadrant — last {TRAIL} sessions
|
||||
</div>
|
||||
{latest && (
|
||||
<div className="text-[11px] text-gray-500">
|
||||
now: regime <span className="text-blue-300">{Math.round(latest.x)}</span> · warning{' '}
|
||||
<span className="text-orange-300">{Math.round(latest.y)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{history.isLoading ? (
|
||||
<SkeletonCard className="mt-3 h-72" />
|
||||
) : !points.length ? (
|
||||
<Callout variant="empty">
|
||||
Not enough history yet — the early-warning fills in as the daily job runs.
|
||||
</Callout>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-3 h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
|
||||
{/* Quadrant shading (drawn first, behind everything) */}
|
||||
<ReferenceArea x1={0} x2={X_DIV} y1={Y_DIV} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
|
||||
<ReferenceArea x1={X_DIV} x2={100} y1={Y_DIV} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
|
||||
<ReferenceArea x1={0} x2={X_DIV} y1={0} y2={Y_DIV} fill="#10b981" fillOpacity={0.07} stroke="none" />
|
||||
<ReferenceArea x1={X_DIV} x2={100} y1={0} y2={Y_DIV} fill="#ef4444" fillOpacity={0.08} stroke="none" />
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
|
||||
<ReferenceLine x={X_DIV} stroke="rgba(255,255,255,0.12)" />
|
||||
<ReferenceLine y={Y_DIV} stroke="rgba(255,255,255,0.12)" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="x"
|
||||
domain={[0, 100]}
|
||||
ticks={[0, 20, 40, 60, 80, 100]}
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
|
||||
label={{ value: 'Regime index →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="y"
|
||||
domain={[0, 100]}
|
||||
ticks={[0, 20, 40, 60, 80, 100]}
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
width={30}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={{ value: 'Early warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
|
||||
/>
|
||||
<ZAxis range={[13, 13]} />
|
||||
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<QuadrantTip />} />
|
||||
{/* Smoothed trail with a recency gradient (old → new) */}
|
||||
<Scatter
|
||||
data={trail}
|
||||
line={{ stroke: 'rgba(96,165,250,0.18)', strokeWidth: 1.5 }}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{trail.map((_, i) => (
|
||||
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
|
||||
))}
|
||||
</Scatter>
|
||||
{/* Today */}
|
||||
{latest && (
|
||||
<Scatter
|
||||
data={[latest]}
|
||||
isAnimationActive={false}
|
||||
shape={(props: { cx?: number; cy?: number }) => (
|
||||
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke="#60a5fa" strokeWidth={2} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
|
||||
<span><span className="text-amber-400">① Hot & brittle</span> — narrow melt-up, shakeout risk</span>
|
||||
<span><span className="text-orange-400">② Transition</span> — break may be starting</span>
|
||||
<span><span className="text-emerald-400">③ Healthy & broad</span> — calm uptrend</span>
|
||||
<span><span className="text-red-400">④ Real downturn</span> — regime breaking, broad</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
|
||||
White dot = today; the trail fades from muted (older) to bright blue (newer) over the last {TRAIL}{' '}
|
||||
sessions, smoothed. The tell isn't a single spot but the move ①→④ (early warning rolling over while
|
||||
the regime index climbs = divergence resolving downward). Observational — not wired into trades.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { getRegimeHistory } from '../../api/regime';
|
||||
import { Callout } from '../ui/Callout';
|
||||
import { SkeletonCard } from '../ui/Skeleton';
|
||||
import { formatDate } from '../../lib/format';
|
||||
|
||||
// Lazy-loaded (see RegimePage) so recharts only ships in the regime-tab chunk.
|
||||
|
||||
const HISTORY_RANGES = [
|
||||
{ key: '1M', days: 30 },
|
||||
{ key: '3M', days: 90 },
|
||||
{ key: '6M', days: 182 },
|
||||
{ key: 'All', days: 100000 },
|
||||
] as const;
|
||||
type HistoryRange = (typeof HISTORY_RANGES)[number]['key'];
|
||||
|
||||
const HISTORY_SERIES = [
|
||||
{ key: 'index', label: 'Index', color: '#60a5fa' },
|
||||
{ key: 'early_warning', label: 'Early warning', color: '#fb923c' },
|
||||
{ key: 'combined', label: 'Combined', color: '#a78bfa' },
|
||||
] as const;
|
||||
|
||||
export default function ScoreHistoryChart() {
|
||||
const [range, setRange] = useState<HistoryRange>('3M');
|
||||
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(400) });
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const data = history.data ?? [];
|
||||
const days = HISTORY_RANGES.find((r) => r.key === range)!.days;
|
||||
if (range === 'All') return data;
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
return data.filter((p) => new Date(p.date) >= cutoff);
|
||||
}, [history.data, range]);
|
||||
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">Score history</div>
|
||||
<div className="flex gap-1">
|
||||
{HISTORY_RANGES.map((r) => (
|
||||
<button
|
||||
key={r.key}
|
||||
type="button"
|
||||
onClick={() => setRange(r.key)}
|
||||
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
|
||||
range === r.key ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{r.key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{history.isLoading ? (
|
||||
<SkeletonCard className="mt-3 h-56" />
|
||||
) : filtered.length < 2 ? (
|
||||
<Callout variant="empty">Not enough history yet — it accumulates as the daily job runs.</Callout>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-3 h-60">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={filtered} margin={{ top: 6, right: 8, left: -18, bottom: 0 }}>
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
tickFormatter={(d) => formatDate(String(d))}
|
||||
minTickGap={28}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
ticks={[0, 30, 60, 80, 100]}
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
width={28}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ReferenceLine y={30} stroke="rgba(255,255,255,0.06)" />
|
||||
<ReferenceLine y={60} stroke="rgba(255,255,255,0.06)" />
|
||||
<ReferenceLine y={80} stroke="rgba(255,255,255,0.06)" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'rgba(17,24,39,0.95)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: '#9ca3af' }}
|
||||
labelFormatter={(l) => formatDate(String(l))}
|
||||
formatter={(value) => (value == null ? '—' : Math.round(Number(value)))}
|
||||
/>
|
||||
{HISTORY_SERIES.map((s) => (
|
||||
<Line
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.label}
|
||||
stroke={s.color}
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-4">
|
||||
{HISTORY_SERIES.map((s) => (
|
||||
<span key={s.key} className="flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,15 +6,30 @@ import { Callout } from '../ui/Callout';
|
||||
import { Disclosure } from '../ui/Disclosure';
|
||||
import { Section } from '../ui/Section';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import type { BacktestBucket } from '../../lib/types';
|
||||
import type { BacktestBucket, BacktestPortfolioPolicy, BacktestStrategyVariant } from '../../lib/types';
|
||||
|
||||
function fmtR(v: number | null): string {
|
||||
if (v === null) return '—';
|
||||
function fmtR(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
|
||||
}
|
||||
function fmtPct(v: number | null): string {
|
||||
return v === null ? '—' : `${v.toFixed(1)}%`;
|
||||
}
|
||||
function fmtMoney(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
function fmtSignedPct(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return `${v > 0 ? '+' : ''}${v.toFixed(1)}%`;
|
||||
}
|
||||
function fmtDays(v: number | null | undefined): string {
|
||||
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
|
||||
}
|
||||
function fmtRPerDay(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return `${v > 0 ? '+' : ''}${v.toFixed(3)}R`;
|
||||
}
|
||||
function rColor(v: number | null): string {
|
||||
if (v === null) return 'text-gray-400';
|
||||
if (v > 0) return 'text-emerald-400';
|
||||
@@ -24,6 +39,7 @@ function rColor(v: number | null): string {
|
||||
|
||||
const SIGNAL_LABELS: Record<string, string> = {
|
||||
mom_12_1: '12–1 month momentum',
|
||||
mom_12_1_resid: '12–1 residual momentum',
|
||||
mom_6_1: '6–1 month momentum',
|
||||
mom_3_1: '3–1 month momentum',
|
||||
reversal_1m: '1-month reversal',
|
||||
@@ -32,6 +48,25 @@ const SIGNAL_LABELS: Record<string, string> = {
|
||||
vol_6m: '6-month realized volatility',
|
||||
};
|
||||
|
||||
const ABLATION_LABELS: Record<string, string> = {
|
||||
all_floors: 'All floors (current gate)',
|
||||
no_confidence_floor: 'Without confidence floor',
|
||||
no_rr_floor: 'Without R:R floor',
|
||||
no_neutral_exclusion: 'Without NEUTRAL exclusion',
|
||||
momentum_only: 'Momentum only (no floors)',
|
||||
};
|
||||
|
||||
const POLICY_LABELS: Record<string, string> = {
|
||||
target: 'S/R target exit',
|
||||
hold: 'Hold to horizon',
|
||||
};
|
||||
|
||||
// Prefer the net-of-costs number when the report carries it; older cached
|
||||
// reports (pre-cost model) fall back to gross.
|
||||
function netOrGross(r: { avg_r: number | null; net_avg_r?: number | null }): number | null {
|
||||
return r.net_avg_r ?? r.avg_r;
|
||||
}
|
||||
|
||||
// An |IC| this large, with a consistent sign, is a real (if small) edge worth
|
||||
// building on; below it, ranking on the signal sorts essentially nothing.
|
||||
const IC_EDGE_THRESHOLD = 0.03;
|
||||
@@ -76,6 +111,11 @@ function BucketRow({ label, b }: { label: string; b: BacktestBucket }) {
|
||||
<td className="num px-4 py-2.5 text-right text-gray-400">{b.expired}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(b.hit_rate)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(b.avg_r)}`}>{fmtR(b.avg_r)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(b.net_avg_r ?? null)}`}>{fmtR(b.net_avg_r ?? null)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-emerald-400">{fmtR(b.best_r)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-red-400">{fmtR(b.worst_r)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-400">{fmtDays(b.avg_hold_days)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(b.net_r_per_day ?? null)}`}>{fmtRPerDay(b.net_r_per_day)}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -85,6 +125,12 @@ export function BacktestPanel() {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
const bestTimeAvgR =
|
||||
report?.time_exit_sweep && report.time_exit_sweep.length > 0
|
||||
? Math.max(...report.time_exit_sweep.map((r) => netOrGross(r) ?? -Infinity))
|
||||
: null;
|
||||
const sim = report?.portfolio_sim ?? null;
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: () => triggerJob('backtest'),
|
||||
onSuccess: (res) => {
|
||||
@@ -131,8 +177,54 @@ export function BacktestPanel() {
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups
|
||||
({report.qualified} qualified) · weekly cadence, {report.params.horizon_days}-day horizon
|
||||
{report.params.cost_per_side_pct != null && (
|
||||
<> · net assumes {report.params.cost_per_side_pct}%/side costs</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{report.recommendation && report.recommendation.items.length > 0 && (
|
||||
<div className="glass border border-blue-400/20 p-4">
|
||||
<p className="section-index">What this backtest recommends</p>
|
||||
{report.recommendation.headline && (
|
||||
<p className="mt-1.5 text-sm font-semibold text-gray-100">
|
||||
{report.recommendation.headline}
|
||||
</p>
|
||||
)}
|
||||
<ul className="mt-2 space-y-1">
|
||||
{report.recommendation.items.map((item) => (
|
||||
<li
|
||||
key={item.topic + item.text}
|
||||
className={`text-xs ${item.text.includes('WARNING') || item.text.includes('LAGS') ? 'text-amber-400' : 'text-gray-400'}`}
|
||||
>
|
||||
{item.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{report.recommendation.note && (
|
||||
<p className="mt-2 text-[11px] text-gray-600">{report.recommendation.note}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.research_recommendation && report.research_recommendation.items.length > 0 && (
|
||||
<div className="glass border border-emerald-400/15 p-4">
|
||||
<p className="section-index">Research candidates</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{report.research_recommendation.items.map((item) => (
|
||||
<li
|
||||
key={item.topic + item.text}
|
||||
className={`text-xs ${item.candidate ? 'text-emerald-400' : 'text-gray-400'}`}
|
||||
>
|
||||
{item.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{report.research_recommendation.note && (
|
||||
<p className="mt-2 text-[11px] text-gray-600">{report.research_recommendation.note}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat
|
||||
label="Qualified Hit Rate"
|
||||
@@ -157,6 +249,30 @@ export function BacktestPanel() {
|
||||
valueClass={rColor(report.overall_qualified.total_r)}
|
||||
sub="cumulative, risk-adjusted"
|
||||
/>
|
||||
{report.overall_qualified.median_net_r != null && (
|
||||
<Stat
|
||||
label="Median Net R"
|
||||
value={fmtR(report.overall_qualified.median_net_r)}
|
||||
valueClass={rColor(report.overall_qualified.median_net_r)}
|
||||
sub="qualified · the typical trade"
|
||||
/>
|
||||
)}
|
||||
{report.overall_qualified.profit_factor != null && (
|
||||
<Stat
|
||||
label="Profit Factor"
|
||||
value={report.overall_qualified.profit_factor.toFixed(2)}
|
||||
valueClass={report.overall_qualified.profit_factor > 1 ? 'text-emerald-400' : 'text-red-400'}
|
||||
sub="qualified · net wins / net losses"
|
||||
/>
|
||||
)}
|
||||
{report.overall_qualified.net_avg_r_ex_top5 != null && (
|
||||
<Stat
|
||||
label="Ex-Top-5% Net R"
|
||||
value={fmtR(report.overall_qualified.net_avg_r_ex_top5)}
|
||||
valueClass={rColor(report.overall_qualified.net_avg_r_ex_top5)}
|
||||
sub="expectancy without the biggest winners"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass overflow-x-auto">
|
||||
@@ -170,6 +286,11 @@ export function BacktestPanel() {
|
||||
<th className="px-4 py-2.5 text-right">Expired</th>
|
||||
<th className="px-4 py-2.5 text-right">Hit Rate</th>
|
||||
<th className="px-4 py-2.5 text-right">Avg R</th>
|
||||
<th className="px-4 py-2.5 text-right">Net Avg R</th>
|
||||
<th className="px-4 py-2.5 text-right">Best R</th>
|
||||
<th className="px-4 py-2.5 text-right">Worst R</th>
|
||||
<th className="px-4 py-2.5 text-right">Avg Hold</th>
|
||||
<th className="px-4 py-2.5 text-right">Net R/d</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -187,11 +308,11 @@ export function BacktestPanel() {
|
||||
{report.sweep && report.sweep.length > 0 && report.sweep[0].min_momentum_percentile != null && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||
Momentum-percentile sweep
|
||||
Residual-momentum percentile sweep
|
||||
</p>
|
||||
<p className="mb-2 text-[11px] text-gray-500">
|
||||
How many setups qualify — and how they perform — at each momentum-rank cutoff (floors
|
||||
held fixed). 80 = only the top 20% of the universe by 12-1 momentum each week; 0 =
|
||||
How many setups qualify — and how they perform — at each production-rank cutoff (floors
|
||||
held fixed). 80 = only the top 20% of the universe by residual 12-1 momentum each week; 0 =
|
||||
floors only. Lower = more trades, watch that expectancy holds. Your current setting is
|
||||
highlighted; set it in Admin → Settings → Activation.
|
||||
</p>
|
||||
@@ -199,12 +320,13 @@ export function BacktestPanel() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-2.5">Min momentum %ile</th>
|
||||
<th className="px-4 py-2.5">Min residual %ile</th>
|
||||
<th className="px-4 py-2.5 text-right">Qualified</th>
|
||||
<th className="px-4 py-2.5 text-right">Wins</th>
|
||||
<th className="px-4 py-2.5 text-right">Losses</th>
|
||||
<th className="px-4 py-2.5 text-right">Hit Rate</th>
|
||||
<th className="px-4 py-2.5 text-right">Avg R</th>
|
||||
<th className="px-4 py-2.5 text-right">Net Avg R</th>
|
||||
<th className="px-4 py-2.5 text-right">Total R</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -222,6 +344,7 @@ export function BacktestPanel() {
|
||||
<td className="num px-4 py-2.5 text-right text-red-400">{row.losses}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.hit_rate)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_avg_r ?? null)}`}>{fmtR(row.net_avg_r ?? null)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -232,46 +355,236 @@ export function BacktestPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.gate_ablation && report.gate_ablation.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||
Probability calibration
|
||||
Gate ablation — which floors earn their keep
|
||||
</p>
|
||||
<p className="mb-2 text-[11px] text-gray-500">
|
||||
Do targets we call “X% likely” actually hit that often? Realized below predicted =
|
||||
the model is over-confident.
|
||||
{report.gate_ablation_note ??
|
||||
'Each row re-qualifies the same candidates at the current momentum cutoff with one floor removed (long-only throughout).'}
|
||||
</p>
|
||||
{report.calibration.length === 0 ? (
|
||||
<Callout variant="empty">Not enough resolved setups to calibrate.</Callout>
|
||||
) : (
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-2.5">Predicted Bucket</th>
|
||||
<th className="px-4 py-2.5">Variant</th>
|
||||
<th className="px-4 py-2.5 text-right">Setups</th>
|
||||
<th className="px-4 py-2.5 text-right">Avg Predicted</th>
|
||||
<th className="px-4 py-2.5 text-right">Realized Hit Rate</th>
|
||||
<th className="px-4 py-2.5 text-right">Hit Rate</th>
|
||||
<th className="px-4 py-2.5 text-right">Avg R</th>
|
||||
<th className="px-4 py-2.5 text-right">Net Avg R</th>
|
||||
<th className="px-4 py-2.5 text-right">Total R</th>
|
||||
<th className="px-4 py-2.5 text-right">Hold Net Avg R</th>
|
||||
<th className="px-4 py-2.5 text-right">Hold Total R</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.calibration.map((row) => {
|
||||
const over = row.realized_hit_rate < row.predicted_avg;
|
||||
return (
|
||||
<tr key={row.bucket} className="border-b border-white/[0.04]">
|
||||
<td className="px-4 py-2.5 text-gray-200">{row.bucket}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-300">{row.n}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-400">{row.predicted_avg.toFixed(0)}%</td>
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${over ? 'text-amber-400' : 'text-emerald-400'}`}>
|
||||
{row.realized_hit_rate.toFixed(0)}%
|
||||
{report.gate_ablation.map((row) => (
|
||||
<tr
|
||||
key={row.variant}
|
||||
className={`border-b border-white/[0.04] ${row.variant === 'all_floors' ? 'bg-blue-400/10' : ''}`}
|
||||
>
|
||||
<td className="px-4 py-2.5 font-medium text-gray-200">
|
||||
{ABLATION_LABELS[row.variant] ?? row.variant}
|
||||
</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.hit_rate)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.net_avg_r ?? null)}`}>
|
||||
{fmtR(row.net_avg_r ?? null)}
|
||||
</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.hold_net_avg_r ?? null)}`}>
|
||||
{fmtR(row.hold_net_avg_r ?? null)}
|
||||
</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.hold_total_r ?? null)}`}>
|
||||
{fmtR(row.hold_total_r ?? null)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.time_exit_sweep && report.time_exit_sweep.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||
Time-based exit
|
||||
</p>
|
||||
<p className="mb-2 text-[11px] text-gray-500">
|
||||
Buy at detection, keep the initial ATR stop, and exit at the{' '}
|
||||
<span className="text-gray-300">day-N close</span> — no target, no trailing. This is the
|
||||
classic cross-sectional momentum implementation (hold ~a month, re-rank).{' '}
|
||||
<span className="text-gray-300">Win Rate = share closed in profit.</span> ★ = best net avg R.
|
||||
</p>
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-2.5">Hold</th>
|
||||
<th className="px-4 py-2.5 text-right">Setups</th>
|
||||
<th className="px-4 py-2.5 text-right">Profitable</th>
|
||||
<th className="px-4 py-2.5 text-right">Win Rate</th>
|
||||
<th className="px-4 py-2.5 text-right">Avg R</th>
|
||||
<th className="px-4 py-2.5 text-right">Net Avg R</th>
|
||||
<th className="px-4 py-2.5 text-right">Total R</th>
|
||||
<th className="px-4 py-2.5 text-right">Best R</th>
|
||||
<th className="px-4 py-2.5 text-right">Worst R</th>
|
||||
<th className="px-4 py-2.5 text-right">Avg Hold</th>
|
||||
<th className="px-4 py-2.5 text-right">Net R/d</th>
|
||||
<th className="px-4 py-2.5 text-right">Median Net R</th>
|
||||
<th className="px-4 py-2.5 text-right">Ex-Top-5%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.time_exit_sweep.map((row) => {
|
||||
const best = netOrGross(row) != null && netOrGross(row) === bestTimeAvgR;
|
||||
return (
|
||||
<tr key={row.hold_days} className={`border-b border-white/[0.04] ${best ? 'bg-emerald-400/[0.06]' : ''}`}>
|
||||
<td className="num px-4 py-2.5 text-gray-200">
|
||||
{best && <span className="mr-1 text-emerald-300">★</span>}
|
||||
{row.hold_days}d
|
||||
</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.win_rate)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.net_avg_r ?? null)}`}>{fmtR(row.net_avg_r ?? null)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-emerald-400">{fmtR(row.best_r)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-red-400">{fmtR(row.worst_r)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-400">{fmtDays(row.avg_hold_days)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_r_per_day ?? null)}`}>{fmtRPerDay(row.net_r_per_day)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.median_net_r ?? null)}`}>{fmtR(row.median_net_r)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_avg_r_ex_top5 ?? null)}`}>{fmtR(row.net_avg_r_ex_top5)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sim && sim.policies.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||
Portfolio simulation
|
||||
</p>
|
||||
<p className="mb-2 text-[11px] text-gray-500">
|
||||
{sim.note ?? 'One capital-constrained book over the qualified setups.'}{' '}
|
||||
<span className="text-gray-300">
|
||||
Start {fmtMoney(sim.params.starting_capital)} · max {sim.params.max_positions} positions ·{' '}
|
||||
{sim.params.risk_per_trade_pct}% risk/trade · {sim.params.notional_cap_pct}% notional cap ·{' '}
|
||||
{sim.params.cost_per_side_pct}%/side costs · {sim.policies[0].start_date} → {sim.policies[0].end_date}
|
||||
</span>
|
||||
</p>
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-2.5">Metric</th>
|
||||
{sim.policies.map((p) => (
|
||||
<th key={p.policy} className="px-4 py-2.5 text-right">
|
||||
{POLICY_LABELS[p.policy] ?? p.policy}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(
|
||||
[
|
||||
['Final equity', (p) => fmtMoney(p.final_equity), (p) => rColor(p.final_equity - p.starting_capital)],
|
||||
['Total return', (p) => fmtSignedPct(p.total_return_pct), (p) => rColor(p.total_return_pct)],
|
||||
['SPY return (same window)', (p) => fmtSignedPct(p.spy_return_pct), () => 'text-gray-300'],
|
||||
['CAGR', (p) => fmtSignedPct(p.cagr_pct), (p) => rColor(p.cagr_pct)],
|
||||
['Max drawdown', (p) => `−${p.max_drawdown_pct.toFixed(1)}%`, () => 'text-amber-400'],
|
||||
['Sharpe (daily, annualized)', (p) => (p.sharpe === null ? '—' : p.sharpe.toFixed(2)), () => 'text-gray-200'],
|
||||
['Trades', (p) => String(p.trades), () => 'text-gray-300'],
|
||||
['Win rate', (p) => fmtPct(p.win_rate), () => 'text-gray-200'],
|
||||
['Avg P&L / trade', (p) => fmtMoney(p.avg_trade_pnl), (p) => rColor(p.avg_trade_pnl)],
|
||||
['Best / worst trade', (p) => `${fmtR(p.best_trade_r)} / ${fmtR(p.worst_trade_r)}`, () => 'text-gray-300'],
|
||||
['Avg holding time', (p) => fmtDays(p.avg_hold_days), () => 'text-gray-300'],
|
||||
[
|
||||
'Per-year returns',
|
||||
(p) =>
|
||||
p.yearly_returns && p.yearly_returns.length > 0
|
||||
? p.yearly_returns
|
||||
.map((y) => `${y.year} ${fmtSignedPct(y.return_pct)}`)
|
||||
.join(' · ')
|
||||
: '—',
|
||||
() => 'text-gray-300',
|
||||
],
|
||||
['Entries skipped (book full)', (p) => String(p.skipped_book_full), () => 'text-gray-500'],
|
||||
] as [string, (p: BacktestPortfolioPolicy) => string, (p: BacktestPortfolioPolicy) => string][]
|
||||
).map(([label, fmt, color]) => (
|
||||
<tr key={label} className="border-b border-white/[0.04]">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-200">{label}</td>
|
||||
{sim.policies.map((p) => (
|
||||
<td key={p.policy} className={`num px-4 py-2.5 text-right ${color(p)}`}>
|
||||
{fmt(p)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.strategy_variants && report.strategy_variants.variants.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||
Strategy variants
|
||||
</p>
|
||||
<p className="mb-2 text-[11px] text-gray-500">
|
||||
{report.strategy_variants.note ?? 'Research-only portfolio variants.'}
|
||||
</p>
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-2.5">Variant</th>
|
||||
<th className="px-4 py-2.5 text-right">Rank</th>
|
||||
<th className="px-4 py-2.5 text-right">Cutoff</th>
|
||||
<th className="px-4 py-2.5 text-right">Max Pos</th>
|
||||
<th className="px-4 py-2.5 text-right">Risk</th>
|
||||
<th className="px-4 py-2.5 text-right">CAGR</th>
|
||||
<th className="px-4 py-2.5 text-right">Max DD</th>
|
||||
<th className="px-4 py-2.5 text-right">Sharpe</th>
|
||||
<th className="px-4 py-2.5 text-right">Total Ret</th>
|
||||
<th className="px-4 py-2.5 text-right">Trades</th>
|
||||
<th className="px-4 py-2.5 text-right">Skipped</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.strategy_variants.variants.map((row: BacktestStrategyVariant) => (
|
||||
<tr key={row.variant} className="border-b border-white/[0.04]">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-200">{row.label}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-300">{row.ranking}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-300">{row.cutoff.toFixed(0)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-300">{row.max_positions}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-300">
|
||||
{`${row.risk_per_trade_pct.toFixed(1)}%`}
|
||||
</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.cagr_pct)}`}>{fmtSignedPct(row.cagr_pct)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-amber-400">−{row.max_drawdown_pct.toFixed(1)}%</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-200">
|
||||
{row.sharpe === null ? '—' : row.sharpe.toFixed(2)}
|
||||
</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_return_pct)}`}>{fmtSignedPct(row.total_return_pct)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-300">{row.trades}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-500">{row.skipped_book_full}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.signal_eval && report.signal_eval.length > 0 && (
|
||||
<div>
|
||||
|
||||
@@ -38,6 +38,7 @@ export function MyTradesPanel() {
|
||||
const rows = (closed ?? []).map((t) => ({ t, p: tradePnl(t) }));
|
||||
const rs = rows.map((r) => r.p?.r).filter((r): r is number => r != null);
|
||||
const pnls = rows.map((r) => r.p?.pnl ?? 0);
|
||||
const alphas = rows.map((r) => r.t.alpha_usd).filter((a): a is number => a != null);
|
||||
const wins = pnls.filter((p) => p > 0).length;
|
||||
const losses = pnls.filter((p) => p < 0).length;
|
||||
const decided = wins + losses;
|
||||
@@ -49,6 +50,7 @@ export function MyTradesPanel() {
|
||||
avgR: rs.length ? rs.reduce((a, b) => a + b, 0) / rs.length : null,
|
||||
totalR: rs.length ? rs.reduce((a, b) => a + b, 0) : null,
|
||||
totalPnl: pnls.reduce((a, b) => a + b, 0),
|
||||
totalAlpha: alphas.length ? alphas.reduce((a, b) => a + b, 0) : null,
|
||||
rows,
|
||||
};
|
||||
}, [closed]);
|
||||
@@ -64,11 +66,12 @@ export function MyTradesPanel() {
|
||||
</Callout>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Stat label="Hit Rate" value={stats.hitRate != null ? `${stats.hitRate.toFixed(1)}%` : '—'} sub={`${stats.wins}W / ${stats.losses}L`} />
|
||||
<Stat label="Expectancy" value={fmtR(stats.avgR)} valueClass={color(stats.avgR)} sub="avg R per closed trade" />
|
||||
<Stat label="Total R" value={fmtR(stats.totalR)} valueClass={color(stats.totalR)} sub={`${stats.total} closed`} />
|
||||
<Stat label="Total P&L" value={money(stats.totalPnl)} valueClass={color(stats.totalPnl)} sub="realized, all closed" />
|
||||
<Stat label="Alpha vs S&P 500" value={stats.totalAlpha != null ? money(stats.totalAlpha) : '—'} valueClass={color(stats.totalAlpha)} sub="realized vs buy-and-hold SPY" />
|
||||
</div>
|
||||
|
||||
<div className="glass overflow-x-auto">
|
||||
@@ -81,6 +84,7 @@ export function MyTradesPanel() {
|
||||
<th className="px-4 py-2.5 text-right">Exit</th>
|
||||
<th className="px-4 py-2.5 text-right">P&L</th>
|
||||
<th className="px-4 py-2.5 text-right">R</th>
|
||||
<th className="px-4 py-2.5 text-right">Alpha</th>
|
||||
<th className="px-4 py-2.5 text-right">Closed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -97,6 +101,7 @@ export function MyTradesPanel() {
|
||||
<td className="num px-4 py-2.5 text-right text-gray-300">{t.close_price != null ? formatPrice(t.close_price) : '—'}</td>
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${p ? color(p.pnl) : 'text-gray-500'}`}>{p ? money(p.pnl) : '—'}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${p?.r != null ? color(p.r) : 'text-gray-500'}`}>{p?.r != null ? fmtR(p.r) : '—'}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${t.alpha_pct != null ? color(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-500">{t.closed_at ? new Date(t.closed_at).toLocaleDateString() : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useActivation } from '../../hooks/useActivation';
|
||||
import { activationSummary } from '../../lib/qualification';
|
||||
import { usePerformance } from '../../hooks/usePerformance';
|
||||
import { useBacktestReport } from '../../hooks/useMarketRegime';
|
||||
import { triggerJob, resetTrackRecord } from '../../api/admin';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Callout } from '../ui/Callout';
|
||||
@@ -15,6 +16,14 @@ import { BacktestPanel } from './BacktestPanel';
|
||||
import { MyTradesPanel } from './MyTradesPanel';
|
||||
import type { OutcomeBucketStats } from '../../lib/types';
|
||||
|
||||
// Need at least this many matured setups before a live-vs-backtest verdict means
|
||||
// anything; below it the live sample is too noisy to compare.
|
||||
const MIN_MATURED = 20;
|
||||
// Live expectancy this far (in R) below the backtest counts as drift, not noise.
|
||||
const DRIFT_TOLERANCE_R = 0.2;
|
||||
|
||||
type TrackingStatus = 'building' | 'tracking' | 'drift' | 'no-backtest';
|
||||
|
||||
function fmtR(value: number | null): string {
|
||||
if (value === null) return '—';
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`;
|
||||
@@ -31,6 +40,17 @@ function rColor(value: number | null): string {
|
||||
return 'text-gray-300';
|
||||
}
|
||||
|
||||
function VerdictChip({ status }: { status: TrackingStatus }) {
|
||||
const styles: Record<TrackingStatus, { cls: string; label: string }> = {
|
||||
tracking: { cls: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', label: '✓ tracking' },
|
||||
drift: { cls: 'border-amber-500/30 bg-amber-500/15 text-amber-300', label: '⚠ drift' },
|
||||
building: { cls: 'border-white/10 bg-white/[0.05] text-gray-400', label: 'building' },
|
||||
'no-backtest': { cls: 'border-white/10 bg-white/[0.05] text-gray-400', label: 'no backtest' },
|
||||
};
|
||||
const s = styles[status];
|
||||
return <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${s.cls}`}>{s.label}</span>;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, valueClass = 'text-gray-100', sub }: {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -57,7 +77,7 @@ function BreakdownTable({ rows, labelHeader, mapLabel }: {
|
||||
}) {
|
||||
const entries = Object.entries(rows);
|
||||
if (entries.length === 0) {
|
||||
return <Callout variant="empty">No evaluated setups in this breakdown yet.</Callout>;
|
||||
return <Callout variant="empty">No matured setups in this breakdown yet.</Callout>;
|
||||
}
|
||||
return (
|
||||
<div className="glass overflow-x-auto">
|
||||
@@ -100,6 +120,7 @@ export function TrackRecordPanel() {
|
||||
const { data, isLoading, isError, error } = usePerformance(
|
||||
qualifiedOnly ? { qualified_only: true } : undefined,
|
||||
);
|
||||
const backtest = useBacktestReport();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
@@ -137,14 +158,60 @@ export function TrackRecordPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
// Live (matured cohort) vs the backtest, like-for-like with the qualified toggle.
|
||||
const live = data?.overall ?? null;
|
||||
const btBucket = qualifiedOnly ? backtest.data?.overall_qualified : backtest.data?.overall_all;
|
||||
const liveAvgR = live?.avg_r ?? null;
|
||||
const liveN = live?.total ?? 0;
|
||||
const btAvgR = btBucket?.avg_r ?? null;
|
||||
|
||||
let status: TrackingStatus = 'building';
|
||||
if (liveAvgR != null && liveN >= MIN_MATURED) {
|
||||
status = btAvgR == null ? 'no-backtest' : liveAvgR >= btAvgR - DRIFT_TOLERANCE_R ? 'tracking' : 'drift';
|
||||
}
|
||||
|
||||
const verdictNote: Record<TrackingStatus, string> = {
|
||||
building: `Not enough matured setups yet (need ~${MIN_MATURED}). Only setups whose full ~30-day window has elapsed are counted — the rest are still maturing. Until then, the backtest is your edge estimate; this becomes a live check as setups age past ~6 weeks.`,
|
||||
'no-backtest': 'Run the backtest below to get a baseline to compare the live record against.',
|
||||
tracking: 'Live setups are resolving in line with the backtest — the running system is faithfully implementing it (no look-ahead, config or data drift).',
|
||||
drift: 'Live expectancy is running materially below the backtest. Could be small-sample noise, a regime shift, or a config/data/look-ahead gap between live and the backtest — worth a look.',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Your real, realized results come first; the signal/theoretical record follows. */}
|
||||
{/* Your real, realized results come first; the live-vs-backtest check follows. */}
|
||||
<MyTradesPanel />
|
||||
<div className="border-t border-white/[0.06]" />
|
||||
|
||||
<div className="glass-sm flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-gray-300">
|
||||
<Section title="Live vs Backtest" hint="is the live system tracking the backtest?">
|
||||
{isError ? (
|
||||
<Callout variant="error">
|
||||
{error instanceof Error ? error.message : 'Failed to load performance stats'}
|
||||
</Callout>
|
||||
) : (
|
||||
<div className="glass-sm space-y-2.5 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
|
||||
<div className="flex flex-wrap items-baseline gap-x-5 gap-y-1">
|
||||
<span className="text-sm text-gray-400">
|
||||
Live <span className={`num font-semibold ${rColor(liveAvgR)}`}>{fmtR(liveAvgR)}</span>
|
||||
</span>
|
||||
<span className="text-sm text-gray-400">
|
||||
Backtest <span className={`num font-semibold ${rColor(btAvgR)}`}>{fmtR(btAvgR)}</span>
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{liveN} matured{data ? ` · ${data.maturing} maturing` : ''} · {qualifiedOnly ? 'qualified' : 'all setups'}
|
||||
</span>
|
||||
</div>
|
||||
<VerdictChip status={status} />
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-gray-500">{verdictNote[status]}</p>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Disclosure summary="Outcome details (matured cohort)">
|
||||
<div className="space-y-4 pt-1">
|
||||
<label className="flex w-fit cursor-pointer items-center gap-2.5 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={qualifiedOnly}
|
||||
@@ -158,31 +225,6 @@ export function TrackRecordPanel() {
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">Confidence breakdown always covers all setups.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Disclosure summary="How outcomes are measured">
|
||||
<p className="text-xs text-gray-400">
|
||||
Each setup is replayed against the daily bars after its detection: a{' '}
|
||||
<span className="text-emerald-400">win</span> means the target was reached before the
|
||||
stop, a <span className="text-red-400">loss</span> means the stop was hit first (bars
|
||||
where both levels fall inside the same day count conservatively as losses). Setups with
|
||||
neither level hit within 30 trading days <span className="text-gray-300">expire</span> at
|
||||
0R. Avg R is the expectancy per trade: wins earn their R:R ratio, losses cost −1R — a
|
||||
positive value means the signals have been profitable on a risk-adjusted basis. The
|
||||
evaluator runs nightly after OHLCV collection.
|
||||
</p>
|
||||
</Disclosure>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending}>
|
||||
{evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={onReset} loading={resetMutation.isPending}>
|
||||
{resetMutation.isPending ? 'Resetting…' : 'Reset'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -190,18 +232,12 @@ export function TrackRecordPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Callout variant="error">
|
||||
{error instanceof Error ? error.message : 'Failed to load performance stats'}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{data && data.overall.total === 0 && (
|
||||
<Callout variant="empty">
|
||||
{qualifiedOnly
|
||||
? 'No evaluated setups meet the activation thresholds yet. Untick "Qualified signals only" to see all evaluated setups, or wait for more outcomes.'
|
||||
: 'No evaluated setups yet. Outcomes appear once setups are old enough for their stop or target to be hit — the evaluator runs nightly, or click Evaluate Now.'}
|
||||
{data.pending > 0 && ` ${data.pending} setup${data.pending === 1 ? '' : 's'} pending evaluation.`}
|
||||
{data.maturing > 0
|
||||
? `No setups have completed their ~30-day window yet — ${data.maturing} still maturing. ` +
|
||||
'Counting them earlier would skew toward quick stop-outs.'
|
||||
: 'No matured setups yet. Outcomes appear once setups complete their evaluation window — the evaluator runs nightly, or click Evaluate Now.'}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
@@ -226,26 +262,42 @@ export function TrackRecordPanel() {
|
||||
sub="cumulative risk-adjusted result"
|
||||
/>
|
||||
<StatCard
|
||||
label="Evaluated"
|
||||
label="Matured"
|
||||
value={String(data.overall.total)}
|
||||
sub={`${data.pending} pending · ${data.overall.expired} expired`}
|
||||
sub={`${data.maturing} maturing · ${data.overall.expired} expired`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Section title="By Direction">
|
||||
<BreakdownTable rows={data.by_direction} labelHeader="Direction" />
|
||||
</Section>
|
||||
|
||||
<Section title="By Recommended Action">
|
||||
<BreakdownTable rows={data.by_action} labelHeader="Action" mapLabel={actionLabel} />
|
||||
</Section>
|
||||
|
||||
<Section title="By Confidence" hint="at detection time">
|
||||
<Section title="By Confidence" hint="at detection time · all setups">
|
||||
<BreakdownTable rows={data.by_confidence} labelHeader="Confidence" />
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-white/[0.06] pt-3">
|
||||
<p className="max-w-2xl text-xs text-gray-500">
|
||||
Each setup is replayed against the daily bars after detection: target before stop = win,
|
||||
stop first = loss (both in one bar counts conservatively as a loss), neither within 30
|
||||
trading days = expired at 0R. Only setups whose full window has elapsed are counted; younger
|
||||
ones are still <span className="text-gray-300">maturing</span> (near stops resolve fast, far
|
||||
targets need time, so early numbers would skew negative). The evaluator runs nightly.
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending}>
|
||||
{evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={onReset} loading={resetMutation.isPending}>
|
||||
{resetMutation.isPending ? 'Resetting…' : 'Reset'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure>
|
||||
|
||||
<div className="border-t border-white/[0.06] pt-2" />
|
||||
<BacktestPanel />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
ZAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
ReferenceArea,
|
||||
} from 'recharts';
|
||||
|
||||
// Lazy-loaded by TickerDetailPage so recharts stays out of the main ticker chunk.
|
||||
|
||||
export interface FieldPoint {
|
||||
symbol: string;
|
||||
composite: number;
|
||||
momentum: number;
|
||||
}
|
||||
|
||||
interface StandingMatrixProps {
|
||||
symbol: string;
|
||||
composite: number | null; // X for the highlighted dot (authoritative, from the scores endpoint)
|
||||
momentum: number | null; // Y for the highlighted dot (residual 12-1 momentum percentile)
|
||||
field: FieldPoint[]; // every tracked ticker, for the background cloud
|
||||
gateMomentum: number; // Y divider = the activation gate's momentum percentile
|
||||
status: 'top-pick' | 'qualified' | 'none';
|
||||
confidence?: number | null; // long confidence, for the verdict sidebar
|
||||
}
|
||||
|
||||
// X divider: composite midpoint between "amber" (40–70) and clearly good (>70).
|
||||
const QUALITY_DIV = 60;
|
||||
|
||||
type Tone = 'emerald' | 'amber' | 'sky' | 'slate';
|
||||
|
||||
const TONE: Record<Tone, { text: string; dot: string }> = {
|
||||
emerald: { text: 'text-emerald-300', dot: '#10b981' },
|
||||
amber: { text: 'text-amber-300', dot: '#f59e0b' },
|
||||
sky: { text: 'text-sky-300', dot: '#38bdf8' },
|
||||
slate: { text: 'text-gray-300', dot: '#94a3b8' },
|
||||
};
|
||||
|
||||
function verdict(composite: number, momentum: number, gate: number): { label: string; tone: Tone; note: string } {
|
||||
const q = composite >= QUALITY_DIV;
|
||||
const m = momentum >= gate;
|
||||
if (m && q) return { label: 'Strong Buy', tone: 'emerald', note: 'Solid quality and top-tier momentum — clears the gate.' };
|
||||
if (m && !q) return { label: 'Momentum', tone: 'amber', note: 'Trending hard, but quality is thin — speculative.' };
|
||||
if (!m && q) return { label: 'Accumulate', tone: 'sky', note: 'Good quality; momentum not yet in the top tier.' };
|
||||
return { label: 'Pass', tone: 'slate', note: 'Neither quality nor momentum stands out yet.' };
|
||||
}
|
||||
|
||||
function MatrixTip({ active, payload }: { active?: boolean; payload?: { payload: FieldPoint }[] }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0].payload;
|
||||
return (
|
||||
<div className="glass px-2.5 py-1.5 text-[11px]">
|
||||
<div className="text-gray-200">{p.symbol}</div>
|
||||
<div className="text-gray-400">
|
||||
quality <span className="text-gray-200">{Math.round(p.composite)}</span> · momentum{' '}
|
||||
<span className="text-gray-200">{Math.round(p.momentum)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span>{label}</span>
|
||||
<span className="num text-gray-300">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StandingMatrix({
|
||||
symbol,
|
||||
composite,
|
||||
momentum,
|
||||
field,
|
||||
gateMomentum,
|
||||
status,
|
||||
confidence,
|
||||
}: StandingMatrixProps) {
|
||||
const navigate = useNavigate();
|
||||
const gate = gateMomentum > 0 ? gateMomentum : 80;
|
||||
const sym = symbol.toUpperCase();
|
||||
|
||||
const here = useMemo<FieldPoint | null>(
|
||||
() => (composite != null && momentum != null ? { symbol: sym, composite, momentum } : null),
|
||||
[sym, composite, momentum],
|
||||
);
|
||||
// Background cloud excludes this ticker — it's drawn separately, highlighted.
|
||||
const others = useMemo(() => field.filter((p) => p.symbol.toUpperCase() !== sym), [field, sym]);
|
||||
|
||||
const v = here ? verdict(here.composite, here.momentum, gate) : null;
|
||||
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||
Standing — quality × momentum vs. the field
|
||||
</div>
|
||||
{status === 'top-pick' && (
|
||||
<span className="rounded-full border border-blue-500/30 bg-blue-500/15 px-2.5 py-0.5 text-[11px] font-medium text-blue-300">
|
||||
★ Top Pick
|
||||
</span>
|
||||
)}
|
||||
{status === 'qualified' && (
|
||||
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/15 px-2.5 py-0.5 text-[11px] font-medium text-emerald-300">
|
||||
✓ Qualified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-4 lg:grid-cols-5">
|
||||
<div className="h-72 lg:col-span-3">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
|
||||
{/* Quadrant shading (behind everything) */}
|
||||
<ReferenceArea x1={QUALITY_DIV} x2={100} y1={gate} y2={100} fill="#10b981" fillOpacity={0.07} stroke="none" />
|
||||
<ReferenceArea x1={0} x2={QUALITY_DIV} y1={gate} y2={100} fill="#f59e0b" fillOpacity={0.06} stroke="none" />
|
||||
<ReferenceArea x1={QUALITY_DIV} x2={100} y1={0} y2={gate} fill="#38bdf8" fillOpacity={0.06} stroke="none" />
|
||||
<ReferenceArea x1={0} x2={QUALITY_DIV} y1={0} y2={gate} fill="#94a3b8" fillOpacity={0.05} stroke="none" />
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
|
||||
<ReferenceLine x={QUALITY_DIV} stroke="rgba(255,255,255,0.12)" />
|
||||
<ReferenceLine y={gate} stroke="rgba(255,255,255,0.12)" strokeDasharray="4 4" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="composite"
|
||||
domain={[0, 100]}
|
||||
ticks={[0, 20, 40, 60, 80, 100]}
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
|
||||
label={{ value: 'Quality (composite) →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="momentum"
|
||||
domain={[0, 100]}
|
||||
ticks={[0, 20, 40, 60, 80, 100]}
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
width={30}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={{ value: 'Momentum pct', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
|
||||
/>
|
||||
<ZAxis range={[20, 20]} />
|
||||
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<MatrixTip />} />
|
||||
<Scatter
|
||||
data={others}
|
||||
isAnimationActive={false}
|
||||
onClick={(p: any) => p?.symbol && navigate(`/ticker/${p.symbol}`)}
|
||||
shape={(props: { cx?: number; cy?: number }) => (
|
||||
<circle cx={props.cx} cy={props.cy} r={3} fill="rgba(148,163,184,0.35)" className="cursor-pointer" />
|
||||
)}
|
||||
/>
|
||||
{here && v && (
|
||||
<Scatter
|
||||
data={[here]}
|
||||
isAnimationActive={false}
|
||||
shape={(props: { cx?: number; cy?: number }) => (
|
||||
<circle
|
||||
cx={props.cx}
|
||||
cy={props.cy}
|
||||
r={7}
|
||||
fill="#ffffff"
|
||||
stroke={TONE[v.tone].dot}
|
||||
strokeWidth={3}
|
||||
style={{ filter: `drop-shadow(0 0 6px ${TONE[v.tone].dot}66)` }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center lg:col-span-2">
|
||||
{v && here ? (
|
||||
<>
|
||||
<div className={`text-2xl font-semibold ${TONE[v.tone].text}`}>{v.label}</div>
|
||||
<p className="mt-1 text-sm leading-snug text-gray-400">{v.note}</p>
|
||||
<div className="mt-3 space-y-1 text-xs text-gray-500">
|
||||
<StatRow label="Quality (composite)" value={`${Math.round(here.composite)}`} />
|
||||
<StatRow label="Residual momentum percentile" value={`${Math.round(here.momentum)}`} />
|
||||
{confidence != null && <StatRow label="Long confidence" value={`${Math.round(confidence)}%`} />}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
No active setup, so this ticker isn’t ranked on the momentum axis yet. Run the scanner to place it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
|
||||
<span><span className="text-emerald-400">Strong Buy</span> — quality + momentum (top-right)</span>
|
||||
<span><span className="text-amber-400">Momentum</span> — trend without the quality</span>
|
||||
<span><span className="text-sky-400">Accumulate</span> — quality, awaiting momentum</span>
|
||||
<span><span className="text-gray-400">Pass</span> — neither stands out</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
|
||||
Each dot is a tracked ticker; <span className="text-gray-300">this one is highlighted</span>. The dashed line is the
|
||||
activation gate ({Math.round(gate)}th-pct residual momentum) — above it qualifies for a top pick. Click any peer to open it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import type { DimensionScoreDetail, CompositeBreakdown } from '../../lib/types';
|
||||
interface ScoreCardProps {
|
||||
compositeScore: number | null;
|
||||
dimensions: DimensionScoreDetail[];
|
||||
compositeBreakdown?: CompositeBreakdown;
|
||||
compositeBreakdown?: CompositeBreakdown | null;
|
||||
/** Hide the composite ring/header when the composite is shown elsewhere
|
||||
* (e.g. the Standing matrix) and this card only carries the dimension detail. */
|
||||
showComposite?: boolean;
|
||||
}
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
@@ -51,7 +54,7 @@ function ScoreRing({ score }: { score: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: ScoreCardProps) {
|
||||
export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, showComposite = true }: ScoreCardProps) {
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleExpand = (dimension: string) => {
|
||||
@@ -60,6 +63,7 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: Sc
|
||||
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
{showComposite && (
|
||||
<div className="flex items-center gap-4">
|
||||
{compositeScore !== null ? (
|
||||
<ScoreRing score={compositeScore} />
|
||||
@@ -72,15 +76,22 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: Sc
|
||||
{compositeScore !== null ? Math.round(compositeScore) : '—'}
|
||||
</p>
|
||||
{compositeBreakdown && (
|
||||
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[200px]" data-testid="renorm-explanation">
|
||||
Weighted average of available dimensions with re-normalized weights.
|
||||
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[220px]" data-testid="renorm-explanation">
|
||||
{compositeBreakdown.sentiment_adjustment != null &&
|
||||
compositeBreakdown.base_score != null &&
|
||||
Math.abs(compositeBreakdown.sentiment_adjustment) >= 0.05
|
||||
? `Base ${Math.round(compositeBreakdown.base_score)} · sentiment ${
|
||||
compositeBreakdown.sentiment_adjustment >= 0 ? '+' : '−'
|
||||
}${Math.abs(compositeBreakdown.sentiment_adjustment).toFixed(1)}`
|
||||
: 'Weighted base of the other dimensions; sentiment adjusts it up or down.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dimensions.length > 0 && (
|
||||
<div className="mt-5 space-y-1">
|
||||
<div className={`${showComposite ? 'mt-5' : ''} space-y-1`}>
|
||||
<p className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Dimensions</p>
|
||||
{dimensions.map((d) => {
|
||||
const isExpanded = expanded[d.dimension] ?? false;
|
||||
@@ -102,11 +113,26 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: Sc
|
||||
{d.dimension}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{weight != null && (
|
||||
{d.dimension === 'sentiment' && compositeBreakdown?.sentiment_adjustment != null ? (
|
||||
<span
|
||||
className={`text-[10px] tabular-nums ${
|
||||
compositeBreakdown.sentiment_adjustment > 0.05
|
||||
? 'text-emerald-400/80'
|
||||
: compositeBreakdown.sentiment_adjustment < -0.05
|
||||
? 'text-red-400/80'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
data-testid="weight-sentiment"
|
||||
title="Points sentiment adds to or subtracts from the base composite"
|
||||
>
|
||||
{compositeBreakdown.sentiment_adjustment >= 0 ? '+' : '−'}
|
||||
{Math.abs(compositeBreakdown.sentiment_adjustment).toFixed(1)}
|
||||
</span>
|
||||
) : weight != null ? (
|
||||
<span className="text-[10px] text-gray-500 tabular-nums" data-testid={`weight-${d.dimension}`}>
|
||||
{Math.round(weight * 100)}%
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
|
||||
<div
|
||||
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function WatchlistTable({ entries }: WatchlistTableProps) {
|
||||
<th className="px-4 py-3">Dimensions</th>
|
||||
<th className="px-4 py-3">R:R</th>
|
||||
<th className="px-4 py-3">Direction</th>
|
||||
<th className="px-4 py-3">S/R Levels</th>
|
||||
<th className="px-4 py-3">Momentum</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -114,15 +114,9 @@ export function WatchlistTable({ entries }: WatchlistTableProps) {
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
{entry.sr_levels.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entry.sr_levels.map((level, i) => (
|
||||
<span key={i} className={`text-xs ${level.type === 'support' ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{formatPrice(level.price_level)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<td className="px-4 py-3.5 num text-gray-200">
|
||||
{entry.momentum_percentile !== null ? (
|
||||
`${Math.round(entry.momentum_percentile)}%ile`
|
||||
) : (
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
|
||||
@@ -283,6 +283,22 @@ export function useBootstrapTickers() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useBackfillTickerNames() {
|
||||
const qc = useQueryClient();
|
||||
const { addToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => adminApi.backfillTickerNames(),
|
||||
onSuccess: (result) => {
|
||||
qc.invalidateQueries({ queryKey: ['tickers'] });
|
||||
addToast('success', `Company names: +${result.updated} filled (${result.unmatched} unmatched)`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast('error', error.message || 'Failed to backfill company names');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Jobs ──
|
||||
|
||||
export function useJobs() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as api from '../api/paperTrades';
|
||||
import type { ExitPolicy } from '../lib/types';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
|
||||
export function usePaperTrades(status?: 'open' | 'closed') {
|
||||
@@ -10,6 +11,27 @@ export function usePaperTrades(status?: 'open' | 'closed') {
|
||||
});
|
||||
}
|
||||
|
||||
export function useExitPolicy() {
|
||||
return useQuery({
|
||||
queryKey: ['paper-trades', 'exit-policy'],
|
||||
queryFn: () => api.getExitPolicy(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateExitPolicy() {
|
||||
const qc = useQueryClient();
|
||||
const { addToast } = useToast();
|
||||
return useMutation({
|
||||
mutationFn: (body: Partial<ExitPolicy>) => api.updateExitPolicy(body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['paper-trades'] });
|
||||
addToast('success', 'Exit policy updated.');
|
||||
},
|
||||
onError: (e: Error) => addToast('error', e.message || 'Failed to update exit policy'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePaperTrade() {
|
||||
const qc = useQueryClient();
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as tickersApi from '../api/tickers';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
@@ -9,6 +10,19 @@ export function useTickers() {
|
||||
});
|
||||
}
|
||||
|
||||
/** symbol (upper) → company name, from the tracked-ticker list. Shared lookup so
|
||||
* any view can show the company behind a symbol without its own request. */
|
||||
export function useTickerNames(): Map<string, string> {
|
||||
const { data } = useTickers();
|
||||
return useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const t of data ?? []) {
|
||||
if (t.name) map.set(t.symbol.toUpperCase(), t.name);
|
||||
}
|
||||
return map;
|
||||
}, [data]);
|
||||
}
|
||||
|
||||
export function useAddTicker() {
|
||||
const qc = useQueryClient();
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -25,6 +25,9 @@ export function summarizeIngestionResult(
|
||||
if (info.status === 'ok') {
|
||||
return `${label} ✓`;
|
||||
}
|
||||
if (info.status === 'warning') {
|
||||
return `${label} ⚠${info.message ? `: ${info.message}` : ': no data'}`;
|
||||
}
|
||||
if (info.status === 'skipped') {
|
||||
return `${label}: skipped${info.message ? ` (${info.message})` : ''}`;
|
||||
}
|
||||
@@ -32,8 +35,10 @@ export function summarizeIngestionResult(
|
||||
});
|
||||
|
||||
const hasError = entries.some(([, source]) => source.status === 'error');
|
||||
const hasWarning = entries.some(([, source]) => source.status === 'warning');
|
||||
const hasSkip = entries.some(([, source]) => source.status === 'skipped');
|
||||
const toastType: IngestionToastType = hasError ? 'error' : hasSkip ? 'info' : 'success';
|
||||
// A warning (e.g. 0 bars returned) must not read as success.
|
||||
const toastType: IngestionToastType = hasError ? 'error' : hasWarning || hasSkip ? 'info' : 'success';
|
||||
|
||||
return {
|
||||
toastType,
|
||||
|
||||
@@ -2,6 +2,13 @@ import type { ActivationConfig, TradeSetup } from './types';
|
||||
|
||||
const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
|
||||
|
||||
function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'short' | 'neutral' {
|
||||
if (!action || action === 'NEUTRAL') return 'neutral';
|
||||
if (action.startsWith('LONG')) return 'long';
|
||||
if (action.startsWith('SHORT')) return 'short';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
export function bestTargetProbability(setup: TradeSetup): number {
|
||||
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0;
|
||||
}
|
||||
@@ -33,15 +40,20 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
|
||||
return false;
|
||||
}
|
||||
if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
|
||||
// Cross-sectional momentum is the core selection (long-only). While the gate is
|
||||
// active, shorts never qualify; the percentile floor is enforced only when a
|
||||
// percentile is attached, otherwise defer to the floors.
|
||||
// Residual cross-sectional momentum is the core selection (long-only). While
|
||||
// the gate is active, shorts never qualify; the percentile floor is enforced
|
||||
// only when a percentile is attached, otherwise defer to the floors.
|
||||
if (config.min_momentum_percentile > 0) {
|
||||
if (setup.direction === 'short') return false;
|
||||
if (setup.momentum_percentile != null && setup.momentum_percentile < config.min_momentum_percentile) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// NEUTRAL = "no clear setup"; an opposite action means this setup is counter-bias.
|
||||
if (config.exclude_neutral) {
|
||||
const actionDir = actionDirection(setup.recommended_action);
|
||||
if (actionDir === 'neutral' || actionDir !== setup.direction) return false;
|
||||
}
|
||||
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
|
||||
return false;
|
||||
}
|
||||
@@ -49,11 +61,31 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbol of the current single 'top pick' — the #1 row the dashboard highlights:
|
||||
* the highest residual 12-1 momentum percentile among qualified setups. Returns
|
||||
* null when there are no actionable setups. Keep in step with the Top Setups
|
||||
* ranking in DashboardPage.
|
||||
*/
|
||||
export function topPickSymbol(
|
||||
trades: TradeSetup[] | undefined,
|
||||
activation: ActivationConfig | undefined,
|
||||
): string | null {
|
||||
const all = trades ?? [];
|
||||
if (all.length === 0) return null;
|
||||
const qualified = activation ? all.filter((t) => qualifiesSetup(t, activation)) : [];
|
||||
const top = [...qualified].sort(
|
||||
(a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity),
|
||||
)[0];
|
||||
return top?.symbol ?? null;
|
||||
}
|
||||
|
||||
/** Short human summary of the active gate, e.g. for tooltips/labels. */
|
||||
export function activationSummary(config: ActivationConfig): string {
|
||||
const parts = [];
|
||||
if (config.min_momentum_percentile > 0) parts.push(`top ${(100 - config.min_momentum_percentile).toFixed(0)}% momentum`);
|
||||
if (config.min_momentum_percentile > 0) parts.push(`top ${(100 - config.min_momentum_percentile).toFixed(0)}% residual momentum`);
|
||||
parts.push(`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`);
|
||||
if (config.exclude_neutral) parts.push('directional');
|
||||
if (config.require_high_conviction) parts.push('high-conviction');
|
||||
if (config.exclude_conflicts) parts.push('clean');
|
||||
return parts.join(' · ');
|
||||
|
||||
+176
-12
@@ -19,6 +19,7 @@ export interface WatchlistEntry {
|
||||
dimensions: DimensionScore[];
|
||||
rr_ratio: number | null;
|
||||
rr_direction: string | null;
|
||||
momentum_percentile: number | null;
|
||||
sr_levels: SRLevelSummary[];
|
||||
last_close: number | null;
|
||||
change_pct: number | null;
|
||||
@@ -81,6 +82,10 @@ export interface CompositeBreakdown {
|
||||
missing_dimensions: string[];
|
||||
renormalized_weights: Record<string, number>;
|
||||
formula: string;
|
||||
base_score?: number | null;
|
||||
sentiment_score?: number | null;
|
||||
sentiment_adjustment?: number | null;
|
||||
max_sentiment_adjustment?: number | null;
|
||||
}
|
||||
|
||||
export interface ScoreResponse {
|
||||
@@ -91,7 +96,7 @@ export interface ScoreResponse {
|
||||
dimensions: DimensionScoreDetail[];
|
||||
missing_dimensions: string[];
|
||||
computed_at: string | null;
|
||||
composite_breakdown?: CompositeBreakdown;
|
||||
composite_breakdown?: CompositeBreakdown | null;
|
||||
}
|
||||
|
||||
export interface DimensionScoreDetail {
|
||||
@@ -99,12 +104,13 @@ export interface DimensionScoreDetail {
|
||||
score: number;
|
||||
is_stale: boolean;
|
||||
computed_at: string | null;
|
||||
breakdown?: ScoreBreakdown;
|
||||
breakdown?: ScoreBreakdown | null;
|
||||
}
|
||||
|
||||
export interface RankingEntry {
|
||||
symbol: string;
|
||||
composite_score: number;
|
||||
composite_stale: boolean;
|
||||
dimensions: DimensionScoreDetail[];
|
||||
}
|
||||
|
||||
@@ -135,9 +141,18 @@ export interface TradeSetup {
|
||||
evaluated_at: string | null;
|
||||
current_price: number | null;
|
||||
momentum_percentile?: number | null;
|
||||
context_as_of?: TradeSetupContextAsOf | null;
|
||||
recommendation_summary?: RecommendationSummary;
|
||||
}
|
||||
|
||||
export interface TradeSetupContextAsOf {
|
||||
setup_detected_at: string;
|
||||
score_computed_at: string | null;
|
||||
sentiment_at: string | null;
|
||||
price_date: string | null;
|
||||
price_updated_at: string | null;
|
||||
}
|
||||
|
||||
// Performance / outcome statistics
|
||||
export interface OutcomeBucketStats {
|
||||
total: number;
|
||||
@@ -152,6 +167,7 @@ export interface OutcomeBucketStats {
|
||||
export interface PerformanceStats {
|
||||
overall: OutcomeBucketStats;
|
||||
pending: number;
|
||||
maturing: number;
|
||||
by_direction: Record<string, OutcomeBucketStats>;
|
||||
by_action: Record<string, OutcomeBucketStats>;
|
||||
by_confidence: Record<string, OutcomeBucketStats>;
|
||||
@@ -164,6 +180,7 @@ export interface ActivationConfig {
|
||||
min_confidence: number;
|
||||
require_high_conviction: boolean;
|
||||
exclude_conflicts: boolean;
|
||||
exclude_neutral: boolean;
|
||||
}
|
||||
|
||||
// Cron schedule for the daily/intraday pipelines + fundamentals
|
||||
@@ -201,6 +218,18 @@ export interface PaperTrade {
|
||||
close_price: number | null;
|
||||
closed_at: string | null;
|
||||
current_price: number | null;
|
||||
benchmark_return_pct: number | null;
|
||||
alpha_pct: number | null;
|
||||
alpha_usd: number | null;
|
||||
close_reason: 'time' | 'trailing' | 'stop' | 'target' | 'manual' | null;
|
||||
trailing_stop: number | null;
|
||||
trailing_distance_pct: number | null;
|
||||
}
|
||||
|
||||
export interface ExitPolicy {
|
||||
mode: 'time' | 'trailing' | 'target';
|
||||
trailing_pct: number;
|
||||
hold_days: number;
|
||||
}
|
||||
|
||||
export interface BacktestBucket {
|
||||
@@ -211,19 +240,112 @@ export interface BacktestBucket {
|
||||
hit_rate: number | null;
|
||||
avg_r: number | null;
|
||||
total_r: number | null;
|
||||
}
|
||||
|
||||
export interface BacktestCalibrationRow {
|
||||
bucket: string;
|
||||
n: number;
|
||||
predicted_avg: number;
|
||||
realized_hit_rate: number;
|
||||
// Net of transaction costs — optional so a stale cached report still renders.
|
||||
net_avg_r?: number | null;
|
||||
net_total_r?: number | null;
|
||||
best_r?: number | null;
|
||||
worst_r?: number | null;
|
||||
avg_hold_days?: number | null;
|
||||
net_r_per_day?: number | null;
|
||||
// Robustness: distribution shape, and expectancy without the top winners.
|
||||
median_net_r?: number | null;
|
||||
profit_factor?: number | null;
|
||||
net_avg_r_ex_top5?: number | null;
|
||||
}
|
||||
|
||||
export interface BacktestSweepRow extends BacktestBucket {
|
||||
min_momentum_percentile: number;
|
||||
}
|
||||
|
||||
export interface BacktestTimeExitRow {
|
||||
hold_days: number;
|
||||
total: number;
|
||||
wins: number;
|
||||
win_rate: number | null;
|
||||
avg_r: number | null;
|
||||
total_r: number | null;
|
||||
net_avg_r?: number | null;
|
||||
net_total_r?: number | null;
|
||||
best_r?: number | null;
|
||||
worst_r?: number | null;
|
||||
avg_hold_days?: number | null;
|
||||
net_r_per_day?: number | null;
|
||||
median_net_r?: number | null;
|
||||
profit_factor?: number | null;
|
||||
net_avg_r_ex_top5?: number | null;
|
||||
}
|
||||
|
||||
export interface BacktestPortfolioPolicy {
|
||||
policy: string;
|
||||
starting_capital: number;
|
||||
final_equity: number;
|
||||
total_return_pct: number;
|
||||
cagr_pct: number | null;
|
||||
max_drawdown_pct: number;
|
||||
sharpe: number | null;
|
||||
trades: number;
|
||||
win_rate: number | null;
|
||||
avg_trade_pnl: number | null;
|
||||
best_trade_r: number | null;
|
||||
worst_trade_r: number | null;
|
||||
best_trade_pnl: number | null;
|
||||
worst_trade_pnl: number | null;
|
||||
avg_hold_days: number | null;
|
||||
skipped_book_full: number;
|
||||
spy_return_pct: number | null;
|
||||
yearly_returns?: { year: number; return_pct: number | null }[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
}
|
||||
|
||||
export interface BacktestRecommendation {
|
||||
headline: string | null;
|
||||
items: { topic: string; text: string }[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface BacktestResearchRecommendation {
|
||||
items: { topic: string; text: string; candidate?: boolean }[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface BacktestPortfolioSim {
|
||||
params: {
|
||||
starting_capital: number;
|
||||
max_positions: number;
|
||||
risk_per_trade_pct: number;
|
||||
notional_cap_pct: number;
|
||||
cost_per_side_pct: number;
|
||||
hold_days: number;
|
||||
};
|
||||
policies: BacktestPortfolioPolicy[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface BacktestStrategyVariant extends BacktestPortfolioPolicy {
|
||||
variant: string;
|
||||
label: string;
|
||||
ranking: 'raw' | 'residual' | string;
|
||||
cutoff: number;
|
||||
max_positions: number;
|
||||
risk_per_trade_pct: number;
|
||||
risk_scale: string | null;
|
||||
}
|
||||
|
||||
export interface BacktestStrategyVariants {
|
||||
variants: BacktestStrategyVariant[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface BacktestGateAblationRow extends BacktestBucket {
|
||||
variant: string;
|
||||
// The same variant graded under the hold-to-horizon time exit.
|
||||
hold_days?: number;
|
||||
hold_avg_r?: number | null;
|
||||
hold_net_avg_r?: number | null;
|
||||
hold_total_r?: number | null;
|
||||
}
|
||||
|
||||
export interface BacktestSignalEvalRow {
|
||||
signal: string;
|
||||
weeks: number;
|
||||
@@ -240,13 +362,24 @@ export interface BacktestReport {
|
||||
tickers: number;
|
||||
candidates: number;
|
||||
qualified: number;
|
||||
params: { step_days: number; horizon_days: number; min_lookback: number };
|
||||
params: {
|
||||
step_days: number;
|
||||
horizon_days: number;
|
||||
min_lookback: number;
|
||||
cost_per_side_pct?: number;
|
||||
};
|
||||
overall_qualified: BacktestBucket;
|
||||
overall_all: BacktestBucket;
|
||||
by_direction: Record<string, BacktestBucket>;
|
||||
min_momentum_percentile: number;
|
||||
sweep: BacktestSweepRow[];
|
||||
calibration: BacktestCalibrationRow[];
|
||||
gate_ablation?: BacktestGateAblationRow[];
|
||||
gate_ablation_note?: string;
|
||||
time_exit_sweep?: BacktestTimeExitRow[];
|
||||
portfolio_sim?: BacktestPortfolioSim;
|
||||
strategy_variants?: BacktestStrategyVariants;
|
||||
recommendation?: BacktestRecommendation;
|
||||
research_recommendation?: BacktestResearchRecommendation;
|
||||
signal_eval?: BacktestSignalEvalRow[];
|
||||
signal_eval_note?: string;
|
||||
note: string;
|
||||
@@ -275,6 +408,20 @@ export interface RegimeSignal {
|
||||
contribution: number;
|
||||
}
|
||||
|
||||
export interface RegimeSubScore {
|
||||
score: number | null;
|
||||
band: RegimeBand | null;
|
||||
delta_7?: number | null;
|
||||
delta_30?: number | null;
|
||||
}
|
||||
|
||||
export interface RegimeHistoryPoint {
|
||||
date: string;
|
||||
index: number;
|
||||
early_warning: number | null;
|
||||
combined: number | null;
|
||||
}
|
||||
|
||||
export interface RegimeMonitor {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
@@ -289,6 +436,10 @@ export interface RegimeMonitor {
|
||||
fundamentals_fetched_at: string | null;
|
||||
};
|
||||
trend?: { delta_7: number | null; delta_30: number | null };
|
||||
// Separate, observational early-warning score (breadth divergence) + a small
|
||||
// combined blend. Decoupled from the index above.
|
||||
early_warning?: RegimeSubScore;
|
||||
combined?: RegimeSubScore;
|
||||
}
|
||||
|
||||
export interface RegimeFundamentals {
|
||||
@@ -316,6 +467,7 @@ export interface EventStudyLeadStats {
|
||||
median_lead_days: number | null;
|
||||
events_with_signal: number;
|
||||
events_total: number;
|
||||
warn_threshold: number;
|
||||
mean_path: { rel_day: number; value: number }[];
|
||||
signal: {
|
||||
base_rate: number;
|
||||
@@ -324,6 +476,13 @@ export interface EventStudyLeadStats {
|
||||
};
|
||||
}
|
||||
|
||||
export interface EventStudyPerEvent {
|
||||
date: string;
|
||||
depth_pct: number;
|
||||
breadth_lead: number | null;
|
||||
coincident_lead: number | null;
|
||||
}
|
||||
|
||||
export interface EventStudyReport {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
@@ -331,14 +490,16 @@ export interface EventStudyReport {
|
||||
params?: {
|
||||
benchmark: string;
|
||||
event_threshold_pct: number;
|
||||
cooldown_days: number;
|
||||
horizon_days: number;
|
||||
warn_threshold: number;
|
||||
warn_percentile: number;
|
||||
};
|
||||
events?: { date: string; index: number; depth_pct: number }[];
|
||||
indicators?: {
|
||||
breadth_divergence: EventStudyLeadStats;
|
||||
coincident_price: EventStudyLeadStats;
|
||||
};
|
||||
per_event?: EventStudyPerEvent[];
|
||||
lead_delta_days?: number | null;
|
||||
recent_breadth?: { date: string; breadth: number; divergence: number | null }[];
|
||||
}
|
||||
@@ -352,6 +513,8 @@ export interface AlertConfig {
|
||||
sr_proximity_enabled: boolean;
|
||||
score_drop_enabled: boolean;
|
||||
digest_enabled: boolean;
|
||||
regime_quadrant_enabled: boolean;
|
||||
trade_closed_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AlertTestResult {
|
||||
@@ -464,6 +627,7 @@ export interface EMACrossResult {
|
||||
export interface Ticker {
|
||||
id: number;
|
||||
symbol: string;
|
||||
name: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { ActivationSettings } from '../components/admin/ActivationSettings';
|
||||
import { ExitPolicySettings } from '../components/admin/ExitPolicySettings';
|
||||
import { AlertSettings } from '../components/admin/AlertSettings';
|
||||
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
|
||||
import { DataCleanup } from '../components/admin/DataCleanup';
|
||||
@@ -33,6 +34,7 @@ export default function AdminPage() {
|
||||
{activeTab === 'Settings' && (
|
||||
<div className="space-y-4">
|
||||
<ActivationSettings />
|
||||
<ExitPolicySettings />
|
||||
<AlertSettings />
|
||||
<SentimentProviderSettings />
|
||||
<TickerUniverseBootstrap />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useActivation } from '../hooks/useActivation';
|
||||
import { useTrades } from '../hooks/useTrades';
|
||||
import { useWatchlist } from '../hooks/useWatchlist';
|
||||
import { usePaperTrades } from '../hooks/usePaperTrades';
|
||||
import { useTickerNames } from '../hooks/useTickers';
|
||||
import { useMarketRegime } from '../hooks/useMarketRegime';
|
||||
import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
@@ -62,6 +63,7 @@ function DirectionTag({ direction }: { direction: string }) {
|
||||
export default function DashboardPage() {
|
||||
const trades = useTrades();
|
||||
const watchlist = useWatchlist();
|
||||
const tickerNames = useTickerNames();
|
||||
const activation = useActivation();
|
||||
const openTrades = usePaperTrades('open');
|
||||
const regime = useMarketRegime();
|
||||
@@ -74,15 +76,12 @@ export default function DashboardPage() {
|
||||
[trades.data, activation.data],
|
||||
);
|
||||
|
||||
// Show qualified setups first; fall back to the full list when none qualify.
|
||||
// Rank by 12-1 momentum percentile so the strongest names sit at the top.
|
||||
const showingQualified = qualifiedSetups.length > 0;
|
||||
// Rank only actionable/qualified setups by residual 12-1 momentum percentile.
|
||||
const topSetups: TradeSetup[] = useMemo(() => {
|
||||
const pool = showingQualified ? qualifiedSetups : trades.data ?? [];
|
||||
return [...pool]
|
||||
return [...qualifiedSetups]
|
||||
.sort((a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity))
|
||||
.slice(0, 5);
|
||||
}, [showingQualified, qualifiedSetups, trades.data]);
|
||||
}, [qualifiedSetups]);
|
||||
|
||||
const topWatchlist = useMemo(
|
||||
() =>
|
||||
@@ -100,8 +99,10 @@ export default function DashboardPage() {
|
||||
const exposure = useMemo(() => {
|
||||
const rows = openTrades.data ?? [];
|
||||
let riskUsd = 0, unrealUsd = 0, unrealR = 0, rPriced = 0, winners = 0, losers = 0;
|
||||
let alphaUsd = 0, alphaPriced = 0;
|
||||
for (const t of rows) {
|
||||
riskUsd += Math.abs(t.entry_price - t.stop_loss) * t.shares;
|
||||
if (t.alpha_usd != null) { alphaUsd += t.alpha_usd; alphaPriced += 1; }
|
||||
const p = tradePnl(t);
|
||||
if (!p) continue;
|
||||
unrealUsd += p.pnl;
|
||||
@@ -109,7 +110,7 @@ export default function DashboardPage() {
|
||||
if (p.pnl > 0) winners += 1;
|
||||
else if (p.pnl < 0) losers += 1;
|
||||
}
|
||||
return { count: rows.length, riskUsd, unrealUsd, unrealR, rPriced, winners, losers };
|
||||
return { count: rows.length, riskUsd, unrealUsd, unrealR, rPriced, winners, losers, alphaUsd, alphaPriced };
|
||||
}, [openTrades.data]);
|
||||
|
||||
return (
|
||||
@@ -141,11 +142,11 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Metric strip */}
|
||||
{(trades.isLoading || openTrades.isLoading) ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Metric
|
||||
label="Live Setups"
|
||||
value={String(trades.data?.length ?? 0)}
|
||||
@@ -172,6 +173,16 @@ export default function DashboardPage() {
|
||||
: 'mark-to-market'
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label="Alpha vs S&P 500"
|
||||
value={exposure.alphaPriced > 0 ? money(exposure.alphaUsd) : '—'}
|
||||
valueClass={
|
||||
exposure.alphaPriced > 0
|
||||
? exposure.alphaUsd >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||||
: 'text-gray-100'
|
||||
}
|
||||
sub={exposure.alphaPriced > 0 ? `${exposure.alphaPriced} open · vs buy-and-hold SPY` : 'vs buy-and-hold SPY'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -183,12 +194,12 @@ export default function DashboardPage() {
|
||||
<div className="xl:col-span-3">
|
||||
<Section
|
||||
title="Top Setups"
|
||||
hint={showingQualified ? 'ranked by expected value' : 'none qualified — showing all'}
|
||||
hint="qualified and ranked by residual momentum"
|
||||
>
|
||||
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
|
||||
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
|
||||
{trades.data && topSetups.length === 0 && (
|
||||
<Callout variant="empty">No active setups. Run the scanner from the Signals page.</Callout>
|
||||
<Callout variant="empty">No qualified actionable setups right now.</Callout>
|
||||
)}
|
||||
{topSetups.length > 0 && (
|
||||
<div className="glass overflow-x-auto">
|
||||
@@ -200,7 +211,7 @@ export default function DashboardPage() {
|
||||
<th className="px-4 py-3 text-right">Entry</th>
|
||||
<th className="px-4 py-3 text-right">R:R</th>
|
||||
<th className="px-4 py-3 text-right">Target Prob</th>
|
||||
<th className="px-4 py-3 text-right">Momentum</th>
|
||||
<th className="px-4 py-3 text-right">Residual Mom.</th>
|
||||
<th className="hidden px-4 py-3 md:table-cell">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -225,6 +236,11 @@ export default function DashboardPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tickerNames.get(setup.symbol.toUpperCase()) && (
|
||||
<div className="max-w-[160px] truncate text-[11px] text-gray-500">
|
||||
{tickerNames.get(setup.symbol.toUpperCase())}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3"><DirectionTag direction={setup.direction} /></td>
|
||||
<td className="num px-4 py-3 text-right text-gray-200">{formatPrice(setup.entry_price)}</td>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, lazy, Suspense, type ReactNode } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
@@ -15,14 +15,18 @@ import {
|
||||
refreshRegimeFundamentals,
|
||||
getEventStudy,
|
||||
} from '../api/regime';
|
||||
|
||||
// Lazy so recharts (heavy) ships in its own chunk, loaded only on this tab.
|
||||
const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart'));
|
||||
const RegimeQuadrant = lazy(() => import('../components/regime/RegimeQuadrant'));
|
||||
import type {
|
||||
RegimeBand,
|
||||
RegimeMonitor,
|
||||
RegimeSignal,
|
||||
RegimeConfig,
|
||||
RegimeFundamentals,
|
||||
EventStudyReport,
|
||||
EventStudyLeadStats,
|
||||
EventStudyPerEvent,
|
||||
} from '../lib/types';
|
||||
|
||||
const BAND_STYLES: Record<RegimeBand, { text: string; bar: string; ring: string; label: string }> = {
|
||||
@@ -48,54 +52,71 @@ function TrendChip({ label, delta }: { label: string; delta: number | null | und
|
||||
);
|
||||
}
|
||||
|
||||
function Gauge({ data }: { data: RegimeMonitor }) {
|
||||
const band = (data.band ?? 'stable') as RegimeBand;
|
||||
const style = BAND_STYLES[band];
|
||||
const score = data.total_score ?? 0;
|
||||
const threshold = data.alert_threshold ?? 65;
|
||||
function ScoreGauge({
|
||||
label,
|
||||
score,
|
||||
band,
|
||||
trend,
|
||||
threshold,
|
||||
footnote,
|
||||
size = 'lg',
|
||||
}: {
|
||||
label: string;
|
||||
score: number | null | undefined;
|
||||
band: RegimeBand | null | undefined;
|
||||
trend?: { delta_7?: number | null; delta_30?: number | null };
|
||||
threshold?: number;
|
||||
footnote?: ReactNode;
|
||||
size?: 'lg' | 'md';
|
||||
}) {
|
||||
const naa = score == null;
|
||||
const style = BAND_STYLES[(band ?? 'stable') as RegimeBand];
|
||||
const s = score ?? 0;
|
||||
const clamp = (v: number) => Math.min(100, Math.max(0, v));
|
||||
const numCls = size === 'lg' ? 'text-6xl' : 'text-4xl';
|
||||
return (
|
||||
<div className={`glass border ${style.ring} p-6`}>
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className={`glass border ${naa ? 'border-white/[0.06]' : style.ring} p-6`}>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className={`font-display text-6xl font-bold ${style.text}`}>{Math.round(score)}</span>
|
||||
<span className="text-sm text-gray-500">/ 100</span>
|
||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">{label}</div>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className={`font-display font-bold ${numCls} ${naa ? 'text-gray-600' : style.text}`}>
|
||||
{naa ? '—' : Math.round(s)}
|
||||
</span>
|
||||
{!naa && <span className="text-sm text-gray-500">/ 100</span>}
|
||||
</div>
|
||||
<p className={`mt-1 text-sm font-medium ${style.text}`}>{style.label}</p>
|
||||
{!naa && <p className={`mt-0.5 text-sm font-medium ${style.text}`}>{style.label}</p>}
|
||||
</div>
|
||||
{trend && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TrendChip label="7d" delta={data.trend?.delta_7} />
|
||||
<TrendChip label="30d" delta={data.trend?.delta_30} />
|
||||
<TrendChip label="7d" delta={trend.delta_7} />
|
||||
<TrendChip label="30d" delta={trend.delta_30} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Band track with score + threshold markers */}
|
||||
<div className="relative mt-6 h-2 w-full rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
|
||||
{!naa && (
|
||||
<>
|
||||
{/* Band track with score (+ optional threshold) markers */}
|
||||
<div className="relative mt-5 h-2 w-full rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
|
||||
{threshold != null && (
|
||||
<div
|
||||
className="absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded bg-gray-300/80"
|
||||
style={{ left: `${clamp(threshold)}%` }}
|
||||
title={`Alert threshold ${threshold}`}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style.bar}`}
|
||||
style={{ left: `${clamp(score)}%` }}
|
||||
style={{ left: `${clamp(s)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
|
||||
<span>0</span><span>30</span><span>60</span><span>80</span><span>100</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs leading-relaxed text-gray-500">
|
||||
An <span className="text-gray-400">index</span> (not a calibrated probability) of how far the AI/Tech bull regime
|
||||
has deteriorated. Mostly coincident signals — it shortens reaction time, it doesn't predict the exact turn.
|
||||
{data.date && <> As of {data.date}.</>}
|
||||
{data.inputs && (data.inputs.vix != null || data.inputs.hy_oas != null) && (
|
||||
<span className="ml-1 text-gray-600">
|
||||
VIX {data.inputs.vix ?? '—'} · HY OAS {data.inputs.hy_oas ?? '—'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{footnote && <p className="mt-4 text-xs leading-relaxed text-gray-500">{footnote}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -285,7 +306,22 @@ function Sparkline({ values, color = '#60a5fa', height = 28 }: { values: number[
|
||||
);
|
||||
}
|
||||
|
||||
function pctLabel(v: number | null): string {
|
||||
return v == null ? '—' : `${Math.round(v * 100)}%`;
|
||||
}
|
||||
|
||||
function leadLabel(v: number | null): string {
|
||||
return v == null ? 'missed' : `${v}d`;
|
||||
}
|
||||
|
||||
function bestPr(stats: EventStudyLeadStats) {
|
||||
const rows = stats.signal.rows.filter((r) => r.precision != null && r.recall != null && r.recall > 0);
|
||||
if (!rows.length) return null;
|
||||
return rows.reduce((a, b) => ((b.precision ?? 0) > (a.precision ?? 0) ? b : a));
|
||||
}
|
||||
|
||||
function LeadStat({ label, stats, highlight }: { label: string; stats: EventStudyLeadStats; highlight?: boolean }) {
|
||||
const pr = bestPr(stats);
|
||||
return (
|
||||
<div className={`rounded-lg border px-3 py-2 ${highlight ? 'border-blue-400/30 bg-blue-400/[0.06]' : 'border-white/[0.06] bg-white/[0.02]'}`}>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
@@ -293,8 +329,46 @@ function LeadStat({ label, stats, highlight }: { label: string; stats: EventStud
|
||||
{stats.median_lead_days != null ? `${stats.median_lead_days}d lead` : 'no signal'}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-600">
|
||||
{stats.events_with_signal}/{stats.events_total} events warned
|
||||
{stats.events_with_signal}/{stats.events_total} warned
|
||||
{stats.warn_threshold != null ? ` · warn ≥ ${Math.round(stats.warn_threshold)}` : ''}
|
||||
</div>
|
||||
{pr && (
|
||||
<div className="text-[11px] text-gray-600">
|
||||
best P {pctLabel(pr.precision)} · R {pctLabel(pr.recall)} @ {pr.threshold}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PerEventTable({ rows }: { rows: EventStudyPerEvent[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left uppercase tracking-wider text-gray-500">
|
||||
<th className="px-3 py-2 font-medium">Drawdown</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Depth</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Breadth lead</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Coincident lead</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((e) => {
|
||||
const earlier = e.breadth_lead != null && (e.coincident_lead == null || e.breadth_lead > e.coincident_lead);
|
||||
return (
|
||||
<tr key={e.date} className="border-b border-white/[0.03] last:border-0">
|
||||
<td className="px-3 py-2 num text-gray-300">{e.date}</td>
|
||||
<td className="px-3 py-2 text-right num text-gray-400">{e.depth_pct}%</td>
|
||||
<td className={`px-3 py-2 text-right num ${earlier ? 'text-emerald-400' : 'text-gray-300'}`}>
|
||||
{leadLabel(e.breadth_lead)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right num text-gray-300">{leadLabel(e.coincident_lead)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -305,25 +379,30 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
|
||||
const recent = report.recent_breadth ?? [];
|
||||
const breadthVals = recent.map((r) => r.breadth);
|
||||
const divVals = recent.map((r) => r.divergence ?? 0);
|
||||
const lead = report.lead_delta_days;
|
||||
const moreCoverage = bd.events_with_signal > cd.events_with_signal;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-gray-500">
|
||||
{report.events?.length ?? 0} drawdown events (≥{report.params?.event_threshold_pct}%) on{' '}
|
||||
{report.params?.benchmark} over ~5y. Higher median lead = earlier warning.
|
||||
{report.params?.benchmark} over ~5y. With so few events, coverage (how many it warned before) matters
|
||||
more than the median lead.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<LeadStat label="Breadth divergence (leading candidate)" stats={bd} highlight={lead != null && lead > 0} />
|
||||
<LeadStat label="Breadth divergence (leading candidate)" stats={bd} highlight={moreCoverage} />
|
||||
<LeadStat label="Coincident price composite (baseline)" stats={cd} />
|
||||
</div>
|
||||
{lead != null && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Breadth divergence warned a median{' '}
|
||||
<span className={`font-medium ${lead > 0 ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
{lead > 0 ? '+' : ''}{lead} days
|
||||
</span>{' '}
|
||||
{lead >= 0 ? 'earlier' : 'later'} than the coincident baseline.
|
||||
Breadth divergence warned before{' '}
|
||||
<span className="font-medium text-emerald-400">{bd.events_with_signal}/{bd.events_total}</span> drawdowns
|
||||
{bd.median_lead_days != null ? ` (median ${bd.median_lead_days}d lead)` : ''}; the coincident baseline only{' '}
|
||||
<span className="font-medium text-gray-300">{cd.events_with_signal}/{cd.events_total}</span>. The median-lead
|
||||
comparison is unreliable when coverage differs this much — see per-drawdown below.
|
||||
</p>
|
||||
{report.per_event && report.per_event.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">Per drawdown (same events, both indicators)</div>
|
||||
<PerEventTable rows={report.per_event} />
|
||||
</div>
|
||||
)}
|
||||
{recent.length > 1 && (
|
||||
<div className="flex flex-wrap items-end gap-6">
|
||||
@@ -441,7 +520,47 @@ export default function RegimePage() {
|
||||
|
||||
{monitor.data && monitor.data.available && (
|
||||
<>
|
||||
<Gauge data={monitor.data} />
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ScoreGauge
|
||||
label="Regime index · coincident"
|
||||
score={monitor.data.total_score}
|
||||
band={monitor.data.band}
|
||||
trend={monitor.data.trend}
|
||||
threshold={monitor.data.alert_threshold}
|
||||
footnote={
|
||||
<>
|
||||
An <span className="text-gray-400">index</span> (not a calibrated probability) of how far the AI/Tech
|
||||
bull regime has deteriorated. Mostly coincident — it shortens reaction time, it doesn't predict
|
||||
the turn.
|
||||
{monitor.data.date && <> As of {monitor.data.date}.</>}
|
||||
{monitor.data.inputs && (monitor.data.inputs.vix != null || monitor.data.inputs.hy_oas != null) && (
|
||||
<span className="ml-1 text-gray-600">
|
||||
VIX {monitor.data.inputs.vix ?? '—'} · HY OAS {monitor.data.inputs.hy_oas ?? '—'}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ScoreGauge
|
||||
label="Early warning · breadth divergence"
|
||||
score={monitor.data.early_warning?.score}
|
||||
band={monitor.data.early_warning?.band}
|
||||
trend={monitor.data.early_warning}
|
||||
footnote={
|
||||
<>
|
||||
Breadth narrowing while price holds. In the event study it led ~6 weeks on 7/11 past drawdowns, but
|
||||
it's noisy (≈2× base rate) and blind to shocks. Observational — separate from the index, not
|
||||
wired into trades.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonCard className="h-80" />}>
|
||||
<RegimeQuadrant />
|
||||
</Suspense>
|
||||
<Suspense fallback={<SkeletonCard className="h-72" />}>
|
||||
<ScoreHistoryChart />
|
||||
</Suspense>
|
||||
{monitor.data.breakdown && <Breakdown breakdown={monitor.data.breakdown} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { useMemo, useEffect, useState, lazy, Suspense } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTickerDetail } from '../hooks/useTickerDetail';
|
||||
import { useFetchSymbolData } from '../hooks/useFetchSymbolData';
|
||||
import { useWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from '../hooks/useWatchlist';
|
||||
import { useTrades } from '../hooks/useTrades';
|
||||
import { usePaperTrades } from '../hooks/usePaperTrades';
|
||||
import { useActivation } from '../hooks/useActivation';
|
||||
import { topPickSymbol, qualifiesSetup } from '../lib/qualification';
|
||||
import type { FetchSelector } from '../api/ingestion';
|
||||
import { CandlestickChart } from '../components/charts/CandlestickChart';
|
||||
import { ScoreCard } from '../components/ui/ScoreCard';
|
||||
import { useTickerNames } from '../hooks/useTickers';
|
||||
import { SkeletonCard } from '../components/ui/Skeleton';
|
||||
import { SentimentPanel } from '../components/ticker/SentimentPanel';
|
||||
import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel';
|
||||
@@ -17,6 +22,10 @@ import { Section } from '../components/ui/Section';
|
||||
import { Tabs } from '../components/ui/Tabs';
|
||||
import { formatPrice } from '../lib/format';
|
||||
import type { TradeSetup } from '../lib/types';
|
||||
import type { FieldPoint } from '../components/ticker/StandingMatrix';
|
||||
|
||||
// Lazy so recharts (heavy) ships in its own chunk, not the main ticker bundle.
|
||||
const StandingMatrix = lazy(() => import('../components/ticker/StandingMatrix'));
|
||||
|
||||
const detailTabs = ['Analysis', 'Indicators', 'S/R Levels'] as const;
|
||||
type DetailTab = (typeof detailTabs)[number];
|
||||
@@ -29,6 +38,21 @@ function SectionError({ message, onRetry }: { message: string; onRetry?: () => v
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ tone, label, title }: { tone: 'blue' | 'emerald'; label: string; title?: string }) {
|
||||
const tones = {
|
||||
blue: 'bg-blue-500/15 text-blue-300 border-blue-500/30',
|
||||
emerald: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
|
||||
} as const;
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium ${tones[tone]}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
@@ -97,6 +121,7 @@ function DataFreshnessBar({
|
||||
|
||||
export default function TickerDetailPage() {
|
||||
const { symbol = '' } = useParams<{ symbol: string }>();
|
||||
const companyName = useTickerNames().get(symbol.toUpperCase());
|
||||
const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol);
|
||||
const ingestion = useFetchSymbolData();
|
||||
const watchlist = useWatchlist();
|
||||
@@ -107,6 +132,21 @@ export default function TickerDetailPage() {
|
||||
[watchlist.data, symbol],
|
||||
);
|
||||
const watchlistBusy = addToWatchlist.isPending || removeFromWatchlist.isPending;
|
||||
|
||||
// Status labels: is there an open paper trade on this ticker, and is it the
|
||||
// current top pick (same ranking the dashboard highlights)?
|
||||
const openTrades = usePaperTrades('open');
|
||||
const allTrades = useTrades();
|
||||
const activation = useActivation();
|
||||
const hasOpenTrade = useMemo(
|
||||
() => (openTrades.data ?? []).some((t) => t.symbol.toUpperCase() === symbol.toUpperCase()),
|
||||
[openTrades.data, symbol],
|
||||
);
|
||||
const isTopPick = useMemo(
|
||||
() => topPickSymbol(allTrades.data, activation.data)?.toUpperCase() === symbol.toUpperCase(),
|
||||
[allTrades.data, activation.data, symbol],
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>('Analysis');
|
||||
const [refreshingLabel, setRefreshingLabel] = useState<string | null>(null);
|
||||
|
||||
@@ -176,6 +216,29 @@ export default function TickerDetailPage() {
|
||||
[setupsForSymbol],
|
||||
);
|
||||
|
||||
// Standing matrix: this ticker's residual momentum percentile + long confidence (from its
|
||||
// setup), the field (every ticker's composite × momentum) for the cloud, and
|
||||
// whether it qualifies / is the top pick.
|
||||
const myMomentum = longSetup?.momentum_percentile ?? shortSetup?.momentum_percentile ?? null;
|
||||
const myConfidence = longSetup?.confidence_score ?? null;
|
||||
const standingField = useMemo<FieldPoint[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: FieldPoint[] = [];
|
||||
for (const t of allTrades.data ?? []) {
|
||||
const s = t.symbol.toUpperCase();
|
||||
if (seen.has(s) || t.momentum_percentile == null) continue;
|
||||
seen.add(s);
|
||||
out.push({ symbol: s, composite: t.composite_score, momentum: t.momentum_percentile });
|
||||
}
|
||||
return out;
|
||||
}, [allTrades.data]);
|
||||
const standingStatus: 'top-pick' | 'qualified' | 'none' = useMemo(() => {
|
||||
if (isTopPick) return 'top-pick';
|
||||
if (longSetup && activation.data && qualifiesSetup(longSetup, activation.data)) return 'qualified';
|
||||
return 'none';
|
||||
}, [isTopPick, longSetup, activation.data]);
|
||||
const gateMomentum = activation.data?.min_momentum_percentile ?? 80;
|
||||
|
||||
// Current price = latest close, with day-over-day change
|
||||
const priceInfo = useMemo(() => {
|
||||
const bars = ohlcv.data;
|
||||
@@ -213,6 +276,9 @@ export default function TickerDetailPage() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-baseline gap-4">
|
||||
<h1 className="text-3xl font-semibold text-gray-100">{symbol.toUpperCase()}</h1>
|
||||
{companyName && (
|
||||
<span className="max-w-[240px] truncate text-sm text-gray-500">{companyName}</span>
|
||||
)}
|
||||
{priceInfo && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="num text-2xl font-semibold text-gray-100">{formatPrice(priceInfo.price)}</span>
|
||||
@@ -226,6 +292,20 @@ export default function TickerDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isTopPick && (
|
||||
<StatusPill
|
||||
tone="blue"
|
||||
label="★ Top Pick"
|
||||
title="Current top pick — highest residual-momentum qualified setup right now"
|
||||
/>
|
||||
)}
|
||||
{hasOpenTrade && (
|
||||
<StatusPill
|
||||
tone="emerald"
|
||||
label="● Open Trade"
|
||||
title="You have an open paper trade on this ticker"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
@@ -266,8 +346,8 @@ export default function TickerDetailPage() {
|
||||
/>
|
||||
|
||||
{/* Chart — always visible */}
|
||||
<Section title="Price Chart">
|
||||
{ohlcv.isLoading && <SkeletonCard className="h-[400px]" />}
|
||||
<Section title="Price & Volume">
|
||||
{ohlcv.isLoading && <SkeletonCard className="h-[440px]" />}
|
||||
{ohlcv.isError && (
|
||||
<SectionError
|
||||
message={ohlcv.error instanceof Error ? ohlcv.error.message : 'Failed to load OHLCV data'}
|
||||
@@ -323,14 +403,55 @@ export default function TickerDetailPage() {
|
||||
<Tabs tabs={detailTabs} active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
{activeTab === 'Analysis' && (
|
||||
<div className="grid gap-6 lg:grid-cols-3 animate-fade-in">
|
||||
<Section title="Scores">
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<Section title="Standing" hint="how this ticker ranks vs. the field">
|
||||
{scores.isLoading && <SkeletonCard className="h-80" />}
|
||||
{scores.isError && (
|
||||
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
|
||||
)}
|
||||
{scores.data && (
|
||||
<>
|
||||
<Suspense fallback={<SkeletonCard className="h-80" />}>
|
||||
<StandingMatrix
|
||||
symbol={symbol}
|
||||
composite={scores.data.composite_score}
|
||||
momentum={myMomentum}
|
||||
field={standingField}
|
||||
gateMomentum={gateMomentum}
|
||||
status={standingStatus}
|
||||
confidence={myConfidence}
|
||||
/>
|
||||
</Suspense>
|
||||
{(() => {
|
||||
const cb = scores.data?.composite_breakdown;
|
||||
const adj = cb?.sentiment_adjustment;
|
||||
const base = cb?.base_score;
|
||||
if (adj == null || base == null || Math.abs(adj) < 0.05) return null;
|
||||
const composite = scores.data?.composite_score ?? base + adj;
|
||||
return (
|
||||
<p className="mt-3 text-center text-[11px] text-gray-500">
|
||||
Composite{' '}
|
||||
<span className="font-semibold text-gray-300">{Math.round(composite)}</span>
|
||||
{' '}= Base {Math.round(base)}{' '}
|
||||
{adj >= 0 ? '+' : '−'} Sentiment{' '}
|
||||
<span className={adj >= 0 ? 'text-emerald-400/80' : 'text-red-400/80'}>
|
||||
{Math.abs(adj).toFixed(1)}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Section title="Dimensions">
|
||||
{scores.isLoading && <SkeletonCard />}
|
||||
{scores.isError && (
|
||||
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
|
||||
)}
|
||||
{scores.data && (
|
||||
<ScoreCard compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions} compositeBreakdown={scores.data.composite_breakdown} />
|
||||
<ScoreCard showComposite={false} compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions} compositeBreakdown={scores.data.composite_breakdown} />
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -350,6 +471,7 @@ export default function TickerDetailPage() {
|
||||
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Indicators' && (
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/papertrades.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/schedulesettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/dashboard/opentradespanel.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/backtestpanel.tsx","./src/components/signals/mytradespanel.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/usepapertrades.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/papertrade.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/papertrades.ts","./src/api/performance.ts","./src/api/regime.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/exitpolicysettings.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/schedulesettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/dashboard/opentradespanel.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/tickersearch.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/regime/regimequadrant.tsx","./src/components/regime/scorehistorychart.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/backtestpanel.tsx","./src/components/signals/mytradespanel.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ticker/standingmatrix.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/usepapertrades.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/papertrade.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/regimepage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
||||
@@ -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,9 +27,10 @@ class TestActivationConfig:
|
||||
assert config == {
|
||||
"min_momentum_percentile": 80.0,
|
||||
"min_rr": 1.2,
|
||||
"min_confidence": 55.0,
|
||||
"min_confidence": 0.0, # off — the July 2026 ablation showed it adds nothing
|
||||
"require_high_conviction": False,
|
||||
"exclude_conflicts": False,
|
||||
"exclude_neutral": True,
|
||||
}
|
||||
|
||||
async def test_update_and_read_back(self, session: AsyncSession):
|
||||
@@ -62,6 +63,12 @@ class TestActivationConfig:
|
||||
assert config["require_high_conviction"] is True
|
||||
assert config["exclude_conflicts"] is True
|
||||
|
||||
async def test_exclude_neutral_round_trip(self, session: AsyncSession):
|
||||
# On by default; can be turned off.
|
||||
assert (await get_activation_config(session))["exclude_neutral"] is True
|
||||
await update_activation_config(session, {"exclude_neutral": False})
|
||||
assert (await get_activation_config(session))["exclude_neutral"] is False
|
||||
|
||||
async def test_rejects_negative_rr(self, session: AsyncSession):
|
||||
with pytest.raises(ValidationError):
|
||||
await update_activation_config(session, {"min_rr": -1.0})
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.alert import AlertLog
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.score import CompositeScore
|
||||
from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
@@ -111,6 +114,26 @@ async def test_score_drop_seeds_then_alerts(session):
|
||||
assert await svc._watermark(session, "AAA") == 60.0
|
||||
|
||||
|
||||
def test_format_qualified_includes_current_price_and_target_move():
|
||||
text = svc._format_qualified({
|
||||
"symbol": "AAPL",
|
||||
"direction": "long",
|
||||
"current_price": 196.42,
|
||||
"entry_price": 195.80,
|
||||
"target": 207.50,
|
||||
"stop_loss": 190.20,
|
||||
"rr_ratio": 2.1,
|
||||
"confidence_score": 76.0,
|
||||
"targets": [{"probability": 63.0}],
|
||||
})
|
||||
|
||||
assert "now 196.42" in text
|
||||
assert "entry 195.80" in text
|
||||
assert "target 207.50 (+5.6%)" in text
|
||||
assert "stop 190.20" in text
|
||||
assert "P(target) 63%" in text
|
||||
|
||||
|
||||
async def _add_ticker(session, symbol: str, *, watchlisted: bool, close: float,
|
||||
levels: list[tuple[float, str, int]]) -> int:
|
||||
user = await session.get(User, 1)
|
||||
@@ -140,6 +163,8 @@ async def test_sr_proximity_merges_close_levels_to_one_alert(session):
|
||||
cvx = [m for m in msgs if "CVX" in m[1]]
|
||||
assert len(cvx) == 1
|
||||
assert "183.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):
|
||||
@@ -168,3 +193,108 @@ async def test_dispatch_no_credentials(session):
|
||||
await svc.update_alert_config(session, enabled=True) # enabled but no token/chat
|
||||
res = await svc.dispatch_alerts(session)
|
||||
assert res["status"] == "no_credentials"
|
||||
|
||||
|
||||
async def test_dispatch_bundles_discovery_alerts_and_logs_each_item(session, monkeypatch):
|
||||
async def fake_collect_qualified(_db):
|
||||
return [
|
||||
("qualified:AAPL:long", "🟢 <b>AAPL LONG</b> | now 196.42 | target 207.50 (+5.6%)"),
|
||||
("qualified:TSLA:short", "🔴 <b>TSLA SHORT</b> | now 292.10 | target 276.50 (-5.3%)"),
|
||||
]
|
||||
|
||||
async def fake_collect_sr(_db):
|
||||
return [
|
||||
("sr:MSFT:resistance", "📍 <b>MSFT</b> resistance | now 508.20 -> 512.00 (+0.7%)"),
|
||||
]
|
||||
|
||||
sent: list[str] = []
|
||||
|
||||
async def fake_send(_client, _token, _chat_id, text):
|
||||
sent.append(text)
|
||||
|
||||
monkeypatch.setattr(svc, "_collect_qualified", fake_collect_qualified)
|
||||
monkeypatch.setattr(svc, "_collect_sr_proximity", fake_collect_sr)
|
||||
monkeypatch.setattr(svc, "_send", fake_send)
|
||||
|
||||
await svc.update_alert_config(
|
||||
session,
|
||||
enabled=True,
|
||||
bot_token="token",
|
||||
telegram_chat_id="chat",
|
||||
score_drop_enabled=False,
|
||||
digest_enabled=False,
|
||||
regime_quadrant_enabled=False,
|
||||
trade_closed_enabled=False,
|
||||
)
|
||||
|
||||
res = await svc.dispatch_alerts(session)
|
||||
|
||||
assert res == {"status": "ok", "sent": 1, "candidates": 3}
|
||||
assert len(sent) == 1
|
||||
assert "<b>Signal run</b> — 3 new alert(s)" in sent[0]
|
||||
assert "<b>Qualified setups</b>" in sent[0]
|
||||
assert "<b>Near support/resistance</b>" in sent[0]
|
||||
assert "AAPL LONG" in sent[0]
|
||||
assert "MSFT" in sent[0]
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(AlertLog.alert_type, AlertLog.dedup_key)
|
||||
.order_by(AlertLog.alert_type, AlertLog.dedup_key)
|
||||
)
|
||||
).all()
|
||||
assert rows == [
|
||||
("qualified", "qualified:AAPL:long"),
|
||||
("qualified", "qualified:TSLA:short"),
|
||||
("sr_proximity", "sr:MSFT:resistance"),
|
||||
]
|
||||
|
||||
|
||||
async def _add_closed_trade(session, symbol: str, reason: str, *,
|
||||
close: float = 110.0, closed_hours_ago: float = 1.0) -> None:
|
||||
if await session.get(User, 1) is None:
|
||||
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
|
||||
await session.flush()
|
||||
t = Ticker(symbol=symbol)
|
||||
session.add(t)
|
||||
await session.flush()
|
||||
now = datetime.now(timezone.utc)
|
||||
session.add(PaperTrade(
|
||||
user_id=1, ticker_id=t.id, direction="long",
|
||||
entry_price=100.0, shares=10.0, stop_loss=95.0, target=120.0,
|
||||
status="closed", opened_at=now - timedelta(days=5),
|
||||
close_price=close, closed_at=now - timedelta(hours=closed_hours_ago),
|
||||
close_reason=reason,
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_config_includes_trade_closed_toggle(session):
|
||||
assert (await svc.get_alert_config(session))["trade_closed_enabled"] is True
|
||||
cfg = await svc.update_alert_config(session, trade_closed_enabled=False)
|
||||
assert cfg["trade_closed_enabled"] is False
|
||||
|
||||
|
||||
async def test_collect_closed_trades_filters_manual_and_old(session):
|
||||
await _add_closed_trade(session, "WIN", "trailing", close=110.0, closed_hours_ago=1)
|
||||
await _add_closed_trade(session, "MAN", "manual", close=110.0, closed_hours_ago=1) # manual → skip
|
||||
await _add_closed_trade(session, "OLD", "stop", close=95.0, closed_hours_ago=100) # too old → skip
|
||||
|
||||
out = await svc._collect_closed_trades(session)
|
||||
assert len(out) == 1
|
||||
_, text = out[0]
|
||||
assert "WIN" in text and "trailing stop" in text
|
||||
|
||||
|
||||
def test_format_closed_trade_win():
|
||||
now = datetime.now(timezone.utc)
|
||||
trade = SimpleNamespace(
|
||||
direction="long", entry_price=100.0, close_price=110.0, shares=10.0,
|
||||
stop_loss=95.0, opened_at=now - timedelta(days=12), closed_at=now,
|
||||
close_reason="trailing",
|
||||
)
|
||||
txt = svc._format_closed_trade(trade, "AAA")
|
||||
assert "✅" in txt # win
|
||||
assert "+10.0%" in txt
|
||||
assert "+2.00R" in txt # +10% over a 5% stop
|
||||
assert "held 12d" in txt
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import date, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -24,7 +25,15 @@ async def session():
|
||||
yield s
|
||||
|
||||
|
||||
def _cand(prob: float, outcome: str, rr: float, qualified: bool = True, direction: str = "long") -> dict:
|
||||
def _cand(
|
||||
prob: float,
|
||||
outcome: str,
|
||||
rr: float,
|
||||
qualified: bool = True,
|
||||
direction: str = "long",
|
||||
risk_pct: float = 0.05,
|
||||
hold_days: int = 10,
|
||||
) -> dict:
|
||||
target_hit = outcome == OUTCOME_TARGET_HIT
|
||||
realized = rr if target_hit else (0.0 if outcome == OUTCOME_EXPIRED else -1.0)
|
||||
return {
|
||||
@@ -35,9 +44,408 @@ def _cand(prob: float, outcome: str, rr: float, qualified: bool = True, directio
|
||||
"realized_r": realized,
|
||||
"qualified": qualified,
|
||||
"direction": direction,
|
||||
"risk_pct": risk_pct,
|
||||
"hold_days": hold_days,
|
||||
}
|
||||
|
||||
|
||||
# Round-trip cost in R for the default _cand risk_pct: 2 * 0.001 / 0.05 = 0.04R.
|
||||
_COST_R_005 = 2 * bt.COST_PER_SIDE / 0.05
|
||||
|
||||
|
||||
def _bar(high: float, low: float, close: float, open_: float | None = None) -> SimpleNamespace:
|
||||
"""Synthetic daily bar. ``open`` defaults to the high so a stop is pierced
|
||||
intraday (fill at the stop level); pass an explicit open beyond the stop to
|
||||
model a gap through it."""
|
||||
return SimpleNamespace(
|
||||
high=high, low=low, close=close, open=open_ if open_ is not None else high
|
||||
)
|
||||
|
||||
|
||||
def _signal_test_series(extra_return: float = 0.0) -> tuple[list[date], list[float], list[float], dict[date, float]]:
|
||||
base = date(2024, 1, 1)
|
||||
dates = [base + timedelta(days=i) for i in range(280)]
|
||||
benchmark = [100.0]
|
||||
closes = [100.0]
|
||||
for i in range(1, len(dates)):
|
||||
market_ret = 0.0004 + 0.002 * math.sin(i / 9.0)
|
||||
benchmark.append(benchmark[-1] * (1.0 + market_ret))
|
||||
# Same market beta for both test stocks; only ``extra_return`` is
|
||||
# idiosyncratic drift, which residual momentum should keep.
|
||||
stock_ret = 1.4 * market_ret + extra_return
|
||||
closes.append(closes[-1] * (1.0 + stock_ret))
|
||||
highs = [c * 1.01 for c in closes]
|
||||
benchmark_closes = dict(zip(dates, benchmark))
|
||||
return dates, closes, highs, benchmark_closes
|
||||
|
||||
|
||||
def test_signal_values_emit_residual_momentum_only_with_benchmark():
|
||||
dates, closes, highs, benchmark = _signal_test_series(extra_return=0.0008)
|
||||
no_benchmark = bt._signal_values(dates, closes, highs, 260)
|
||||
with_benchmark = bt._signal_values(dates, closes, highs, 260, benchmark)
|
||||
|
||||
assert "mom_12_1" in no_benchmark
|
||||
assert "mom_12_1_resid" not in no_benchmark
|
||||
assert "mom_12_1_resid" in with_benchmark
|
||||
|
||||
|
||||
def test_residual_momentum_removes_market_beta_but_keeps_specific_drift():
|
||||
dates, pure_beta, highs, benchmark = _signal_test_series(extra_return=0.0)
|
||||
_, drift_stock, drift_highs, _ = _signal_test_series(extra_return=0.0008)
|
||||
|
||||
pure = bt._signal_values(dates, pure_beta, highs, 260, benchmark)
|
||||
drift = bt._signal_values(dates, drift_stock, drift_highs, 260, benchmark)
|
||||
|
||||
assert pure["mom_12_1_resid"] == pytest.approx(0.0, abs=0.03)
|
||||
assert drift["mom_12_1_resid"] > pure["mom_12_1_resid"] + 0.12
|
||||
|
||||
|
||||
def test_assigns_raw_and_residual_percentiles_independently():
|
||||
cands = [
|
||||
{"iso_week": (2026, 1), "momentum": 0.10, "residual_momentum": 0.30},
|
||||
{"iso_week": (2026, 1), "momentum": 0.30, "residual_momentum": 0.10},
|
||||
{"iso_week": (2026, 1), "momentum": 0.20, "residual_momentum": 0.20},
|
||||
]
|
||||
|
||||
bt._assign_momentum_percentiles(cands)
|
||||
bt._assign_residual_momentum_percentiles(cands)
|
||||
|
||||
by_raw = {c["momentum"]: c["momentum_percentile"] for c in cands}
|
||||
by_resid = {c["residual_momentum"]: c["residual_momentum_percentile"] for c in cands}
|
||||
assert by_raw[0.30] == 100.0
|
||||
assert by_raw[0.10] == 0.0
|
||||
assert by_resid[0.30] == 100.0
|
||||
assert by_resid[0.10] == 0.0
|
||||
|
||||
|
||||
def test_activation_percentile_prefers_residual_with_raw_fallback():
|
||||
cands = [
|
||||
{"momentum_percentile": 80.0, "residual_momentum_percentile": 95.0},
|
||||
{"momentum_percentile": 70.0, "residual_momentum_percentile": None},
|
||||
]
|
||||
|
||||
bt._assign_activation_momentum_percentiles(cands)
|
||||
|
||||
assert cands[0][bt.PRODUCTION_PERCENTILE_KEY] == 95.0
|
||||
assert cands[1][bt.PRODUCTION_PERCENTILE_KEY] == 70.0
|
||||
|
||||
|
||||
def test_strategy_variants_keep_only_current_research_candidates():
|
||||
variants = {cfg["variant"]: cfg for cfg in bt.STRATEGY_VARIANTS}
|
||||
|
||||
assert "production_raw_80_fixed10" not in variants
|
||||
assert "raw_80_regime_scaled" not in variants
|
||||
assert "residual_80_regime_scaled" not in variants
|
||||
assert "residual_90_fixed10" not in variants
|
||||
assert "raw_90_fixed15" not in variants
|
||||
assert "residual_80_fixed20" not in variants
|
||||
assert variants["production_residual_80_fixed10"]["percentile_key"] == bt.PRODUCTION_PERCENTILE_KEY
|
||||
assert variants["legacy_raw_80_fixed10"]["percentile_key"] == bt.RAW_PERCENTILE_KEY
|
||||
assert variants["residual_80_fixed15"]["max_positions"] == 15
|
||||
assert all(cfg["risk_scale"] is None for cfg in bt.STRATEGY_VARIANTS)
|
||||
|
||||
|
||||
def test_strategy_variant_sims_emit_fixed_variants_without_mutating_qualified(monkeypatch):
|
||||
cands = [{
|
||||
"qualified": False,
|
||||
"meets_core": True,
|
||||
"direction": "long",
|
||||
"momentum_percentile": 90.0,
|
||||
"residual_momentum_percentile": 91.0,
|
||||
"activation_momentum_percentile": 91.0,
|
||||
}]
|
||||
calls = []
|
||||
|
||||
def fake_sim(candidates, prices, spy_closes, exit_policy, hold_days, **kwargs):
|
||||
calls.append({"exit_policy": exit_policy, "hold_days": hold_days, **kwargs})
|
||||
return {
|
||||
"starting_capital": bt.SIM_STARTING_CAPITAL,
|
||||
"final_equity": 11_000.0,
|
||||
"total_return_pct": 10.0,
|
||||
"cagr_pct": 9.0,
|
||||
"max_drawdown_pct": 5.0,
|
||||
"sharpe": 1.1,
|
||||
"trades": 1,
|
||||
"win_rate": 100.0,
|
||||
"avg_trade_pnl": 100.0,
|
||||
"best_trade_r": 1.0,
|
||||
"worst_trade_r": 1.0,
|
||||
"best_trade_pnl": 100.0,
|
||||
"worst_trade_pnl": 100.0,
|
||||
"avg_hold_days": 30.0,
|
||||
"skipped_book_full": 0,
|
||||
"spy_return_pct": 1.0,
|
||||
"yearly_returns": [],
|
||||
"start_date": "2026-01-01",
|
||||
"end_date": "2026-02-01",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(bt, "_simulate_portfolio", fake_sim)
|
||||
rows = bt._strategy_variant_sims(cands, {}, {}, 30)
|
||||
|
||||
assert [r["variant"] for r in rows] == [cfg["variant"] for cfg in bt.STRATEGY_VARIANTS]
|
||||
assert all(call["exit_policy"] == "hold" for call in calls)
|
||||
assert any(call["ranking_key"] == bt.PRODUCTION_PERCENTILE_KEY for call in calls)
|
||||
assert any(call["ranking_key"] == bt.RAW_PERCENTILE_KEY for call in calls)
|
||||
assert any(call["max_positions"] == 15 for call in calls)
|
||||
assert cands[0]["qualified"] is False
|
||||
|
||||
|
||||
def test_build_research_recommendation_applies_promotion_rules():
|
||||
report = {
|
||||
"strategy_variants": {"variants": [
|
||||
{"variant": "production_residual_80_fixed10", "label": "Base", "sharpe": 1.40,
|
||||
"max_drawdown_pct": 20.0, "cagr_pct": 32.0, "skipped_book_full": 7},
|
||||
{"variant": "residual_80_fixed15", "label": "Capacity", "sharpe": 1.39,
|
||||
"max_drawdown_pct": 20.0, "cagr_pct": 32.0, "skipped_book_full": 0},
|
||||
{"variant": "raw_90_fixed10", "label": "Cutoff 90", "sharpe": 1.25,
|
||||
"max_drawdown_pct": 19.0, "cagr_pct": 28.0},
|
||||
]},
|
||||
}
|
||||
|
||||
rec = bt._build_research_recommendation(report)
|
||||
by_topic = {item["topic"]: item for item in rec["items"]}
|
||||
|
||||
assert by_topic["capacity_15"]["candidate"] is False
|
||||
assert "not needed yet" in by_topic["capacity_15"]["text"]
|
||||
assert by_topic["cutoff_90"]["candidate"] is False
|
||||
assert "Cutoff 90" in by_topic["cutoff_90"]["text"]
|
||||
|
||||
|
||||
class TestStopFillR:
|
||||
def test_intraday_fill_at_stop(self):
|
||||
assert bt._stop_fill_r("long", 100.0, 95.0, _bar(101, 94, 96)) == pytest.approx(-1.0)
|
||||
|
||||
def test_gap_fill_at_open(self):
|
||||
# Opens at 92, below the 95 stop → filled at the open, worse than −1R.
|
||||
assert bt._stop_fill_r("long", 100.0, 95.0, _bar(93, 90, 91, open_=92)) == pytest.approx(-1.6)
|
||||
|
||||
def test_short_gap_fill_at_open(self):
|
||||
# Short stop 105; opens at 107 above it → fill 107.
|
||||
assert bt._stop_fill_r("short", 100.0, 105.0, _bar(110, 104, 108, open_=107)) == pytest.approx(-1.4)
|
||||
|
||||
|
||||
class TestRiskAndStopDay:
|
||||
def test_no_stop(self):
|
||||
risk, stop_day = bt._risk_and_stop_day("long", 100.0, 95.0, [_bar(109, 101, 108)], 30)
|
||||
assert risk == pytest.approx(0.05)
|
||||
assert stop_day is None
|
||||
|
||||
def test_stop_day_is_one_based(self):
|
||||
bars = [_bar(102, 99, 101), _bar(101, 94, 96)]
|
||||
risk, stop_day = bt._risk_and_stop_day("long", 100.0, 95.0, bars, 30)
|
||||
assert risk == pytest.approx(0.05)
|
||||
assert stop_day == 2
|
||||
|
||||
def test_short_direction(self):
|
||||
_, stop_day = bt._risk_and_stop_day("short", 100.0, 105.0, [_bar(106, 101, 104)], 30)
|
||||
assert stop_day == 1
|
||||
|
||||
|
||||
class TestTimeExits:
|
||||
def test_long_exits_at_horizon_close(self):
|
||||
bars = [_bar(103, 99, 102), _bar(105, 101, 104), _bar(107, 103, 106)]
|
||||
res = bt._time_exits("long", 100.0, 95.0, bars, (2, 5))
|
||||
assert res[2] == pytest.approx(0.8) # close 104 → +4% / 5% risk
|
||||
assert res[5] == pytest.approx(1.2) # only 3 bars → last close 106
|
||||
|
||||
def test_stop_on_first_bar_loses_everywhere(self):
|
||||
res = bt._time_exits("long", 100.0, 95.0, [_bar(101, 94, 96), _bar(105, 101, 104)], (1, 5))
|
||||
assert res[1] == pytest.approx(-1.0)
|
||||
assert res[5] == pytest.approx(-1.0)
|
||||
|
||||
def test_stop_after_short_horizon_only_hits_long_hold(self):
|
||||
# Day-2 close banked by the 2-day hold; the stop on day 3 only hits n=5.
|
||||
bars = [_bar(103, 99, 102), _bar(104, 100, 103), _bar(101, 94, 95)]
|
||||
res = bt._time_exits("long", 100.0, 95.0, bars, (2, 5))
|
||||
assert res[2] == pytest.approx(0.6) # close 103 → +3% / 5% risk
|
||||
assert res[5] == pytest.approx(-1.0)
|
||||
|
||||
def test_short_direction(self):
|
||||
res = bt._time_exits("short", 100.0, 105.0, [_bar(101, 95, 96)], (1,))
|
||||
assert res[1] == pytest.approx(0.8) # close 96 → +4% / 5% risk
|
||||
|
||||
def test_zero_risk_returns_zero(self):
|
||||
res = bt._time_exits("long", 100.0, 100.0, [_bar(103, 99, 102)], (5,))
|
||||
assert res[5] == 0.0
|
||||
|
||||
def test_gap_through_stop_fills_at_open(self):
|
||||
res = bt._time_exits("long", 100.0, 95.0, [_bar(93, 90, 91, open_=92)], (5,))
|
||||
assert res[5] == pytest.approx(-1.6)
|
||||
|
||||
|
||||
class TestTimeExitBucket:
|
||||
def test_bucket(self):
|
||||
cands = [
|
||||
{"time_r": {5: 1.4, 21: 0.8}, "risk_pct": 0.10},
|
||||
{"time_r": {5: -1.0, 21: -1.0}, "risk_pct": 0.10},
|
||||
{"time_r": {5: 0.5, 21: 0.5}, "risk_pct": 0.10},
|
||||
]
|
||||
b = bt._time_exit_bucket(cands, 5)
|
||||
assert b["hold_days"] == 5
|
||||
assert b["total"] == 3
|
||||
assert b["wins"] == 2
|
||||
assert b["win_rate"] == pytest.approx(66.7, abs=0.1)
|
||||
assert b["avg_r"] == pytest.approx(0.3, abs=0.01)
|
||||
assert b["net_avg_r"] == pytest.approx(0.28, abs=0.01)
|
||||
assert b["best_r"] == pytest.approx(1.4)
|
||||
assert b["worst_r"] == pytest.approx(-1.0)
|
||||
# No stop_day on any candidate → every hold runs the full 5 days.
|
||||
assert b["avg_hold_days"] == 5.0
|
||||
assert b["net_r_per_day"] == pytest.approx(0.28 / 5.0, abs=0.001)
|
||||
# robustness on net rs [1.38, -1.02, 0.48]
|
||||
assert b["median_net_r"] == pytest.approx(0.48, abs=0.001)
|
||||
assert b["profit_factor"] == pytest.approx(1.86 / 1.02, abs=0.01)
|
||||
assert b["net_avg_r_ex_top5"] == pytest.approx((0.48 - 1.02) / 2, abs=0.001)
|
||||
|
||||
def test_missing_hold_skipped(self):
|
||||
b = bt._time_exit_bucket([{"time_r": {5: 1.0}}], 21)
|
||||
assert b["total"] == 0
|
||||
assert b["avg_r"] is None
|
||||
|
||||
|
||||
def _acand(
|
||||
rr: float = 2.0,
|
||||
conf: float = 60.0,
|
||||
action: str = "LONG_MODERATE",
|
||||
mp: float | None = 90.0,
|
||||
direction: str = "long",
|
||||
) -> dict:
|
||||
"""Ablation candidate: meets_core mirrors the default floors (min_rr 1.2,
|
||||
min_confidence 55, exclude_neutral on)."""
|
||||
action_dir = "long" if action.startswith("LONG") else "short" if action.startswith("SHORT") else "neutral"
|
||||
meets = rr >= 1.2 and conf >= 55.0 and action_dir != "neutral" and action_dir == direction
|
||||
return {
|
||||
"rr": rr,
|
||||
"confidence": conf,
|
||||
"action": action,
|
||||
"momentum_percentile": mp,
|
||||
"activation_momentum_percentile": mp,
|
||||
"direction": direction,
|
||||
"meets_core": meets,
|
||||
"risk_level": "Low",
|
||||
"target_hit": True,
|
||||
"outcome": OUTCOME_TARGET_HIT,
|
||||
"realized_r": rr,
|
||||
"risk_pct": 0.05,
|
||||
"time_r": {d: 0.5 for d in bt.TIME_EXIT_DAYS},
|
||||
}
|
||||
|
||||
|
||||
class TestGateAblation:
|
||||
ACTIVATION = {
|
||||
"min_rr": 1.2,
|
||||
"min_confidence": 55.0,
|
||||
"exclude_neutral": True,
|
||||
"require_high_conviction": False,
|
||||
"exclude_conflicts": False,
|
||||
}
|
||||
|
||||
def test_variant_counts(self):
|
||||
cands = [
|
||||
_acand(), # clears everything
|
||||
_acand(conf=40.0), # fails confidence floor
|
||||
_acand(rr=1.0), # fails R:R floor
|
||||
_acand(action="NEUTRAL"), # fails NEUTRAL exclusion
|
||||
_acand(mp=50.0), # fails the momentum cutoff
|
||||
_acand(direction="short", action="SHORT_MODERATE", mp=95.0), # short — gated out
|
||||
]
|
||||
rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 80.0)}
|
||||
assert rows["all_floors"]["total"] == 1
|
||||
assert rows["no_confidence_floor"]["total"] == 2
|
||||
assert rows["no_rr_floor"]["total"] == 2
|
||||
assert rows["no_neutral_exclusion"]["total"] == 2
|
||||
assert rows["momentum_only"]["total"] == 4
|
||||
assert rows["all_floors"]["net_avg_r"] is not None
|
||||
# Every variant is also graded under the hold-to-horizon exit.
|
||||
assert rows["all_floors"]["hold_days"] == max(bt.TIME_EXIT_DAYS)
|
||||
assert rows["all_floors"]["hold_avg_r"] == pytest.approx(0.5)
|
||||
assert rows["all_floors"]["hold_net_avg_r"] is not None
|
||||
assert rows["momentum_only"]["hold_total_r"] == pytest.approx(4 * 0.5, abs=0.01)
|
||||
|
||||
def test_threshold_zero_disables_momentum_gate(self):
|
||||
# Floors only: the short and the low-momentum long both pass all_floors.
|
||||
cands = [_acand(mp=50.0), _acand(direction="short", action="SHORT_MODERATE", mp=None)]
|
||||
rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 0.0)}
|
||||
assert rows["all_floors"]["total"] == 2
|
||||
|
||||
|
||||
def _sim_prices(start_ord: int, closes: list[float]) -> tuple:
|
||||
"""Column arrays for consecutive daily bars: open = close (no gaps),
|
||||
high/low = close ± 1."""
|
||||
ords = list(range(start_ord, start_ord + len(closes)))
|
||||
return (
|
||||
ords,
|
||||
list(closes),
|
||||
[c + 1.0 for c in closes],
|
||||
[c - 1.0 for c in closes],
|
||||
list(closes),
|
||||
[1_000_000] * len(closes),
|
||||
)
|
||||
|
||||
|
||||
def _sim_cand(
|
||||
sym: str, day_ord: int, entry: float, stop: float, target: float, mp: float = 90.0
|
||||
) -> dict:
|
||||
return {
|
||||
"qualified": True,
|
||||
"direction": "long",
|
||||
"symbol": sym,
|
||||
"date": date.fromordinal(day_ord).isoformat(),
|
||||
"entry": entry,
|
||||
"stop": stop,
|
||||
"target": target,
|
||||
"momentum_percentile": mp,
|
||||
"activation_momentum_percentile": mp,
|
||||
}
|
||||
|
||||
|
||||
class TestSimulatePortfolio:
|
||||
ORD = date(2025, 1, 6).toordinal()
|
||||
|
||||
def test_hold_policy_accounting(self):
|
||||
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
|
||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0)
|
||||
sim = bt._simulate_portfolio([cand], prices, None, "hold", 3)
|
||||
assert sim is not None
|
||||
assert sim["trades"] == 1
|
||||
# 20 shares (1% risk / $5 stop distance), exit at the day-3 close 106:
|
||||
# pnl = 2120 − 2000 − 2.00 entry cost − 2.12 exit cost = 115.88
|
||||
assert sim["final_equity"] == pytest.approx(10_115.88, abs=0.01)
|
||||
assert sim["win_rate"] == 100.0
|
||||
assert sim["best_trade_r"] == pytest.approx(1.2)
|
||||
assert sim["avg_hold_days"] == 3.0
|
||||
assert sim["max_drawdown_pct"] == 0.0
|
||||
assert sim["cagr_pct"] is None # window far too short to annualize
|
||||
assert sim["spy_return_pct"] is None
|
||||
assert sim["yearly_returns"] == [
|
||||
{"year": 2025, "return_pct": pytest.approx(1.2, abs=0.05)}
|
||||
]
|
||||
|
||||
def test_target_policy_exits_at_target(self):
|
||||
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
|
||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=105.0)
|
||||
sim = bt._simulate_portfolio([cand], prices, None, "target", 30)
|
||||
assert sim is not None
|
||||
assert sim["trades"] == 1
|
||||
assert sim["best_trade_r"] == pytest.approx(1.0) # filled exactly at 105
|
||||
|
||||
def test_stop_gap_fills_at_open(self):
|
||||
# Day-1 bar gaps to a 90 open, below the 95 stop → fill at the open.
|
||||
ords = list(range(self.ORD, self.ORD + 2))
|
||||
prices = {"AAA": (ords, [100.0, 90.0], [101.0, 92.0], [99.0, 88.0], [100.0, 91.0], [1, 1])}
|
||||
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=120.0)
|
||||
sim = bt._simulate_portfolio([cand], prices, None, "hold", 30)
|
||||
assert sim is not None
|
||||
assert sim["trades"] == 1
|
||||
assert sim["worst_trade_r"] == pytest.approx(-2.0) # (90 − 100) / 5
|
||||
|
||||
def test_nothing_qualified_returns_none(self):
|
||||
assert bt._simulate_portfolio([], {}, None, "hold", 30) is None
|
||||
|
||||
|
||||
def test_bucket_stats_counts_and_expectancy():
|
||||
cands = [
|
||||
_cand(70, OUTCOME_TARGET_HIT, 3.0), # +3R win
|
||||
@@ -55,6 +463,18 @@ def test_bucket_stats_counts_and_expectancy():
|
||||
# avg R = (3 + 2 - 1 + 0) / 4 = 1.0
|
||||
assert s["avg_r"] == 1.0
|
||||
assert s["total_r"] == 4.0
|
||||
# net = gross minus a 0.04R round trip per candidate (risk_pct 0.05)
|
||||
assert s["net_avg_r"] == pytest.approx(1.0 - _COST_R_005, abs=0.001)
|
||||
assert s["net_total_r"] == pytest.approx(4.0 - 4 * _COST_R_005, abs=0.01)
|
||||
assert s["best_r"] == 3.0
|
||||
assert s["worst_r"] == -1.0
|
||||
assert s["avg_hold_days"] == 10.0
|
||||
assert s["net_r_per_day"] == pytest.approx((1.0 - _COST_R_005) / 10.0, abs=0.001)
|
||||
# robustness: net rs are [2.96, 1.96, -1.04, -0.04]
|
||||
assert s["median_net_r"] == pytest.approx(0.96, abs=0.001)
|
||||
assert s["profit_factor"] == pytest.approx(4.92 / 1.08, abs=0.01)
|
||||
# ex-top-5%: ceil(4 * 0.05) = 1 winner trimmed → mean of the remaining three
|
||||
assert s["net_avg_r_ex_top5"] == pytest.approx((1.96 - 1.04 - 0.04) / 3, abs=0.001)
|
||||
|
||||
|
||||
def test_bucket_stats_empty():
|
||||
@@ -62,26 +482,102 @@ def test_bucket_stats_empty():
|
||||
assert s["total"] == 0
|
||||
assert s["hit_rate"] is None
|
||||
assert s["avg_r"] is None
|
||||
assert s["net_avg_r"] is None
|
||||
|
||||
|
||||
def test_calibration_buckets():
|
||||
cands = [
|
||||
_cand(65, OUTCOME_TARGET_HIT, 2.0),
|
||||
_cand(62, OUTCOME_STOP_HIT, 2.0),
|
||||
_cand(15, OUTCOME_STOP_HIT, 2.0),
|
||||
]
|
||||
rows = bt._calibration(cands)
|
||||
by_bucket = {r["bucket"]: r for r in rows}
|
||||
assert by_bucket["60-80%"]["n"] == 2
|
||||
assert by_bucket["60-80%"]["realized_hit_rate"] == 50.0 # 1 of 2 hit
|
||||
assert by_bucket["0-20%"]["n"] == 1
|
||||
assert by_bucket["0-20%"]["realized_hit_rate"] == 0.0
|
||||
def test_bucket_stats_no_risk_pct_means_no_cost():
|
||||
c = _cand(50, OUTCOME_TARGET_HIT, 2.0)
|
||||
del c["risk_pct"]
|
||||
s = bt._bucket_stats([c])
|
||||
assert s["net_avg_r"] == s["avg_r"]
|
||||
assert s["net_total_r"] == s["total_r"]
|
||||
|
||||
|
||||
def test_build_recommendation_reads_the_report():
|
||||
report = {
|
||||
"overall_qualified": {"net_avg_r": 0.13, "net_avg_r_ex_top5": 0.05},
|
||||
"time_exit_sweep": [
|
||||
{"hold_days": 21, "net_avg_r": 0.38},
|
||||
{"hold_days": 30, "net_avg_r": 0.50, "net_avg_r_ex_top5": 0.21},
|
||||
],
|
||||
"gate_ablation": [
|
||||
{"variant": "all_floors", "total": 100, "hold_net_avg_r": 0.50},
|
||||
{"variant": "no_confidence_floor", "total": 130, "hold_net_avg_r": 0.49},
|
||||
{"variant": "no_rr_floor", "total": 400, "hold_net_avg_r": 0.34},
|
||||
{"variant": "no_neutral_exclusion", "total": 120, "hold_net_avg_r": 0.46},
|
||||
],
|
||||
"sweep": [
|
||||
{"min_momentum_percentile": 80.0, "net_avg_r": 0.13, "total": 100},
|
||||
{"min_momentum_percentile": 60.0, "net_avg_r": 0.05, "total": 300},
|
||||
{"min_momentum_percentile": 0.0, "net_avg_r": -0.12, "total": 1000},
|
||||
],
|
||||
"portfolio_sim": {"policies": [
|
||||
{"policy": "target", "cagr_pct": 23.7, "total_return_pct": 134.8,
|
||||
"spy_return_pct": 95.9, "max_drawdown_pct": 20.7},
|
||||
{"policy": "hold", "cagr_pct": 31.9, "total_return_pct": 203.6,
|
||||
"spy_return_pct": 95.9, "max_drawdown_pct": 21.2},
|
||||
]},
|
||||
}
|
||||
rec = bt._build_recommendation(report)
|
||||
by_topic: dict[str, list[str]] = {}
|
||||
for item in rec["items"]:
|
||||
by_topic.setdefault(item["topic"], []).append(item["text"])
|
||||
|
||||
assert rec["headline"] is not None and "hold 30" in rec["headline"]
|
||||
assert any("hold 30 trading days" in t for t in by_topic["exit"])
|
||||
gate_texts = " | ".join(by_topic["gate"])
|
||||
assert "confidence floor adds nothing" in gate_texts
|
||||
assert "keep the R:R floor" in gate_texts
|
||||
assert "keep the NEUTRAL exclusion" in gate_texts
|
||||
assert "80" in by_topic["cutoff"][0]
|
||||
assert "beats" in by_topic["benchmark"][0]
|
||||
# robustness is judged under the RECOMMENDED exit (the 30d hold), not the
|
||||
# target model the recommendation advises abandoning
|
||||
assert any(
|
||||
"not a handful of outliers" in t and "under the recommended 30d hold" in t
|
||||
for t in by_topic["robustness"]
|
||||
)
|
||||
|
||||
|
||||
def test_build_recommendation_flags_outlier_dependence():
|
||||
rec = bt._build_recommendation({
|
||||
"overall_qualified": {"net_avg_r": 0.13, "net_avg_r_ex_top5": -0.02},
|
||||
})
|
||||
robustness = [i["text"] for i in rec["items"] if i["topic"] == "robustness"]
|
||||
assert robustness and "WARNING" in robustness[0]
|
||||
|
||||
|
||||
def test_window_setups_too_short_returns_empty():
|
||||
assert bt._window_setups([], {}, {}) == []
|
||||
|
||||
|
||||
def test_replay_ticker_candidates_carry_gate_fields():
|
||||
"""The ablation recomputes floors from candidate fields — a candidate missing
|
||||
action/risk_level silently zeroes the ablation rows (July 2026 regression)."""
|
||||
from app.services.admin_service import ACTIVATION_DEFAULTS
|
||||
from app.services.recommendation_service import DEFAULT_RECOMMENDATION_CONFIG
|
||||
|
||||
base = date(2025, 1, 1)
|
||||
bars = []
|
||||
for i in range(160):
|
||||
close = 100.0 + 8.0 * math.sin(i / 6.0)
|
||||
bars.append(SimpleNamespace(
|
||||
date=base + timedelta(days=i),
|
||||
open=close,
|
||||
high=close + 1.5,
|
||||
low=close - 1.5,
|
||||
close=close,
|
||||
volume=1_000_000 + (i % 5) * 1000,
|
||||
))
|
||||
cands = bt._replay_ticker(
|
||||
"OSC", bars, dict(DEFAULT_RECOMMENDATION_CONFIG), dict(ACTIVATION_DEFAULTS)
|
||||
)
|
||||
assert cands, "expected the oscillating series to produce candidates"
|
||||
for c in cands:
|
||||
assert c.get("action") is not None
|
||||
assert "risk_level" in c
|
||||
|
||||
|
||||
async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None:
|
||||
t = Ticker(symbol=symbol)
|
||||
session.add(t)
|
||||
@@ -108,16 +604,37 @@ async def test_run_backtest_smoke(session):
|
||||
# well-formed report
|
||||
assert report["tickers"] == 1
|
||||
assert isinstance(report["candidates"], int)
|
||||
for key in ("overall_qualified", "overall_all", "by_direction", "calibration", "sweep"):
|
||||
for key in (
|
||||
"overall_qualified", "overall_all", "by_direction", "sweep",
|
||||
"gate_ablation", "time_exit_sweep", "portfolio_sim", "strategy_variants",
|
||||
"recommendation", "research_recommendation",
|
||||
):
|
||||
assert key in report
|
||||
# the oscillating series should yield at least some resolved setups
|
||||
assert report["candidates"] >= 1
|
||||
|
||||
# cost assumption is reported, and every bucket carries net numbers
|
||||
assert report["params"]["cost_per_side_pct"] == pytest.approx(bt.COST_PER_SIDE * 100)
|
||||
assert "net_avg_r" in report["overall_all"]
|
||||
|
||||
# ablation baseline reproduces the qualified set exactly, and every row
|
||||
# carries the hold-to-horizon grading alongside the target model
|
||||
ablation = {r["variant"]: r for r in report["gate_ablation"]}
|
||||
assert ablation["all_floors"]["total"] == report["overall_qualified"]["total"]
|
||||
for row in report["gate_ablation"]:
|
||||
assert "hold_net_avg_r" in row
|
||||
|
||||
# time-exit sweep covers the configured hold lengths
|
||||
assert [r["hold_days"] for r in report["time_exit_sweep"]] == list(bt.TIME_EXIT_DAYS)
|
||||
|
||||
# portfolio simulation section is always present (policies may be empty
|
||||
# when nothing qualifies)
|
||||
assert "portfolio_sim" in report
|
||||
assert isinstance(report["portfolio_sim"]["policies"], list)
|
||||
assert report["portfolio_sim"]["params"]["max_positions"] == bt.SIM_MAX_POSITIONS
|
||||
assert isinstance(report["strategy_variants"]["variants"], list)
|
||||
|
||||
# sweep: lowering the momentum-percentile cutoff can only add qualifiers
|
||||
sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True)
|
||||
counts = [r["total"] for r in sweep]
|
||||
assert counts == sorted(counts) # ascending as threshold descends
|
||||
# every calibration row is internally consistent
|
||||
for row in report["calibration"]:
|
||||
assert 0 <= row["realized_hit_rate"] <= 100
|
||||
assert row["n"] >= 1
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests for benchmark return / alpha helper (pure, no DB)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.benchmark_service import benchmark_return_pct
|
||||
|
||||
|
||||
def test_benchmark_return_basic():
|
||||
closes = {date(2026, 1, 2): 100.0, date(2026, 1, 5): 110.0}
|
||||
assert benchmark_return_pct(closes, date(2026, 1, 2), date(2026, 1, 5)) == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_benchmark_return_uses_nearest_prior_trading_day():
|
||||
# No bar on the 4th (weekend) → falls back to the 2nd; as-of the 12th → the 9th.
|
||||
closes = {date(2026, 1, 2): 100.0, date(2026, 1, 9): 120.0}
|
||||
assert benchmark_return_pct(closes, date(2026, 1, 4), date(2026, 1, 12)) == pytest.approx(20.0)
|
||||
|
||||
|
||||
def test_benchmark_return_none_when_empty():
|
||||
assert benchmark_return_pct({}, date(2026, 1, 2), date(2026, 1, 5)) is None
|
||||
|
||||
|
||||
def test_benchmark_return_none_when_open_before_history():
|
||||
closes = {date(2026, 1, 10): 100.0}
|
||||
assert benchmark_return_pct(closes, date(2026, 1, 2), date(2026, 1, 12)) is None
|
||||
@@ -6,6 +6,8 @@ from datetime import date, timedelta
|
||||
|
||||
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
|
||||
from app.services.event_study_service import (
|
||||
_lead,
|
||||
_percentile,
|
||||
detect_events,
|
||||
event_centered,
|
||||
signal_centered,
|
||||
@@ -40,6 +42,29 @@ def test_detect_events_two_after_recovery():
|
||||
assert len(events) == 2
|
||||
|
||||
|
||||
def test_detect_events_cooldown_suppresses_close_recross():
|
||||
# Dips below threshold then re-crosses only a few bars later.
|
||||
closes = [100.0] * 300 + [85.0] * 3 + [100.0] * 3 + [85.0] * 3
|
||||
dates = _days(len(closes))
|
||||
assert len(detect_events(closes, dates, threshold_pct=15.0, cooldown=40)) == 1
|
||||
assert len(detect_events(closes, dates, threshold_pct=15.0, cooldown=3)) == 2
|
||||
|
||||
|
||||
def test_percentile_interpolation():
|
||||
vals = [float(v) for v in range(0, 101, 10)] # 0,10,...,100
|
||||
assert _percentile(vals, 50) == 50.0
|
||||
assert _percentile(vals, 80) == 80.0
|
||||
assert _percentile([], 50) is None
|
||||
|
||||
|
||||
def test_lead_earliest_crossing():
|
||||
dates = _days(200)
|
||||
t0 = 120
|
||||
indicator = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
|
||||
assert _lead(indicator, t0, dates, pre=60, threshold=60.0) == 30
|
||||
assert _lead(indicator, t0, dates, pre=60, threshold=80.0) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event-centered lead time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for the ingestion service — honest reporting of empty provider fetches."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.ticker import Ticker
|
||||
from app.providers.protocol import OHLCVData
|
||||
from app.services import ingestion_service as svc
|
||||
from tests.conftest import MockMarketDataProvider, _test_session_factory # type: ignore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session():
|
||||
async with _test_session_factory() as s:
|
||||
yield s
|
||||
|
||||
|
||||
async def _add_ticker(session, symbol: str) -> None:
|
||||
session.add(Ticker(symbol=symbol))
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _bars(symbol: str, n: int) -> list[OHLCVData]:
|
||||
today = date.today()
|
||||
return [
|
||||
OHLCVData(ticker=symbol, date=today - timedelta(days=i),
|
||||
open=100.0, high=101.0, low=99.0, close=100.0, volume=1000)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
async def test_empty_fetch_on_new_ticker_reports_no_data(session):
|
||||
# A non-US symbol (e.g. RHM/Rheinmetall) Alpaca doesn't cover → empty bars.
|
||||
# This must NOT report success; it surfaces as no_data.
|
||||
await _add_ticker(session, "RHM")
|
||||
result = await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=[]), "RHM")
|
||||
|
||||
assert result.status == "no_data"
|
||||
assert result.records_ingested == 0
|
||||
assert "provider" in (result.message or "").lower()
|
||||
|
||||
|
||||
async def test_happy_path_ingests_bars(session):
|
||||
await _add_ticker(session, "AAA")
|
||||
result = await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=_bars("AAA", 3)), "AAA")
|
||||
|
||||
assert result.status == "complete"
|
||||
assert result.records_ingested == 3
|
||||
|
||||
|
||||
async def test_empty_fetch_with_existing_history_is_up_to_date(session):
|
||||
# Covered ticker, just no new bars in the window → complete, not no_data.
|
||||
await _add_ticker(session, "BBB")
|
||||
await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=_bars("BBB", 2)), "BBB")
|
||||
|
||||
result = await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=[]), "BBB")
|
||||
|
||||
assert result.status == "complete"
|
||||
assert result.records_ingested == 0
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for the cross-sectional 12-1 momentum ranking."""
|
||||
"""Unit tests for the cross-sectional activation momentum ranking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -35,6 +35,21 @@ async def _seed(session, symbol: str, rate: float, n: int = 280) -> None:
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _seed_closes(session, symbol: str, closes: list[float]) -> None:
|
||||
t = Ticker(symbol=symbol)
|
||||
session.add(t)
|
||||
await session.flush()
|
||||
base = date(2024, 1, 1)
|
||||
for i, close in enumerate(closes):
|
||||
session.add(OHLCVRecord(
|
||||
ticker_id=t.id,
|
||||
date=base + timedelta(days=i),
|
||||
open=close, high=close, low=close, close=close,
|
||||
volume=1_000_000,
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
def test_compute_momentum_insufficient_history():
|
||||
assert ms.compute_12_1_momentum([100.0] * 100) is None
|
||||
|
||||
@@ -47,7 +62,11 @@ def test_compute_momentum_value():
|
||||
assert m > 0
|
||||
|
||||
|
||||
async def test_ranks_universe_into_percentiles(session):
|
||||
async def test_ranks_universe_into_raw_percentiles_when_benchmark_missing(session, monkeypatch):
|
||||
async def no_benchmark(_db):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
|
||||
await _seed(session, "HIGH", rate=1.010) # strong uptrend → top momentum
|
||||
await _seed(session, "MID", rate=1.002)
|
||||
await _seed(session, "LOW", rate=0.999) # declining → bottom momentum
|
||||
@@ -58,7 +77,31 @@ async def test_ranks_universe_into_percentiles(session):
|
||||
assert pct["LOW"] == 0.0
|
||||
|
||||
|
||||
async def test_short_history_ticker_is_unranked(session):
|
||||
async def test_ranks_universe_into_residual_percentiles_when_benchmark_available(session, monkeypatch):
|
||||
base = date(2024, 1, 1)
|
||||
n = 280
|
||||
benchmark = {base + timedelta(days=i): 100.0 * (1.001 ** i) for i in range(n)}
|
||||
|
||||
async def with_benchmark(_db):
|
||||
return benchmark
|
||||
|
||||
monkeypatch.setattr(ms, "_load_activation_benchmark", with_benchmark)
|
||||
market = [benchmark[base + timedelta(days=i)] for i in range(n)]
|
||||
await _seed_closes(session, "DRIFT", [market[i] * (1.0008 ** i) for i in range(n)])
|
||||
await _seed_closes(session, "BETA", market)
|
||||
await _seed_closes(session, "LAG", [market[i] * (0.9992 ** i) for i in range(n)])
|
||||
|
||||
pct = await ms.compute_momentum_percentiles(session)
|
||||
assert pct["DRIFT"] == 100.0
|
||||
assert pct["BETA"] == 50.0
|
||||
assert pct["LAG"] == 0.0
|
||||
|
||||
|
||||
async def test_short_history_ticker_is_unranked(session, monkeypatch):
|
||||
async def no_benchmark(_db):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
|
||||
await _seed(session, "LONG", rate=1.005)
|
||||
await _seed(session, "SHORTHX", rate=1.005, n=100) # < 1y → no momentum
|
||||
|
||||
@@ -67,5 +110,9 @@ async def test_short_history_ticker_is_unranked(session):
|
||||
assert "SHORTHX" not in pct
|
||||
|
||||
|
||||
async def test_empty_universe_returns_empty(session):
|
||||
async def test_empty_universe_returns_empty(session, monkeypatch):
|
||||
async def no_benchmark(_db):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
|
||||
assert await ms.compute_momentum_percentiles(session) == {}
|
||||
|
||||
@@ -317,3 +317,26 @@ class TestGetPerformanceStats:
|
||||
|
||||
stats = await get_performance_stats(db_session)
|
||||
assert stats["overall"]["total"] == 1
|
||||
|
||||
async def test_immature_setups_excluded_and_counted_as_maturing(self, db_session: AsyncSession):
|
||||
ticker = await _make_ticker(db_session)
|
||||
now = datetime.now(timezone.utc)
|
||||
# Matured (detected well beyond the window) → counted in the stats.
|
||||
db_session.add(_make_setup(
|
||||
ticker, rr=2.0, actual_outcome=OUTCOME_TARGET_HIT, detected=now - timedelta(days=90),
|
||||
))
|
||||
# Young but already resolved → excluded from stats, reported as maturing.
|
||||
db_session.add(_make_setup(
|
||||
ticker, rr=2.0, actual_outcome=OUTCOME_STOP_HIT, detected=now,
|
||||
))
|
||||
# Young and still pending → also maturing.
|
||||
db_session.add(_make_setup(ticker, detected=now))
|
||||
await db_session.flush()
|
||||
|
||||
stats = await get_performance_stats(db_session)
|
||||
|
||||
assert stats["overall"]["total"] == 1 # only the matured win
|
||||
assert stats["overall"]["wins"] == 1
|
||||
assert stats["overall"]["hit_rate"] == 100.0
|
||||
assert stats["pending"] == 1
|
||||
assert stats["maturing"] == 2 # young resolved + pending
|
||||
|
||||
@@ -7,7 +7,9 @@ from datetime import date, datetime, timedelta, timezone
|
||||
import pytest
|
||||
|
||||
from app.exceptions import ValidationError
|
||||
from app.models.benchmark_price import BenchmarkPrice
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.user import User
|
||||
from app.services import paper_trade_service as svc
|
||||
@@ -92,6 +94,7 @@ async def _add_bars(session, ticker_id: int, highs_lows: list[tuple[float, float
|
||||
|
||||
|
||||
async def test_resolve_closes_on_target(session):
|
||||
await svc.set_exit_policy(session, mode="target")
|
||||
tid = await _seed(session, "AAA", close=100.0)
|
||||
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
||||
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0)
|
||||
@@ -105,6 +108,7 @@ async def test_resolve_closes_on_target(session):
|
||||
|
||||
|
||||
async def test_resolve_closes_on_stop(session):
|
||||
await svc.set_exit_policy(session, mode="target")
|
||||
tid = await _seed(session, "AAA", close=100.0)
|
||||
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
||||
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0)
|
||||
@@ -116,6 +120,7 @@ async def test_resolve_closes_on_stop(session):
|
||||
|
||||
|
||||
async def test_resolve_leaves_open_when_neither_hit(session):
|
||||
await svc.set_exit_policy(session, mode="target")
|
||||
tid = await _seed(session, "AAA", close=100.0)
|
||||
await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
||||
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0)
|
||||
@@ -124,3 +129,174 @@ async def test_resolve_leaves_open_when_neither_hit(session):
|
||||
assert closed == 0
|
||||
rows = await svc.list_trades(session, 1, status="open")
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
async def _seed_benchmark(session, points: dict) -> None:
|
||||
for d, close in points.items():
|
||||
session.add(BenchmarkPrice(symbol="SPY", date=d, close=close))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _add_open_trade(session, ticker_id: int, direction: str, *, entry: float,
|
||||
shares: float, days_ago: int) -> None:
|
||||
session.add(PaperTrade(
|
||||
user_id=1, ticker_id=ticker_id, direction=direction, entry_price=entry,
|
||||
shares=shares, stop_loss=entry * 0.95, target=entry * 1.2, status="open",
|
||||
opened_at=datetime.now(timezone.utc) - timedelta(days=days_ago),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_alpha_long_open(session):
|
||||
tid = await _seed(session, "AAA", close=110.0) # current price 110 → +10% on a 100 entry
|
||||
today = date.today()
|
||||
await _seed_benchmark(session, {today - timedelta(days=10): 400.0, today: 420.0}) # SPY +5%
|
||||
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
|
||||
|
||||
row = (await svc.list_trades(session, 1, status="open"))[0]
|
||||
assert row["benchmark_return_pct"] == pytest.approx(5.0)
|
||||
assert row["alpha_pct"] == pytest.approx(5.0) # +10% trade − 5% bench
|
||||
assert row["alpha_usd"] == pytest.approx(50.0) # 5% of 100*10
|
||||
|
||||
|
||||
async def test_alpha_short_and_missing_benchmark(session):
|
||||
tid = await _seed(session, "BBB", close=90.0) # price fell to 90 → short +10%
|
||||
today = date.today()
|
||||
await _add_open_trade(session, tid, "short", entry=100.0, shares=4, days_ago=10)
|
||||
|
||||
# No benchmark data yet → alpha unset, not an error.
|
||||
row = (await svc.list_trades(session, 1, status="open"))[0]
|
||||
assert row["alpha_pct"] is None
|
||||
assert row["benchmark_return_pct"] is None
|
||||
|
||||
# Flat benchmark → alpha equals the (direction-signed) trade return.
|
||||
await _seed_benchmark(session, {today - timedelta(days=10): 400.0, today: 400.0})
|
||||
row = (await svc.list_trades(session, 1, status="open"))[0]
|
||||
assert row["benchmark_return_pct"] == pytest.approx(0.0)
|
||||
assert row["alpha_pct"] == pytest.approx(10.0)
|
||||
|
||||
|
||||
def _b(d: date, hi: float, lo: float):
|
||||
return svc.Bar(date=d, high=hi, low=lo)
|
||||
|
||||
|
||||
class TestTrailingClose:
|
||||
def test_long_locks_gain(self):
|
||||
# Runs to 120; the 12%-from-peak stop (120 → 105.6) is pierced on the drop.
|
||||
bars = [_b(date(2026, 1, 2), 120, 110), _b(date(2026, 1, 3), 130, 100)]
|
||||
hit = svc._trailing_close("long", 100.0, 95.0, 0.12, bars)
|
||||
assert hit is not None
|
||||
price, when, reason = hit
|
||||
assert price == pytest.approx(105.6)
|
||||
assert reason == "trailing"
|
||||
assert when == date(2026, 1, 3)
|
||||
|
||||
def test_initial_stop_caps_loss(self):
|
||||
bars = [_b(date(2026, 1, 2), 101, 94)] # through the initial stop before running
|
||||
hit = svc._trailing_close("long", 100.0, 95.0, 0.12, bars)
|
||||
assert hit is not None
|
||||
price, _, reason = hit
|
||||
assert price == pytest.approx(95.0)
|
||||
assert reason == "stop"
|
||||
|
||||
def test_none_when_neither_hit(self):
|
||||
bars = [_b(date(2026, 1, 2), 105, 99), _b(date(2026, 1, 3), 106, 100)]
|
||||
assert svc._trailing_close("long", 100.0, 95.0, 0.12, bars) is None
|
||||
|
||||
|
||||
async def test_exit_policy_defaults_and_round_trip(session):
|
||||
# Default: the backtest-validated hold-to-horizon exit.
|
||||
assert await svc.get_exit_policy(session) == {
|
||||
"mode": "time", "trailing_pct": 12.0, "hold_days": 30,
|
||||
}
|
||||
updated = await svc.set_exit_policy(session, mode="target", trailing_pct=15.0, hold_days=21)
|
||||
assert updated == {"mode": "target", "trailing_pct": 15.0, "hold_days": 21}
|
||||
assert (await svc.get_exit_policy(session))["mode"] == "target"
|
||||
|
||||
|
||||
async def test_exit_policy_rejects_bad_input(session):
|
||||
with pytest.raises(ValidationError):
|
||||
await svc.set_exit_policy(session, mode="bogus")
|
||||
with pytest.raises(ValidationError):
|
||||
await svc.set_exit_policy(session, trailing_pct=200.0)
|
||||
with pytest.raises(ValidationError):
|
||||
await svc.set_exit_policy(session, hold_days=1)
|
||||
|
||||
|
||||
def _r(d: date, open_: float, hi: float, lo: float, close: float) -> tuple:
|
||||
return (d, open_, hi, lo, close)
|
||||
|
||||
|
||||
class TestTimeClose:
|
||||
def test_closes_at_hold_days_close(self):
|
||||
rows = [
|
||||
_r(date(2026, 1, 2), 101, 103, 100, 102),
|
||||
_r(date(2026, 1, 3), 102, 104, 101, 103),
|
||||
_r(date(2026, 1, 4), 103, 106, 102, 105),
|
||||
]
|
||||
assert svc._time_close("long", 95.0, 3, rows) == (105.0, date(2026, 1, 4), "time")
|
||||
|
||||
def test_stop_before_horizon(self):
|
||||
rows = [_r(date(2026, 1, 2), 100, 101, 94, 96)]
|
||||
assert svc._time_close("long", 95.0, 30, rows) == (95.0, date(2026, 1, 2), "stop")
|
||||
|
||||
def test_gap_through_stop_fills_at_open(self):
|
||||
rows = [_r(date(2026, 1, 2), 92, 93, 90, 91)]
|
||||
assert svc._time_close("long", 95.0, 30, rows) == (92.0, date(2026, 1, 2), "stop")
|
||||
|
||||
def test_none_before_horizon(self):
|
||||
rows = [_r(date(2026, 1, 2), 101, 103, 100, 102)]
|
||||
assert svc._time_close("long", 95.0, 5, rows) is None
|
||||
|
||||
|
||||
async def test_resolve_time_mode_closes_at_horizon(session):
|
||||
await svc.set_exit_policy(session, mode="time", hold_days=2)
|
||||
tid = await _seed(session, "AAA", close=100.0)
|
||||
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
||||
entry_price=100.0, shares=10, stop_loss=95.0, target=200.0)
|
||||
await _add_bars(session, tid, [(103, 101), (105, 102)], start=date.today())
|
||||
assert await svc.resolve_open_trades(session) == 1
|
||||
await session.refresh(trade)
|
||||
assert trade.status == "closed"
|
||||
assert trade.close_reason == "time"
|
||||
assert trade.close_price == pytest.approx((105 + 102) / 2) # day-2 close (= bar mid)
|
||||
|
||||
|
||||
async def test_resolve_time_mode_stop_still_governs(session):
|
||||
await svc.set_exit_policy(session, mode="time", hold_days=30)
|
||||
tid = await _seed(session, "AAA", close=100.0)
|
||||
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
||||
entry_price=100.0, shares=10, stop_loss=95.0, target=200.0)
|
||||
await _add_bars(session, tid, [(101, 94)], start=date.today()) # low pierces the stop
|
||||
assert await svc.resolve_open_trades(session) == 1
|
||||
await session.refresh(trade)
|
||||
assert trade.close_reason == "stop"
|
||||
assert trade.close_price == pytest.approx(95.0)
|
||||
|
||||
|
||||
async def test_resolve_trailing_closes_with_reason(session):
|
||||
await svc.set_exit_policy(session, mode="trailing", trailing_pct=12.0)
|
||||
tid = await _seed(session, "AAA", close=100.0)
|
||||
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
|
||||
await _add_bars(session, tid, [(120, 110), (130, 100)], start=date.today()) # run up, pull back
|
||||
assert await svc.resolve_open_trades(session) == 1
|
||||
closed = await svc.list_trades(session, 1, status="closed")
|
||||
assert closed[0]["close_reason"] == "trailing"
|
||||
|
||||
|
||||
async def test_manual_close_sets_reason(session):
|
||||
await _seed(session, "AAA", close=112.0)
|
||||
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
||||
entry_price=100.0, shares=5, stop_loss=95.0, target=120.0)
|
||||
await svc.close_trade(session, 1, trade.id)
|
||||
assert (await svc.list_trades(session, 1, status="closed"))[0]["close_reason"] == "manual"
|
||||
|
||||
|
||||
async def test_list_open_exposes_trailing_stop(session):
|
||||
await svc.set_exit_policy(session, mode="trailing", trailing_pct=12.0)
|
||||
tid = await _seed(session, "AAA", close=120.0)
|
||||
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
|
||||
await _add_bars(session, tid, [(125, 118)], start=date.today()) # peak 125
|
||||
row = (await svc.list_trades(session, 1, status="open"))[0]
|
||||
assert row["trailing_stop"] == pytest.approx(110.0) # 125 * (1 - 0.12)
|
||||
assert row["trailing_distance_pct"] is not None
|
||||
|
||||
@@ -31,6 +31,7 @@ STRICT_GATE = {
|
||||
|
||||
def _setup(**kwargs):
|
||||
base = dict(
|
||||
direction="long",
|
||||
rr_ratio=3.0,
|
||||
confidence_score=80.0,
|
||||
recommended_action="LONG_HIGH",
|
||||
@@ -111,6 +112,33 @@ class TestStrictTighteners:
|
||||
assert setup_qualifies(s, STRICT_GATE) is False
|
||||
|
||||
|
||||
NEUTRAL_GATE = {**DEFAULT_GATE, "exclude_neutral": True}
|
||||
|
||||
|
||||
class TestExcludeNeutral:
|
||||
def test_neutral_excluded_when_on(self):
|
||||
assert setup_qualifies(_setup(recommended_action="NEUTRAL"), NEUTRAL_GATE) is False
|
||||
|
||||
def test_missing_action_treated_as_neutral(self):
|
||||
assert setup_qualifies(_setup(recommended_action=None), NEUTRAL_GATE) is False
|
||||
|
||||
def test_directional_passes_when_on(self):
|
||||
assert setup_qualifies(_setup(recommended_action="LONG_MODERATE"), NEUTRAL_GATE) is True
|
||||
|
||||
def test_opposing_short_action_fails_for_long_setup(self):
|
||||
assert setup_qualifies(_setup(direction="long", recommended_action="SHORT_MODERATE"), NEUTRAL_GATE) is False
|
||||
|
||||
def test_matching_short_action_still_fails_long_only_momentum_gate(self):
|
||||
assert setup_qualifies(
|
||||
_setup(direction="short", recommended_action="SHORT_MODERATE", momentum_percentile=95.0),
|
||||
{**NEUTRAL_GATE, "min_momentum_percentile": 80.0},
|
||||
) is False
|
||||
|
||||
def test_neutral_allowed_when_off(self):
|
||||
# Flag absent from the config → NEUTRAL still qualifies (backward compatible).
|
||||
assert setup_qualifies(_setup(recommended_action="NEUTRAL"), DEFAULT_GATE) is True
|
||||
|
||||
|
||||
class TestBestTargetProbability:
|
||||
def test_returns_max(self):
|
||||
s = _setup(targets=[{"probability": 40.0}, {"probability": 72.0}, {"probability": 55.0}])
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import date, timedelta
|
||||
|
||||
from app.services.regime_monitor_service import (
|
||||
DEFAULT_CONFIG,
|
||||
_attach_early_warning,
|
||||
band_for,
|
||||
compute_regime_score,
|
||||
f2_credit_spreads,
|
||||
@@ -35,6 +36,32 @@ def test_band_for():
|
||||
assert band_for(90) == "breaking"
|
||||
|
||||
|
||||
def test_attach_early_warning_blends():
|
||||
result = {"total_score": 80.0}
|
||||
_attach_early_warning(result, 40.0, {"coincident": 0.6, "early_warning": 0.4})
|
||||
assert result["early_warning"]["score"] == 40.0
|
||||
assert result["early_warning"]["band"] == "watch"
|
||||
# combined = (80*0.6 + 40*0.4) / 1.0 = 64
|
||||
assert result["combined"]["score"] == 64.0
|
||||
assert result["combined"]["band"] == "elevated"
|
||||
|
||||
|
||||
def test_attach_early_warning_none_falls_back_to_index():
|
||||
result = {"total_score": 80.0}
|
||||
_attach_early_warning(result, None, {"coincident": 0.6, "early_warning": 0.4})
|
||||
assert result["early_warning"]["score"] is None
|
||||
assert result["combined"]["score"] == 80.0 # no early warning -> just the index
|
||||
|
||||
|
||||
def test_divergence_asof_tolerates_small_lag():
|
||||
from app.services.regime_monitor_service import _divergence_asof
|
||||
items = [(date(2026, 6, 1), 55.0), (date(2026, 6, 3), 60.0)]
|
||||
assert _divergence_asof(items, date(2026, 6, 3)) == 60.0 # exact date
|
||||
assert _divergence_asof(items, date(2026, 6, 4)) == 60.0 # 1-day lag -> newest
|
||||
assert _divergence_asof(items, date(2026, 6, 20)) is None # too stale
|
||||
assert _divergence_asof([], date(2026, 6, 3)) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price sub-scores
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Tests for the regime quadrant classification + hysteresis (anti-flicker)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.alert_service import _classify_quadrant
|
||||
|
||||
|
||||
# Quadrant ids: 1=① hot&brittle (regime low, warning high), 2=② transition
|
||||
# (both high), 3=③ healthy (both low), 4=④ real downturn (regime high, warning low).
|
||||
# Dividers: regime 40, early-warning 60; margin 5.
|
||||
|
||||
|
||||
def test_fresh_classification():
|
||||
assert _classify_quadrant(20, 90, None) == "1" # low regime, high warning
|
||||
assert _classify_quadrant(70, 90, None) == "2" # both high
|
||||
assert _classify_quadrant(20, 30, None) == "3" # both low
|
||||
assert _classify_quadrant(70, 30, None) == "4" # high regime, low warning
|
||||
|
||||
|
||||
def test_hysteresis_holds_inside_deadband():
|
||||
# From ③ (both low): early-warning nudging just past 60 stays ③ until it
|
||||
# clears 60 + margin (65).
|
||||
assert _classify_quadrant(20, 62, prev="3") == "3" # within deadband → no flip
|
||||
assert _classify_quadrant(20, 66, prev="3") == "1" # clears 65 → flips to ①
|
||||
|
||||
|
||||
def test_hysteresis_sticky_when_already_high():
|
||||
# From ① (warning high): a dip below 60 keeps ① until it drops past 60 - margin (55).
|
||||
assert _classify_quadrant(20, 58, prev="1") == "1" # still high (deadband)
|
||||
assert _classify_quadrant(20, 54, prev="1") == "3" # drops past 55 → back to ③
|
||||
|
||||
|
||||
def test_hysteresis_on_regime_axis():
|
||||
# From ③: regime rising past 40 stays ③ until it clears 45.
|
||||
assert _classify_quadrant(43, 30, prev="3") == "3"
|
||||
assert _classify_quadrant(46, 30, prev="3") == "4"
|
||||
# From ④: regime easing keeps ④ until below 35.
|
||||
assert _classify_quadrant(37, 30, prev="4") == "4"
|
||||
assert _classify_quadrant(34, 30, prev="4") == "3"
|
||||
|
||||
|
||||
def test_boundary_sitting_does_not_flip():
|
||||
# A point parked exactly on both dividers keeps whatever quadrant it had.
|
||||
for q in ("1", "2", "3", "4"):
|
||||
assert _classify_quadrant(40, 60, prev=q) == q
|
||||
@@ -11,20 +11,29 @@ Zero-candidate and single-candidate scenarios must produce identical results.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings, HealthCheck, strategies as st
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||
from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.trade_setup import TradeSetup
|
||||
from app.models.score import CompositeScore
|
||||
from app.models.score import CompositeScore, DimensionScore
|
||||
from app.models.sentiment import SentimentScore
|
||||
from app.services.rr_scanner_service import scan_ticker, get_trade_setups
|
||||
|
||||
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -431,3 +440,389 @@ async def test_get_trade_setups_sorting_rr_desc_composite_desc(db_session: Async
|
||||
f"Expected symbol order ['SORTD', 'SORTC', 'SORTB', 'SORTA'], "
|
||||
f"got {symbols}"
|
||||
)
|
||||
|
||||
|
||||
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
|
||||
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context
|
||||
(bullish sentiment, composite 96) that yields live confidence 97."""
|
||||
old_scan = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||
old_reasoning = (
|
||||
"LONG (high confidence): 82% with aligned signals "
|
||||
"(technical=88, momentum=60, sentiment=neutral)."
|
||||
)
|
||||
|
||||
ticker = Ticker(symbol="TTWO")
|
||||
db_session.add(ticker)
|
||||
await db_session.flush()
|
||||
|
||||
stale_setup = TradeSetup(
|
||||
ticker_id=ticker.id,
|
||||
direction="long",
|
||||
entry_price=235.0,
|
||||
stop_loss=220.0,
|
||||
target=265.0,
|
||||
rr_ratio=2.0,
|
||||
composite_score=71.8,
|
||||
detected_at=old_scan,
|
||||
confidence_score=82.0,
|
||||
recommended_action="LONG_HIGH",
|
||||
reasoning=old_reasoning,
|
||||
risk_level="High",
|
||||
)
|
||||
db_session.add(stale_setup)
|
||||
|
||||
db_session.add_all([
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="technical",
|
||||
score=88.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="momentum",
|
||||
score=60.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="fundamental",
|
||||
score=95.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="sentiment",
|
||||
score=85.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
CompositeScore(
|
||||
ticker_id=ticker.id,
|
||||
score=96.0,
|
||||
is_stale=False,
|
||||
weights_json="{}",
|
||||
computed_at=current,
|
||||
),
|
||||
SentimentScore(
|
||||
ticker_id=ticker.id,
|
||||
classification="bullish",
|
||||
confidence=85,
|
||||
source="test",
|
||||
timestamp=current,
|
||||
reasoning="",
|
||||
citations_json="[]",
|
||||
),
|
||||
])
|
||||
await db_session.flush()
|
||||
return stale_setup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_recommendation_payload_uses_current_score_and_sentiment(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Latest setup payload should not show stale scan text when score context moved."""
|
||||
stale_setup = await _seed_stale_setup_with_current_scores(db_session)
|
||||
old_reasoning = stale_setup.reasoning
|
||||
|
||||
rows = await get_trade_setups(
|
||||
db_session,
|
||||
symbol="TTWO",
|
||||
live_recommendation=True,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["composite_score"] == pytest.approx(96.0)
|
||||
assert row["confidence_score"] == pytest.approx(97.0)
|
||||
assert row["recommended_action"] == "LONG_HIGH"
|
||||
assert "sentiment=bullish" in row["reasoning"]
|
||||
assert "sentiment=neutral" not in row["reasoning"]
|
||||
|
||||
persisted = await db_session.get(TradeSetup, stale_setup.id)
|
||||
assert persisted is not None
|
||||
assert persisted.composite_score == pytest.approx(71.8)
|
||||
assert persisted.reasoning == old_reasoning
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_recommendation_filters_apply_to_live_values(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""min_confidence must judge the overlaid live confidence, not the stored one."""
|
||||
await _seed_stale_setup_with_current_scores(db_session)
|
||||
|
||||
# Stored confidence is 82 — a stored-column filter would drop this row.
|
||||
# Live confidence is 97, so it must pass.
|
||||
rows = await get_trade_setups(
|
||||
db_session,
|
||||
symbol="TTWO",
|
||||
min_confidence=90.0,
|
||||
live_recommendation=True,
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["confidence_score"] == pytest.approx(97.0)
|
||||
|
||||
# And a floor above the live value must drop it.
|
||||
rows = await get_trade_setups(
|
||||
db_session,
|
||||
symbol="TTWO",
|
||||
min_confidence=98.0,
|
||||
live_recommendation=True,
|
||||
)
|
||||
assert rows == []
|
||||
|
||||
|
||||
async def _seed_two_direction_setup(db_session: AsyncSession) -> None:
|
||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||
ticker = Ticker(symbol="BOTH")
|
||||
db_session.add(ticker)
|
||||
await db_session.flush()
|
||||
|
||||
db_session.add_all([
|
||||
TradeSetup(
|
||||
ticker_id=ticker.id,
|
||||
direction="long",
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
target=112.0,
|
||||
rr_ratio=2.4,
|
||||
composite_score=30.0,
|
||||
detected_at=current,
|
||||
confidence_score=25.0,
|
||||
recommended_action="NEUTRAL",
|
||||
risk_level="Low",
|
||||
),
|
||||
TradeSetup(
|
||||
ticker_id=ticker.id,
|
||||
direction="short",
|
||||
entry_price=100.0,
|
||||
stop_loss=105.0,
|
||||
target=88.0,
|
||||
rr_ratio=2.4,
|
||||
composite_score=30.0,
|
||||
detected_at=current,
|
||||
confidence_score=90.0,
|
||||
recommended_action="SHORT_HIGH",
|
||||
risk_level="Low",
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="technical",
|
||||
score=10.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="momentum",
|
||||
score=10.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="fundamental",
|
||||
score=10.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
CompositeScore(
|
||||
ticker_id=ticker.id,
|
||||
score=30.0,
|
||||
is_stale=False,
|
||||
weights_json="{}",
|
||||
computed_at=current,
|
||||
),
|
||||
SentimentScore(
|
||||
ticker_id=ticker.id,
|
||||
classification="bearish",
|
||||
confidence=90,
|
||||
source="test",
|
||||
timestamp=current,
|
||||
reasoning="",
|
||||
citations_json="[]",
|
||||
),
|
||||
])
|
||||
await db_session.flush()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_recommendation_action_independent_of_direction_filter(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
await _seed_two_direction_setup(db_session)
|
||||
|
||||
all_rows = await get_trade_setups(
|
||||
db_session,
|
||||
symbol="BOTH",
|
||||
live_recommendation=True,
|
||||
)
|
||||
filtered_rows = await get_trade_setups(
|
||||
db_session,
|
||||
symbol="BOTH",
|
||||
direction="long",
|
||||
live_recommendation=True,
|
||||
)
|
||||
|
||||
long_from_all = next(row for row in all_rows if row["direction"] == "long")
|
||||
assert len(filtered_rows) == 1
|
||||
assert long_from_all["recommended_action"] == "SHORT_HIGH"
|
||||
assert filtered_rows[0]["recommended_action"] == "SHORT_HIGH"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_overlay_preserves_setup_specific_risk_and_context(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||
ticker = Ticker(symbol="RISK")
|
||||
db_session.add(ticker)
|
||||
await db_session.flush()
|
||||
db_session.add_all([
|
||||
TradeSetup(
|
||||
ticker_id=ticker.id,
|
||||
direction="long",
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
target=112.0,
|
||||
rr_ratio=2.4,
|
||||
composite_score=50.0,
|
||||
detected_at=current,
|
||||
confidence_score=50.0,
|
||||
recommended_action="NEUTRAL",
|
||||
risk_level="Medium",
|
||||
conflict_flags_json=json.dumps([
|
||||
"target-availability: Fewer than 3 valid S/R targets available"
|
||||
]),
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="technical",
|
||||
score=50.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="momentum",
|
||||
score=50.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
CompositeScore(
|
||||
ticker_id=ticker.id,
|
||||
score=50.0,
|
||||
is_stale=False,
|
||||
weights_json="{}",
|
||||
computed_at=current,
|
||||
),
|
||||
SentimentScore(
|
||||
ticker_id=ticker.id,
|
||||
classification="neutral",
|
||||
confidence=50,
|
||||
source="test",
|
||||
timestamp=current,
|
||||
reasoning="",
|
||||
citations_json="[]",
|
||||
),
|
||||
OHLCVRecord(
|
||||
ticker_id=ticker.id,
|
||||
date=date(2026, 7, 3),
|
||||
open=101.0,
|
||||
high=102.0,
|
||||
low=100.0,
|
||||
close=101.0,
|
||||
volume=1000,
|
||||
created_at=current,
|
||||
),
|
||||
])
|
||||
await db_session.flush()
|
||||
|
||||
rows = await get_trade_setups(
|
||||
db_session,
|
||||
symbol="RISK",
|
||||
live_recommendation=True,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["risk_level"] == "Medium"
|
||||
assert row["conflict_flags"] == [
|
||||
"target-availability: Fewer than 3 valid S/R targets available"
|
||||
]
|
||||
assert row["current_price"] == pytest.approx(101.0)
|
||||
assert _as_utc(row["context_as_of"]["score_computed_at"]) == current
|
||||
assert _as_utc(row["context_as_of"]["sentiment_at"]) == current
|
||||
assert row["context_as_of"]["price_date"] == date(2026, 7, 3)
|
||||
assert _as_utc(row["context_as_of"]["price_updated_at"]) == current
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_trade_setup_read_does_not_recompute_scores(db_session: AsyncSession):
|
||||
await _seed_stale_setup_with_current_scores(db_session)
|
||||
|
||||
with patch(
|
||||
"app.services.scoring_service.compute_all_dimensions",
|
||||
new=AsyncMock(side_effect=AssertionError("GET must not recompute dimensions")),
|
||||
), patch(
|
||||
"app.services.scoring_service.compute_composite_score",
|
||||
new=AsyncMock(side_effect=AssertionError("GET must not recompute composite")),
|
||||
):
|
||||
rows = await get_trade_setups(
|
||||
db_session,
|
||||
symbol="TTWO",
|
||||
live_recommendation=True,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intraday_price_update_changes_live_price_without_new_signal_rows(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||
ticker = Ticker(symbol="LIVEP")
|
||||
db_session.add(ticker)
|
||||
await db_session.flush()
|
||||
setup = TradeSetup(
|
||||
ticker_id=ticker.id,
|
||||
direction="long",
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
target=112.0,
|
||||
rr_ratio=2.4,
|
||||
composite_score=50.0,
|
||||
detected_at=current,
|
||||
)
|
||||
price = OHLCVRecord(
|
||||
ticker_id=ticker.id,
|
||||
date=date(2026, 7, 3),
|
||||
open=100.0,
|
||||
high=101.0,
|
||||
low=99.0,
|
||||
close=100.0,
|
||||
volume=1000,
|
||||
created_at=current,
|
||||
)
|
||||
db_session.add_all([setup, price])
|
||||
await db_session.flush()
|
||||
|
||||
rows = await get_trade_setups(db_session, symbol="LIVEP", live_recommendation=True)
|
||||
assert rows[0]["current_price"] == pytest.approx(100.0)
|
||||
|
||||
price.close = 102.0
|
||||
await db_session.flush()
|
||||
|
||||
rows = await get_trade_setups(db_session, symbol="LIVEP", live_recommendation=True)
|
||||
assert rows[0]["current_price"] == pytest.approx(102.0)
|
||||
setup_count = await db_session.scalar(select(func.count()).select_from(TradeSetup))
|
||||
snapshot_count = await db_session.scalar(select(func.count()).select_from(SignalContextSnapshot))
|
||||
assert setup_count == 1
|
||||
assert snapshot_count == 0
|
||||
|
||||
@@ -80,6 +80,7 @@ class TestConfigureScheduler:
|
||||
assert job_ids == {
|
||||
"data_collector",
|
||||
"data_backfill",
|
||||
"benchmark_collector",
|
||||
"sentiment_collector",
|
||||
"fundamental_collector",
|
||||
"rr_scanner",
|
||||
@@ -103,6 +104,7 @@ class TestConfigureScheduler:
|
||||
assert sorted(job_ids) == sorted([
|
||||
"alerts",
|
||||
"backtest",
|
||||
"benchmark_collector",
|
||||
"daily_pipeline",
|
||||
"intraday_pipeline",
|
||||
"data_collector",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Guard: the scores router must forward the sentiment-adjustment breakdown fields.
|
||||
|
||||
get_score computes base_score / sentiment_adjustment, but the router builds the
|
||||
response model by hand — so it has to explicitly pass those through, or the ticker
|
||||
page loses the "Composite = Base + Sentiment" caption and the ± dimension marker.
|
||||
"""
|
||||
|
||||
from app.routers.scores import _map_composite_breakdown
|
||||
|
||||
|
||||
def test_forwards_sentiment_fields():
|
||||
raw = {
|
||||
"weights": {"technical": 0.25},
|
||||
"available_dimensions": ["technical"],
|
||||
"missing_dimensions": ["sentiment"],
|
||||
"renormalized_weights": {"technical": 1.0},
|
||||
"formula": "x",
|
||||
"base_score": 78.0,
|
||||
"sentiment_score": 75.0,
|
||||
"sentiment_adjustment": 5.0,
|
||||
"max_sentiment_adjustment": 10.0,
|
||||
}
|
||||
model = _map_composite_breakdown(raw)
|
||||
assert model is not None
|
||||
assert model.base_score == 78.0
|
||||
assert model.sentiment_score == 75.0
|
||||
assert model.sentiment_adjustment == 5.0
|
||||
assert model.max_sentiment_adjustment == 10.0
|
||||
|
||||
|
||||
def test_none_stays_none():
|
||||
assert _map_composite_breakdown(None) is None
|
||||
|
||||
|
||||
def test_old_shaped_dict_defaults_new_fields_to_none():
|
||||
raw = {
|
||||
"weights": {},
|
||||
"available_dimensions": [],
|
||||
"missing_dimensions": [],
|
||||
"renormalized_weights": {},
|
||||
"formula": "x",
|
||||
}
|
||||
model = _map_composite_breakdown(raw)
|
||||
assert model.sentiment_adjustment is None
|
||||
assert model.base_score is None
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Composite scoring: sentiment applied as a signed adjustment around neutral,
|
||||
not averaged in. Going from no sentiment to bullish must never lower the score."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.database import Base
|
||||
from app.models.score import DimensionScore
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import scoring_service as svc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db():
|
||||
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with factory() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# Non-sentiment dims all at 78 → base = 78 at any positive weights.
|
||||
BASE_DIMS = {"technical": 78.0, "sr_quality": 78.0, "fundamental": 78.0, "momentum": 78.0}
|
||||
|
||||
|
||||
async def _seed(session, dims: dict[str, float]) -> None:
|
||||
ticker = Ticker(symbol="AAA")
|
||||
session.add(ticker)
|
||||
await session.flush()
|
||||
now = datetime.now(timezone.utc)
|
||||
for dim, score in dims.items():
|
||||
session.add(DimensionScore(
|
||||
ticker_id=ticker.id, dimension=dim, score=score, is_stale=False, computed_at=now
|
||||
))
|
||||
await session.flush()
|
||||
|
||||
|
||||
def test_sentiment_adjustment_formula():
|
||||
# weight 0.10 → MAX_ADJ = 10 points
|
||||
assert svc._sentiment_adjustment(None, 0.10) == 0.0
|
||||
assert svc._sentiment_adjustment(50.0, 0.10) == 0.0 # neutral / coin-flip
|
||||
assert svc._sentiment_adjustment(75.0, 0.10) == pytest.approx(5.0) # bullish 75%
|
||||
assert svc._sentiment_adjustment(100.0, 0.10) == pytest.approx(10.0)
|
||||
assert svc._sentiment_adjustment(25.0, 0.10) == pytest.approx(-5.0) # bearish 75%
|
||||
assert svc._sentiment_adjustment(0.0, 0.10) == pytest.approx(-10.0)
|
||||
|
||||
|
||||
async def test_no_sentiment_equals_base(db):
|
||||
await _seed(db, BASE_DIMS)
|
||||
composite, missing = await svc.compute_composite_score(db, "AAA")
|
||||
assert composite == pytest.approx(78.0)
|
||||
assert "sentiment" in missing
|
||||
|
||||
|
||||
async def test_bullish_raises_above_base(db):
|
||||
await _seed(db, {**BASE_DIMS, "sentiment": 75.0}) # bullish, 75% confidence
|
||||
composite, _ = await svc.compute_composite_score(db, "AAA")
|
||||
assert composite == pytest.approx(83.0) # 78 + 5 — the whole point
|
||||
|
||||
|
||||
async def test_neutral_leaves_base_unchanged(db):
|
||||
await _seed(db, {**BASE_DIMS, "sentiment": 50.0})
|
||||
composite, _ = await svc.compute_composite_score(db, "AAA")
|
||||
assert composite == pytest.approx(78.0)
|
||||
|
||||
|
||||
async def test_bearish_lowers_base(db):
|
||||
await _seed(db, {**BASE_DIMS, "sentiment": 25.0}) # bearish, 75% confidence
|
||||
composite, _ = await svc.compute_composite_score(db, "AAA")
|
||||
assert composite == pytest.approx(73.0) # 78 - 5
|
||||
|
||||
|
||||
async def test_only_sentiment_uses_neutral_base(db):
|
||||
await _seed(db, {"sentiment": 75.0})
|
||||
composite, _ = await svc.compute_composite_score(db, "AAA")
|
||||
assert composite == pytest.approx(55.0) # base 50 + 5
|
||||
|
||||
|
||||
async def test_no_dimensions_returns_none(db):
|
||||
ticker = Ticker(symbol="AAA")
|
||||
db.add(ticker)
|
||||
await db.flush()
|
||||
composite, missing = await svc.compute_composite_score(db, "AAA")
|
||||
assert composite is None
|
||||
assert len(missing) == 5
|
||||
@@ -1,24 +1,24 @@
|
||||
"""Unit tests for get_score composite breakdown and dimension breakdown wiring."""
|
||||
"""Unit tests for read-only get_score composite breakdown wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.database import Base
|
||||
from app.models.score import CompositeScore, DimensionScore
|
||||
from app.models.ticker import Ticker
|
||||
from app.services.scoring_service import get_score, _DIMENSION_COMPUTERS
|
||||
from app.services.scoring_service import get_score
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fresh_db():
|
||||
"""Provide a non-transactional session so get_score can commit."""
|
||||
"""Provide a non-transactional session for persisted score reads."""
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -30,176 +30,101 @@ async def fresh_db():
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _make_ohlcv_records(n: int, base_close: float = 100.0) -> list:
|
||||
"""Create n mock OHLCV records with realistic price data."""
|
||||
records = []
|
||||
for i in range(n):
|
||||
price = base_close + (i * 0.5)
|
||||
records.append(
|
||||
SimpleNamespace(
|
||||
date=date(2024, 1, 1),
|
||||
open=price - 0.5,
|
||||
high=price + 1.0,
|
||||
low=price - 1.0,
|
||||
close=price,
|
||||
volume=1000000,
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def _mock_none_computer():
|
||||
"""Return an AsyncMock that returns (None, None) — simulates missing dimension data."""
|
||||
return AsyncMock(return_value=(None, None))
|
||||
|
||||
|
||||
def _mock_score_computer(score: float, breakdown: dict | None = None):
|
||||
"""Return an AsyncMock that returns a fixed (score, breakdown) tuple."""
|
||||
bd = breakdown or {
|
||||
"sub_scores": [{"name": "mock", "score": score, "weight": 1.0, "raw_value": score, "description": "mock"}],
|
||||
"formula": "mock formula",
|
||||
"unavailable": [],
|
||||
}
|
||||
return AsyncMock(return_value=(score, bd))
|
||||
|
||||
|
||||
async def _seed_ticker(session: AsyncSession, symbol: str = "AAPL") -> Ticker:
|
||||
"""Insert a ticker row and return it."""
|
||||
ticker = Ticker(symbol=symbol)
|
||||
session.add(ticker)
|
||||
await session.commit()
|
||||
return ticker
|
||||
|
||||
|
||||
async def _seed_scores(session: AsyncSession, ticker: Ticker, *, stale: bool = False) -> None:
|
||||
now = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||
session.add_all([
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="technical",
|
||||
score=70.0,
|
||||
is_stale=stale,
|
||||
computed_at=now,
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="momentum",
|
||||
score=60.0,
|
||||
is_stale=False,
|
||||
computed_at=now,
|
||||
),
|
||||
CompositeScore(
|
||||
ticker_id=ticker.id,
|
||||
score=66.0,
|
||||
is_stale=stale,
|
||||
weights_json="{}",
|
||||
computed_at=now,
|
||||
),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_score_returns_composite_breakdown(fresh_db):
|
||||
"""get_score should include a composite_breakdown dict with weights and re-normalization info."""
|
||||
await _seed_ticker(fresh_db, "AAPL")
|
||||
|
||||
original = dict(_DIMENSION_COMPUTERS)
|
||||
try:
|
||||
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0)
|
||||
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0)
|
||||
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
|
||||
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
|
||||
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
|
||||
async def test_get_score_returns_composite_breakdown_without_recomputing(fresh_db):
|
||||
ticker = await _seed_ticker(fresh_db, "AAPL")
|
||||
await _seed_scores(fresh_db, ticker)
|
||||
|
||||
with patch(
|
||||
"app.services.scoring_service.compute_dimension_score",
|
||||
new=AsyncMock(side_effect=AssertionError("GET must not recompute dimensions")),
|
||||
), patch(
|
||||
"app.services.scoring_service.compute_composite_score",
|
||||
new=AsyncMock(side_effect=AssertionError("GET must not recompute composite")),
|
||||
):
|
||||
result = await get_score(fresh_db, "AAPL")
|
||||
finally:
|
||||
_DIMENSION_COMPUTERS.update(original)
|
||||
|
||||
assert "composite_breakdown" in result
|
||||
assert result["composite_score"] == 66.0
|
||||
cb = result["composite_breakdown"]
|
||||
assert cb is not None
|
||||
assert "weights" in cb
|
||||
assert "available_dimensions" in cb
|
||||
assert "missing_dimensions" in cb
|
||||
assert "renormalized_weights" in cb
|
||||
assert "formula" in cb
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_score_composite_breakdown_has_correct_available_missing(fresh_db):
|
||||
"""Composite breakdown should correctly list available and missing dimensions."""
|
||||
await _seed_ticker(fresh_db, "AAPL")
|
||||
|
||||
original = dict(_DIMENSION_COMPUTERS)
|
||||
try:
|
||||
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0)
|
||||
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0)
|
||||
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
|
||||
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
|
||||
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
|
||||
|
||||
result = await get_score(fresh_db, "AAPL")
|
||||
finally:
|
||||
_DIMENSION_COMPUTERS.update(original)
|
||||
|
||||
cb = result["composite_breakdown"]
|
||||
assert "technical" in cb["available_dimensions"]
|
||||
assert "momentum" in cb["available_dimensions"]
|
||||
assert "sentiment" in cb["missing_dimensions"]
|
||||
assert "fundamental" in cb["missing_dimensions"]
|
||||
assert "sr_quality" in cb["missing_dimensions"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_score_renormalized_weights_sum_to_one(fresh_db):
|
||||
"""Re-normalized weights should sum to 1.0 when at least one dimension is available."""
|
||||
await _seed_ticker(fresh_db, "AAPL")
|
||||
|
||||
original = dict(_DIMENSION_COMPUTERS)
|
||||
try:
|
||||
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0)
|
||||
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0)
|
||||
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
|
||||
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
|
||||
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
|
||||
|
||||
result = await get_score(fresh_db, "AAPL")
|
||||
finally:
|
||||
_DIMENSION_COMPUTERS.update(original)
|
||||
|
||||
cb = result["composite_breakdown"]
|
||||
assert cb["renormalized_weights"]
|
||||
total = sum(cb["renormalized_weights"].values())
|
||||
assert abs(total - 1.0) < 1e-9
|
||||
assert abs(sum(cb["renormalized_weights"].values()) - 1.0) < 1e-9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_score_dimensions_include_breakdowns(fresh_db):
|
||||
"""Each available dimension entry should include a breakdown dict."""
|
||||
await _seed_ticker(fresh_db, "AAPL")
|
||||
|
||||
tech_breakdown = {
|
||||
"sub_scores": [
|
||||
{"name": "ADX", "score": 72.0, "weight": 0.4, "raw_value": 72.0, "description": "ADX value"},
|
||||
{"name": "EMA", "score": 65.0, "weight": 0.3, "raw_value": 1.5, "description": "EMA diff"},
|
||||
{"name": "RSI", "score": 62.0, "weight": 0.3, "raw_value": 62.0, "description": "RSI value"},
|
||||
],
|
||||
"formula": "Weighted average: 0.4*ADX + 0.3*EMA + 0.3*RSI",
|
||||
"unavailable": [],
|
||||
}
|
||||
|
||||
original = dict(_DIMENSION_COMPUTERS)
|
||||
try:
|
||||
_DIMENSION_COMPUTERS["technical"] = _mock_score_computer(68.2, tech_breakdown)
|
||||
_DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(55.0)
|
||||
_DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer()
|
||||
_DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer()
|
||||
_DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer()
|
||||
async def test_get_score_dimensions_do_not_recompute_breakdowns(fresh_db):
|
||||
ticker = await _seed_ticker(fresh_db, "AAPL")
|
||||
await _seed_scores(fresh_db, ticker)
|
||||
|
||||
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)
|
||||
assert tech_dim is not None
|
||||
assert "breakdown" in tech_dim
|
||||
assert tech_dim["breakdown"] is not None
|
||||
assert len(tech_dim["breakdown"]["sub_scores"]) == 3
|
||||
names = [s["name"] for s in tech_dim["breakdown"]["sub_scores"]]
|
||||
assert "ADX" in names
|
||||
assert "EMA" in names
|
||||
assert "RSI" in names
|
||||
assert tech_dim["breakdown"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_score_all_dimensions_missing(fresh_db):
|
||||
"""When all dimensions return None, composite_breakdown should list all as missing."""
|
||||
await _seed_ticker(fresh_db, "AAPL")
|
||||
|
||||
original = dict(_DIMENSION_COMPUTERS)
|
||||
try:
|
||||
for dim in _DIMENSION_COMPUTERS:
|
||||
_DIMENSION_COMPUTERS[dim] = _mock_none_computer()
|
||||
|
||||
result = await get_score(fresh_db, "AAPL")
|
||||
finally:
|
||||
_DIMENSION_COMPUTERS.update(original)
|
||||
|
||||
cb = result["composite_breakdown"]
|
||||
assert cb["available_dimensions"] == []
|
||||
assert len(cb["missing_dimensions"]) == 5
|
||||
assert cb["renormalized_weights"] == {}
|
||||
assert result["composite_score"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_score_reports_stale_without_refreshing(fresh_db):
|
||||
ticker = await _seed_ticker(fresh_db, "AAPL")
|
||||
await _seed_scores(fresh_db, ticker, stale=True)
|
||||
|
||||
result = await get_score(fresh_db, "AAPL")
|
||||
|
||||
assert result["composite_stale"] is True
|
||||
assert "technical" in result["missing_dimensions"]
|
||||
tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None)
|
||||
assert tech_dim is not None
|
||||
assert tech_dim["is_stale"] is True
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Unit tests for get_rankings: bulk-load fast path, sorting, exclusion, and
|
||||
lazy recompute of stale scores."""
|
||||
"""Unit tests for read-only get_rankings: bulk-load, sorting, and staleness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,7 +6,6 @@ from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.database import Base
|
||||
@@ -20,7 +18,7 @@ TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
@pytest.fixture
|
||||
async def fresh_db():
|
||||
"""Non-transactional session so get_rankings can commit recomputes."""
|
||||
"""Non-transactional session for persisted ranking reads."""
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -84,46 +82,34 @@ async def test_fast_path_sorts_and_does_not_recompute(fresh_db: AsyncSession):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ticker_without_computable_composite_is_excluded(fresh_db: AsyncSession):
|
||||
"""A ticker whose composite can't be computed (recompute yields no row) is
|
||||
omitted from the rankings rather than appearing with a null score."""
|
||||
"""A ticker without a persisted composite is omitted from rankings."""
|
||||
fresh = await _seed_ticker(fresh_db, "OK")
|
||||
await _seed_ticker(fresh_db, "NONE") # no composite; recompute can't make one
|
||||
await _seed_ticker(fresh_db, "NONE")
|
||||
fresh_db.add_all([_composite(fresh.id, 50.0), _dimension(fresh.id, "technical", 50.0)])
|
||||
await fresh_db.commit()
|
||||
|
||||
# Recompute is a no-op that produces no composite row for NONE.
|
||||
with patch("app.services.scoring_service.compute_dimension_score",
|
||||
new=AsyncMock(return_value=None)), \
|
||||
new=AsyncMock(side_effect=AssertionError("should not recompute"))), \
|
||||
patch("app.services.scoring_service.compute_composite_score",
|
||||
new=AsyncMock(return_value=(None, ["technical"]))):
|
||||
new=AsyncMock(side_effect=AssertionError("should not recompute"))):
|
||||
result = await get_rankings(fresh_db)
|
||||
|
||||
assert [r["symbol"] for r in result["rankings"]] == ["OK"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_composite_is_recomputed(fresh_db: AsyncSession):
|
||||
"""A stale composite triggers a recompute and then appears in the rankings."""
|
||||
async def test_stale_composite_is_reported_without_recompute(fresh_db: AsyncSession):
|
||||
"""A stale composite appears with its stale flag and is not recomputed."""
|
||||
ticker = await _seed_ticker(fresh_db, "STALE")
|
||||
fresh_db.add(_composite(ticker.id, 10.0, stale=True))
|
||||
await fresh_db.commit()
|
||||
|
||||
async def _fake_recompute(db, symbol, weights=None):
|
||||
# Mirror the real upsert: refresh the existing row in place.
|
||||
existing = (await db.execute(
|
||||
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
|
||||
)).scalar_one()
|
||||
existing.score = 77.0
|
||||
existing.is_stale = False
|
||||
return 77.0, []
|
||||
|
||||
# Dimension recompute is a no-op; composite recompute refreshes the score.
|
||||
with patch("app.services.scoring_service.compute_dimension_score",
|
||||
new=AsyncMock(return_value=55.0)), \
|
||||
new=AsyncMock(side_effect=AssertionError("should not recompute"))), \
|
||||
patch("app.services.scoring_service.compute_composite_score",
|
||||
new=AsyncMock(side_effect=_fake_recompute)) as comp_mock:
|
||||
new=AsyncMock(side_effect=AssertionError("should not recompute"))):
|
||||
result = await get_rankings(fresh_db)
|
||||
|
||||
comp_mock.assert_awaited() # recompute path was taken
|
||||
assert [r["symbol"] for r in result["rankings"]] == ["STALE"]
|
||||
assert result["rankings"][0]["composite_score"] == 77.0 # reflects the recompute
|
||||
assert result["rankings"][0]["composite_score"] == 10.0
|
||||
assert result["rankings"][0]["composite_stale"] is True
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
"""Tests for sentiment-collection scoping (``_get_sentiment_priority_tickers``).
|
||||
|
||||
The activation gate qualifies setups on 12-1 momentum percentile, a different
|
||||
axis than composite score. These tests pin the fix that adds the gate's momentum
|
||||
leaders to the sentiment relevant-set so a freshly-qualifying ticker isn't left
|
||||
without sentiment.
|
||||
A dashboard 'top pick' is the highest residual-momentum *qualified* long setup. Sentiment
|
||||
can never move a ticker's activation percentile (the gate's core axis) — only its
|
||||
confidence and EV ranking. So the tickers that are, or could become with positive
|
||||
sentiment, a top pick are exactly the residual-momentum leaders that already carry a
|
||||
tradeable long setup over the R:R floor. These tests pin that priority tier
|
||||
(always refreshed, cap-exempt) and the capped filler tier behind it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app import scheduler
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.score import CompositeScore
|
||||
from app.models.sentiment import SentimentScore
|
||||
from app.models.settings import SystemSetting
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.trade_setup import TradeSetup
|
||||
from app.models.watchlist import WatchlistEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -26,56 +32,216 @@ async def session():
|
||||
yield s
|
||||
|
||||
|
||||
async def _seed_history(session, symbol: str, rate: float, n: int = 280) -> Ticker:
|
||||
"""Seed a ticker with a full year+ of daily closes growing at ``rate``."""
|
||||
async def _add_ticker(session, symbol: str) -> Ticker:
|
||||
t = Ticker(symbol=symbol)
|
||||
session.add(t)
|
||||
await session.flush()
|
||||
base = date(2024, 1, 1)
|
||||
for i in range(n):
|
||||
close = 100.0 * (rate ** i)
|
||||
session.add(OHLCVRecord(
|
||||
ticker_id=t.id,
|
||||
date=base + timedelta(days=i),
|
||||
open=close, high=close, low=close, close=close,
|
||||
volume=1_000_000,
|
||||
))
|
||||
await session.commit()
|
||||
return t
|
||||
|
||||
|
||||
async def _set_min_momentum(session, value: str) -> None:
|
||||
session.add(SystemSetting(
|
||||
key="activation_min_momentum_percentile",
|
||||
value=value,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
async def _add_setup(
|
||||
session,
|
||||
ticker: Ticker,
|
||||
*,
|
||||
direction: str = "long",
|
||||
momentum_percentile: float | None = 95.0,
|
||||
rr_ratio: float = 2.0,
|
||||
detected_at: datetime | None = None,
|
||||
) -> TradeSetup:
|
||||
session.add(TradeSetup(
|
||||
ticker_id=ticker.id,
|
||||
direction=direction,
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
target=110.0,
|
||||
rr_ratio=rr_ratio,
|
||||
composite_score=60.0,
|
||||
momentum_percentile=momentum_percentile,
|
||||
detected_at=detected_at or datetime.now(timezone.utc),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_momentum_leader_is_included_without_composite_or_watchlist(session):
|
||||
"""A top-percentile momentum ticker is fetched even when it has no composite
|
||||
score, no watchlist entry, and no open trade — the case that previously left
|
||||
qualifying setups with no sentiment."""
|
||||
await _seed_history(session, "LEADER", rate=1.010) # strong uptrend → pct 100
|
||||
await _seed_history(session, "LAGGARD", rate=0.999) # declining → pct 0
|
||||
await _set_min_momentum(session, "80")
|
||||
async def _add_composite(session, ticker: Ticker, score: float) -> None:
|
||||
session.add(CompositeScore(
|
||||
ticker_id=ticker.id,
|
||||
score=score,
|
||||
is_stale=False,
|
||||
weights_json="{}",
|
||||
computed_at=datetime.now(timezone.utc),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _add_watchlist(session, ticker: Ticker) -> None:
|
||||
session.add(WatchlistEntry(
|
||||
user_id=1,
|
||||
ticker_id=ticker.id,
|
||||
entry_type="manual",
|
||||
added_at=datetime.now(timezone.utc),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _add_open_trade(session, ticker: Ticker) -> None:
|
||||
session.add(PaperTrade(
|
||||
user_id=1,
|
||||
ticker_id=ticker.id,
|
||||
direction="long",
|
||||
entry_price=100.0,
|
||||
shares=10.0,
|
||||
stop_loss=95.0,
|
||||
target=110.0,
|
||||
status="open",
|
||||
opened_at=datetime.now(timezone.utc),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _add_sentiment(session, ticker: Ticker, hours_ago: float) -> None:
|
||||
session.add(SentimentScore(
|
||||
ticker_id=ticker.id,
|
||||
classification="bullish",
|
||||
confidence=80,
|
||||
source="test",
|
||||
timestamp=datetime.now(timezone.utc) - timedelta(hours=hours_ago),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _set_setting(session, key: str, value: str) -> None:
|
||||
session.add(SystemSetting(key=key, value=value, updated_at=datetime.now(timezone.utc)))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_top_pick_feeder_included_below_cutoff_excluded(session):
|
||||
"""A momentum leader with a tradeable long setup over the R:R floor is fetched;
|
||||
one whose setup is below the gate's percentile is not."""
|
||||
feeder = await _add_ticker(session, "FEEDER")
|
||||
await _add_setup(session, feeder, momentum_percentile=95.0)
|
||||
laggard = await _add_ticker(session, "LAGGARD")
|
||||
await _add_setup(session, laggard, momentum_percentile=50.0) # below the gate
|
||||
await _set_setting(session, "activation_min_momentum_percentile", "80")
|
||||
|
||||
symbols = await scheduler._get_sentiment_priority_tickers(session)
|
||||
|
||||
assert "LEADER" in symbols
|
||||
# Below the gate's percentile and not otherwise relevant → not fetched.
|
||||
assert "FEEDER" in symbols
|
||||
assert "LAGGARD" not in symbols
|
||||
|
||||
|
||||
async def test_momentum_leaders_skipped_when_gate_disabled(session):
|
||||
"""With the momentum gate off (min percentile 0), the leader is no longer
|
||||
pulled in solely on momentum — scoping falls back to the base relevant set."""
|
||||
await _seed_history(session, "LEADER", rate=1.010)
|
||||
await _seed_history(session, "LAGGARD", rate=0.999)
|
||||
await _set_min_momentum(session, "0")
|
||||
async def test_leader_without_a_setup_excluded(session):
|
||||
"""A ticker with no long setup can't be a top pick, so it's no longer pulled in
|
||||
on momentum alone — the budget goes to actual top-pick feeders."""
|
||||
await _add_ticker(session, "NOSETUP")
|
||||
await _set_setting(session, "activation_min_momentum_percentile", "80")
|
||||
|
||||
symbols = await scheduler._get_sentiment_priority_tickers(session)
|
||||
|
||||
assert "LEADER" not in symbols
|
||||
assert "LAGGARD" not in symbols
|
||||
assert "NOSETUP" not in symbols
|
||||
|
||||
|
||||
async def test_short_only_setup_excluded(session):
|
||||
"""The gate is long-only while active; a short setup can never be a top pick,
|
||||
so positive sentiment can't promote it and it stays out of scope."""
|
||||
t = await _add_ticker(session, "SHORTY")
|
||||
await _add_setup(session, t, direction="short", momentum_percentile=95.0)
|
||||
await _set_setting(session, "activation_min_momentum_percentile", "80")
|
||||
|
||||
symbols = await scheduler._get_sentiment_priority_tickers(session)
|
||||
|
||||
assert "SHORTY" not in symbols
|
||||
|
||||
|
||||
async def test_long_setup_below_rr_floor_excluded(session):
|
||||
"""A long leader whose setup doesn't clear the R:R floor isn't tradeable as a
|
||||
top pick regardless of sentiment."""
|
||||
t = await _add_ticker(session, "THINRR")
|
||||
await _add_setup(session, t, momentum_percentile=95.0, rr_ratio=0.5)
|
||||
await _set_setting(session, "activation_min_momentum_percentile", "80")
|
||||
await _set_setting(session, "activation_min_rr", "1.2")
|
||||
|
||||
symbols = await scheduler._get_sentiment_priority_tickers(session)
|
||||
|
||||
assert "THINRR" not in symbols
|
||||
|
||||
|
||||
async def test_gate_disabled_no_priority_tier(session):
|
||||
"""With the momentum gate off there is no leader axis to anchor on, so a strong
|
||||
long setup is not pulled in on its own — scope falls back to the filler set."""
|
||||
t = await _add_ticker(session, "FEEDER")
|
||||
await _add_setup(session, t, momentum_percentile=95.0)
|
||||
await _set_setting(session, "activation_min_momentum_percentile", "0")
|
||||
|
||||
symbols = await scheduler._get_sentiment_priority_tickers(session)
|
||||
|
||||
assert "FEEDER" not in symbols
|
||||
|
||||
|
||||
async def test_fresh_feeder_skipped_stale_refetched(session):
|
||||
"""A feeder refreshed within the fresh window is skipped; one past it is
|
||||
re-fetched."""
|
||||
fresh = await _add_ticker(session, "FRESH")
|
||||
await _add_setup(session, fresh, momentum_percentile=95.0)
|
||||
await _add_sentiment(session, fresh, hours_ago=1.0)
|
||||
stale = await _add_ticker(session, "STALE")
|
||||
await _add_setup(session, stale, momentum_percentile=95.0)
|
||||
await _add_sentiment(session, stale, hours_ago=settings_fresh_hours() + 50)
|
||||
await _set_setting(session, "activation_min_momentum_percentile", "80")
|
||||
|
||||
symbols = await scheduler._get_sentiment_priority_tickers(session)
|
||||
|
||||
assert "FRESH" not in symbols
|
||||
assert "STALE" in symbols
|
||||
|
||||
|
||||
async def test_watchlist_and_open_trades_always_included(session):
|
||||
"""The curated watchlist and open paper trades are always in scope — they're
|
||||
the set we never want shown without sentiment, independent of any top pick."""
|
||||
await _set_setting(session, "activation_min_momentum_percentile", "80")
|
||||
wl = await _add_ticker(session, "WATCHED")
|
||||
await _add_watchlist(session, wl)
|
||||
held = await _add_ticker(session, "HELD")
|
||||
await _add_open_trade(session, held)
|
||||
|
||||
symbols = await scheduler._get_sentiment_priority_tickers(session)
|
||||
|
||||
assert "WATCHED" in symbols
|
||||
assert "HELD" in symbols
|
||||
|
||||
|
||||
async def test_dismissed_watchlist_entry_excluded(session):
|
||||
"""A dismissed watchlist entry is not refreshed."""
|
||||
await _set_setting(session, "activation_min_momentum_percentile", "80")
|
||||
t = await _add_ticker(session, "DISMISSED")
|
||||
session.add(WatchlistEntry(
|
||||
user_id=1,
|
||||
ticker_id=t.id,
|
||||
entry_type="dismissed",
|
||||
added_at=datetime.now(timezone.utc),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
symbols = await scheduler._get_sentiment_priority_tickers(session)
|
||||
|
||||
assert "DISMISSED" not in symbols
|
||||
|
||||
|
||||
async def test_no_per_run_cap_everything_stale_is_fetched(session, monkeypatch):
|
||||
"""No truncation: every stale name in the relevant set is returned, however
|
||||
many there are (the cap was removed)."""
|
||||
await _set_setting(session, "activation_min_momentum_percentile", "80")
|
||||
feeders = [f"F{i:02d}" for i in range(30)] # well past the old cap of 25
|
||||
for sym in feeders:
|
||||
t = await _add_ticker(session, sym)
|
||||
await _add_setup(session, t, momentum_percentile=95.0)
|
||||
filler = await _add_ticker(session, "FILL")
|
||||
await _add_composite(session, filler, score=99.0)
|
||||
|
||||
symbols = await scheduler._get_sentiment_priority_tickers(session)
|
||||
|
||||
assert set(feeders).issubset(set(symbols)) # all feeders, no truncation
|
||||
assert "FILL" in symbols # filler fetched too — nothing crowded out
|
||||
|
||||
|
||||
def settings_fresh_hours() -> float:
|
||||
return float(scheduler.settings.sentiment_fresh_hours)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for point-in-time signal context snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.fundamental import FundamentalData
|
||||
from app.models.score import CompositeScore, DimensionScore
|
||||
from app.models.sentiment import SentimentScore
|
||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.trade_setup import TradeSetup
|
||||
from app.services import rr_scanner_service as rr
|
||||
from tests.conftest import _test_session_factory # type: ignore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session():
|
||||
async with _test_session_factory() as s:
|
||||
yield s
|
||||
|
||||
|
||||
async def test_create_signal_context_snapshot_captures_latest_context(session):
|
||||
now = datetime(2026, 7, 2, 12, tzinfo=timezone.utc)
|
||||
ticker = Ticker(symbol="CTX")
|
||||
session.add(ticker)
|
||||
await session.flush()
|
||||
|
||||
session.add_all([
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="technical",
|
||||
score=71.0,
|
||||
is_stale=False,
|
||||
computed_at=now,
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="momentum",
|
||||
score=82.0,
|
||||
is_stale=False,
|
||||
computed_at=now,
|
||||
),
|
||||
CompositeScore(
|
||||
ticker_id=ticker.id,
|
||||
score=76.5,
|
||||
is_stale=False,
|
||||
weights_json='{"technical": 0.25}',
|
||||
computed_at=now,
|
||||
),
|
||||
SentimentScore(
|
||||
ticker_id=ticker.id,
|
||||
classification="BULLISH",
|
||||
confidence=78,
|
||||
source="test",
|
||||
timestamp=now,
|
||||
reasoning="",
|
||||
citations_json="[]",
|
||||
recommendation="BUY",
|
||||
),
|
||||
FundamentalData(
|
||||
ticker_id=ticker.id,
|
||||
pe_ratio=25.0,
|
||||
revenue_growth=0.18,
|
||||
earnings_surprise=0.05,
|
||||
market_cap=1_000_000_000.0,
|
||||
next_earnings_date=date(2026, 8, 1),
|
||||
fetched_at=now,
|
||||
unavailable_fields_json="{}",
|
||||
),
|
||||
])
|
||||
setup = TradeSetup(
|
||||
ticker_id=ticker.id,
|
||||
direction="long",
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
target=120.0,
|
||||
rr_ratio=4.0,
|
||||
composite_score=76.5,
|
||||
detected_at=now,
|
||||
confidence_score=64.0,
|
||||
momentum_percentile=88.0,
|
||||
recommended_action="LONG_HIGH",
|
||||
risk_level="Low",
|
||||
)
|
||||
session.add(setup)
|
||||
await session.flush()
|
||||
|
||||
await rr._create_signal_context_snapshots(session, [setup])
|
||||
await session.commit()
|
||||
|
||||
row = (await session.get(SignalContextSnapshot, 1))
|
||||
assert row is not None
|
||||
assert row.trade_setup_id == setup.id
|
||||
assert row.strategy_version == rr.STRATEGY_VERSION
|
||||
assert row.momentum_percentile == 88.0
|
||||
|
||||
score = json.loads(row.score_context_json)
|
||||
sentiment = json.loads(row.sentiment_context_json)
|
||||
fundamental = json.loads(row.fundamental_context_json)
|
||||
|
||||
assert score["composite_score"] == 76.5
|
||||
assert score["dimensions"]["technical"]["score"] == 71.0
|
||||
assert sentiment["classification"] == "BULLISH"
|
||||
assert sentiment["confidence"] == 78
|
||||
assert fundamental["pe_ratio"] == 25.0
|
||||
assert fundamental["next_earnings_date"] == "2026-08-01"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user