Merge branch 'codex/sr-v2-research-harness'
- Finalize GTL and retire S/R research harness - Cleanup retired research scaffolding (remove dead filters, mark diagnostic code, document env vars)
This commit is contained in:
@@ -17,9 +17,13 @@ build/
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/settings.local.json
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Local AI tool metadata
|
||||
mcps/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -6,9 +6,9 @@ Investing-signal platform for US equities. It runs one strategy, and it is a bor
|
||||
|
||||
**Philosophy:** don't predict price — rank it. The edge is *relative* strength across the universe, and the discipline is in the exit: cut losers fast, let winners run until the trail catches them.
|
||||
|
||||
**What is NOT the edge — read this before trusting a number on screen.** The composite score, the 5 dimensions, sentiment, fundamentals, and the support/resistance engine are **display and screening context**. None has a measured edge. In particular:
|
||||
**What is NOT the edge — read this before trusting a number on screen.** The composite score, the 5 dimensions, sentiment, fundamentals, and Structural S/R are **display context**, not validated predictors. The Gate Target Ladder is screening machinery that preserves the production setup population; it is not a claim about true market structure. In particular:
|
||||
|
||||
- **The S/R "target" is not an exit.** It exists only to compute the R:R and touch-odds that admit a setup through the activation gate. The live exit never reads it. Across 320 backtested production trades the exit reasons were **144 initial stop, 98 trailing stop, 78 max hold — and 0 targets.** Honoring the target as a take-profit was tested and *halves CAGR* ([research](docs/research/sr-levels-and-exits.md)).
|
||||
- **The headline "target" is not an exit.** It comes from the internal **Gate Target Ladder** and exists only to compute the R:R and reach-probability used by the activation gate. Human-facing chart S/R is a separate model. The live exit reads neither. Across 320 backtested production trades the exit reasons were **144 initial stop, 98 trailing stop, 78 max hold — and 0 targets.** Honoring the target as a take-profit was tested and *halves CAGR* ([research](docs/research/sr-levels-and-exits.md)).
|
||||
- **The composite score does not select trades.** Residual momentum does.
|
||||
|
||||
Full experiment log — everything tested, kept, and rejected: **[docs/research/](docs/research/README.md)**.
|
||||
@@ -21,10 +21,10 @@ flowchart TD
|
||||
M --> R["Rank cross-sectionally<br/>into percentiles"]
|
||||
R --> G1{"Top 20%?<br/>percentile ≥ 80"}
|
||||
G1 -->|no| SKIP["Not traded<br/><i>(still scored — the control group)</i>"]
|
||||
G1 -->|yes| S["Build the setup<br/>entry = last close<br/><b>stop = entry − 1.5 × ATR</b><br/>level = nearest S/R above"]
|
||||
G1 -->|yes| S["Build the setup<br/>entry = last close<br/><b>stop = entry − 1.5 × ATR</b><br/>primary = Gate Target Ladder proposal"]
|
||||
|
||||
S --> G2{"Activation gate"}
|
||||
G2 --> G2a["R:R ≥ 2.0 <i>(to the S/R level)</i>"]
|
||||
G2 --> G2a["R:R ≥ 2.0 <i>(to the primary gate target)</i>"]
|
||||
G2 --> G2b["touch odds ≥ 20%"]
|
||||
G2 --> G2c["action not NEUTRAL<br/>and matches direction"]
|
||||
G2a & G2b & G2c --> Q{"qualified?"}
|
||||
@@ -39,7 +39,7 @@ flowchart TD
|
||||
EXIT --> E1["Initial stop hit<br/>entry − 1.5 × ATR → −1R<br/><b>45% of trades</b>"]
|
||||
EXIT --> E2["Trailing stop hit<br/>highest close − 3 × ATR<br/><i>only binds once price is ~1R up</i><br/><b>31% of trades</b>"]
|
||||
EXIT --> E3["Max hold reached<br/>30 trading days<br/><b>24% of trades</b>"]
|
||||
EXIT -.->|"NEVER"| E4["S/R target<br/><b>0% of trades</b>"]
|
||||
EXIT -.->|"NEVER"| E4["Gate Target Ladder target<br/><b>0% of trades</b>"]
|
||||
|
||||
style M fill:#1e3a5f,color:#fff
|
||||
style OPEN fill:#1e4d2b,color:#fff
|
||||
@@ -54,13 +54,74 @@ flowchart TD
|
||||
|
||||
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).
|
||||
|
||||
### Price-level architecture: two different jobs
|
||||
|
||||
The platform deliberately has two price-level components. Calling both of them
|
||||
"S/R" hid an important distinction, so the internal screening component is now
|
||||
named the **Gate Target Ladder (GTL)**.
|
||||
|
||||
| Component | Purpose | Lifetime | Consumed by |
|
||||
|---|---|---|---|
|
||||
| **Structural S/R** | A small set of meaningful support/resistance zones for humans | Persisted as `SRLevel` | Charts and alerts |
|
||||
| **Gate Target Ladder** | A broad set of price proposals that preserves the validated setup screen | Built transiently per scan; never persisted as S/R | Target table, headline R:R and activation gate |
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
O["Ticker OHLCV history"] --> SR["Structural S/R detector<br/>volume peaks + prominent pivots + round numbers<br/>rejection and recency strength"]
|
||||
SR --> DB[("Persisted SRLevel rows")]
|
||||
DB --> UI["Charts and alerts"]
|
||||
|
||||
O --> GTL["Gate Target Ladder<br/>20 price-range centers + 5-bar pivots<br/>no volume calculation"]
|
||||
GTL --> TRAFFIC["Score historical price traffic<br/>merge nearby proposals and tag side"]
|
||||
TRAFFIC --> HAS{"Any directional proposal<br/>with R:R ≥ 1.5?"}
|
||||
HAS -->|no| NONE["No setup for that direction"]
|
||||
HAS -->|yes| TARGETS["Build up to 5 target candidates<br/>estimate reach-probability"]
|
||||
TARGETS --> PRIMARY["Headline target<br/>most likely candidate clearing<br/>R:R ≥ 1.5 and probability ≥ 20%"]
|
||||
PRIMARY --> GATE{"Live activation gate<br/>headline R:R ≥ 2.0<br/>probability ≥ 20%<br/>momentum and direction pass?"}
|
||||
GATE -->|no| OBS["Keep as unqualified observation"]
|
||||
GATE -->|yes| BOOK["Eligible for production ranking/book"]
|
||||
BOOK --> EXIT["Exit only by ATR stop/trail<br/>or max hold — never by target"]
|
||||
```
|
||||
|
||||
The Gate Target Ladder works step by step:
|
||||
|
||||
1. Build 20 evenly spaced centers over the ticker's observed high/low range and add unfiltered five-bar swing pivots. Volume is not used.
|
||||
2. Count how often historical bars pass through each proposal, convert that traffic to strength, merge proposals within 0.5%, and label them above/below spot.
|
||||
3. For each direction, require at least one proposal with scanner R:R ≥ 1.5 against the 1.5× ATR initial stop.
|
||||
4. Collapse nearby proposals into target zones, discard unsuitable ATR distances, and retain up to five candidates spanning near to far.
|
||||
5. Estimate each candidate's probability of reaching the target before the stop. The headline target is the most likely candidate with R:R ≥ 1.5 and probability ≥ 20%; if none clears both, the most likely candidate remains headline so a distant lottery target cannot game the gate.
|
||||
6. Apply the separate live activation floor to that headline target: production requires R:R ≥ 2.0 and probability ≥ 20%, plus the momentum/direction rules.
|
||||
7. If traded, ignore the target for exits. The initial ATR stop, 3× ATR trail and maximum hold remain authoritative.
|
||||
|
||||
The ladder is intentionally broad and mechanical. It is not presented as
|
||||
market structure, and its transient negative level IDs must never be stored as
|
||||
chart S/R. The full-period parity run reproduced all 202,765 backtest setup
|
||||
candidates, all 1,086 qualified setups, and the production book exactly
|
||||
(Sharpe 2.03, CAGR 50.0%, max drawdown 21.4%, 321 trades). See the
|
||||
[S/R and Gate Target Ladder research](docs/research/sr-levels-and-exits.md#explicit-gate-target-ladder).
|
||||
|
||||
**Ticker-chart diagnostic.** The optional **GTL traffic** toggle draws a
|
||||
right-edge horizontal profile aligned to the price axis. It borrows the visual
|
||||
grammar of a volume profile, but not its meaning: bar width is relative
|
||||
historical OHLCV-bar crossings at each GTL proposal, not traded volume at that
|
||||
price. Hover a bar to inspect its price, crossing count, strength and source.
|
||||
The violet profile is deliberately distinct from the Structural S/R lines and
|
||||
is off by default; it is a research aid, not another trade overlay.
|
||||
|
||||
Below the chart, the **Production rank** strip makes the current 80/20 ordering
|
||||
snapshot explicit: a blue residual-momentum contribution and amber realized-
|
||||
volatility contribution add to the stored strategy rank, while separate
|
||||
percentile rails show each input. Only momentum carries the live activation-
|
||||
gate marker. These are cross-sectional scan percentiles, not historical chart
|
||||
indicators.
|
||||
|
||||
### 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 plus the promoted 80/20 production rank.
|
||||
3. **R:R Scan** — persist clean Structural S/R for charts/alerts, recompute the 5-dimension scores, and build long/short setups from a transient Gate Target Ladder (ATR stops and nominal gate targets) for every ticker. Attach each ticker's residual 12‑1 momentum activation percentile plus the promoted 80/20 production rank.
|
||||
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: 3x ATR trail with a 30-trading-day max hold).
|
||||
5. **Market Regime** — recompute the regime index (breadth/trend).
|
||||
6. **Regime Monitor** — observational early-warning snapshot (VIX, credit spreads via FRED); feeds nothing else.
|
||||
@@ -78,11 +139,11 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we
|
||||
### 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). **Display and ranking only — it does not select trades.**
|
||||
2. **Setups** — the scanner builds long/short setups with a 1.5× ATR stop and picks the nearest S/R level as a nominal target, then adds a confidence score, conflict flags and a per-level touch-probability.
|
||||
3. **Activation gate** — a setup *qualifies* only if it ranks in the top residual-momentum percentile of the universe (**the actual selection**, long-only), clears the R:R floor, **and** its primary level carries at least a 20% touch-probability. The confidence floor was ablated to zero effect and defaults off.
|
||||
2. **Setups** — the scanner builds long/short setups with a 1.5× ATR stop, generates up to five candidates from the transient Gate Target Ladder, and makes the most likely worthwhile candidate the headline target. It then adds confidence and conflict context plus a per-target reach-probability.
|
||||
3. **Activation gate** — a setup *qualifies* only if it ranks in the top residual-momentum percentile of the universe (**the actual selection**, long-only), its headline target clears the live R:R floor, **and** that target carries at least a 20% reach-probability. The confidence floor was ablated to zero effect and defaults off.
|
||||
4. **Top pick** — qualified setups are ordered by the production rank: 80% residual momentum percentile + 20% 6-month realized-volatility percentile. The #1 is highlighted on the Dashboard and labelled on the ticker page.
|
||||
|
||||
**What the R:R and touch-probability in step 3 actually are.** They are *gate inputs*, computed from an S/R level the trade will never exit at — they exist to filter setups, not to forecast the trade you're about to take. A setup with "R:R 2.4:1, 34% touch odds" is not a claim that you'll make 2.4R with 34% probability; it's a claim that this setup cleared the screen. What actually happens to a trade is in the exit box of the diagram above, and on the "what usually happens" panel in the UI. Conflating the two is the single easiest way to misread this app.
|
||||
**What the R:R and reach-probability in step 3 actually are.** They are *gate inputs*, computed from a Gate Target Ladder proposal the trade will never exit at — they exist to filter setups, not to forecast the trade you're about to take. A setup with "R:R 2.4:1, 34% reach probability" is not a claim that you'll make 2.4R with 34% probability; it's a claim that this setup cleared the screen. What actually happens to a trade is in the exit box of the diagram above, and on the "what usually happens" panel in the UI. Conflating the two is the single easiest way to misread this app.
|
||||
|
||||
## Strategy Status — What's Validated and What Isn't
|
||||
|
||||
@@ -94,13 +155,14 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we
|
||||
|---|---|---|
|
||||
| **Residual 12-1 cross-sectional momentum** (the activation gate, long-only) | **Production gate — 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 |
|
||||
| **3× ATR trailing exit** (+ 1.5× ATR initial stop, 30-day max hold) | **Production exit — best Sharpe of every exit tested** | Beat hold / SMA50 / 20-day-low / technical-40 and both take-profit variants (July 2026) |
|
||||
| S/R setup engine (ATR stops, S/R levels, touch-probability) | **Gate input only — NOT an exit, and not an edge** | The exit never reads the target (0 of 320 trades). Honoring it as a take-profit drops Sharpe 2.04 → 1.47. The R:R floor does real work *as a filter*; the detector itself is methodologically weak. [Full write-up](docs/research/sr-levels-and-exits.md) |
|
||||
| **Structural S/R** | **Human-facing context only — not a gate and not an exit** | Clean, capped zones are persisted for charts and alerts. The scanner deliberately does not read them. |
|
||||
| **Gate Target Ladder** | **Gate input only — not market structure and not an exit** | Volume-free range grid + pivots preserves the useful legacy screening behavior exactly: 1,086/1,086 qualified setups retained and identical Sharpe 2.03 / CAGR 50.0% / DD 21.4% / 321 trades. The exit never reads its target. [Full write-up](docs/research/sr-levels-and-exits.md#explicit-gate-target-ladder) |
|
||||
| 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. Orphaned settings dropped in migration 020 |
|
||||
| S/R target as a take-profit (tested July 2026) | **Rejected** | Sharpe 2.04 → 1.47, CAGR halved. Win rate *rose* — it truncates the right tail where the edge lives |
|
||||
| Gate target as a take-profit (tested July 2026) | **Rejected** | Sharpe 2.04 → 1.47, CAGR halved. Win rate *rose* — it truncates the right tail where the edge lives |
|
||||
| "Clear-air" gate relaxation (tested July 2026) | **Rejected — failed out-of-sample** | Strictly better in-sample (Sharpe 2.07 / CAGR 62.3% / DD 20.1%), then lost on a real train/test split (Sharpe 2.78 → 2.45). A cautionary tale: nested lookbacks are not OOS |
|
||||
|
||||
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.
|
||||
@@ -112,7 +174,7 @@ Use this as a regression guardrail for future strategy changes, not as a return
|
||||
| Item | Current baseline |
|
||||
|---|---|
|
||||
| Strategy version | `residual_highvol_80_20_atr_trail3_v1` |
|
||||
| Production gate | Long-only, residual 12-1 momentum percentile >= 80, R:R >= 2.0 (live `activation_min_rr`; the code default is 1.2), primary-level touch-probability >= 20%, NEUTRAL excluded, confidence floor off (0) |
|
||||
| Production gate | Long-only, residual 12-1 momentum percentile >= 80, headline gate-target R:R >= 2.0 (live `activation_min_rr`; the code default is 1.2), primary-target reach-probability >= 20%, NEUTRAL excluded, confidence floor off (0) |
|
||||
| Production rank | 80% residual momentum percentile + 20% 6-month realized-volatility percentile |
|
||||
| Exit | Initial ATR stop plus 3x ATR trailing stop, max 30 trading days |
|
||||
| Portfolio CAGR | +50.4% |
|
||||
@@ -183,7 +245,7 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
|
||||
|
||||
## Key Use Cases
|
||||
|
||||
- **Find today's best long setup.** On the **Dashboard**, the *Top Setups* table lists residual-gated qualified setups ranked by the production 80/20 residual/high-vol score, with the #1 flagged "Top pick". Each row opens the ticker page for the chart, scores, S/R targets and entry/stop.
|
||||
- **Find today's best long setup.** On the **Dashboard**, the *Top Setups* table lists residual-gated qualified setups ranked by the production 80/20 residual/high-vol score, with the #1 flagged "Top pick". Each row opens the ticker page for its chart, Structural S/R, Gate Target Ladder 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: 3x ATR trail with a 30-trading-day max hold), and its sentiment stays fresh while open. *Signals → Track Record* shows the realized edge.
|
||||
|
||||
## Stack
|
||||
@@ -208,12 +270,13 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
|
||||
- Universe bootstrap for `sp500`, `nasdaq100`, `nasdaq_all` via admin endpoint
|
||||
- OHLCV price storage with upsert and validation
|
||||
- Technical indicators: ADX, EMA, RSI, ATR, Volume Profile, Pivot Points, EMA Cross
|
||||
- Support/Resistance detection with strength scoring and merge-within-tolerance
|
||||
- Structural Support/Resistance detection with rejection/recency strength, ATR-adaptive merging and a hard cap; persisted for charts and alerts
|
||||
- Transient Gate Target Ladder — volume-free range grid plus pivots, used only for nominal targets, reach-probability and gate R:R
|
||||
- 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, 1.5x ATR stops, S/R-based nominal targets, configurable scan R:R threshold (default 1.5:1 — distinct from the activation floor below)
|
||||
- Activation gate — qualifies setups on a residual-momentum percentile floor (the actual selection), an R:R floor (prod: 2.0) and a 20% primary-level touch-probability floor (validated long-only edge)
|
||||
- Risk:Reward scanner — long and short setups, 1.5x ATR stops, Gate Target Ladder nominal targets, configurable scan R:R threshold (default 1.5:1 — distinct from the activation floor below)
|
||||
- Activation gate — qualifies setups on a residual-momentum percentile floor (the actual selection), a headline gate-target R:R floor (prod: 2.0) and a 20% primary-target reach-probability 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: 3x ATR trail with a 30-trading-day max hold; time / percent-trailing / target-stop selectable), realized track record + outcome evaluation
|
||||
- Market-regime index + FRED early-warning monitor (VIX, credit spreads); weekly backtest + manual event study
|
||||
@@ -227,6 +290,8 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
|
||||
- Glassmorphism UI with frosted glass panels, gradient text, ambient glow effects, mesh gradient background
|
||||
- Interactive candlestick chart (Canvas 2D) with hover tooltips showing OHLCV values
|
||||
- Support/Resistance level overlays on chart (top 6 by strength, dashed lines with labels)
|
||||
- Optional GTL price-traffic profile on the ticker chart (right-edge diagnostic; explicitly not volume)
|
||||
- Production-rank strip below the ticker chart (80/20 contribution ledger plus separate momentum and volatility percentiles)
|
||||
- Data freshness bar showing availability and recency of each data source
|
||||
- Watchlist with composite scores, R:R ratios, and S/R summaries
|
||||
- Ticker detail page: chart, scores, sentiment breakdown, fundamentals, technical indicators, S/R table
|
||||
@@ -265,6 +330,7 @@ All under `/api/v1/`. Interactive docs at `/docs` (Swagger) and `/redoc`.
|
||||
| Ingestion | `POST /ingestion/fetch/{symbol}` |
|
||||
| Indicators | `GET /indicators/{symbol}/{type}`, `GET /indicators/{symbol}/ema-cross` |
|
||||
| S/R Levels | `GET /sr-levels/{symbol}` |
|
||||
| Gate Target Ladder | `GET /gate-target-ladder/{symbol}` |
|
||||
| Sentiment | `GET /sentiment/{symbol}` |
|
||||
| Fundamentals | `GET /fundamentals/{symbol}` |
|
||||
| Scores | `GET /scores/{symbol}`, `GET /rankings`, `PUT /scores/weights` |
|
||||
@@ -393,6 +459,21 @@ 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.
|
||||
|
||||
The local runner, scheduled job, and Admin UI all default to
|
||||
`production_gtl`, matching the live scanner's target path. For a deliberate
|
||||
comparison, select **Structural S/R (comparison)** in the UI or pass
|
||||
`--target-model structural_sr` locally. Every report records the selected model
|
||||
and whether it is the production path.
|
||||
|
||||
### Archived GTL tuning decision
|
||||
|
||||
The completed replacement, cohort-composition, and strength-sensitivity
|
||||
matrices found no stable improvement over the frozen Gate Target Ladder. The
|
||||
temporary matrix runners and tuning hooks have been retired; their three compact
|
||||
consolidated report pairs remain in `reports/` as the decision audit. Keep the
|
||||
GTL unchanged and evaluate any future challenger only on new forward data. See
|
||||
the [full research record](docs/research/sr-levels-and-exits.md#gtl-tuning-matrix).
|
||||
|
||||
### Reading a local backtest report
|
||||
|
||||
The deployed **Signals → Track Record** page is deliberately trimmed to validation
|
||||
@@ -616,9 +697,9 @@ Context for whoever — human or AI — continues this work. The owner pushes st
|
||||
### 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 S/R target is a gate input, never an exit.** `_atr_trailing_close()` does not take it as a parameter, and it must stay that way — take-profit exits were tested and halve CAGR. Any UI or alert that implies the trade exits at the target is a bug ([research](docs/research/sr-levels-and-exits.md)).
|
||||
- **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`, `detect_gate_target_ladder`, the recommendation helpers). New strategy logic must stay in pure functions consumed by both paths, or the backtest stops measuring what production actually does.
|
||||
- **Keep the two price-level models separate.** `detect_sr_levels` produces persisted Structural S/R for charts and alerts. `detect_gate_target_ladder` produces transient screening proposals and must never be persisted or presented as market structure. The scanner must not read `SRLevel` rows for target generation.
|
||||
- **The Gate Target Ladder target is a gate input, never an exit.** `_atr_trailing_close()` does not take it as a parameter, and it must stay that way — take-profit exits were tested and halve CAGR. Any UI or alert that implies the trade exits at the target is a bug ([research](docs/research/sr-levels-and-exits.md#explicit-gate-target-ladder)).
|
||||
- **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.
|
||||
@@ -631,7 +712,7 @@ Context for whoever — human or AI — continues this work. The owner pushes st
|
||||
|---|---|
|
||||
| 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` |
|
||||
| Setup construction (ATR stop, Gate Target Ladder 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`) |
|
||||
@@ -639,7 +720,7 @@ Context for whoever — human or AI — continues this work. The owner pushes st
|
||||
| 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` |
|
||||
| Structural S/R detection, Gate Target Ladder & zone clustering | `app/services/sr_service.py` |
|
||||
| **Research log — what's been tested and rejected** | **`docs/research/`** |
|
||||
| SPY benchmark for residual momentum + paper-trade alpha | `app/services/benchmark_service.py` |
|
||||
| Pipelines & job registration | `app/scheduler.py` |
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.schemas.admin import (
|
||||
AlertConfigUpdate,
|
||||
CreateUserRequest,
|
||||
DataCleanupRequest,
|
||||
JobTriggerRequest,
|
||||
JobToggle,
|
||||
RecommendationConfigUpdate,
|
||||
ScheduleConfigUpdate,
|
||||
@@ -376,11 +377,16 @@ async def get_pipeline_readiness(
|
||||
@router.post("/admin/jobs/{job_name}/trigger", response_model=APIEnvelope)
|
||||
async def trigger_job(
|
||||
job_name: str,
|
||||
body: JobTriggerRequest | None = None,
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Trigger a manual job run (placeholder)."""
|
||||
result = await admin_service.trigger_job(db, job_name)
|
||||
"""Trigger a manual job run, optionally with one-run parameters."""
|
||||
result = await admin_service.trigger_job(
|
||||
db,
|
||||
job_name,
|
||||
target_model=body.target_model if body is not None else None,
|
||||
)
|
||||
return APIEnvelope(status="success", data=result)
|
||||
|
||||
|
||||
|
||||
@@ -5,17 +5,77 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, require_access
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.schemas.sr_level import SRLevelResponse, SRLevelResult, SRZoneResult
|
||||
from app.schemas.sr_level import (
|
||||
GateTargetLadderResponse,
|
||||
GateTargetLevelResult,
|
||||
SRLevelResponse,
|
||||
SRLevelResult,
|
||||
SRZoneResult,
|
||||
)
|
||||
from app.services.price_service import query_ohlcv
|
||||
from app.services.sr_service import cluster_sr_zones, get_sr_levels
|
||||
from app.services.sr_service import (
|
||||
cluster_sr_zones,
|
||||
detect_gate_target_ladder,
|
||||
get_sr_levels,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["sr-levels"])
|
||||
|
||||
|
||||
@router.get("/gate-target-ladder/{symbol}", response_model=APIEnvelope)
|
||||
async def read_gate_target_ladder(
|
||||
symbol: str,
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Return the transient, volume-free GTL for chart diagnostics.
|
||||
|
||||
These proposals are not persisted ``SRLevel`` rows and must not be
|
||||
presented as structural support/resistance.
|
||||
"""
|
||||
records = await query_ohlcv(db, symbol)
|
||||
if not records:
|
||||
data = GateTargetLadderResponse(
|
||||
symbol=symbol.upper(),
|
||||
levels=[],
|
||||
count=0,
|
||||
lookback_bars=0,
|
||||
)
|
||||
return APIEnvelope(status="success", data=data.model_dump())
|
||||
|
||||
highs = [float(record.high) for record in records]
|
||||
lows = [float(record.low) for record in records]
|
||||
closes = [float(record.close) for record in records]
|
||||
detected = detect_gate_target_ladder(highs, lows, closes)
|
||||
levels = [
|
||||
GateTargetLevelResult(
|
||||
price_level=float(level["price_level"]),
|
||||
type=level["type"],
|
||||
strength=int(level["strength"]),
|
||||
detection_method=str(level.get("detection_method", "unknown")),
|
||||
sources=list(level.get("sources") or []),
|
||||
traffic_count=int(level.get("rejection_count", 0) or 0),
|
||||
)
|
||||
for level in sorted(detected, key=lambda row: float(row["price_level"]))
|
||||
]
|
||||
data = GateTargetLadderResponse(
|
||||
symbol=symbol.upper(),
|
||||
levels=levels,
|
||||
count=len(levels),
|
||||
lookback_bars=len(records),
|
||||
)
|
||||
return APIEnvelope(status="success", data=data.model_dump())
|
||||
|
||||
|
||||
@router.get("/sr-levels/{symbol}", response_model=APIEnvelope)
|
||||
async def read_sr_levels(
|
||||
symbol: str,
|
||||
tolerance: float = Query(0.005, ge=0, le=0.1, description="Merge tolerance (default 0.5%)"),
|
||||
tolerance: float | None = Query(
|
||||
None,
|
||||
ge=0,
|
||||
le=0.1,
|
||||
description="Merge tolerance as fraction of price; omit for ATR-adaptive default",
|
||||
),
|
||||
max_zones: int = Query(6, ge=0, description="Max S/R zones to return (default 6)"),
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
+44
-4
@@ -35,7 +35,12 @@ from app.providers.fundamentals_chain import build_fundamental_provider_chain
|
||||
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.backtest_service import (
|
||||
BACKTEST_TARGET_MODELS,
|
||||
PRODUCTION_GTL_TARGET_MODEL,
|
||||
run_and_store as run_backtest_and_store,
|
||||
validate_backtest_target_model,
|
||||
)
|
||||
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
|
||||
@@ -106,6 +111,7 @@ def _idle_runtime() -> dict[str, object]:
|
||||
|
||||
|
||||
_job_runtime: dict[str, dict[str, object]] = {name: _idle_runtime() for name in _JOB_NAMES}
|
||||
_next_backtest_target_model = PRODUCTION_GTL_TARGET_MODEL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -113,6 +119,26 @@ _job_runtime: dict[str, dict[str, object]] = {name: _idle_runtime() for name in
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def queue_backtest_target_model(target_model: str | None) -> str:
|
||||
"""Select the model for the next manual backtest run only.
|
||||
|
||||
Scheduled runs and subsequent manual runs return to the production GTL.
|
||||
"""
|
||||
global _next_backtest_target_model
|
||||
selected = validate_backtest_target_model(
|
||||
target_model or PRODUCTION_GTL_TARGET_MODEL
|
||||
)
|
||||
_next_backtest_target_model = selected
|
||||
return selected
|
||||
|
||||
|
||||
def _consume_backtest_target_model() -> str:
|
||||
global _next_backtest_target_model
|
||||
selected = _next_backtest_target_model
|
||||
_next_backtest_target_model = PRODUCTION_GTL_TARGET_MODEL
|
||||
return selected
|
||||
|
||||
|
||||
def _log_event(level: int, event: str, **fields: object) -> None:
|
||||
"""Emit a structured JSON log line: {"event": ..., **fields}."""
|
||||
logger.log(level, json.dumps({"event": event, **fields}))
|
||||
@@ -939,7 +965,13 @@ async def compute_regime_monitor() -> None:
|
||||
async def run_backtest_job() -> None:
|
||||
"""Replay the price-derived engine over history and cache the report."""
|
||||
job_name = "backtest"
|
||||
_log_event(logging.INFO, "job_start", job=job_name)
|
||||
target_model = _consume_backtest_target_model()
|
||||
_log_event(
|
||||
logging.INFO,
|
||||
"job_start",
|
||||
job=job_name,
|
||||
target_model=target_model,
|
||||
)
|
||||
_runtime_start(job_name)
|
||||
|
||||
def _on_progress(done: int, count: int, symbol: str) -> None:
|
||||
@@ -952,12 +984,20 @@ async def run_backtest_job() -> None:
|
||||
_runtime_finish(job_name, "skipped", processed=0, total=0, message="Disabled")
|
||||
return
|
||||
|
||||
report = await run_backtest_and_store(db, _on_progress)
|
||||
report = await run_backtest_and_store(
|
||||
db,
|
||||
_on_progress,
|
||||
target_model=target_model,
|
||||
)
|
||||
|
||||
_runtime_finish(
|
||||
job_name, "completed",
|
||||
processed=report.get("tickers", 0), total=report.get("tickers", 0),
|
||||
message=f"{report.get('candidates', 0)} setups, {report.get('qualified', 0)} qualified",
|
||||
message=(
|
||||
f"{BACKTEST_TARGET_MODELS[target_model]}: "
|
||||
f"{report.get('candidates', 0)} setups, "
|
||||
f"{report.get('qualified', 0)} qualified"
|
||||
),
|
||||
)
|
||||
_log_event(logging.INFO, "job_complete", job=job_name, candidates=report.get("candidates"))
|
||||
except Exception as exc:
|
||||
|
||||
@@ -43,6 +43,11 @@ class JobToggle(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class JobTriggerRequest(BaseModel):
|
||||
"""Optional parameters for a one-time manual job run."""
|
||||
target_model: Literal["production_gtl", "structural_sr"] | None = None
|
||||
|
||||
|
||||
class RecommendationConfigUpdate(BaseModel):
|
||||
high_confidence_threshold: float | None = Field(default=None, ge=0, le=100)
|
||||
moderate_confidence_threshold: float | None = Field(default=None, ge=0, le=100)
|
||||
|
||||
+23
-1
@@ -15,7 +15,9 @@ class SRLevelResult(BaseModel):
|
||||
price_level: float
|
||||
type: Literal["support", "resistance"]
|
||||
strength: int = Field(ge=0, le=100)
|
||||
detection_method: Literal["volume_profile", "pivot_point", "merged"]
|
||||
detection_method: Literal[
|
||||
"volume_profile", "pivot_point", "merged", "round_number"
|
||||
]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -38,3 +40,23 @@ class SRLevelResponse(BaseModel):
|
||||
zones: list[SRZoneResult] = []
|
||||
visible_levels: list[SRLevelResult] = []
|
||||
count: int
|
||||
|
||||
|
||||
class GateTargetLevelResult(BaseModel):
|
||||
"""A transient Gate Target Ladder proposal for diagnostic display."""
|
||||
|
||||
price_level: float
|
||||
type: Literal["support", "resistance"]
|
||||
strength: int = Field(ge=0, le=100)
|
||||
detection_method: str
|
||||
sources: list[str] = Field(default_factory=list)
|
||||
traffic_count: int = Field(ge=0)
|
||||
|
||||
|
||||
class GateTargetLadderResponse(BaseModel):
|
||||
"""Volume-free Gate Target Ladder computed from current OHLCV history."""
|
||||
|
||||
symbol: str
|
||||
levels: list[GateTargetLevelResult]
|
||||
count: int
|
||||
lookback_bars: int
|
||||
|
||||
@@ -602,13 +602,20 @@ async def list_jobs(db: AsyncSession) -> list[dict]:
|
||||
return jobs_out
|
||||
|
||||
|
||||
async def trigger_job(db: AsyncSession, job_name: str) -> dict[str, str]:
|
||||
async def trigger_job(
|
||||
db: AsyncSession,
|
||||
job_name: str,
|
||||
*,
|
||||
target_model: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Trigger a manual job run via the scheduler.
|
||||
|
||||
Runs the job immediately (in addition to its regular schedule).
|
||||
"""
|
||||
if job_name not in VALID_JOB_NAMES:
|
||||
raise ValidationError(f"Unknown job: {job_name}. Valid jobs: {', '.join(sorted(VALID_JOB_NAMES))}")
|
||||
if target_model is not None and job_name != "backtest":
|
||||
raise ValidationError("target_model is supported only for the backtest job")
|
||||
|
||||
from app.scheduler import get_job_runtime_snapshot, scheduler
|
||||
|
||||
@@ -635,11 +642,19 @@ async def trigger_job(db: AsyncSession, job_name: str) -> dict[str, str]:
|
||||
if job is None:
|
||||
return {"job": job_name, "status": "not_found", "message": f"Job '{job_name}' is not registered in the scheduler"}
|
||||
|
||||
if job_name == "backtest":
|
||||
from app.scheduler import queue_backtest_target_model
|
||||
|
||||
target_model = queue_backtest_target_model(target_model)
|
||||
|
||||
job.modify(next_run_time=None) # Reset, then trigger immediately
|
||||
from datetime import datetime, timezone
|
||||
job.modify(next_run_time=datetime.now(timezone.utc))
|
||||
|
||||
return {"job": job_name, "status": "triggered", "message": f"Job '{job_name}' triggered for immediate execution"}
|
||||
result = {"job": job_name, "status": "triggered", "message": f"Job '{job_name}' triggered for immediate execution"}
|
||||
if target_model is not None:
|
||||
result["target_model"] = target_model
|
||||
return result
|
||||
|
||||
|
||||
async def toggle_job(db: AsyncSession, job_name: str, enabled: bool) -> SystemSetting:
|
||||
|
||||
@@ -18,6 +18,18 @@ after D to record the realized outcome. The report contains:
|
||||
|
||||
Limitation: sentiment and fundamentals have no point-in-time history, so they're
|
||||
held neutral here — this calibrates the price/S-R machinery only.
|
||||
|
||||
Environment variables (see also run_backtest_snapshot.py):
|
||||
Production / general use:
|
||||
BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD # disjoint train/test split
|
||||
BACKTEST_SNAPSHOT_OFFLINE=1
|
||||
BACKTEST_ALLOW_SPAWN=1 # for Windows multiprocessing
|
||||
|
||||
Research / diagnostic only (retired experiments — do not use for live decisions):
|
||||
BACKTEST_ATR_TARGET_FALLBACK=3
|
||||
BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1
|
||||
BACKTEST_RESEARCH_EXITS=1
|
||||
BACKTEST_MIN_RR_SWEEP=1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -81,7 +93,7 @@ from app.services.scoring_service import (
|
||||
compute_momentum_from_closes,
|
||||
compute_technical_from_arrays,
|
||||
)
|
||||
from app.services.sr_service import detect_sr_levels
|
||||
from app.services.sr_service import detect_gate_target_ladder, detect_sr_levels
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,6 +103,12 @@ STEP_DAYS = 5 # weekly cadence (≈ 5 trading days)
|
||||
MIN_LOOKBACK = 60 # bars needed before D for indicators (EMA cross needs 51)
|
||||
HORIZON = 30 # trading days to resolve an outcome (matches the evaluator)
|
||||
ATR_MULTIPLIER = 1.5
|
||||
PRODUCTION_GTL_TARGET_MODEL = "production_gtl"
|
||||
STRUCTURAL_SR_TARGET_MODEL = "structural_sr"
|
||||
BACKTEST_TARGET_MODELS = {
|
||||
PRODUCTION_GTL_TARGET_MODEL: "Live GTL (production)",
|
||||
STRUCTURAL_SR_TARGET_MODEL: "Structural S/R (comparison)",
|
||||
}
|
||||
|
||||
# Cross-sectional signal evaluation (factor IC). Each candidate signal is a
|
||||
# point-in-time number computed from closes alone (sentiment/fundamentals have no
|
||||
@@ -120,18 +138,38 @@ def _wrap_levels(level_dicts: list[dict]) -> list[Any]:
|
||||
price_level=float(d["price_level"]),
|
||||
type=d["type"],
|
||||
strength=int(d["strength"]),
|
||||
detection_method=d.get("detection_method", "unknown"),
|
||||
sources=list(d.get("sources") or [d.get("detection_method", "unknown")]),
|
||||
rejection_count=int(d.get("rejection_count", 0) or 0),
|
||||
last_rejection_age=d.get("last_rejection_age"),
|
||||
)
|
||||
for i, d in enumerate(level_dicts)
|
||||
]
|
||||
|
||||
|
||||
def validate_backtest_target_model(value: str) -> str:
|
||||
"""Validate the small, user-facing set of supported backtest target models."""
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in BACKTEST_TARGET_MODELS:
|
||||
allowed = ", ".join(BACKTEST_TARGET_MODELS)
|
||||
raise ValueError(f"Unknown backtest target model {value!r}; expected one of {allowed}")
|
||||
return normalized
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RESEARCH / DIAGNOSTIC FALLBACKS (retired experiments)
|
||||
#
|
||||
# These implement behavior from experiments that were rejected for production
|
||||
# (clear-air synthetic targets, blanket ATR fallbacks). They are OFF by default
|
||||
# and exist only to reproduce historical research results or run future ablations.
|
||||
# See docs/research/sr-levels-and-exits.md.
|
||||
# Do NOT enable for production decision making.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _atr_target_fallback_k() -> float | None:
|
||||
"""Research ablation: k for a synthetic k*ATR target when a direction has no
|
||||
S/R level to aim at. Off (None) by default, which is production behavior —
|
||||
no resistance above means no long setup at all. That veto lands hardest on
|
||||
names at 52-week highs (clear air above), i.e. exactly what the momentum gate
|
||||
selects, so this flag exists to measure what the veto costs. Set
|
||||
BACKTEST_ATR_TARGET_FALLBACK=3 to enable. See docs/research/sr-levels-and-exits.md."""
|
||||
"""RESEARCH DIAGNOSTIC: k for a synthetic k*ATR target when no S/R level.
|
||||
Off (None) by default (production behavior). Set BACKTEST_ATR_TARGET_FALLBACK=3
|
||||
to enable. See docs/research/sr-levels-and-exits.md."""
|
||||
raw = os.getenv("BACKTEST_ATR_TARGET_FALLBACK", "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
@@ -143,13 +181,8 @@ def _atr_target_fallback_k() -> float | None:
|
||||
|
||||
|
||||
def _fallback_clear_air_only() -> bool:
|
||||
"""Restrict the fallback to setups with genuinely NO structure ahead.
|
||||
|
||||
Without this, the fallback also fires when levels DO exist ahead but
|
||||
``TargetGenerator``'s distance filters rejected them (nearer than 1 ATR, or
|
||||
past ``max_atr_multiple``). Measured on the snapshot, that's 65% of what the
|
||||
fallback admits — a different population from the clear-air breakouts, which
|
||||
confounds the famine test. Set BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1."""
|
||||
"""RESEARCH DIAGNOSTIC: restrict fallback to genuine clear-air cases only.
|
||||
Set BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1."""
|
||||
return os.getenv("BACKTEST_FALLBACK_CLEAR_AIR_ONLY", "").strip().lower() in {
|
||||
"1", "true", "yes", "on",
|
||||
}
|
||||
@@ -170,11 +203,7 @@ def _has_structure_ahead(direction: str, entry: float, sr_levels: list[Any]) ->
|
||||
def _atr_fallback_target(
|
||||
direction: str, entry: float, stop: float, atr: float, k: float
|
||||
) -> dict:
|
||||
"""A synthetic target k*ATR from entry, shaped like a TargetGenerator row.
|
||||
|
||||
``sr_strength`` is 50 (neutral) so the probability model's strength magnet
|
||||
contributes nothing — the target stands on distance alone.
|
||||
"""
|
||||
"""RESEARCH DIAGNOSTIC: synthetic target k*ATR (neutral strength)."""
|
||||
price = entry + k * atr if direction == "long" else entry - k * atr
|
||||
distance = abs(price - entry)
|
||||
risk = abs(entry - stop)
|
||||
@@ -193,6 +222,8 @@ def _window_setups(
|
||||
window_records: list,
|
||||
config: dict,
|
||||
activation: dict,
|
||||
*,
|
||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||
) -> list[dict]:
|
||||
"""Rebuild the setup(s) at the last bar of ``window_records`` (the as-of date),
|
||||
using only those bars. Returns one dict per tradeable direction."""
|
||||
@@ -219,10 +250,21 @@ def _window_setups(
|
||||
if atr <= 0:
|
||||
return []
|
||||
|
||||
sr_levels = _wrap_levels(detect_sr_levels(highs, lows, closes, volumes))
|
||||
target_model = validate_backtest_target_model(target_model)
|
||||
if target_model == PRODUCTION_GTL_TARGET_MODEL:
|
||||
detected_levels = detect_gate_target_ladder(
|
||||
highs,
|
||||
lows,
|
||||
closes,
|
||||
)
|
||||
else:
|
||||
detected_levels = detect_sr_levels(highs, lows, closes, volumes)
|
||||
sr_levels = _wrap_levels(detected_levels)
|
||||
if not sr_levels:
|
||||
return []
|
||||
|
||||
gate_levels = list(sr_levels)
|
||||
|
||||
technical = (compute_technical_from_arrays(highs, lows, closes, volumes)[0]) or 50.0
|
||||
momentum = (compute_momentum_from_closes(closes)[0]) or 50.0
|
||||
dim_scores = {"technical": technical, "momentum": momentum}
|
||||
@@ -237,14 +279,19 @@ def _window_setups(
|
||||
per_dir: dict[str, dict] = {}
|
||||
for direction in ("long", "short"):
|
||||
stop = entry - atr * ATR_MULTIPLIER if direction == "long" else entry + atr * ATR_MULTIPLIER
|
||||
zone_levels = _zone_representative_levels(sr_levels, entry)
|
||||
zone_levels = _zone_representative_levels(
|
||||
gate_levels,
|
||||
entry,
|
||||
strength_mode="sum",
|
||||
)
|
||||
targets = target_generator.generate_targets(direction, entry, stop, zone_levels, atr)
|
||||
if not targets:
|
||||
fallback_k = _atr_target_fallback_k()
|
||||
if fallback_k is None:
|
||||
continue
|
||||
# RESEARCH DIAGNOSTIC only (see _atr_target_fallback_k etc.)
|
||||
if _fallback_clear_air_only() and _has_structure_ahead(direction, entry, sr_levels):
|
||||
continue # structure exists ahead; the distance filters rejected it, not the famine
|
||||
continue
|
||||
targets = [_atr_fallback_target(direction, entry, stop, atr, fallback_k)]
|
||||
for t in targets:
|
||||
t["probability"] = probability_estimator.estimate_probability(
|
||||
@@ -254,7 +301,10 @@ def _window_setups(
|
||||
# Collapse duplicate floor-pinned lottery targets (parity with
|
||||
# enhance_trade_setup).
|
||||
targets = _prune_floor_pinned_targets(targets)
|
||||
primary = _select_primary_target(targets)
|
||||
primary = _select_primary_target(
|
||||
targets,
|
||||
min_rr=1.5,
|
||||
)
|
||||
if primary is None:
|
||||
continue
|
||||
# Flag the primary so qualification's EV uses the primary target's
|
||||
@@ -309,6 +359,18 @@ def _window_setups(
|
||||
"meets_core": meets_core,
|
||||
"action": action,
|
||||
"risk_level": risk_level,
|
||||
"target_model": target_model,
|
||||
"primary_sources": list(primary.get("sr_sources") or []),
|
||||
"primary_strength": float(primary.get("sr_strength", 0.0)),
|
||||
"primary_rejection_count": int(
|
||||
primary.get("sr_rejection_count", 0) or 0
|
||||
),
|
||||
"primary_last_rejection_age": primary.get("sr_last_rejection_age"),
|
||||
"primary_distance_atr": float(
|
||||
primary.get("distance_atr_multiple", 0.0)
|
||||
),
|
||||
"raw_level_count": len(sr_levels),
|
||||
"gate_level_count": len(gate_levels),
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -392,6 +454,7 @@ def _replay_ticker(
|
||||
config: dict,
|
||||
activation: dict,
|
||||
benchmark_closes: dict[date, float] | None = None,
|
||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||
) -> list[dict]:
|
||||
"""Walk one ticker's history weekly, building setups and their realized outcomes."""
|
||||
candidates: list[dict] = []
|
||||
@@ -410,7 +473,13 @@ def _replay_ticker(
|
||||
)
|
||||
vol_6m = _realized_vol_6m(closes, len(window) - 1)
|
||||
|
||||
for s in _window_setups(window, config, activation):
|
||||
setups = _window_setups(
|
||||
window,
|
||||
config,
|
||||
activation,
|
||||
target_model=target_model,
|
||||
)
|
||||
for s in setups:
|
||||
outcome, outcome_date = evaluate_setup_against_bars(
|
||||
s["direction"], s["stop"], s["target"], forward_bars, HORIZON
|
||||
)
|
||||
@@ -458,6 +527,14 @@ def _replay_ticker(
|
||||
# every candidate looks NEUTRAL and the ablation rows collapse.
|
||||
"action": s["action"],
|
||||
"risk_level": s["risk_level"],
|
||||
"target_model": s["target_model"],
|
||||
"primary_sources": s["primary_sources"],
|
||||
"primary_strength": s["primary_strength"],
|
||||
"primary_rejection_count": s["primary_rejection_count"],
|
||||
"primary_last_rejection_age": s["primary_last_rejection_age"],
|
||||
"primary_distance_atr": s["primary_distance_atr"],
|
||||
"raw_level_count": s["raw_level_count"],
|
||||
"gate_level_count": s["gate_level_count"],
|
||||
"outcome": outcome,
|
||||
"target_hit": target_hit,
|
||||
"realized_r": realized_r,
|
||||
@@ -518,6 +595,45 @@ def _robustness_stats(net_rs: list[float]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _target_model_diagnostics(candidates: list[dict], target_model: str) -> dict:
|
||||
"""Compact target-source diagnostics for the selected supported model."""
|
||||
source_counts: dict[str, int] = defaultdict(int)
|
||||
round_only = 0
|
||||
strengths: list[float] = []
|
||||
distances: list[float] = []
|
||||
rejections: list[int] = []
|
||||
raw_counts: list[int] = []
|
||||
gate_counts: list[int] = []
|
||||
for cand in candidates:
|
||||
sources = list(cand.get("primary_sources") or [])
|
||||
for source in sources:
|
||||
source_counts[str(source)] += 1
|
||||
if set(sources) == {"round_number"}:
|
||||
round_only += 1
|
||||
strengths.append(float(cand.get("primary_strength", 0.0)))
|
||||
distances.append(float(cand.get("primary_distance_atr", 0.0)))
|
||||
rejections.append(int(cand.get("primary_rejection_count", 0) or 0))
|
||||
raw_counts.append(int(cand.get("raw_level_count", 0) or 0))
|
||||
gate_counts.append(int(cand.get("gate_level_count", 0) or 0))
|
||||
|
||||
def avg(values: list[float] | list[int]) -> float | None:
|
||||
return round(sum(values) / len(values), 3) if values else None
|
||||
|
||||
return {
|
||||
"target_model": target_model,
|
||||
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
|
||||
"candidate_count": len(candidates),
|
||||
"primary_source_counts": dict(sorted(source_counts.items())),
|
||||
"primary_round_only": round_only,
|
||||
"primary_strength_100": sum(1 for value in strengths if value >= 100.0),
|
||||
"avg_primary_strength": avg(strengths),
|
||||
"avg_primary_distance_atr": avg(distances),
|
||||
"avg_primary_rejection_count": avg(rejections),
|
||||
"avg_raw_level_count": avg(raw_counts),
|
||||
"avg_gate_level_count": avg(gate_counts),
|
||||
}
|
||||
|
||||
|
||||
# The fixed take-profit and trailing-stop sweeps were retired 2026-07: swept
|
||||
# TPs never found an interior optimum (momentum's edge lives in the right tail)
|
||||
# and wide trails converged to the hold-to-horizon exit, so the time-exit sweep
|
||||
@@ -850,6 +966,7 @@ def _replay_and_signals(
|
||||
config: dict,
|
||||
activation: dict,
|
||||
benchmark_closes: dict[date, float] | None = None,
|
||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""The CPU-bound per-ticker work, as a top-level (picklable) function so it can
|
||||
run in a worker process. Takes primitive column arrays (cheap to pickle),
|
||||
@@ -862,7 +979,14 @@ def _replay_and_signals(
|
||||
for o, op, hi, lo, cl, vo in zip(date_ords, opens, highs, lows, closes, volumes)
|
||||
]
|
||||
return (
|
||||
_replay_ticker(symbol, bars, config, activation, benchmark_closes),
|
||||
_replay_ticker(
|
||||
symbol,
|
||||
bars,
|
||||
config,
|
||||
activation,
|
||||
benchmark_closes,
|
||||
target_model,
|
||||
),
|
||||
_signal_series(bars, benchmark_closes),
|
||||
)
|
||||
|
||||
@@ -1195,6 +1319,7 @@ def _simulate_portfolio(
|
||||
|
||||
entries_by_ord: dict[int, list[dict]] = defaultdict(list)
|
||||
start_ord = start_date.toordinal() if start_date is not None else None
|
||||
# Explicit simulator/holdout end dates are exclusive split boundaries.
|
||||
end_ord = end_date.toordinal() if end_date is not None else None
|
||||
for c in candidates:
|
||||
if not qualified_fn(c) or c.get("direction") != "long":
|
||||
@@ -1910,6 +2035,10 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _portfolio_monitor_strategies() -> tuple[dict, ...]:
|
||||
return PORTFOLIO_MONITOR_STRATEGIES
|
||||
|
||||
|
||||
def _entry_variant_config(variant: str) -> dict | None:
|
||||
return next((cfg for cfg in STRATEGY_VARIANTS if cfg["variant"] == variant), None)
|
||||
|
||||
@@ -2196,16 +2325,19 @@ def _portfolio_monitor(
|
||||
) -> dict:
|
||||
latest_ord = max((max(cols[0]) for cols in prices.values() if cols[0]), default=None)
|
||||
rows: list[dict] = []
|
||||
for strategy in PORTFOLIO_MONITOR_STRATEGIES:
|
||||
strategies = _portfolio_monitor_strategies()
|
||||
for strategy in strategies:
|
||||
entry_cfg = _entry_variant_config(str(strategy["entry_variant"]))
|
||||
if entry_cfg is None:
|
||||
continue
|
||||
ranking_key = str(entry_cfg.get("ranking_key") or entry_cfg["percentile_key"])
|
||||
# The production row must replay the LIVE configuration: the runtime
|
||||
# qualification flag (Admin activation settings) instead of the frozen
|
||||
# research-variant gate, and the Admin exit policy instead of the
|
||||
# hardcoded 3x-trail/30d defaults. Research rows stay frozen so they
|
||||
# remain comparable across runs.
|
||||
ranking_key = str(
|
||||
strategy.get("ranking_key")
|
||||
or entry_cfg.get("ranking_key")
|
||||
or entry_cfg["percentile_key"]
|
||||
)
|
||||
# Live-config rows replay the runtime qualification flag and Admin exit
|
||||
# policy. The overlay opts into this deliberately so only ordering
|
||||
# changes relative to the production row.
|
||||
use_live = bool(strategy.get("use_live_config"))
|
||||
exit_policy = str(strategy["exit_policy"])
|
||||
row_hold_days = hold_days
|
||||
@@ -2246,6 +2378,7 @@ def _portfolio_monitor(
|
||||
"description": strategy["description"],
|
||||
"is_production": bool(strategy.get("is_production")),
|
||||
"entry_variant": strategy["entry_variant"],
|
||||
"ranking_key": ranking_key,
|
||||
"exit_policy": exit_policy,
|
||||
"live_exit_mode": live_exit_mode,
|
||||
"lookback": lookback["lookback"],
|
||||
@@ -2261,7 +2394,7 @@ def _portfolio_monitor(
|
||||
"description": s["description"],
|
||||
"is_production": bool(s.get("is_production")),
|
||||
}
|
||||
for s in PORTFOLIO_MONITOR_STRATEGIES
|
||||
for s in strategies
|
||||
],
|
||||
"lookbacks": [
|
||||
{"lookback": lb["lookback"], "label": lb["label"]}
|
||||
@@ -2270,7 +2403,9 @@ def _portfolio_monitor(
|
||||
"runs": rows,
|
||||
"note": (
|
||||
"Portfolio monitor runs supported named strategies across cached lookbacks. "
|
||||
"Local snapshot backtests remain the research surface for broad variant sweeps."
|
||||
"The structural overlay appears only in its explicit research arm and changes "
|
||||
"ordering, not production qualification. Local snapshot backtests remain the "
|
||||
"research surface for broad variant sweeps."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -2327,7 +2462,7 @@ def _sharpe_key(row: dict) -> float:
|
||||
|
||||
|
||||
def _build_research_recommendation(report: dict) -> dict:
|
||||
"""Advisory rules for the remaining research variants after residual promotion."""
|
||||
"""Build advisory notes from any strategy variants present in the report."""
|
||||
variants = {
|
||||
v.get("variant"): v
|
||||
for v in (report.get("strategy_variants") or {}).get("variants", [])
|
||||
@@ -2650,8 +2785,11 @@ def _build_recommendation(report: dict) -> dict:
|
||||
async def run_backtest(
|
||||
db: AsyncSession,
|
||||
progress_cb: Callable[[int, int, str], None] | None = None,
|
||||
*,
|
||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||
) -> dict:
|
||||
"""Replay every ticker and aggregate the Phase-1 reports for the current config."""
|
||||
target_model = validate_backtest_target_model(target_model)
|
||||
config = await get_recommendation_config(db)
|
||||
activation = await get_activation_config(db)
|
||||
|
||||
@@ -2716,6 +2854,7 @@ async def run_backtest(
|
||||
futures.append(loop.run_in_executor(
|
||||
pool, _replay_and_signals, ticker.symbol, columns, config, activation,
|
||||
benchmark_closes,
|
||||
target_model,
|
||||
))
|
||||
for result in await asyncio.gather(*futures, return_exceptions=True):
|
||||
if isinstance(result, Exception):
|
||||
@@ -2737,6 +2876,7 @@ async def run_backtest(
|
||||
_merge(await asyncio.to_thread(
|
||||
_replay_and_signals, ticker.symbol, columns, config, activation,
|
||||
benchmark_closes,
|
||||
target_model,
|
||||
))
|
||||
except Exception:
|
||||
logger.exception("Backtest replay failed for %s", ticker.symbol)
|
||||
@@ -2851,6 +2991,9 @@ async def run_backtest(
|
||||
"horizon_days": HORIZON,
|
||||
"min_lookback": MIN_LOOKBACK,
|
||||
"cost_per_side_pct": round(COST_PER_SIDE * 100, 3),
|
||||
"target_model": target_model,
|
||||
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
|
||||
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
|
||||
},
|
||||
"activation": activation,
|
||||
"overall_qualified": _bucket_stats(qualified),
|
||||
@@ -2914,6 +3057,10 @@ async def run_backtest(
|
||||
"portfolio_monitor": portfolio_monitor_report,
|
||||
"holdout": holdout_report,
|
||||
"min_rr_sweep": min_rr_sweep_report,
|
||||
"target_model_diagnostics": _target_model_diagnostics(
|
||||
candidates,
|
||||
target_model,
|
||||
),
|
||||
"signal_eval": _signal_evaluation(collected),
|
||||
"signal_eval_note": (
|
||||
"Cross-sectional rank-IC of price-only signals vs the forward "
|
||||
@@ -2941,9 +3088,11 @@ async def run_backtest(
|
||||
async def run_and_store(
|
||||
db: AsyncSession,
|
||||
progress_cb: Callable[[int, int, str], None] | None = None,
|
||||
*,
|
||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||
) -> dict:
|
||||
"""Run the backtest and cache the report in a SystemSetting. Job entrypoint."""
|
||||
report = await run_backtest(db, progress_cb)
|
||||
report = await run_backtest(db, progress_cb, target_model=target_model)
|
||||
await update_setting(db, KEY_REPORT, json.dumps(report))
|
||||
return report
|
||||
|
||||
|
||||
@@ -256,6 +256,12 @@ def compute_volume_profile(
|
||||
) -> dict[str, Any]:
|
||||
"""Compute Volume Profile: POC, Value Area, HVN, LVN.
|
||||
|
||||
Volume is assigned to the bin containing each bar's **close** (no
|
||||
double-counting across the high–low span).
|
||||
|
||||
HVN = local peaks in the volume histogram (not every bin above mean).
|
||||
LVN = local valleys in the histogram.
|
||||
|
||||
Score: proximity of latest close to POC (closer = higher).
|
||||
"""
|
||||
n = len(closes)
|
||||
@@ -275,13 +281,17 @@ def compute_volume_profile(
|
||||
price_min + (i + 0.5) * bin_width for i in range(num_bins)
|
||||
]
|
||||
|
||||
# Assign each bar's full volume to the close's bin only.
|
||||
for i in range(n):
|
||||
# Distribute volume across bins the bar spans
|
||||
bar_low, bar_high = lows[i], highs[i]
|
||||
for b in range(num_bins):
|
||||
bl = price_min + b * bin_width
|
||||
bh = bl + bin_width
|
||||
if bar_high >= bl and bar_low <= bh:
|
||||
c = closes[i]
|
||||
if c <= price_min:
|
||||
b = 0
|
||||
elif c >= price_max:
|
||||
b = num_bins - 1
|
||||
else:
|
||||
b = int((c - price_min) / bin_width)
|
||||
if b >= num_bins:
|
||||
b = num_bins - 1
|
||||
bins[b] += volumes[i]
|
||||
|
||||
total_vol = sum(bins)
|
||||
@@ -304,10 +314,17 @@ def compute_volume_profile(
|
||||
va_low = round(price_min + min(va_indices) * bin_width, 4)
|
||||
va_high = round(price_min + (max(va_indices) + 1) * bin_width, 4)
|
||||
|
||||
# HVN / LVN: bins above/below average volume
|
||||
# HVN / LVN: local peaks / valleys (require above/below mean to skip noise)
|
||||
avg_vol = total_vol / num_bins
|
||||
hvn = [round(bin_prices[i], 4) for i in range(num_bins) if bins[i] > avg_vol]
|
||||
lvn = [round(bin_prices[i], 4) for i in range(num_bins) if bins[i] < avg_vol]
|
||||
hvn: list[float] = []
|
||||
lvn: list[float] = []
|
||||
for i in range(num_bins):
|
||||
left = bins[i - 1] if i > 0 else bins[i]
|
||||
right = bins[i + 1] if i < num_bins - 1 else bins[i]
|
||||
if bins[i] > left and bins[i] > right and bins[i] > avg_vol:
|
||||
hvn.append(round(bin_prices[i], 4))
|
||||
elif bins[i] < left and bins[i] < right and bins[i] < avg_vol:
|
||||
lvn.append(round(bin_prices[i], 4))
|
||||
|
||||
# Score: proximity of latest close to POC
|
||||
latest = closes[-1]
|
||||
@@ -333,10 +350,14 @@ def compute_pivot_points(
|
||||
lows: list[float],
|
||||
closes: list[float],
|
||||
window: int = 2,
|
||||
min_prominence: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Detect swing highs/lows as pivot points.
|
||||
|
||||
A swing high at index *i* means highs[i] >= all highs in [i-window, i+window].
|
||||
When *min_prominence* is set, only keep swings whose window range
|
||||
(max high − min low) is at least that amount — filters tiny noise fractals.
|
||||
|
||||
Score: based on number of pivots near current price.
|
||||
"""
|
||||
n = len(closes)
|
||||
@@ -349,11 +370,23 @@ def compute_pivot_points(
|
||||
swing_lows: list[float] = []
|
||||
|
||||
for i in range(window, n - window):
|
||||
lo = i - window
|
||||
hi = i + window + 1
|
||||
# Swing high
|
||||
if all(highs[i] >= highs[j] for j in range(i - window, i + window + 1)):
|
||||
if all(highs[i] >= highs[j] for j in range(lo, hi)):
|
||||
if min_prominence is None or min_prominence <= 0:
|
||||
swing_highs.append(round(highs[i], 4))
|
||||
else:
|
||||
depth = highs[i] - min(lows[j] for j in range(lo, hi))
|
||||
if depth >= min_prominence:
|
||||
swing_highs.append(round(highs[i], 4))
|
||||
# Swing low
|
||||
if all(lows[i] <= lows[j] for j in range(i - window, i + window + 1)):
|
||||
if all(lows[i] <= lows[j] for j in range(lo, hi)):
|
||||
if min_prominence is None or min_prominence <= 0:
|
||||
swing_lows.append(round(lows[i], 4))
|
||||
else:
|
||||
depth = max(highs[j] for j in range(lo, hi)) - lows[i]
|
||||
if depth >= min_prominence:
|
||||
swing_lows.append(round(lows[i], 4))
|
||||
|
||||
all_pivots = swing_highs + swing_lows
|
||||
|
||||
@@ -56,7 +56,12 @@ def _clamp(value: float, low: float, high: float) -> float:
|
||||
return max(low, min(high, value))
|
||||
|
||||
|
||||
def _zone_representative_levels(sr_levels: list[SRLevel], entry_price: float) -> list[Any]:
|
||||
def _zone_representative_levels(
|
||||
sr_levels: list[SRLevel],
|
||||
entry_price: float,
|
||||
*,
|
||||
strength_mode: str = "sum",
|
||||
) -> list[Any]:
|
||||
"""Collapse near-duplicate S/R levels into one representative per zone.
|
||||
|
||||
Targets are generated from these representatives, so a clustered wall (e.g.
|
||||
@@ -71,11 +76,25 @@ def _zone_representative_levels(sr_levels: list[SRLevel], entry_price: float) ->
|
||||
if not sr_levels or entry_price <= 0:
|
||||
return list(sr_levels)
|
||||
|
||||
level_dicts = [
|
||||
{"price_level": float(lv.price_level), "strength": int(lv.strength), "type": lv.type}
|
||||
for lv in sr_levels
|
||||
]
|
||||
zones = cluster_sr_zones(level_dicts, entry_price, tolerance=_SR_ZONE_TOLERANCE)
|
||||
level_dicts = []
|
||||
for lv in sr_levels:
|
||||
level_dicts.append({
|
||||
"price_level": float(lv.price_level),
|
||||
"strength": int(lv.strength),
|
||||
"type": lv.type,
|
||||
"detection_method": getattr(lv, "detection_method", "unknown"),
|
||||
"sources": list(getattr(lv, "sources", None) or [
|
||||
getattr(lv, "detection_method", "unknown")
|
||||
]),
|
||||
"rejection_count": int(getattr(lv, "rejection_count", 0) or 0),
|
||||
"last_rejection_age": getattr(lv, "last_rejection_age", None),
|
||||
})
|
||||
zones = cluster_sr_zones(
|
||||
level_dicts,
|
||||
entry_price,
|
||||
tolerance=_SR_ZONE_TOLERANCE,
|
||||
strength_mode=strength_mode,
|
||||
)
|
||||
|
||||
reps: list[Any] = []
|
||||
for zone in zones:
|
||||
@@ -94,6 +113,10 @@ def _zone_representative_levels(sr_levels: list[SRLevel], entry_price: float) ->
|
||||
price_level=float(near_edge),
|
||||
type=zone["type"],
|
||||
strength=int(zone["strength"]),
|
||||
detection_method=getattr(strongest, "detection_method", "unknown"),
|
||||
sources=list(zone.get("sources") or []),
|
||||
rejection_count=int(zone.get("rejection_count", 0)),
|
||||
last_rejection_age=zone.get("last_rejection_age"),
|
||||
)
|
||||
)
|
||||
return reps
|
||||
@@ -312,6 +335,15 @@ class TargetGenerator:
|
||||
"classification": "Moderate",
|
||||
"sr_level_id": int(level.id),
|
||||
"sr_strength": float(level.strength),
|
||||
"sr_sources": list(getattr(level, "sources", None) or [
|
||||
getattr(level, "detection_method", "unknown")
|
||||
]),
|
||||
"sr_rejection_count": int(
|
||||
getattr(level, "rejection_count", 0) or 0
|
||||
),
|
||||
"sr_last_rejection_age": getattr(
|
||||
level, "last_rejection_age", None
|
||||
),
|
||||
"quality": float(quality),
|
||||
}
|
||||
)
|
||||
@@ -581,7 +613,6 @@ def build_recommendation_snapshot(
|
||||
}
|
||||
|
||||
|
||||
PRIMARY_TARGET_MIN_RR = 1.5
|
||||
# Below this the target is a lottery ticket. Shared with the activation gate
|
||||
# (qualification.MIN_TARGET_PROBABILITY) so the primary selection and the gate
|
||||
# agree on what counts as a probability-backed target.
|
||||
@@ -610,7 +641,7 @@ def _prune_floor_pinned_targets(targets: list[dict]) -> list[dict]:
|
||||
|
||||
def _select_primary_target(
|
||||
targets: list[dict],
|
||||
min_rr: float = PRIMARY_TARGET_MIN_RR,
|
||||
min_rr: float,
|
||||
min_probability: float = PRIMARY_TARGET_MIN_PROBABILITY,
|
||||
) -> dict | None:
|
||||
"""Primary = the most LIKELY target that still offers real asymmetry.
|
||||
@@ -651,6 +682,7 @@ async def enhance_trade_setup(
|
||||
sr_levels: list[SRLevel],
|
||||
sentiment_classification: str | None,
|
||||
atr_value: float,
|
||||
primary_min_rr: float,
|
||||
available_directions: set[str] | None = None,
|
||||
) -> TradeSetup:
|
||||
config = await get_recommendation_config(db)
|
||||
@@ -698,7 +730,7 @@ async def enhance_trade_setup(
|
||||
# _select_primary_target), not the old quality-score pick that ignored
|
||||
# probability. Sync the setup's headline target/rr_ratio so the chart, gate
|
||||
# and outcome eval all agree with the table's starred row.
|
||||
primary = _select_primary_target(targets)
|
||||
primary = _select_primary_target(targets, min_rr=primary_min_rr)
|
||||
if primary is not None:
|
||||
for target in targets:
|
||||
target["is_primary"] = target is primary
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""R:R Scanner service.
|
||||
"""R:R scanner service.
|
||||
|
||||
Scans tracked tickers for asymmetric risk-reward trade setups.
|
||||
Long: target = nearest SR above, stop = entry - ATR × multiplier.
|
||||
Short: target = nearest SR below, stop = entry + ATR × multiplier.
|
||||
Filters by configurable R:R threshold (default 1.5).
|
||||
Scans tracked tickers for asymmetric risk-reward trade setups. Candidate
|
||||
targets come from a transient, volume-free proposal ladder; persisted S/R is
|
||||
reserved for human-facing charts and alerts. Stops remain ATR-based.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,6 +11,8 @@ import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -23,11 +24,11 @@ from app.models.paper_trade import PaperTrade
|
||||
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.sr_service import detect_gate_target_ladder
|
||||
from app.services.recommendation_service import (
|
||||
_risk_level_from_conflicts,
|
||||
build_recommendation_snapshot,
|
||||
@@ -38,6 +39,7 @@ from app.services.recommendation_service import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1"
|
||||
PRIMARY_TARGET_MIN_RR = 1.5
|
||||
|
||||
# A setup counts as live only while the daily scan keeps re-emitting it. The
|
||||
# scan runs every day (07:00 UTC cron), so anything older than this was NOT
|
||||
@@ -49,6 +51,28 @@ STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1"
|
||||
LIVE_SETUP_MAX_AGE_DAYS = 3
|
||||
|
||||
|
||||
def _materialize_gate_target_levels(
|
||||
highs: list[float],
|
||||
lows: list[float],
|
||||
closes: list[float],
|
||||
) -> list[Any]:
|
||||
"""Create transient level objects for target generation, never persistence."""
|
||||
detected = detect_gate_target_ladder(highs, lows, closes)
|
||||
return [
|
||||
SimpleNamespace(
|
||||
id=-(index + 1),
|
||||
price_level=float(level["price_level"]),
|
||||
type=str(level["type"]),
|
||||
strength=int(level["strength"]),
|
||||
detection_method=str(level.get("detection_method", "range_grid")),
|
||||
sources=list(level.get("sources") or ["range_grid"]),
|
||||
rejection_count=int(level.get("rejection_count", 0) or 0),
|
||||
last_rejection_age=level.get("last_rejection_age"),
|
||||
)
|
||||
for index, level in enumerate(detected)
|
||||
]
|
||||
|
||||
|
||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||
normalised = symbol.strip().upper()
|
||||
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
|
||||
@@ -412,15 +436,27 @@ async def scan_ticker(
|
||||
momentum_percentile: float | None = None,
|
||||
strategy_rank: float | None = None,
|
||||
volatility_percentile: float | None = None,
|
||||
primary_min_rr: float | None = None,
|
||||
gate_levels_override: list[Any] | None = None,
|
||||
) -> list[TradeSetup]:
|
||||
"""Scan a single ticker for trade setups meeting the R:R threshold.
|
||||
|
||||
``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. ``strategy_rank`` is the
|
||||
production ordering score used for top-pick ranking."""
|
||||
production ordering score used for top-pick ranking.
|
||||
|
||||
``primary_min_rr`` controls target selection only. Its 1.5 default is
|
||||
intentionally independent of the later activation floor (2.0 in the live
|
||||
Admin configuration). ``gate_levels_override`` is dependency injection for
|
||||
deterministic scanner tests; production builds the transient ladder from
|
||||
the ticker's OHLCV window.
|
||||
"""
|
||||
ticker = await _get_ticker(db, symbol)
|
||||
|
||||
if primary_min_rr is None:
|
||||
primary_min_rr = PRIMARY_TARGET_MIN_RR
|
||||
|
||||
records = await query_ohlcv(db, symbol)
|
||||
if not records or len(records) < 15:
|
||||
logger.info(
|
||||
@@ -443,21 +479,22 @@ async def scan_ticker(
|
||||
logger.info("Skipping %s: ATR is zero or negative", symbol)
|
||||
return []
|
||||
|
||||
sr_result = await db.execute(
|
||||
select(SRLevel).where(SRLevel.ticker_id == ticker.id)
|
||||
gate_levels = (
|
||||
list(gate_levels_override)
|
||||
if gate_levels_override is not None
|
||||
else _materialize_gate_target_levels(highs, lows, closes)
|
||||
)
|
||||
sr_levels = list(sr_result.scalars().all())
|
||||
|
||||
if not sr_levels:
|
||||
logger.info("Skipping %s: no SR levels available", symbol)
|
||||
if not gate_levels:
|
||||
logger.info("Skipping %s: no gate target levels available", symbol)
|
||||
return []
|
||||
|
||||
levels_above = sorted(
|
||||
[lv for lv in sr_levels if lv.price_level > entry_price],
|
||||
[lv for lv in gate_levels if lv.price_level > entry_price],
|
||||
key=lambda lv: lv.price_level,
|
||||
)
|
||||
levels_below = sorted(
|
||||
[lv for lv in sr_levels if lv.price_level < entry_price],
|
||||
[lv for lv in gate_levels if lv.price_level < entry_price],
|
||||
key=lambda lv: lv.price_level,
|
||||
reverse=True,
|
||||
)
|
||||
@@ -555,9 +592,10 @@ async def scan_ticker(
|
||||
ticker=ticker,
|
||||
setup=setup,
|
||||
dimension_scores=dimension_scores,
|
||||
sr_levels=sr_levels,
|
||||
sr_levels=gate_levels,
|
||||
sentiment_classification=sentiment_classification,
|
||||
atr_value=atr_value,
|
||||
primary_min_rr=primary_min_rr,
|
||||
available_directions=available_directions,
|
||||
)
|
||||
enhanced_setups.append(enhanced)
|
||||
@@ -641,6 +679,7 @@ async def scan_all_tickers(
|
||||
momentum_percentile=(ranks.get(symbol) or {}).get("momentum_percentile"),
|
||||
strategy_rank=(ranks.get(symbol) or {}).get("strategy_rank"),
|
||||
volatility_percentile=(ranks.get(symbol) or {}).get("volatility_percentile"),
|
||||
primary_min_rr=PRIMARY_TARGET_MIN_RR,
|
||||
)
|
||||
all_setups.extend(setups)
|
||||
except Exception:
|
||||
|
||||
+551
-76
@@ -1,12 +1,15 @@
|
||||
"""S/R Detector service.
|
||||
|
||||
Detects support/resistance levels from Volume Profile (HVN/LVN) and
|
||||
Pivot Points (swing highs/lows), assigns strength scores, merges nearby
|
||||
levels, tags as support/resistance, and persists to DB.
|
||||
Detects support/resistance levels from Volume Profile (POC/VA/HVN peaks)
|
||||
and Pivot Points (prominent swing highs/lows), plus light psychological
|
||||
round numbers. Scores by rejection-weighted recent touches, merges nearby
|
||||
levels with ATR-adaptive tolerance, tags support/resistance, caps count,
|
||||
and persists to DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
@@ -17,12 +20,43 @@ from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
from app.services.indicator_service import (
|
||||
_extract_ohlcv,
|
||||
compute_atr,
|
||||
compute_pivot_points,
|
||||
compute_volume_profile,
|
||||
)
|
||||
from app.services.price_service import query_ohlcv
|
||||
|
||||
DEFAULT_TOLERANCE = 0.005 # 0.5%
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tunable constants (keep detection pure / deterministic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_TOLERANCE = 0.005 # fallback when ATR unavailable; also API legacy default
|
||||
|
||||
VP_LOOKBACK = 252
|
||||
TOUCH_LOOKBACK = 252
|
||||
PIVOT_LOOKBACK = 504
|
||||
PIVOT_PROMINENCE_ATR = 0.75
|
||||
PIVOT_PROMINENCE_PCT = 0.006
|
||||
|
||||
MERGE_TOL_ATR_MULT = 0.35
|
||||
MERGE_TOL_MIN = 0.004 # 0.4%
|
||||
MERGE_TOL_MAX = 0.015 # 1.5%
|
||||
|
||||
MAX_LEVELS = 16
|
||||
STRENGTH_HALF_LIFE = 60 # bars
|
||||
# Raw respect score is soft-mapped to 0–100 (see _raw_to_strength).
|
||||
STRENGTH_SCALE = 8.0
|
||||
STRENGTH_SOFT_K = 35.0 # higher → slower approach to 100
|
||||
|
||||
ROUND_NUMBER_RANGE = 0.15 # ±15% of spot
|
||||
ROUND_NUMBER_MAX = 8
|
||||
|
||||
# Base strength seed before touch scoring (method priors)
|
||||
_METHOD_BASE_STRENGTH = {
|
||||
"volume_profile": 12,
|
||||
"pivot_point": 8,
|
||||
"round_number": 4,
|
||||
}
|
||||
|
||||
|
||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||
@@ -35,36 +69,235 @@ async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||
return ticker
|
||||
|
||||
|
||||
def _count_price_touches(
|
||||
def _slice_tail(
|
||||
highs: list[float],
|
||||
lows: list[float],
|
||||
closes: list[float],
|
||||
volumes: list[int],
|
||||
lookback: int,
|
||||
) -> tuple[list[float], list[float], list[float], list[int]]:
|
||||
"""Return the last *lookback* bars (or all if shorter)."""
|
||||
n = len(closes)
|
||||
if lookback <= 0 or n <= lookback:
|
||||
return highs, lows, closes, volumes
|
||||
start = n - lookback
|
||||
return highs[start:], lows[start:], closes[start:], volumes[start:]
|
||||
|
||||
|
||||
def _atr_pct(
|
||||
highs: list[float],
|
||||
lows: list[float],
|
||||
closes: list[float],
|
||||
) -> float | None:
|
||||
"""ATR as a fraction of last close, or None if insufficient data."""
|
||||
try:
|
||||
result = compute_atr(highs, lows, closes)
|
||||
except ValidationError:
|
||||
return None
|
||||
atr = result["atr"]
|
||||
last = closes[-1]
|
||||
if last == 0:
|
||||
return None
|
||||
return atr / last
|
||||
|
||||
|
||||
def _merge_tolerance(
|
||||
highs: list[float],
|
||||
lows: list[float],
|
||||
closes: list[float],
|
||||
tolerance: float | None,
|
||||
) -> float:
|
||||
"""Resolve merge tolerance: explicit value or ATR-adaptive clamp."""
|
||||
if tolerance is not None:
|
||||
return tolerance
|
||||
atr_frac = _atr_pct(highs, lows, closes)
|
||||
if atr_frac is None:
|
||||
return DEFAULT_TOLERANCE
|
||||
return max(MERGE_TOL_MIN, min(MERGE_TOL_MAX, MERGE_TOL_ATR_MULT * atr_frac))
|
||||
|
||||
|
||||
def _bar_respect_weight(
|
||||
price_level: float,
|
||||
high: float,
|
||||
low: float,
|
||||
close: float,
|
||||
prev_close: float | None,
|
||||
tolerance: float,
|
||||
) -> float:
|
||||
"""Weight for how much a bar *respects* a level (not mere occupancy).
|
||||
|
||||
Only bars whose high/low **probes near the level** and closes away from
|
||||
that extreme count as rejections. Full-range pass-throughs score near zero.
|
||||
"""
|
||||
tol = price_level * tolerance if price_level != 0 else tolerance
|
||||
if tol <= 0:
|
||||
tol = abs(price_level) * DEFAULT_TOLERANCE if price_level else DEFAULT_TOLERANCE
|
||||
# Tight probe band: ~0.4% of price (capped), not 2× merge tolerance
|
||||
band = min(max(abs(price_level) * 0.004, tol * 0.35), abs(price_level) * 0.008)
|
||||
if band <= 0:
|
||||
band = abs(price_level) * 0.004 if price_level else 0.01
|
||||
|
||||
if high + band < price_level or low - band > price_level:
|
||||
return 0.0
|
||||
|
||||
bar_range = high - low
|
||||
# Support test: low probes near level, close recovers above
|
||||
support_test = abs(low - price_level) <= band and close > price_level
|
||||
if support_test and bar_range > 0:
|
||||
support_test = (close - low) >= 0.25 * bar_range
|
||||
# Resistance test: high probes near level, close rejects below
|
||||
resist_test = abs(high - price_level) <= band and close < price_level
|
||||
if resist_test and bar_range > 0:
|
||||
resist_test = (high - close) >= 0.25 * bar_range
|
||||
|
||||
if support_test or resist_test:
|
||||
return 1.0
|
||||
|
||||
# Clear directional pass-through — barely counts
|
||||
if (
|
||||
prev_close is not None
|
||||
and (prev_close - price_level) * (close - price_level) < 0
|
||||
and low < price_level - tol
|
||||
and high > price_level + tol
|
||||
):
|
||||
return 0.1
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
def _raw_to_strength(raw: float) -> int:
|
||||
"""Map unbounded raw score to 0–100 with soft saturation (no hard pin)."""
|
||||
if raw <= 0:
|
||||
return 0
|
||||
# 1 - e^(-raw/k): raw=k → ~63, 2k → ~86, 3k → ~95
|
||||
return max(0, min(100, int(round(100.0 * (1.0 - math.exp(-raw / STRENGTH_SOFT_K))))))
|
||||
|
||||
|
||||
def _respect_evidence(
|
||||
price_level: float,
|
||||
highs: list[float],
|
||||
lows: list[float],
|
||||
closes: list[float],
|
||||
tolerance: float = DEFAULT_TOLERANCE,
|
||||
) -> int:
|
||||
"""Count how many bars touched/respected a price level within tolerance."""
|
||||
count = 0
|
||||
tol = price_level * tolerance if price_level != 0 else tolerance
|
||||
for i in range(len(closes)):
|
||||
# A bar "touches" the level if the level is within the bar's range
|
||||
# (within tolerance)
|
||||
if lows[i] - tol <= price_level <= highs[i] + tol:
|
||||
count += 1
|
||||
return count
|
||||
base: int = 0,
|
||||
half_life: float = STRENGTH_HALF_LIFE,
|
||||
lookback: int = TOUCH_LOOKBACK,
|
||||
cooldown: int = 3,
|
||||
) -> dict[str, float | int | None]:
|
||||
"""Return rejection evidence and its soft-mapped strength.
|
||||
|
||||
|
||||
def _strength_from_touches(touches: int, total_bars: int) -> int:
|
||||
"""Convert touch count to a 0-100 strength score.
|
||||
|
||||
More touches relative to total bars = higher strength.
|
||||
Cap at 100.
|
||||
*cooldown* bars after a full rejection are ignored so multi-day chop at a
|
||||
level counts as one test cluster, not N identical rejections.
|
||||
"""
|
||||
if total_bars == 0:
|
||||
return 0
|
||||
# Scale: each touch contributes proportionally, with a multiplier
|
||||
# so that a level touched ~20% of bars gets score ~100
|
||||
raw = (touches / total_bars) * 500.0
|
||||
return max(0, min(100, int(round(raw))))
|
||||
n = len(closes)
|
||||
if n == 0:
|
||||
return {
|
||||
"strength": _raw_to_strength(float(base)),
|
||||
"rejection_count": 0,
|
||||
"last_rejection_age": None,
|
||||
"weighted_respects": 0.0,
|
||||
}
|
||||
|
||||
start = max(0, n - lookback) if lookback > 0 else 0
|
||||
weighted = 0.0
|
||||
rejection_count = 0
|
||||
last_rejection_age: int | None = None
|
||||
next_ok = start
|
||||
for i in range(start, n):
|
||||
age = n - 1 - i
|
||||
decay = 0.5 ** (age / half_life) if half_life > 0 else 1.0
|
||||
prev = closes[i - 1] if i > 0 else None
|
||||
w = _bar_respect_weight(
|
||||
price_level, highs[i], lows[i], closes[i], prev, tolerance
|
||||
)
|
||||
if w >= 0.9:
|
||||
if i < next_ok:
|
||||
continue
|
||||
weighted += decay * w
|
||||
rejection_count += 1
|
||||
if last_rejection_age is None or age < last_rejection_age:
|
||||
last_rejection_age = age
|
||||
next_ok = i + max(cooldown, 1)
|
||||
elif w > 0:
|
||||
weighted += decay * w
|
||||
|
||||
raw = float(base) + weighted * STRENGTH_SCALE
|
||||
return {
|
||||
"strength": _raw_to_strength(raw),
|
||||
"rejection_count": rejection_count,
|
||||
"last_rejection_age": last_rejection_age,
|
||||
"weighted_respects": round(weighted, 6),
|
||||
}
|
||||
|
||||
|
||||
def _strength_from_respects(
|
||||
price_level: float,
|
||||
highs: list[float],
|
||||
lows: list[float],
|
||||
closes: list[float],
|
||||
tolerance: float = DEFAULT_TOLERANCE,
|
||||
base: int = 0,
|
||||
half_life: float = STRENGTH_HALF_LIFE,
|
||||
lookback: int = TOUCH_LOOKBACK,
|
||||
cooldown: int = 3,
|
||||
) -> int:
|
||||
"""Compatibility wrapper returning only the evidence-derived strength."""
|
||||
return int(_respect_evidence(
|
||||
price_level,
|
||||
highs,
|
||||
lows,
|
||||
closes,
|
||||
tolerance,
|
||||
base,
|
||||
half_life,
|
||||
lookback,
|
||||
cooldown,
|
||||
)["strength"])
|
||||
|
||||
|
||||
def _round_number_candidates(
|
||||
current_price: float,
|
||||
range_pct: float = ROUND_NUMBER_RANGE,
|
||||
max_count: int = ROUND_NUMBER_MAX,
|
||||
) -> list[float]:
|
||||
"""Psychological round levels near spot (cheap order-magnet candidates)."""
|
||||
if current_price <= 0:
|
||||
return []
|
||||
|
||||
if current_price < 5:
|
||||
steps = [0.5, 1.0]
|
||||
elif current_price < 20:
|
||||
steps = [1.0, 5.0]
|
||||
elif current_price < 100:
|
||||
steps = [5.0, 10.0, 25.0]
|
||||
elif current_price < 500:
|
||||
steps = [10.0, 25.0, 50.0, 100.0]
|
||||
else:
|
||||
steps = [25.0, 50.0, 100.0, 250.0]
|
||||
|
||||
lo = current_price * (1.0 - range_pct)
|
||||
hi = current_price * (1.0 + range_pct)
|
||||
found: set[float] = set()
|
||||
|
||||
for step in steps:
|
||||
if step <= 0:
|
||||
continue
|
||||
# Start at first multiple at or below lo
|
||||
k = math.floor(lo / step)
|
||||
while True:
|
||||
level = round(k * step, 4)
|
||||
if level > hi + step:
|
||||
break
|
||||
if lo <= level <= hi and level > 0:
|
||||
# Skip levels that are essentially current price
|
||||
if abs(level - current_price) / current_price > 0.001:
|
||||
found.add(level)
|
||||
k += 1
|
||||
if k > 1_000_000: # safety
|
||||
break
|
||||
|
||||
ordered = sorted(found, key=lambda p: abs(p - current_price))
|
||||
return ordered[:max_count]
|
||||
|
||||
|
||||
def _extract_candidate_levels(
|
||||
@@ -73,55 +306,187 @@ def _extract_candidate_levels(
|
||||
closes: list[float],
|
||||
volumes: list[int],
|
||||
) -> list[tuple[float, str]]:
|
||||
"""Extract candidate S/R levels from Volume Profile and Pivot Points.
|
||||
"""Extract candidate S/R levels from VP nodes, prominent pivots, rounds.
|
||||
|
||||
Returns list of (price_level, detection_method) tuples.
|
||||
"""
|
||||
candidates: list[tuple[float, str]] = []
|
||||
if not closes:
|
||||
return candidates
|
||||
|
||||
# Volume Profile: HVN and LVN as candidate levels
|
||||
current_price = closes[-1]
|
||||
|
||||
# --- Volume profile on recent window ---
|
||||
vp_h, vp_l, vp_c, vp_v = _slice_tail(
|
||||
highs, lows, closes, volumes, VP_LOOKBACK
|
||||
)
|
||||
try:
|
||||
vp = compute_volume_profile(highs, lows, closes, volumes)
|
||||
vp = compute_volume_profile(vp_h, vp_l, vp_c, vp_v)
|
||||
# Structural VP levels: POC, value-area edges, local HVN peaks.
|
||||
# LVN intentionally omitted (rejection voids ≠ support/resistance lines).
|
||||
for key in ("poc", "value_area_low", "value_area_high"):
|
||||
price = vp.get(key)
|
||||
if price is not None and price > 0:
|
||||
candidates.append((float(price), "volume_profile"))
|
||||
for price in vp.get("hvn", []):
|
||||
candidates.append((price, "volume_profile"))
|
||||
for price in vp.get("lvn", []):
|
||||
candidates.append((price, "volume_profile"))
|
||||
candidates.append((float(price), "volume_profile"))
|
||||
except ValidationError:
|
||||
pass # Not enough data for volume profile
|
||||
pass
|
||||
|
||||
# --- Prominent pivots on pivot lookback ---
|
||||
p_h, p_l, p_c, _ = _slice_tail(highs, lows, closes, volumes, PIVOT_LOOKBACK)
|
||||
atr_frac = _atr_pct(p_h, p_l, p_c)
|
||||
last = p_c[-1] if p_c else current_price
|
||||
if atr_frac is not None and last > 0:
|
||||
prominence = max(PIVOT_PROMINENCE_ATR * atr_frac * last, PIVOT_PROMINENCE_PCT * last)
|
||||
else:
|
||||
prominence = PIVOT_PROMINENCE_PCT * last if last > 0 else None
|
||||
|
||||
# Pivot Points: swing highs and lows
|
||||
try:
|
||||
pp = compute_pivot_points(highs, lows, closes)
|
||||
pp = compute_pivot_points(p_h, p_l, p_c, min_prominence=prominence)
|
||||
for price in pp.get("swing_highs", []):
|
||||
candidates.append((price, "pivot_point"))
|
||||
candidates.append((float(price), "pivot_point"))
|
||||
for price in pp.get("swing_lows", []):
|
||||
candidates.append((price, "pivot_point"))
|
||||
candidates.append((float(price), "pivot_point"))
|
||||
except ValidationError:
|
||||
pass # Not enough data for pivot points
|
||||
pass
|
||||
|
||||
# --- Psychological round numbers near spot ---
|
||||
for price in _round_number_candidates(current_price):
|
||||
candidates.append((price, "round_number"))
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _gate_target_range_centers(
|
||||
highs: list[float],
|
||||
lows: list[float],
|
||||
closes: list[float],
|
||||
num_bins: int = 20,
|
||||
) -> list[float]:
|
||||
"""Return the evenly spaced price proposals used by the production GTL."""
|
||||
if len(closes) < 20:
|
||||
raise ValidationError(
|
||||
f"Range grid requires at least 20 bars, got {len(closes)}"
|
||||
)
|
||||
price_min = min(lows)
|
||||
price_max = max(highs)
|
||||
if price_max == price_min:
|
||||
price_max = price_min + 1.0
|
||||
bin_width = (price_max - price_min) / num_bins
|
||||
return [
|
||||
round(price_min + (i + 0.5) * bin_width, 4)
|
||||
for i in range(num_bins)
|
||||
]
|
||||
|
||||
|
||||
def detect_gate_target_ladder(
|
||||
highs: list[float],
|
||||
lows: list[float],
|
||||
closes: list[float],
|
||||
tolerance: float = DEFAULT_TOLERANCE,
|
||||
) -> list[dict]:
|
||||
"""Build the scanner's internal, volume-free target proposal ladder.
|
||||
|
||||
This is intentionally not human-facing support/resistance. It builds the
|
||||
production gate's broad 20-bin range grid, adds unfiltered pivots, scores
|
||||
historical price traffic, and merges nearby proposals. The returned levels
|
||||
are transient and must not be persisted as chart S/R.
|
||||
"""
|
||||
if not closes:
|
||||
return []
|
||||
|
||||
candidates: list[tuple[float, str]] = []
|
||||
try:
|
||||
candidates.extend(
|
||||
(float(price), "range_grid")
|
||||
for price in _gate_target_range_centers(highs, lows, closes)
|
||||
)
|
||||
except ValidationError:
|
||||
pass
|
||||
try:
|
||||
pivots = compute_pivot_points(highs, lows, closes)
|
||||
candidates.extend(
|
||||
(float(price), "pivot_point")
|
||||
for price in pivots.get("swing_highs", []) + pivots.get("swing_lows", [])
|
||||
)
|
||||
except ValidationError:
|
||||
pass
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
total_bars = len(closes)
|
||||
raw: list[dict] = []
|
||||
for price, method in candidates:
|
||||
tol = price * tolerance if price != 0 else tolerance
|
||||
touches = sum(
|
||||
1 for low, high in zip(lows, highs, strict=False)
|
||||
if low - tol <= price <= high + tol
|
||||
)
|
||||
strength = max(0, min(100, int(round((touches / total_bars) * 500.0))))
|
||||
raw.append({
|
||||
"price_level": price,
|
||||
"strength": strength,
|
||||
"detection_method": method,
|
||||
"type": "",
|
||||
"sources": [method],
|
||||
"rejection_count": touches,
|
||||
"last_rejection_age": None,
|
||||
"weighted_respects": float(touches),
|
||||
})
|
||||
|
||||
merged: list[dict] = []
|
||||
for level in sorted(raw, key=lambda row: row["price_level"]):
|
||||
if not merged:
|
||||
merged.append(dict(level))
|
||||
continue
|
||||
last = merged[-1]
|
||||
ref = last["price_level"]
|
||||
tol = ref * tolerance if ref != 0 else tolerance
|
||||
if abs(level["price_level"] - ref) > tol:
|
||||
merged.append(dict(level))
|
||||
continue
|
||||
last["price_level"] = round(
|
||||
(last["price_level"] + level["price_level"]) / 2.0, 4
|
||||
)
|
||||
last["strength"] = min(100, last["strength"] + level["strength"])
|
||||
sources = set(last.get("sources") or [last["detection_method"]])
|
||||
sources |= set(level.get("sources") or [level["detection_method"]])
|
||||
last["sources"] = sorted(sources)
|
||||
last["detection_method"] = (
|
||||
next(iter(sources)) if len(sources) == 1 else "merged"
|
||||
)
|
||||
last["rejection_count"] = max(
|
||||
int(last.get("rejection_count", 0)),
|
||||
int(level.get("rejection_count", 0)),
|
||||
)
|
||||
|
||||
_tag_levels(merged, closes[-1])
|
||||
merged.sort(key=lambda row: row["strength"], reverse=True)
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_levels(
|
||||
levels: list[dict],
|
||||
tolerance: float = DEFAULT_TOLERANCE,
|
||||
) -> list[dict]:
|
||||
"""Merge levels within tolerance into consolidated levels.
|
||||
|
||||
Levels from different methods within tolerance are merged.
|
||||
Merged levels combine strength scores (capped at 100) and get
|
||||
detection_method = "merged".
|
||||
Strength combines via max + partial min (avoids instant saturation) with
|
||||
a confluence bonus when detection methods differ. Price is strength-weighted.
|
||||
"""
|
||||
if not levels:
|
||||
return []
|
||||
|
||||
# Sort by price
|
||||
sorted_levels = sorted(levels, key=lambda x: x["price_level"])
|
||||
merged: list[dict] = []
|
||||
|
||||
for level in sorted_levels:
|
||||
if not merged:
|
||||
merged.append(dict(level))
|
||||
entry = dict(level)
|
||||
sources = level.get("sources") or [level["detection_method"]]
|
||||
entry["sources"] = sorted(set(sources))
|
||||
merged.append(entry)
|
||||
continue
|
||||
|
||||
last = merged[-1]
|
||||
@@ -129,19 +494,52 @@ def _merge_levels(
|
||||
tol = ref_price * tolerance if ref_price != 0 else tolerance
|
||||
|
||||
if abs(level["price_level"] - ref_price) <= tol:
|
||||
# Merge: average price, combine strength, mark as merged
|
||||
combined_strength = min(100, last["strength"] + level["strength"])
|
||||
avg_price = (last["price_level"] + level["price_level"]) / 2.0
|
||||
method = (
|
||||
"merged"
|
||||
if last["detection_method"] != level["detection_method"]
|
||||
else last["detection_method"]
|
||||
)
|
||||
last["price_level"] = round(avg_price, 4)
|
||||
last["strength"] = combined_strength
|
||||
last["detection_method"] = method
|
||||
s1 = last["strength"]
|
||||
s2 = level["strength"]
|
||||
# Soft combine — avoid merge math pinning everything at 100
|
||||
combined = int(round(0.85 * max(s1, s2) + 0.15 * min(s1, s2)))
|
||||
sources = set(last.get("sources") or [last["detection_method"]])
|
||||
sources |= set(level.get("sources") or [level["detection_method"]])
|
||||
if len(sources) > 1:
|
||||
combined = min(100, combined + 5)
|
||||
else:
|
||||
merged.append(dict(level))
|
||||
combined = min(100, combined)
|
||||
|
||||
w1, w2 = max(s1, 1), max(s2, 1)
|
||||
avg_price = (last["price_level"] * w1 + level["price_level"] * w2) / (w1 + w2)
|
||||
|
||||
if len(sources) == 1:
|
||||
method = next(iter(sources))
|
||||
else:
|
||||
method = "merged"
|
||||
|
||||
last["price_level"] = round(avg_price, 4)
|
||||
last["strength"] = combined
|
||||
last["detection_method"] = method
|
||||
last["sources"] = sorted(sources)
|
||||
# Nearby candidates often describe the same price reaction, so do
|
||||
# not add their rejection counts and double-count one market event.
|
||||
last["rejection_count"] = max(
|
||||
int(last.get("rejection_count", 0)),
|
||||
int(level.get("rejection_count", 0)),
|
||||
)
|
||||
ages = [
|
||||
age for age in (
|
||||
last.get("last_rejection_age"),
|
||||
level.get("last_rejection_age"),
|
||||
)
|
||||
if age is not None
|
||||
]
|
||||
last["last_rejection_age"] = min(ages) if ages else None
|
||||
last["weighted_respects"] = max(
|
||||
float(last.get("weighted_respects", 0.0)),
|
||||
float(level.get("weighted_respects", 0.0)),
|
||||
)
|
||||
else:
|
||||
entry = dict(level)
|
||||
sources = level.get("sources") or [level["detection_method"]]
|
||||
entry["sources"] = sorted(set(sources))
|
||||
merged.append(entry)
|
||||
|
||||
return merged
|
||||
|
||||
@@ -159,14 +557,66 @@ def _tag_levels(
|
||||
return levels
|
||||
|
||||
|
||||
def _cap_levels(
|
||||
levels: list[dict],
|
||||
max_levels: int = MAX_LEVELS,
|
||||
) -> list[dict]:
|
||||
"""Keep up to *max_levels* levels, interleaving support/resistance by strength."""
|
||||
if max_levels <= 0 or len(levels) <= max_levels:
|
||||
return levels
|
||||
|
||||
support = sorted(
|
||||
[lvl for lvl in levels if lvl.get("type") == "support"],
|
||||
key=lambda x: x["strength"],
|
||||
reverse=True,
|
||||
)
|
||||
resistance = sorted(
|
||||
[lvl for lvl in levels if lvl.get("type") != "support"],
|
||||
key=lambda x: x["strength"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
selected: list[dict] = []
|
||||
si, ri = 0, 0
|
||||
pick_support = True
|
||||
while len(selected) < max_levels and (si < len(support) or ri < len(resistance)):
|
||||
if pick_support:
|
||||
if si < len(support):
|
||||
selected.append(support[si])
|
||||
si += 1
|
||||
elif ri < len(resistance):
|
||||
selected.append(resistance[ri])
|
||||
ri += 1
|
||||
else:
|
||||
if ri < len(resistance):
|
||||
selected.append(resistance[ri])
|
||||
ri += 1
|
||||
elif si < len(support):
|
||||
selected.append(support[si])
|
||||
si += 1
|
||||
pick_support = not pick_support
|
||||
|
||||
selected.sort(key=lambda x: x["strength"], reverse=True)
|
||||
return selected
|
||||
|
||||
|
||||
def detect_sr_levels(
|
||||
highs: list[float],
|
||||
lows: list[float],
|
||||
closes: list[float],
|
||||
volumes: list[int],
|
||||
tolerance: float = DEFAULT_TOLERANCE,
|
||||
tolerance: float | None = None,
|
||||
max_levels: int = MAX_LEVELS,
|
||||
) -> list[dict]:
|
||||
"""Detect, score, merge, and tag S/R levels from OHLCV data.
|
||||
"""Detect, score, merge, tag, and cap S/R levels from OHLCV data.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tolerance:
|
||||
Relative merge tolerance. ``None`` (default) uses ATR-adaptive
|
||||
tolerance clamped to [0.4%, 1.5%]. Pass an explicit fraction to override.
|
||||
max_levels:
|
||||
Hard cap after merge (balanced support/resistance). 0 = no cap.
|
||||
|
||||
Returns list of dicts with keys: price_level, type, strength,
|
||||
detection_method — sorted by strength descending.
|
||||
@@ -178,37 +628,42 @@ def detect_sr_levels(
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
total_bars = len(closes)
|
||||
current_price = closes[-1]
|
||||
merge_tol = _merge_tolerance(highs, lows, closes, tolerance)
|
||||
# Touch tolerance for strength: use merge tol (same price scale)
|
||||
touch_tol = merge_tol
|
||||
|
||||
# Build level dicts with strength scores
|
||||
# Score each candidate on recent rejection-weighted touches
|
||||
raw_levels: list[dict] = []
|
||||
for price, method in candidates:
|
||||
touches = _count_price_touches(price, highs, lows, closes, tolerance)
|
||||
strength = _strength_from_touches(touches, total_bars)
|
||||
base = _METHOD_BASE_STRENGTH.get(method, 0)
|
||||
evidence = _respect_evidence(
|
||||
price, highs, lows, closes, touch_tol, base=base
|
||||
)
|
||||
raw_levels.append({
|
||||
"price_level": price,
|
||||
"strength": strength,
|
||||
"strength": int(evidence["strength"]),
|
||||
"detection_method": method,
|
||||
"type": "", # will be tagged after merge
|
||||
"type": "",
|
||||
"sources": [method],
|
||||
"rejection_count": int(evidence["rejection_count"]),
|
||||
"last_rejection_age": evidence["last_rejection_age"],
|
||||
"weighted_respects": float(evidence["weighted_respects"]),
|
||||
})
|
||||
|
||||
# Merge nearby levels
|
||||
merged = _merge_levels(raw_levels, tolerance)
|
||||
|
||||
# Tag as support/resistance
|
||||
merged = _merge_levels(raw_levels, merge_tol)
|
||||
tagged = _tag_levels(merged, current_price)
|
||||
capped = _cap_levels(tagged, max_levels=max_levels)
|
||||
capped.sort(key=lambda x: x["strength"], reverse=True)
|
||||
return capped
|
||||
|
||||
# Sort by strength descending
|
||||
tagged.sort(key=lambda x: x["strength"], reverse=True)
|
||||
|
||||
return tagged
|
||||
|
||||
def cluster_sr_zones(
|
||||
levels: list[dict],
|
||||
current_price: float,
|
||||
tolerance: float = 0.02,
|
||||
max_zones: int | None = None,
|
||||
strength_mode: str = "sum",
|
||||
) -> list[dict]:
|
||||
"""Cluster nearby S/R levels into zones.
|
||||
|
||||
@@ -263,8 +718,26 @@ def cluster_sr_zones(
|
||||
low = min(prices)
|
||||
high = max(prices)
|
||||
midpoint = (low + high) / 2.0
|
||||
strength = min(100, sum(lvl["strength"] for lvl in cluster))
|
||||
if strength_mode == "soft":
|
||||
strongest = max(int(lvl["strength"]) for lvl in cluster)
|
||||
all_sources = {
|
||||
source
|
||||
for lvl in cluster
|
||||
for source in (lvl.get("sources") or [lvl.get("detection_method", "unknown")])
|
||||
}
|
||||
strength = min(100, strongest + (5 if len(all_sources) > 1 else 0))
|
||||
elif strength_mode == "sum":
|
||||
strength = min(100, sum(int(lvl["strength"]) for lvl in cluster))
|
||||
all_sources = {
|
||||
source
|
||||
for lvl in cluster
|
||||
for source in (lvl.get("sources") or [lvl.get("detection_method", "unknown")])
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unsupported S/R zone strength mode: {strength_mode}")
|
||||
level_count = len(cluster)
|
||||
rejection_count = max(int(lvl.get("rejection_count", 0)) for lvl in cluster)
|
||||
ages = [lvl.get("last_rejection_age") for lvl in cluster if lvl.get("last_rejection_age") is not None]
|
||||
|
||||
# 4. Tag zone type
|
||||
zone_type = "support" if midpoint < current_price else "resistance"
|
||||
@@ -276,6 +749,9 @@ def cluster_sr_zones(
|
||||
"strength": strength,
|
||||
"type": zone_type,
|
||||
"level_count": level_count,
|
||||
"sources": sorted(all_sources),
|
||||
"rejection_count": rejection_count,
|
||||
"last_rejection_age": min(ages) if ages else None,
|
||||
})
|
||||
|
||||
# 5. Split into support and resistance pools, each sorted by strength desc
|
||||
@@ -319,11 +795,10 @@ def cluster_sr_zones(
|
||||
return selected
|
||||
|
||||
|
||||
|
||||
async def recalculate_sr_levels(
|
||||
db: AsyncSession,
|
||||
symbol: str,
|
||||
tolerance: float = DEFAULT_TOLERANCE,
|
||||
tolerance: float | None = None,
|
||||
) -> list[SRLevel]:
|
||||
"""Recalculate S/R levels for a ticker and persist to DB.
|
||||
|
||||
@@ -380,7 +855,7 @@ async def recalculate_sr_levels(
|
||||
async def get_sr_levels(
|
||||
db: AsyncSession,
|
||||
symbol: str,
|
||||
tolerance: float = DEFAULT_TOLERANCE,
|
||||
tolerance: float | None = None,
|
||||
) -> list[SRLevel]:
|
||||
"""Get S/R levels for a ticker, recalculating on every request (MVP).
|
||||
|
||||
|
||||
+13
-9
@@ -9,7 +9,8 @@ was run and the data said no.** Detail lives in the linked docs and in
|
||||
cross-sectional momentum book** — buy the top quintile by beta-adjusted 12-1
|
||||
momentum, tilt toward higher volatility, hold ≤ 10 names, cut at 1.5× ATR, then
|
||||
trail at 3× ATR for up to 30 trading days. Everything else in the app (composite
|
||||
score, S/R levels, sentiment, fundamentals) is **display or screening**, not edge.
|
||||
score, Structural S/R, the Gate Target Ladder, sentiment, fundamentals) is
|
||||
**display or screening**, not edge.
|
||||
|
||||
---
|
||||
|
||||
@@ -22,6 +23,8 @@ score, S/R levels, sentiment, fundamentals) is **display or screening**, not edg
|
||||
| 1.5× ATR initial stop | Real exit | Cuts losers fast |
|
||||
| 3× ATR trailing stop, 30-day max hold | Real exit | Best Sharpe of every exit tested |
|
||||
| Max 10 concurrent positions, 1% risk per trade | Sizing | Cap never binds in practice |
|
||||
| Structural S/R | Human-facing product context | Clean, capped zones for charts and alerts; not read by the scanner |
|
||||
| Gate Target Ladder | Screening machinery | Volume-free transient proposals preserve the production candidate set exactly; never an exit |
|
||||
|
||||
---
|
||||
|
||||
@@ -29,7 +32,7 @@ score, S/R levels, sentiment, fundamentals) is **display or screening**, not edg
|
||||
|
||||
| # | Experiment | Result | Decision | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **S/R target as a take-profit** (exit at the target, with or without the trail) | Sharpe **2.04 → 1.47**, CAGR halved (50.4% → 28.9%). Win rate *rose* (37.5% → 40.0%) — the tell: it truncates the right tail | **Rejected.** The target must never become an exit | [sr-levels-and-exits.md](sr-levels-and-exits.md) · `backtest-20260712-sr-target-exit.json` |
|
||||
| 1 | **Gate target as a take-profit** (exit at the target, with or without the trail) | Sharpe **2.04 → 1.47**, CAGR halved (50.4% → 28.9%). Win rate *rose* (37.5% → 40.0%) — the tell: it truncates the right tail | **Rejected.** The target must never become an exit | [sr-levels-and-exits.md](sr-levels-and-exits.md) · `backtest-20260712-sr-target-exit.json` |
|
||||
| 2 | **Clear-air fallback** — synthesize a 3× ATR target so 52-week-high breakouts stop being vetoed by "no resistance above" | Looked *strictly better* in-sample (Sharpe 2.07, CAGR 62.3%, DD 20.1%) but **failed a real out-of-sample holdout**: Sharpe 2.78 → 2.45, higher drawdown | **Rejected.** Gate stays as-is | [sr-levels-and-exits.md](sr-levels-and-exits.md) · `backtest-20260712-holdout-*.json` |
|
||||
| 3 | **Blanket S/R fallback** (any missing target, not just clear air) | Sharpe 1.82, per-setup expectancy 0.583 → 0.280 R | **Rejected.** 65% of what it admitted were ATR/R:R filter misses, which are actively bad | [sr-levels-and-exits.md](sr-levels-and-exits.md) |
|
||||
| 4 | **Expected-value gate** (`min_expected_value` replacing the R:R + probability pair) | Structurally favoured distant lottery targets; selected *worse*-than-random setups | **Removed June 2026.** Settings dropped in migration 020 | migration 009, 020 |
|
||||
@@ -57,6 +60,7 @@ invites overfitting.
|
||||
| Selection cutoff {70…90} × book size {10, 15, 20} | **Keep 80 × 10** — monotonically worse in both directions |
|
||||
| Position sizing (equal-weight, inverse-vol, risk-% sweep) | **Keep 1% fixed-fractional** |
|
||||
| Primary-target probability floor | **Keep 20%** — pruned lottery targets, 1,428 → 1,089 qualified, lifted Sharpe |
|
||||
| Primary-target R:R selector | **Keep 1.5** — target choice is intentionally independent of the later 2.0 activation floor |
|
||||
| Exit policy (hold / SMA50 / 20-day low / technical-40 / ATR trail) | **Keep 3× ATR trail** — best Sharpe (2.04) |
|
||||
| **Activation R:R floor `min_rr`** (swept 2026-07-12) | **Keep 2.0** — best in-sample *and* out-of-sample. But it is a **spike, not a plateau** — see below |
|
||||
|
||||
@@ -105,7 +109,6 @@ and it would also sever the last dependency the *gate* has on the weak S/R detec
|
||||
| **Broader universe** (`nasdaq_all`) | Strengthens every week's cross-section and the IC t-stat | Also where `fip_id` could become tradeable |
|
||||
| **Forward paper-trade record** | The only true out-of-sample evidence the snapshot cannot give | Time |
|
||||
| **Better target model for clear-air names** | The return is demonstrably there (#2 wins on raw CAGR in *both* train and test); it's the *flat* 3× ATR target that makes it too expensive in risk | Needs a per-name model, not a constant k×ATR |
|
||||
| **S/R detector quality** | POC/VAH/VAL computed then discarded; HVN = "any above-mean bin"; volume double-counted 1.48×; "touch" counts pass-throughs; no round numbers | Worth fixing for the levels users *see* — but it does **not** reach P&L, so don't justify it on returns |
|
||||
|
||||
---
|
||||
|
||||
@@ -133,11 +136,12 @@ out-of-sample, or turned out to be measuring something other than what it claime
|
||||
What's left is a boring, well-documented result: **cross-sectional momentum works;
|
||||
the machinery around it mostly doesn't.**
|
||||
|
||||
The S/R engine, the composite score, the sentiment and fundamentals dimensions are
|
||||
all still in the product — they make the app legible and are useful context for a
|
||||
human — but none of them has a measured edge, and the platform is honest about
|
||||
that in the UI (see the exit plan and base-rate panels on every setup card). The
|
||||
one component that *does* have an edge is the momentum gate, and every knob on it
|
||||
has been swept and confirmed.
|
||||
Structural S/R, the composite score, sentiment and fundamentals remain useful
|
||||
human context but have no measured edge. The Gate Target Ladder is different:
|
||||
it is internal screening machinery whose broad historical-price-traffic behavior
|
||||
was preserved explicitly and volume-free, with exact full-period parity. It is
|
||||
still neither market structure nor an exit. The one component that *does* have
|
||||
measured predictive edge is the momentum gate, and every knob on it has been
|
||||
swept and confirmed.
|
||||
|
||||
The next real evidence is **forward**, not backward: the live paper-trade record.
|
||||
|
||||
@@ -4,23 +4,37 @@
|
||||
**Question that started it:** are our support/resistance levels built the way best
|
||||
practice says they should be, and do we actually use them that way?
|
||||
|
||||
Short answer: the detector is weak against best practice, but its reach into P&L
|
||||
runs entirely through the **entry gate** — not the exit. Honoring the target as a
|
||||
take-profit was tested and is decisively worse. Whether the S/R-derived gate is
|
||||
net-positive is the open question, tracked below.
|
||||
Final answer: one level model should not serve two different jobs. The clean
|
||||
**Structural S/R** detector now supplies persisted chart and alert structure.
|
||||
The transient **Gate Target Ladder** preserves the broad historical-price
|
||||
traffic proposals that the setup screen depends on. Its headline target affects
|
||||
entry qualification only; honoring it as a take-profit is decisively worse.
|
||||
The final volume-free implementation reproduced the production candidate set
|
||||
and portfolio exactly. The sections below retain the investigation that led to
|
||||
that split.
|
||||
|
||||
---
|
||||
|
||||
## 1. How the levels are built today
|
||||
|
||||
`app/services/sr_service.py::detect_sr_levels`, over **all stored history**
|
||||
(`query_ohlcv` with no date range — 5 years / ~1260 daily bars per ticker):
|
||||
> **Update (2026-07-12 detector rewrite):** several gaps below were addressed in
|
||||
> `sr_service` / `indicator_service` — close-bin VP, local-peak HVN, POC/VAH/VAL
|
||||
> as candidates, LVN dropped from S/R, pivot prominence + lookbacks, rejection-
|
||||
> weighted recency strength, ATR-adaptive merge, hard cap, round numbers. The
|
||||
> table documents the *pre-rewrite* failure modes measured on the snapshot; keep
|
||||
> it for historical context. Re-measure density on prod after deploy if gate
|
||||
> rates shift.
|
||||
|
||||
1. Candidates = volume-profile **HVN and LVN** bins + **pivot** swing highs/lows.
|
||||
2. Strength = share of bars that "touched" the level, scaled so ~20% of bars → 100.
|
||||
3. Nearby levels merged within 0.5%; tagged `support` if below spot, else `resistance`.
|
||||
`app/services/sr_service.py::detect_sr_levels` (post-rewrite):
|
||||
|
||||
### Where that departs from best practice
|
||||
1. Candidates = VP **POC / VAH / VAL / local HVN peaks** (lookback 252) +
|
||||
**prominent** swing pivots (lookback 504) + nearby **round numbers**.
|
||||
2. Strength = rejection-weighted touches on last 252 bars with recency decay
|
||||
(pass-throughs down-weighted); method base + confluence on merge.
|
||||
3. Nearby levels merged with **ATR-adaptive** tolerance (clamped ~0.4–1.5%);
|
||||
capped (~16, interleaved S/R); tagged `support` if below spot, else `resistance`.
|
||||
|
||||
### Where the pre-rewrite detector departed from best practice
|
||||
|
||||
Measured on `backtest_snapshots/prod.sqlite` (AAPL, 1261 bars, spot $308.63):
|
||||
|
||||
@@ -35,8 +49,8 @@ Measured on `backtest_snapshots/prod.sqlite` (AAPL, 1261 bars, spot $308.63):
|
||||
| **Round-number levels absent** — the mechanism with the best empirical support ([Osler 2000](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=888805)). | not implemented |
|
||||
|
||||
Not a defect: the pivot `window=2` is the standard 5-bar Williams fractal. What it
|
||||
lacks is a **prominence filter** — AAPL yields 338 pivots over 1261 bars, one every
|
||||
~3.7 bars.
|
||||
lacked pre-rewrite is a **prominence filter** — AAPL yielded 338 pivots over 1261
|
||||
bars, one every ~3.7 bars.
|
||||
|
||||
### The structural problem: resistance famine
|
||||
|
||||
@@ -383,6 +397,473 @@ Note `--allow-spawn` is required on Windows: `_mp_context()` has no `fork`/
|
||||
large, consistent across five nested windows — and still didn't survive a holdout.
|
||||
Nested lookbacks are not out-of-sample. Split by entry date before believing anything.
|
||||
|
||||
## 7. Archived S/R v2 investigation (2026-07-12/13)
|
||||
|
||||
The detector rewrite was decomposed into the causal arms below. They are names
|
||||
in the historical experiment record, not supported runtime configuration:
|
||||
|
||||
| arm | behavior |
|
||||
|---|---|
|
||||
| `production_control` | deployed detector plus legacy 1.5 primary selection |
|
||||
| `rr_aligned_control` | deployed detector; primary selection uses activation `min_rr` |
|
||||
| `rewrite` | rewritten detector with activation-aligned primary selection |
|
||||
| `soft_zones` | rewrite plus max-strength/confluence zone aggregation |
|
||||
| `confirmed_rounds` | soft zones; standalone rounds need two rejection clusters |
|
||||
| `gate_v2` | confirmed rounds plus uncapped gate evidence |
|
||||
|
||||
The matrix runner, comparator, environment switch, and candidate-level audit
|
||||
were removed when the investigation closed. The compact comparison JSONs,
|
||||
cohort CSVs, and this narrative retain the decisions; Git history retains the
|
||||
raw implementation and reports for forensic reconstruction.
|
||||
|
||||
> Validation result: `confirmed_rounds` is rejected and is no longer a lockable
|
||||
> arm. It remains in the corrected training matrix only to preserve the causal
|
||||
> experiment record. The first training reports used an entry end bound without
|
||||
> forwarding it to the portfolio calendar, leaving each book in flat cash through
|
||||
> the test period. The simulator now treats `BACKTEST_ENTRY_END` as an inclusive
|
||||
> entry bound and truncates the calendar after the final position can resolve.
|
||||
|
||||
The isolated `rr_aligned_control` validation also failed (Sharpe 2.78 to 1.32,
|
||||
CAGR 73.3% to 33.1%, drawdown 11.7% to 18.4%). The live 1.5 primary-selection
|
||||
behavior is therefore frozen: although it predates the 2.0 activation gate, it
|
||||
acts as a useful selectivity mechanism. The final detector matrix holds that
|
||||
behavior constant and varies only detection/zone policy:
|
||||
|
||||
- `rewrite_legacy_primary`
|
||||
- `soft_zones_legacy_primary`
|
||||
- `confirmed_rounds_legacy_primary`
|
||||
- `gate_v2_legacy_primary`
|
||||
|
||||
The post-2024 interval has informed earlier research, so this is validation rather
|
||||
than a pristine holdout; do not sweep variants on it. No deployment follows
|
||||
automatically. A lower validation Sharpe or higher drawdown remains a no-ship
|
||||
result even when CAGR rises.
|
||||
|
||||
## 8. Final detector-only result: no rewritten gate arm advances
|
||||
|
||||
The last matrix froze production's effective gate (`primary min_rr=1.5`,
|
||||
activation `min_rr=2.0`) and varied only level detection/zone policy on entries
|
||||
through 2024-06-30. Corrected portfolio calendars end after the last position can
|
||||
resolve; there is no flat-cash tail.
|
||||
|
||||
| arm | Sharpe | CAGR | MaxDD | qualified | net avg R | ex-top-5% |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| production control | **1.28** | **28.8%** | 21.4% | 676 | **0.230** | **0.066** |
|
||||
| rewrite + legacy primary | 0.96 | 21.0% | 22.2% | 1,200 | 0.037 | -0.102 |
|
||||
| soft zones + legacy primary | 1.08 | 25.1% | **17.8%** | 1,161 | 0.045 | -0.096 |
|
||||
| confirmed rounds + legacy primary | 1.14 | 22.1% | 20.2% | 570 | 0.189 | 0.045 |
|
||||
| gate v2 + legacy primary | 0.87 | 16.7% | 20.7% | 604 | 0.187 | 0.041 |
|
||||
|
||||
No arm advances to validation. The raw rewrite retains only 249 of 676 production
|
||||
setups, removes 427 good setups, and adds 951 setups with negative expectancy.
|
||||
Round confirmation repairs the added cohort but still removes 430 production
|
||||
setups whose 30-day average (+0.680R) exceeds the additions (+0.511R). Uncapping
|
||||
recovers only 16 of those missing setups. The old detector averages 43.3 gate
|
||||
levels versus 15.0 rewritten and 24.0 rewritten-uncapped levels.
|
||||
|
||||
**Standing no-ship decision:** keep both the deployed detector and the legacy 1.5
|
||||
primary-selection behavior in the trading path. The rewritten structure may only
|
||||
proceed as a separately computed display model. Do not merge this research branch
|
||||
into production as-is.
|
||||
|
||||
### Hidden-feature isolation
|
||||
|
||||
The deployed detector accidentally measures long-memory historical price traffic
|
||||
rather than genuine S/R. A dedicated matrix holds the complete gate fixed and
|
||||
changes one legacy component at a time:
|
||||
|
||||
- `legacy_geometry_neutral`: old locations, every merged strength fixed at 50;
|
||||
- `legacy_pivots_only`: unfiltered full-history pivots, no VP grid;
|
||||
- `legacy_traffic_grid_only`: deployed HVN+LVN grid, no pivots.
|
||||
|
||||
The traffic matrix first established whether geometry, pivots, or the
|
||||
range-occupancy grid reproduced production on pre-2024 training data.
|
||||
|
||||
Corrected training result:
|
||||
|
||||
| arm | qualified | Sharpe | CAGR | MaxDD | net avg R | ex-top-5% |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| production control | 676 | 1.28 | 28.8% | 21.4% | 0.230 | 0.066 |
|
||||
| corrected neutral geometry | 505 | 1.48 | 33.5% | 18.2% | 0.230 | 0.087 |
|
||||
| pivots only | 717 | 1.38 | 32.9% | 25.2% | 0.236 | 0.076 |
|
||||
| traffic grid only | 504 | **1.72** | **41.8%** | **16.8%** | **0.271** | **0.136** |
|
||||
|
||||
The traffic grid is the first research arm to beat control simultaneously on
|
||||
Sharpe, CAGR, drawdown, and robust expectancy. It retains 264 production setups
|
||||
at +0.214R ex-top-5%, adds 240 at +0.051R, and removes 412 at only +0.010R.
|
||||
Against corrected neutral geometry, only 239 qualified setups overlap; the 265
|
||||
traffic-only setups return +0.140R ex-top-5% versus +0.073R for the 266
|
||||
neutral-only setups. This is different selection, not merely a lower trade count.
|
||||
|
||||
The old `volume_profile` name is misleading. Its helper returns both HVN and LVN
|
||||
bins, so their union retains almost every one of the 20 evenly spaced centers over
|
||||
the expanding historical high-low range (19.987 levels on average in the audit).
|
||||
The later strength calculation does not use volume; it counts bars whose ranges
|
||||
cross each center. The candidate feature is therefore:
|
||||
|
||||
1. a normalized, expanding 20-bin price-range grid;
|
||||
2. range-touch occupancy strength;
|
||||
3. no full-history pivot ladder or pivot/grid strength saturation.
|
||||
|
||||
Two final training arms isolate the first two items explicitly:
|
||||
|
||||
- `legacy_range_grid_touch`: all 20 range centers, no volume calculation, legacy
|
||||
touch strength;
|
||||
- `legacy_range_grid_neutral`: identical centers, strength fixed at 50 after
|
||||
clustering.
|
||||
|
||||
The touch arm reproduced `legacy_traffic_grid_only` exactly: all 121,464
|
||||
candidates, 504 qualified setups, cohort membership, expectancy, and portfolio
|
||||
metrics match. Volume contributes nothing. Neutral strength won the training
|
||||
portfolio comparison (Sharpe 1.98 versus 1.72), but failed the locked validation:
|
||||
|
||||
| validation arm | Sharpe | CAGR | MaxDD | net avg R | ex-top-5% |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| production control | **2.78** | **73.3%** | **11.7%** | 0.174 | 0.022 |
|
||||
| neutral range grid | 1.85 | 43.2% | 15.2% | **0.178** | **0.039** |
|
||||
|
||||
The neutral grid is a no-ship. The validation failure prompted a causal audit of
|
||||
the control rather than another detector sweep. One relationship survives both
|
||||
periods: dense legacy ladders are a proxy for a wide multiplicative price range.
|
||||
|
||||
| control cohort | training ex-top-5% | validation ex-top-5% |
|
||||
|---|---:|---:|
|
||||
| at least 70 legacy levels | +0.165R | +0.185R |
|
||||
| fewer than 70 levels | +0.004R | -0.379R |
|
||||
|
||||
Level count is not independently useful after controlling for the last 504
|
||||
trading days' range. For `log(max(high) / min(low)) >= 1.0315` (about a 2.8x
|
||||
high/low ratio), the overlap cohort returns +0.292R training and +0.322R
|
||||
validation ex-top-5%. High density without high range returns -0.096R and
|
||||
+0.029R. Correlation between the explicit range and legacy level count is 0.864
|
||||
training and 0.822 validation.
|
||||
|
||||
This isolates the hidden feature as a two-year realized price-excursion factor,
|
||||
accidentally encoded by how many full-history pivots survive a 0.5% merge. It is
|
||||
not evidence that the arbitrary lines are structural. A rounded threshold of
|
||||
`log range >= 1.0` remains positive across a 0.9/1.0/1.1 sensitivity plateau.
|
||||
|
||||
Two diagnostic-only arms now test whether the explicit scalar replaces the side
|
||||
effect:
|
||||
|
||||
- `production_range504`: deployed targets plus the explicit range gate;
|
||||
- `rewrite_range504_legacy_primary`: clean targets, frozen primary selection,
|
||||
plus the identical range gate.
|
||||
|
||||
Result:
|
||||
|
||||
| training arm | qualified | Sharpe | CAGR | MaxDD | net avg R | ex-top-5% |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| production control | 676 | 1.28 | 28.8% | 21.4% | 0.230 | 0.066 |
|
||||
| production + range504 | 180 | **1.65** | 31.4% | **15.9%** | **0.476** | **0.250** |
|
||||
| clean rewrite | 1,200 | 0.96 | 21.0% | 22.2% | 0.037 | -0.102 |
|
||||
| clean rewrite + range504 | 291 | 1.46 | **31.5%** | 18.4% | 0.292 | 0.158 |
|
||||
|
||||
The scalar recovers most of the detector rewrite's regression. The clean arm now
|
||||
beats the original production baseline on Sharpe, CAGR, drawdown, and robust
|
||||
expectancy. Old target geometry retains a smaller edge over the equally filtered
|
||||
clean arm.
|
||||
|
||||
The residual cohort is target selection, not another range feature. The clean arm
|
||||
retains 63 exceptionally strong common setups (+0.665R ex-top-5%), adds 228 weak
|
||||
setups (+0.018R), and misses 117 strong old-geometry setups (+0.175R). Of the
|
||||
clean-only additions, 156 have a standalone round-number primary; all 162
|
||||
round-only qualified setups have fewer than two observed rejections and return
|
||||
-0.024R ex-top-5%. Structural-only and round-confluent primaries return +0.394R
|
||||
and +0.369R.
|
||||
|
||||
For 113 of the 117 missed setups, the clean detector does produce a target, but
|
||||
its selected primary averages 1.56R; 77 land in the 1.5-2R veto band. A final
|
||||
two-arm residual matrix therefore holds the clean detector and range factor fixed:
|
||||
|
||||
- `rewrite_range504_structural_legacy_primary`: exclude standalone round targets,
|
||||
retain the deployed 1.5 primary floor;
|
||||
- `rewrite_range504_structural_primary2`: identical, but select the primary from
|
||||
targets clearing 2.0R.
|
||||
|
||||
### Full-period production comparison
|
||||
|
||||
The frozen `rewrite_range504_structural_legacy_primary` candidate was run beside
|
||||
a fresh `production_control` over the complete snapshot with identical
|
||||
portfolio and exit settings. The retained decision files are
|
||||
`reports/sr-full-production-vs-candidate-comparison.json` and its cohort CSV;
|
||||
the redundant full candidate-row reports were removed after consolidation.
|
||||
|
||||
This is an apples-to-apples full-history diagnostic against the current live
|
||||
production path. It is not a new untouched holdout because the post-2024 data
|
||||
was already inspected while isolating the range factor.
|
||||
|
||||
Full-period results reject the clean range-gated candidate as a replacement:
|
||||
it improves robust setup expectancy and drawdown, but cuts the qualified set
|
||||
from 1,086 to 290 and the live-path book from 321 to 170 trades. Its 73 unique
|
||||
qualified symbols are also concentrated (36.2% of setups in the top ten names),
|
||||
so overlapping setup expectancy does not translate into independent portfolio
|
||||
opportunity.
|
||||
|
||||
### Structural confirmation as a ranking overlay
|
||||
|
||||
The next experiment preserves the production detector, setup geometry,
|
||||
qualified universe, activation gate, and live exit. For each production setup,
|
||||
the clean detector is evaluated point-in-time only to attach a binary feature:
|
||||
whether `rewrite_range504_structural_legacy_primary` also clears its core gate.
|
||||
That confirmation receives a single pre-registered 5% weight:
|
||||
|
||||
```text
|
||||
overlay_rank = 95% * production_80_20_rank + 5% * structural_confirmation
|
||||
```
|
||||
|
||||
There is deliberately no weight sweep and no union with clean-only setups. The
|
||||
report must first reproduce the production qualified count and production book;
|
||||
otherwise the comparison is invalid. The candidate advances only if
|
||||
the overlay improves full-period Sharpe, does not worsen drawdown, and retains
|
||||
at least 90% of production CAGR. The 1-year and 6-month rows must not both
|
||||
deteriorate. This remains contaminated full-history research, not promotion
|
||||
validation.
|
||||
|
||||
Result: **reject the overlay**. Production parity is exact, but the overlay
|
||||
reduces full-period Sharpe from 2.03 to 2.00 and CAGR from 50.0% to 48.4%; max
|
||||
drawdown improves from 21.4% to 20.0%. Confirmation has real standalone quality
|
||||
(+0.263R ex-top-5% versus +0.024R), but little marginal ranking value: confirmed
|
||||
setups already average production rank 92.3 and 46.1% come from ten symbols. The
|
||||
5% boost changes ordering on only 45 active dates and overweights the same
|
||||
concentrated names.
|
||||
|
||||
### Explicit gate target ladder
|
||||
|
||||
The legacy detector's volume-profile label is misleading. It returns both HVN
|
||||
and LVN bins, whose union is the complete 20-bin price-range grid, then adds
|
||||
unfiltered pivots and touch strength. For the gate this behaves as a broad
|
||||
target-proposal ladder, not human-facing support/resistance.
|
||||
|
||||
**Component name: Gate Target Ladder (GTL).** "Structural S/R" names the
|
||||
separate, persisted human-facing model. "Gate Target Ladder" names this
|
||||
transient screening component and avoids implying that its dense proposals are
|
||||
real support/resistance.
|
||||
|
||||
#### Runtime decision flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
O["Ticker OHLCV history"] --> STRUCT["Structural S/R<br/>clean detector"]
|
||||
STRUCT --> STORE[("Persist SRLevel")]
|
||||
STORE --> HUMAN["Charts and alerts"]
|
||||
|
||||
O --> LADDER["Gate Target Ladder<br/>20 range centers + 5-bar pivots"]
|
||||
LADDER --> SCORE["Count historical price traffic<br/>strength + 0.5% merge + side tag"]
|
||||
SCORE --> SCAN{"Directional proposal<br/>with R:R ≥ 1.5?"}
|
||||
SCAN -->|no| NOSETUP["No setup for that direction"]
|
||||
SCAN -->|yes| ZONES["Cluster 2% target zones<br/>use reachable near edge"]
|
||||
ZONES --> FILTER["ATR-distance filter<br/>retain up to 5 near-to-far candidates"]
|
||||
FILTER --> PROB["Estimate target-before-stop<br/>reach probability"]
|
||||
PROB --> PRIMARY["Headline = most likely candidate<br/>clearing R:R ≥ 1.5 and probability ≥ 20%"]
|
||||
PRIMARY --> ACTIVATE{"Activation gate<br/>headline R:R ≥ 2.0<br/>probability ≥ 20%<br/>momentum/direction pass?"}
|
||||
ACTIVATE -->|no| OBS["Store as unqualified observation"]
|
||||
ACTIVATE -->|yes| QUAL["Eligible for production book"]
|
||||
QUAL --> EXIT["ATR stop/trail or max hold<br/>target is never an exit"]
|
||||
```
|
||||
|
||||
Step by step:
|
||||
|
||||
1. `scan_ticker` loads the ticker's OHLCV history and computes the 1.5× ATR
|
||||
initial stop. It does not query persisted `SRLevel` rows for targets.
|
||||
2. `detect_gate_target_ladder` creates 20 evenly spaced centers over the
|
||||
observed low/high range and adds unfiltered five-bar swing highs/lows. The
|
||||
implementation performs no volume calculation.
|
||||
3. Each proposal is scored by the share of historical bars whose range crosses
|
||||
it. Proposals within 0.5% are merged, their traffic strengths combine, and
|
||||
they are tagged support/resistance relative to the latest close. The scanner
|
||||
materializes them with negative transient IDs; they are never persisted.
|
||||
4. A direction exists only when at least one proposal clears the scanner's 1.5
|
||||
R:R floor. This is setup construction, not the later live activation gate.
|
||||
5. The recommendation layer clusters proposals into 2% target zones, uses each
|
||||
zone's reachable near edge, removes unsuitable ATR distances, and keeps up
|
||||
to five candidates spanning near, moderate and far distances.
|
||||
6. Each retained candidate gets a target-before-stop reach probability based on
|
||||
distance, R:R, traffic strength and signal alignment.
|
||||
7. The headline target is the most likely candidate clearing both R:R ≥ 1.5
|
||||
and probability ≥ 20%. If none does, the most likely target overall remains
|
||||
headline so a distant high-R:R lottery target cannot game qualification.
|
||||
8. The separate live gate then requires headline R:R ≥ 2.0 and probability ≥
|
||||
20%, plus the residual-momentum and direction rules. A traded setup still
|
||||
exits only through the ATR stop/trail or maximum hold.
|
||||
|
||||
#### Ticker-chart diagnostic
|
||||
|
||||
The optional **GTL traffic** overlay uses a volume-profile-like layout because
|
||||
price-axis bars make the ladder's density easy to read. The comparison stops at
|
||||
the layout:
|
||||
|
||||
| Profile | Bar width measures | Valid interpretation |
|
||||
|---|---|---|
|
||||
| Volume profile | Traded volume assigned to a price bin | Where trading activity was accepted |
|
||||
| GTL price traffic | Relative count of historical OHLCV bars crossing a GTL proposal | How strongly the legacy gate geometry revisited that price |
|
||||
|
||||
The chart renders GTL traffic as violet bars extending left from the current
|
||||
price axis. It includes only proposals inside the displayed price range, so old
|
||||
far-away ladder levels do not compress the candles. Hover reveals price,
|
||||
crossings, capped strength, side and source. The overlay is off by default,
|
||||
loaded only on demand from `GET /gate-target-ladder/{symbol}`, and must remain
|
||||
visually distinct from persisted Structural S/R.
|
||||
|
||||
The production GTL replaces only the irrelevant volume pass with the complete
|
||||
range grid. It retains pivots, touch strength, merge geometry, primary
|
||||
selection, qualification, ranking, and exit behavior. Grid levels are labelled
|
||||
`range_grid`, making the internal purpose explicit.
|
||||
|
||||
The arm advances only on exact parity with the full-period production control:
|
||||
no added or removed qualified setups and identical production-book Sharpe,
|
||||
CAGR, drawdown, and trade count. Passing parity supports a dual-purpose design:
|
||||
clean structure for charts and alerts, explicit target ladder for the gate.
|
||||
Failing parity means the supposedly irrelevant volume pass still affects an
|
||||
edge case and must be located before any architectural change.
|
||||
|
||||
Result: **exact parity**. Both arms produce 1,086 qualified setups from 202,765
|
||||
candidates, with 1,086 retained, zero added, and zero removed. Both production
|
||||
books have Sharpe 2.03, CAGR 50.0%, max drawdown 21.4%, and 321 trades. The
|
||||
retained cohort is +0.2086R net average and +0.0492R after removing the top 5%.
|
||||
This proves that neither volume nor the `volume_profile` interpretation is part
|
||||
of the deployed edge.
|
||||
|
||||
Implementation decision: keep the clean `detect_sr_levels` output persisted as
|
||||
human-facing S/R for charts and alerts. The scanner instead builds
|
||||
`detect_gate_target_ladder` directly from its OHLCV window, materializes it only
|
||||
for the current scan, and never writes those proposal levels to `SRLevel`. The
|
||||
primary-target selector retains its independently researched 1.5 floor; the
|
||||
later live activation gate remains 2.0, and the ATR-trailing exit is unchanged.
|
||||
The `production_gtl` backtest model calls the same pure helper as the live
|
||||
scanner, so the final full-period rerun is an implementation-parity check rather
|
||||
than another detector experiment.
|
||||
|
||||
Final implementation-parity result after commit `8161c35`: **pass**. The
|
||||
regenerated full-period report differs from the pre-integration parity report
|
||||
only in `generated_at`; all 202,765 candidates, 1,086 qualified setups, cohort
|
||||
statistics, and portfolio results are unchanged. This closes the local
|
||||
backtest gate for the dual-purpose implementation. It does not itself authorize
|
||||
or perform a production deployment.
|
||||
|
||||
#### GTL tuning matrix
|
||||
|
||||
Exact parity established a safe, explicit control but did not prove that the
|
||||
inherited GTL constants were optimal. A temporary offline harness exposed those
|
||||
constants without changing the live scanner. That harness has now been retired;
|
||||
the compact consolidated reports remain as the reproducible decision record.
|
||||
|
||||
The single-command matrix contains 20 full-period arms. Each non-control arm
|
||||
changes exactly one input:
|
||||
|
||||
| Knob | Frozen control | Alternatives | Question isolated |
|
||||
|---|---:|---|---|
|
||||
| History | All available bars | 252 / 504 / 756 bars | Is recent or long-cycle range geometry useful? |
|
||||
| Candidate cap | 5 | 8 / unlimited | Does early pruning discard the useful headline? |
|
||||
| Max target distance | Existing volatility-dependent rule | 5.5 / 8 ATR universally | Is the medium-volatility unlimited branch the hidden edge? |
|
||||
| Traffic touch padding | 0.5% | 0 / 0.25% | Does padded price traffic carry information? |
|
||||
| Proposal merge | 0.5% | 0.25% / 1% | Is proposal density or consolidation important? |
|
||||
| Target zones | 2% | 1% / 3% | Does the reachable near edge manufacture the gate geometry? |
|
||||
| Range centers | 20 | 12 / 32 | Is coarse ladder density the useful feature? |
|
||||
| Pivots | Five-bar swings | None / eleven-bar swings | Do pivots add anything beyond the range ladder? |
|
||||
| Traffic strength scale | 500 | 250 / 1000 | Does strength saturation affect probability/selection? |
|
||||
|
||||
Arms execute sequentially so multiprocessing pools never compete. Each arm
|
||||
produces full-period production metrics, train/test books split at 2024-07-01,
|
||||
robust expectancy after removing the top 5% of setups, and retained/added/
|
||||
removed cohorts against control. The consolidated JSON and Markdown table are
|
||||
checkpointed after every arm; successful runs delete temporary per-arm reports
|
||||
unless `--keep-arm-reports` is set.
|
||||
|
||||
The pre-registered screen requires all of the following versus control:
|
||||
full/train/test Sharpe not worse, full-period drawdown not worse, at least 80%
|
||||
of production trades retained, and positive qualified expectancy after removing
|
||||
the top 5%. Passing identifies a candidate for forward paper validation, not an
|
||||
automatic deployment. The post-2024 interval has already influenced this
|
||||
research, so the split is a robustness check rather than a pristine holdout.
|
||||
|
||||
Result on the 2026-07-13 snapshot: **20/20 arms completed; no replacement arm
|
||||
passed all six checks.** The frozen control remained best on full-period Sharpe
|
||||
(2.03), CAGR (50.0%), and post-2024 Sharpe (2.78), with 321 production trades
|
||||
and 21.4% drawdown. The closest replacement, 0.25% touch padding, still fell to
|
||||
Sharpe 1.94 / CAGR 47.9%. This rejects direct constant replacement; the inherited
|
||||
behavior is not explained by one obvious GTL knob.
|
||||
|
||||
The paired cohorts do expose a narrower mechanism worth testing:
|
||||
|
||||
| Variant | Retained control setups | Added by variant | Removed from control |
|
||||
|---|---|---|---|
|
||||
| 0.25% touch | 1,049 at +0.214R (+0.058 ex-top-5%) | 18 at -0.235R | 37 at -0.056R |
|
||||
| Strength 1000 | 1,037 at +0.225R (+0.069) | 122 at +0.301R (+0.152) | 49 at +0.142R (-0.044) |
|
||||
| 0.25% merge | 791 at +0.238R (+0.078) | 365 at +0.161R (+0.023) | 295 at +0.129R (-0.008) |
|
||||
| Grid without pivots | 428 at +0.232R (+0.100) | 392 at +0.176R (+0.046) | 658 at +0.208R (+0.041) |
|
||||
|
||||
Replacement mixes the retained and added cohorts and also discards the removed
|
||||
cohort, so its portfolio result could not say which part helped. The archived
|
||||
confirmation matrix therefore preserved frozen control geometry and decomposed
|
||||
each selected variant into:
|
||||
|
||||
- **intersection** — only control setups also core-qualified by the variant;
|
||||
- **union** — all core-qualified control setups plus genuinely added variant
|
||||
setups, using tuned geometry only for those additions.
|
||||
|
||||
It also tests pre-registered intersections among the three high-breadth
|
||||
confirmers. Its control path exactly reproduced the completed tuning matrix.
|
||||
|
||||
Result: **13/13 arms completed with exact control parity; no arm passed all six
|
||||
guardrails.** The decomposition does identify one near-hit:
|
||||
|
||||
| Arm | Full Sharpe | Train | Post-2024 | CAGR | Max DD | Trades |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| Control | 2.03 | 1.28 | 2.78 | 50.0% | 21.4% | 321 |
|
||||
| Strength-1000 intersection | **2.06** | **1.30** | **2.82** | **50.7%** | 21.7% | 316 |
|
||||
|
||||
Strength confirmation removes only 49 of 1,086 qualified control setups. Those
|
||||
removed setups average +0.142R, but turn negative after removing their largest
|
||||
5% of outcomes (-0.044R); the retained 1,037 average +0.212R and +0.054R
|
||||
ex-top-5%. This is consistent with a weak tail-dependence filter. It is not yet
|
||||
a winner: the original drawdown guardrail remains fixed, and 21.7% is worse
|
||||
than 21.4% even though the difference is small.
|
||||
|
||||
The final parameter test was deliberately one-dimensional. It swept
|
||||
coarse strength scales around 1000 (625, 750, 875, 1000, 1125, 1250, 1500,
|
||||
2000), using intersection only. It reproduced both the frozen control and the
|
||||
completed strength-1000 result exactly. Promotion required at least two
|
||||
adjacent non-control scales to pass all six original checks; an isolated winner
|
||||
was rejected as sensitivity.
|
||||
|
||||
Final result: **9/9 arms completed, control parity passed, and the
|
||||
strength-1000 replication passed.** Scale 1500 was the only arm to clear all
|
||||
six original checks, but neither adjacent scale (1250 or 2000) cleared them, so
|
||||
the pre-registered stable-plateau requirement failed. It also traded away
|
||||
return and setup quality despite its screen pass: CAGR fell from 50.0% to 48.8%
|
||||
and qualified expectancy from +0.209R to +0.188R.
|
||||
|
||||
The sensitivity curve shows a real but non-dominating trade-off. Scales
|
||||
750–1000 produce small Sharpe improvements in parts of the sample but each
|
||||
misses a different unchanged guardrail; higher scales eventually reduce
|
||||
drawdown by filtering more setups, while CAGR, expectancy, and then Sharpe
|
||||
decline. There is no robust parameter neighborhood that improves the whole
|
||||
book.
|
||||
|
||||
**Final decision: keep the frozen Gate Target Ladder and do not deploy a
|
||||
strength-confirmation gate.** The GTL remains the explicit, volume-free
|
||||
compatibility component that exactly reproduces the validated production
|
||||
screen. Clean Structural S/R remains the separate human-facing chart/alert
|
||||
model. This snapshot is now exhausted for GTL fitting; any future challenger
|
||||
must be pre-registered and evaluated on genuinely new forward data rather than
|
||||
another iteration over the same history.
|
||||
|
||||
The scheduled backtest, Admin UI, and local snapshot runner therefore default
|
||||
to `production_gtl`. A manual UI/local run may select `structural_sr` as an
|
||||
explicit comparison; every report records the model and whether it is the
|
||||
production path. After deployment, rerun the Admin backtest once to replace any
|
||||
cached report produced by the former default.
|
||||
|
||||
The temporary GTL matrix scripts, configurable detector branches, and
|
||||
confirmation hooks were removed after this decision. The normal snapshot
|
||||
backtester now exposes only the production GTL and clean Structural S/R
|
||||
comparison models.
|
||||
|
||||
The post-2024 window has been opened and is now analysis data, not a valid final
|
||||
promotion holdout. These arms can isolate mechanism, but neither may ship without
|
||||
new future data or a separately pre-registered walk-forward protocol.
|
||||
|
||||
**Next runs, if picked back up:**
|
||||
|
||||
- A **per-name target model** for clear-air setups instead of a constant k×ATR. This
|
||||
|
||||
@@ -200,8 +200,11 @@ export interface TriggerJobResponse {
|
||||
job: string;
|
||||
status: 'triggered' | 'busy' | 'blocked' | 'not_found';
|
||||
message: string;
|
||||
target_model?: BacktestTargetModel;
|
||||
}
|
||||
|
||||
export type BacktestTargetModel = 'production_gtl' | 'structural_sr';
|
||||
|
||||
export function listJobs() {
|
||||
return apiClient.get<JobStatus[]>('admin/jobs').then((r) => r.data);
|
||||
}
|
||||
@@ -216,9 +219,9 @@ export function toggleJob(jobName: string, enabled: boolean) {
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function triggerJob(jobName: string) {
|
||||
export function triggerJob(jobName: string, options?: { target_model?: BacktestTargetModel }) {
|
||||
return apiClient
|
||||
.post<TriggerJobResponse>(`admin/jobs/${jobName}/trigger`)
|
||||
.post<TriggerJobResponse>(`admin/jobs/${jobName}/trigger`, options)
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import apiClient from './client';
|
||||
import type { SRLevelResponse } from '../lib/types';
|
||||
import type { GateTargetLadderResponse, SRLevelResponse } from '../lib/types';
|
||||
|
||||
export function getLevels(symbol: string) {
|
||||
return apiClient
|
||||
.get<SRLevelResponse>(`sr-levels/${symbol}`)
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getGateTargetLadder(symbol: string) {
|
||||
return apiClient
|
||||
.get<GateTargetLadderResponse>(`gate-target-ladder/${symbol}`)
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types';
|
||||
import type {
|
||||
GateTargetLevel,
|
||||
OHLCVBar,
|
||||
SRLevel,
|
||||
SRZone,
|
||||
TradeSetup,
|
||||
} from '../../lib/types';
|
||||
import { formatPrice, formatDate, formatLargeNumber } from '../../lib/format';
|
||||
|
||||
interface CandlestickChartProps {
|
||||
data: OHLCVBar[];
|
||||
srLevels?: SRLevel[];
|
||||
zones?: SRZone[];
|
||||
gateTargetLevels?: GateTargetLevel[];
|
||||
gateTargetLookbackBars?: number;
|
||||
gateTargetLoading?: boolean;
|
||||
gateTargetError?: boolean;
|
||||
showGateTraffic?: boolean;
|
||||
onShowGateTrafficChange?: (visible: boolean) => void;
|
||||
tradeSetup?: TradeSetup;
|
||||
currentPrice?: number;
|
||||
}
|
||||
@@ -74,7 +86,19 @@ function startIndexForPreset(data: OHLCVBar[], preset: RangePreset): number {
|
||||
return idx < 0 ? 0 : idx;
|
||||
}
|
||||
|
||||
export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, currentPrice }: CandlestickChartProps) {
|
||||
export function CandlestickChart({
|
||||
data,
|
||||
srLevels = [],
|
||||
zones = [],
|
||||
gateTargetLevels = [],
|
||||
gateTargetLookbackBars = 0,
|
||||
gateTargetLoading = false,
|
||||
gateTargetError = false,
|
||||
showGateTraffic = false,
|
||||
onShowGateTrafficChange,
|
||||
tradeSetup,
|
||||
currentPrice,
|
||||
}: CandlestickChartProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -210,6 +234,57 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
ctx.fillRect(x - volumeW / 2, yVolume, volumeW, hVolume);
|
||||
});
|
||||
|
||||
// Gate Target Ladder diagnostic: a right-edge PRICE-traffic profile. It is
|
||||
// intentionally one violet channel (not support/resistance colors), and
|
||||
// width reflects relative historical bar crossings — never volume.
|
||||
const visibleGateLevels = showGateTraffic
|
||||
? gateTargetLevels.filter(
|
||||
(level) => level.price_level >= lo && level.price_level <= hi,
|
||||
)
|
||||
: [];
|
||||
const gateProfileMaxWidth = Math.min(cw * 0.24, 160);
|
||||
const gateProfileEndX = ml + cw;
|
||||
const maxGateTraffic = Math.max(
|
||||
...visibleGateLevels.map((level) => level.traffic_count),
|
||||
1,
|
||||
);
|
||||
const gateProfileRows = visibleGateLevels.map((level) => {
|
||||
const width = Math.max(
|
||||
3,
|
||||
(level.traffic_count / maxGateTraffic) * gateProfileMaxWidth,
|
||||
);
|
||||
return { level, y: yScale(level.price_level), width };
|
||||
});
|
||||
|
||||
if (gateProfileRows.length > 0) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(139, 92, 246, 0.22)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(gateProfileEndX, mt);
|
||||
ctx.lineTo(gateProfileEndX, priceBottom);
|
||||
ctx.stroke();
|
||||
|
||||
gateProfileRows.forEach(({ level, y, width }) => {
|
||||
const alpha = 0.12 + (level.strength / 100) * 0.24;
|
||||
ctx.fillStyle = `rgba(139, 92, 246, ${alpha})`;
|
||||
ctx.fillRect(gateProfileEndX - width, y - 1.5, width, 3);
|
||||
ctx.fillStyle = 'rgba(196, 181, 253, 0.7)';
|
||||
ctx.fillRect(gateProfileEndX - width, y - 1.5, 1, 3);
|
||||
});
|
||||
if (gateProfileMaxWidth >= 120) {
|
||||
ctx.fillStyle = 'rgba(139, 92, 246, 0.78)';
|
||||
ctx.font = '9px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(
|
||||
'GTL PRICE TRAFFIC · NO VOLUME',
|
||||
gateProfileEndX - gateProfileMaxWidth,
|
||||
mt + 9,
|
||||
);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Nearest support/resistance only (band if it came from a zone)
|
||||
markers.forEach((m) => {
|
||||
const isSupport = m.role === 'support';
|
||||
@@ -267,15 +342,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Take-profit zone: green semi-transparent rectangle between entry and target
|
||||
const tpTop = Math.min(entryY, targetY);
|
||||
const tpHeight = Math.max(Math.abs(targetY - entryY), 1);
|
||||
ctx.fillStyle = 'rgba(47, 157, 178, 0.13)';
|
||||
ctx.fillRect(ml, tpTop, cw, tpHeight);
|
||||
// Target border
|
||||
ctx.strokeStyle = 'rgba(47, 157, 178, 0.45)';
|
||||
// Gate target: a diagnostic marker, not a take-profit zone. Violet ties
|
||||
// it to the GTL profile without implying that the trade exits here.
|
||||
ctx.strokeStyle = 'rgba(139, 92, 246, 0.65)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.setLineDash([2, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, targetY);
|
||||
ctx.lineTo(ml + cw, targetY);
|
||||
@@ -299,8 +370,8 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
ctx.fillText(`Entry ${formatPrice(tradeSetup.entry_price)}`, ml + cw + 4, entryY + 3);
|
||||
ctx.fillStyle = 'rgba(239, 145, 130, 0.9)';
|
||||
ctx.fillText(`SL ${formatPrice(tradeSetup.stop_loss)}`, ml + cw + 4, stopY + 3);
|
||||
ctx.fillStyle = 'rgba(110, 201, 219, 0.9)';
|
||||
ctx.fillText(`TP ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3);
|
||||
ctx.fillStyle = 'rgba(196, 181, 253, 0.95)';
|
||||
ctx.fillText(`Gate ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3);
|
||||
}
|
||||
|
||||
// Current price line — the anchor for everything else (drawn on top)
|
||||
@@ -367,6 +438,13 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
volumeTop,
|
||||
volumeH,
|
||||
volumeBottom,
|
||||
gateProfile: gateProfileRows.length > 0
|
||||
? {
|
||||
startX: gateProfileEndX - gateProfileMaxWidth,
|
||||
endX: gateProfileEndX,
|
||||
rows: gateProfileRows,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
// Size the overlay canvas to match
|
||||
@@ -377,7 +455,16 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
overlay.style.width = `${W}px`;
|
||||
overlay.style.height = `${H}px`;
|
||||
}
|
||||
}, [data, srLevels, visibleRange, zones, tradeSetup, currentPrice]);
|
||||
}, [
|
||||
currentPrice,
|
||||
data,
|
||||
gateTargetLevels,
|
||||
showGateTraffic,
|
||||
srLevels,
|
||||
tradeSetup,
|
||||
visibleRange,
|
||||
zones,
|
||||
]);
|
||||
|
||||
const drawCrosshair = useCallback(() => {
|
||||
const overlay = overlayCanvasRef.current;
|
||||
@@ -655,6 +742,43 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
tip.style.left = `${Math.min(mx + 14, rect.width - 180)}px`;
|
||||
tip.style.top = `${Math.max(my - 80, 8)}px`;
|
||||
|
||||
let gateTooltipHtml = '';
|
||||
const gateRows = (meta.gateProfile?.rows ?? []) as Array<{
|
||||
level: GateTargetLevel;
|
||||
y: number;
|
||||
width: number;
|
||||
}>;
|
||||
if (
|
||||
meta.gateProfile
|
||||
&& mx >= meta.gateProfile.startX
|
||||
&& mx <= meta.gateProfile.endX
|
||||
) {
|
||||
const hovered = gateRows
|
||||
.filter(
|
||||
({ y, width }) =>
|
||||
Math.abs(my - y) <= 5
|
||||
&& mx >= meta.gateProfile.endX - width - 4,
|
||||
)
|
||||
.sort((a, b) => Math.abs(my - a.y) - Math.abs(my - b.y))[0];
|
||||
if (hovered) {
|
||||
const level = hovered.level;
|
||||
const sources = (level.sources.length
|
||||
? level.sources
|
||||
: [level.detection_method])
|
||||
.map((source) => source.replace(/_/g, ' '))
|
||||
.join(' + ');
|
||||
gateTooltipHtml = `
|
||||
<div class="border-t border-violet-400/30 mt-1.5 pt-1.5 text-violet-200 font-medium mb-1">GTL price traffic · not volume</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-gray-400">
|
||||
<span>Price</span><span class="text-right text-violet-200">${formatPrice(level.price_level)}</span>
|
||||
<span>Crossings</span><span class="text-right text-gray-200">${level.traffic_count}</span>
|
||||
<span>Strength</span><span class="text-right text-gray-200">${level.strength}</span>
|
||||
<span>Side</span><span class="text-right text-gray-200">${level.type}</span>
|
||||
<span>Source</span><span class="text-right text-gray-200">${sources}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cursor is near trade overlay zone
|
||||
let tradeTooltipHtml = '';
|
||||
if (tradeSetup && meta.yScale) {
|
||||
@@ -670,7 +794,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
<span>Direction</span><span class="text-right text-gray-200">${tradeSetup.direction}</span>
|
||||
<span>Entry</span><span class="text-right text-blue-300">${formatPrice(tradeSetup.entry_price)}</span>
|
||||
<span>Stop</span><span class="text-right text-red-300">${formatPrice(tradeSetup.stop_loss)}</span>
|
||||
<span>Target</span><span class="text-right text-emerald-300">${formatPrice(tradeSetup.target)}</span>
|
||||
<span>Gate target</span><span class="text-right text-violet-200">${formatPrice(tradeSetup.target)}</span>
|
||||
<span>R:R</span><span class="text-right text-gray-200">${tradeSetup.rr_ratio.toFixed(2)}</span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -684,7 +808,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
<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" title="${bar.volume.toLocaleString()}">${formatLargeNumber(bar.volume)}</span>
|
||||
</div>${tradeTooltipHtml}`;
|
||||
</div>${gateTooltipHtml}${tradeTooltipHtml}`;
|
||||
} else {
|
||||
tip.style.display = 'none';
|
||||
}
|
||||
@@ -733,6 +857,29 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-1 text-[10px] text-gray-600">scroll to zoom · drag to pan</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={showGateTraffic}
|
||||
onClick={() => onShowGateTrafficChange?.(!showGateTraffic)}
|
||||
title={
|
||||
'Show the Gate Target Ladder as relative historical price traffic (not volume)'
|
||||
}
|
||||
className={`ml-auto inline-flex items-center gap-1.5 rounded px-2 py-1 text-[11px] font-medium transition-colors ${
|
||||
showGateTraffic
|
||||
? 'bg-violet-400/15 text-violet-200'
|
||||
: 'text-gray-500 hover:text-violet-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-1.5 w-4 bg-gradient-to-l from-violet-400/80 to-violet-400/10"
|
||||
/>
|
||||
{gateTargetLoading
|
||||
? 'Loading GTL…'
|
||||
: gateTargetError
|
||||
? 'GTL unavailable'
|
||||
: 'GTL traffic'}
|
||||
</button>
|
||||
</div>
|
||||
<div ref={containerRef} className="relative w-full" style={{ height: CHART_HEIGHT }}>
|
||||
<canvas
|
||||
@@ -756,6 +903,25 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
{showGateTraffic && gateTargetLevels.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-[10px] text-gray-500">
|
||||
<span>
|
||||
<span className="text-violet-300">GTL price traffic</span>
|
||||
{' · '}bar width = relative historical crossings{' · '}not volume
|
||||
</span>
|
||||
<span className="num text-gray-600">
|
||||
{gateTargetLevels.length} proposals
|
||||
{gateTargetLookbackBars > 0 ? ` · ${gateTargetLookbackBars} bars` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showGateTraffic && !gateTargetLoading && gateTargetLevels.length === 0 && (
|
||||
<p className="mt-2 text-[10px] text-gray-600">
|
||||
{gateTargetError
|
||||
? 'Gate Target Ladder diagnostic could not be loaded.'
|
||||
: 'No Gate Target Ladder proposals are available for this history.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useBacktestReport } from '../../hooks/useMarketRegime';
|
||||
import { triggerJob } from '../../api/admin';
|
||||
import type { BacktestTargetModel } from '../../api/admin';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Callout } from '../ui/Callout';
|
||||
import { Disclosure } from '../ui/Disclosure';
|
||||
@@ -143,6 +144,7 @@ export function BacktestPanel() {
|
||||
const toast = useToast();
|
||||
const [selectedStrategy, setSelectedStrategy] = useState('');
|
||||
const [selectedLookback, setSelectedLookback] = useState('');
|
||||
const [targetModel, setTargetModel] = useState<BacktestTargetModel>('production_gtl');
|
||||
|
||||
const monitor = report?.portfolio_monitor ?? null;
|
||||
const activeStrategy =
|
||||
@@ -159,10 +161,11 @@ export function BacktestPanel() {
|
||||
);
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: () => triggerJob('backtest'),
|
||||
mutationFn: () => triggerJob('backtest', { target_model: targetModel }),
|
||||
onSuccess: (res) => {
|
||||
if (res.status === 'triggered') {
|
||||
toast.addToast('success', 'Backtest started — results appear when it finishes (a minute or two).');
|
||||
const label = targetModel === 'production_gtl' ? 'Live GTL' : 'Structural S/R comparison';
|
||||
toast.addToast('success', `${label} backtest started — results appear when it finishes.`);
|
||||
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['backtest-report'] }), 8000);
|
||||
} else {
|
||||
toast.addToast('info', res.message || 'Could not start backtest');
|
||||
@@ -184,10 +187,62 @@ export function BacktestPanel() {
|
||||
so read it as directional.
|
||||
</p>
|
||||
</Disclosure>
|
||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:items-end">
|
||||
<fieldset className="grid w-full grid-cols-1 gap-2 sm:w-[34rem] sm:grid-cols-2">
|
||||
<legend className="mb-1 text-[11px] font-medium uppercase tracking-wider text-gray-500">
|
||||
Target model for this run
|
||||
</legend>
|
||||
<label
|
||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-blue-400/60 ${
|
||||
targetModel === 'production_gtl'
|
||||
? 'border-blue-400/60 bg-blue-500/10'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
name="backtest-target-model"
|
||||
value="production_gtl"
|
||||
checked={targetModel === 'production_gtl'}
|
||||
onChange={() => setTargetModel('production_gtl')}
|
||||
/>
|
||||
<span className="flex items-center justify-between gap-2 text-sm font-medium text-gray-100">
|
||||
Live GTL
|
||||
<span className="rounded-full border border-blue-400/40 bg-blue-400/10 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-widest text-blue-300">
|
||||
Production
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
|
||||
Exact target path used by the live scanner and scheduled backtest.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-amber-400/60 ${
|
||||
targetModel === 'structural_sr'
|
||||
? 'border-amber-400/50 bg-amber-500/10'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
name="backtest-target-model"
|
||||
value="structural_sr"
|
||||
checked={targetModel === 'structural_sr'}
|
||||
onChange={() => setTargetModel('structural_sr')}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-200">Structural S/R</span>
|
||||
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
|
||||
Comparison only; uses chart structure as the target source.
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
|
||||
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && <Callout variant="empty">Loading…</Callout>}
|
||||
|
||||
@@ -206,6 +261,10 @@ export function BacktestPanel() {
|
||||
{report.params.cost_per_side_pct != null && (
|
||||
<> · net of {report.params.cost_per_side_pct}%/side costs</>
|
||||
)}
|
||||
{' '}· target model:{' '}
|
||||
<span className={report.params.is_production_target_model === false ? 'text-amber-300' : 'text-blue-300'}>
|
||||
{report.params.target_model_label ?? 'Unknown (legacy report)'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{monitor && monitorRun ? (
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { TradeSetup } from '../../lib/types';
|
||||
|
||||
const MOMENTUM_WEIGHT = 0.8;
|
||||
const VOLATILITY_WEIGHT = 0.2;
|
||||
|
||||
interface ProductionRankStripProps {
|
||||
setup?: TradeSetup;
|
||||
momentumGate: number;
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.min(100, Math.max(0, value));
|
||||
}
|
||||
|
||||
function topShare(value: number): string {
|
||||
return `${Math.max(1, Math.round(100 - clampPercent(value)))}%`;
|
||||
}
|
||||
|
||||
function PercentileRail({
|
||||
value,
|
||||
colorClass,
|
||||
gate,
|
||||
gateLabel,
|
||||
}: {
|
||||
value: number | null;
|
||||
colorClass: string;
|
||||
gate?: number;
|
||||
gateLabel?: string;
|
||||
}) {
|
||||
const normalized = value == null ? 0 : clampPercent(value);
|
||||
const normalizedGate = gate == null ? null : clampPercent(gate);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mt-2 h-1.5 rounded-full bg-white/[0.07]"
|
||||
role="meter"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={value == null ? undefined : normalized}
|
||||
aria-label={value == null ? 'Percentile unavailable' : `${normalized.toFixed(1)} percentile`}
|
||||
>
|
||||
{value != null && (
|
||||
<span
|
||||
className={`absolute inset-y-0 left-0 rounded-full ${colorClass}`}
|
||||
style={{ width: `${normalized}%` }}
|
||||
/>
|
||||
)}
|
||||
{normalizedGate != null && (
|
||||
<span
|
||||
className="absolute -top-1.5 h-4 w-px bg-gray-300/70"
|
||||
style={{ left: `${normalizedGate}%` }}
|
||||
title={gateLabel}
|
||||
>
|
||||
<span className="absolute -top-4 left-1/2 -translate-x-1/2 whitespace-nowrap text-[8px] uppercase tracking-[0.14em] text-gray-500">
|
||||
gate
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductionRankStrip({ setup, momentumGate }: ProductionRankStripProps) {
|
||||
const momentum = setup?.momentum_percentile ?? null;
|
||||
const volatility = setup?.volatility_percentile ?? null;
|
||||
const storedRank = setup?.strategy_rank ?? null;
|
||||
const hasBlend = momentum != null && volatility != null;
|
||||
const computedBlend = hasBlend
|
||||
? momentum * MOMENTUM_WEIGHT + volatility * VOLATILITY_WEIGHT
|
||||
: null;
|
||||
const rank = storedRank ?? computedBlend ?? momentum;
|
||||
|
||||
if (rank == null && momentum == null && volatility == null) return null;
|
||||
|
||||
const normalizedRank = clampPercent(rank ?? 0);
|
||||
const momentumContribution = hasBlend ? clampPercent(momentum) * MOMENTUM_WEIGHT : normalizedRank;
|
||||
const volatilityContribution = hasBlend ? clampPercent(volatility) * VOLATILITY_WEIGHT : 0;
|
||||
const gateEnabled = momentumGate > 0;
|
||||
const gatePassed = momentum != null && (!gateEnabled || momentum >= momentumGate);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mt-4 border-y border-white/[0.07] py-4"
|
||||
aria-label="Production ranking snapshot"
|
||||
>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(170px,0.55fr)_minmax(0,1.8fr)] lg:gap-8">
|
||||
<div className="flex items-end justify-between gap-4 lg:block">
|
||||
<div>
|
||||
<p className="num text-[9px] uppercase tracking-[0.22em] text-gray-500">
|
||||
Production rank
|
||||
</p>
|
||||
{rank != null ? (
|
||||
<p className="font-display mt-1 text-3xl font-semibold tracking-tight text-gray-100">
|
||||
{normalizedRank.toFixed(1)}
|
||||
<span className="ml-1 text-sm font-normal text-gray-500">%ile</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="font-display mt-1 text-2xl font-semibold text-gray-500">Unavailable</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right lg:mt-2 lg:text-left">
|
||||
{rank != null && (
|
||||
<p className="text-[11px] text-gray-400">top {topShare(normalizedRank)} of the universe</p>
|
||||
)}
|
||||
{momentum != null && gateEnabled && (
|
||||
<p className={`mt-0.5 text-[10px] ${gatePassed ? 'text-blue-300' : 'text-red-300'}`}>
|
||||
momentum gate {gatePassed ? 'passed' : 'not passed'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<p className="text-[11px] font-medium text-gray-300">80/20 weighted rank</p>
|
||||
<p className="num text-[10px] text-gray-500">
|
||||
{hasBlend
|
||||
? `${momentumContribution.toFixed(1)} momentum + ${volatilityContribution.toFixed(1)} volatility`
|
||||
: 'momentum-only fallback / volatility rank unavailable'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative mt-2 h-2.5 overflow-visible rounded-full bg-white/[0.07]"
|
||||
role="meter"
|
||||
aria-label={rank == null ? 'Production rank unavailable' : `Production rank ${normalizedRank.toFixed(1)} percentile`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={rank == null ? undefined : normalizedRank}
|
||||
>
|
||||
<span
|
||||
className={`absolute inset-y-0 left-0 bg-blue-500 ${
|
||||
volatilityContribution > 0 ? 'rounded-l-full' : 'rounded-full'
|
||||
}`}
|
||||
style={{ width: `${momentumContribution}%` }}
|
||||
title={`Momentum contribution ${momentumContribution.toFixed(1)} points`}
|
||||
/>
|
||||
{volatilityContribution > 0 && (
|
||||
<span
|
||||
className="absolute inset-y-0 rounded-r-full bg-amber-400"
|
||||
style={{ left: `${momentumContribution}%`, width: `${volatilityContribution}%` }}
|
||||
title={`Volatility contribution ${volatilityContribution.toFixed(1)} points`}
|
||||
/>
|
||||
)}
|
||||
{rank != null && (
|
||||
<span
|
||||
className="absolute -top-1.5 h-5 w-px bg-gray-100 shadow-[0_0_8px_rgba(237,238,243,0.55)]"
|
||||
style={{ left: `${normalizedRank}%` }}
|
||||
title={`Combined rank ${normalizedRank.toFixed(1)}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-blue-200">Residual 12-1 momentum</p>
|
||||
<p className="mt-0.5 text-[9px] text-gray-600">80% weight / activation signal</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="num text-sm text-gray-200">
|
||||
{momentum == null ? '-' : momentum.toFixed(1)}
|
||||
</p>
|
||||
{momentum != null && (
|
||||
<p className="text-[9px] text-gray-600">top {topShare(momentum)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PercentileRail
|
||||
value={momentum}
|
||||
colorClass="bg-blue-500"
|
||||
gate={gateEnabled ? momentumGate : undefined}
|
||||
gateLabel={gateEnabled ? `Activation requires at least the ${momentumGate.toFixed(0)}th percentile` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-amber-200">6-month realized volatility</p>
|
||||
<p className="mt-0.5 text-[9px] text-gray-600">20% weight / ordering tilt only</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="num text-sm text-gray-200">
|
||||
{volatility == null ? '-' : volatility.toFixed(1)}
|
||||
</p>
|
||||
{volatility != null && (
|
||||
<p className="text-[9px] text-gray-600">top {topShare(volatility)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PercentileRail value={volatility} colorClass="bg-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[9px] leading-relaxed text-gray-600">
|
||||
Cross-sectional snapshot from the latest setup scan - not a historical price indicator.
|
||||
Volatility can improve ordering, but it never opens the activation gate by itself.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ function TargetTable({ setup, selectedPrice, onSelect, honorsTarget }: {
|
||||
honorsTarget: boolean;
|
||||
}) {
|
||||
if (!setup.targets || setup.targets.length === 0) {
|
||||
return <p className="text-xs text-gray-500">No overhead levels detected.</p>;
|
||||
return <p className="text-xs text-gray-500">No gate target proposals detected.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -129,7 +129,7 @@ function TargetTable({ setup, selectedPrice, onSelect, honorsTarget }: {
|
||||
aria-label={
|
||||
honorsTarget
|
||||
? 'Choose the take-profit level for the rail and paper trade'
|
||||
: 'Choose a level to preview on the rail (does not affect the exit)'
|
||||
: 'Choose a Gate Target Ladder proposal to preview (does not affect the exit)'
|
||||
}
|
||||
>
|
||||
<thead>
|
||||
@@ -140,8 +140,8 @@ function TargetTable({ setup, selectedPrice, onSelect, honorsTarget }: {
|
||||
<th className="py-2 pr-3" title="Reward-to-risk if the trade were exited at this level. Used by the activation gate — not an exit.">
|
||||
Gate R:R
|
||||
</th>
|
||||
<th className="py-2" title="Modelled odds of price TOUCHING this level within ~30 days. Not the odds of the trade winning — the trade does not exit here.">
|
||||
Touch odds
|
||||
<th className="py-2" title="Modelled probability of reaching this target before the stop within ~30 days. Not the odds of the trade winning — the production trade does not exit here.">
|
||||
Reach probability
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -455,7 +455,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
|
||||
>
|
||||
{setup.targets.map((t) => (
|
||||
<option key={`${t.sr_level_id}-${t.price}`} value={t.price} className="bg-[#14161f]">
|
||||
{formatPrice(t.price)} · {t.probability.toFixed(0)}% touch odds · {t.classification}{t.is_primary ? ' · primary' : ''}
|
||||
{formatPrice(t.price)} · {t.probability.toFixed(0)}% reach probability · {t.classification}{t.is_primary ? ' · primary' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -482,21 +482,21 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* Levels ladder — still fully explorable (clicking a row drives the rail
|
||||
and the candlestick overlay), but framed as what it is: overhead
|
||||
structure used to screen the setup, not a menu of exits. */}
|
||||
{/* GTL targets remain explorable (clicking a row drives the rail and
|
||||
candlestick marker), but they screen the setup rather than defining
|
||||
production exits. */}
|
||||
{setup.targets && setup.targets.length > 0 && (
|
||||
<details className="mt-3" open>
|
||||
<summary className="cursor-pointer text-[11px] font-medium text-gray-500 transition-colors hover:text-gray-300">
|
||||
{honorsTarget
|
||||
? `Take-profit levels (${setup.targets.length}) · select one to preview it and use it when taking`
|
||||
: `Overhead levels (${setup.targets.length}) · select one to preview it on the rail and chart`}
|
||||
: `Gate targets (${setup.targets.length}) · select one to preview it on the rail and chart`}
|
||||
</summary>
|
||||
{!honorsTarget && (
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-gray-600">
|
||||
Resistance levels the scanner found. Their R:R and touch odds are what got this setup
|
||||
through the gate — but the trade exits on the trailing stop, so price reaching one of
|
||||
these is not a sell signal. Clicking only moves the marker.
|
||||
Gate Target Ladder proposals used by the scanner. Their headline R:R and reach
|
||||
probability determine gate eligibility, but the trade exits on the trailing stop;
|
||||
reaching one is not a sell signal. Clicking only moves the marker.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -39,6 +39,7 @@ export function useFetchSymbolData(options: UseFetchSymbolDataOptions = {}) {
|
||||
queryClient.invalidateQueries({ queryKey: ['sentiment', symbol] });
|
||||
queryClient.invalidateQueries({ queryKey: ['fundamentals', symbol] });
|
||||
queryClient.invalidateQueries({ queryKey: ['sr-levels', symbol] });
|
||||
queryClient.invalidateQueries({ queryKey: ['gate-target-ladder', symbol] });
|
||||
queryClient.invalidateQueries({ queryKey: ['scores', symbol] });
|
||||
// Fetch re-runs the scanner → setups/confidence change. Refresh both the
|
||||
// per-ticker trades (['trades', symbol]) and the Overview list (['trades']).
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getOHLCV } from '../api/ohlcv';
|
||||
import { getScores } from '../api/scores';
|
||||
import { getLevels } from '../api/sr-levels';
|
||||
import { getGateTargetLadder, getLevels } from '../api/sr-levels';
|
||||
import { getSentiment } from '../api/sentiment';
|
||||
import { getFundamentals } from '../api/fundamentals';
|
||||
import * as tradesApi from '../api/trades';
|
||||
|
||||
export function useTickerDetail(symbol: string) {
|
||||
export function useTickerDetail(symbol: string, includeGateTargetLadder = false) {
|
||||
const ohlcv = useQuery({
|
||||
queryKey: ['ohlcv', symbol],
|
||||
queryFn: () => getOHLCV(symbol),
|
||||
@@ -25,6 +25,12 @@ export function useTickerDetail(symbol: string) {
|
||||
enabled: !!symbol,
|
||||
});
|
||||
|
||||
const gateTargetLadder = useQuery({
|
||||
queryKey: ['gate-target-ladder', symbol],
|
||||
queryFn: () => getGateTargetLadder(symbol),
|
||||
enabled: !!symbol && includeGateTargetLadder,
|
||||
});
|
||||
|
||||
const sentiment = useQuery({
|
||||
queryKey: ['sentiment', symbol],
|
||||
queryFn: () => getSentiment(symbol),
|
||||
@@ -43,5 +49,13 @@ export function useTickerDetail(symbol: string) {
|
||||
enabled: !!symbol,
|
||||
});
|
||||
|
||||
return { ohlcv, scores, srLevels, sentiment, fundamentals, trades };
|
||||
return {
|
||||
ohlcv,
|
||||
scores,
|
||||
srLevels,
|
||||
gateTargetLadder,
|
||||
sentiment,
|
||||
fundamentals,
|
||||
trades,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* What actually closes a trade.
|
||||
*
|
||||
* The setup's `target` is NOT an exit under the production policy: it is a
|
||||
* screening artifact — the nearest S/R level, used to compute the R:R and
|
||||
* screening artifact — the headline Gate Target Ladder proposal, used to
|
||||
* compute the R:R and
|
||||
* probability that admit the setup through the activation gate. The live exit
|
||||
* (`paper_trade_service.resolve_open_trades`) never reads it; `atr_trailing`
|
||||
* closes on the initial stop, a trailing stop, or the max hold.
|
||||
|
||||
@@ -399,6 +399,9 @@ export interface BacktestReport {
|
||||
horizon_days: number;
|
||||
min_lookback: number;
|
||||
cost_per_side_pct?: number;
|
||||
target_model?: 'production_gtl' | 'structural_sr';
|
||||
target_model_label?: string;
|
||||
is_production_target_model?: boolean;
|
||||
};
|
||||
overall_qualified: BacktestBucket;
|
||||
overall_all: BacktestBucket;
|
||||
@@ -604,6 +607,22 @@ export interface SRLevelResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface GateTargetLevel {
|
||||
price_level: number;
|
||||
type: 'support' | 'resistance';
|
||||
strength: number;
|
||||
detection_method: string;
|
||||
sources: string[];
|
||||
traffic_count: number;
|
||||
}
|
||||
|
||||
export interface GateTargetLadderResponse {
|
||||
symbol: string;
|
||||
levels: GateTargetLevel[];
|
||||
count: number;
|
||||
lookback_bars: number;
|
||||
}
|
||||
|
||||
// Sentiment
|
||||
export interface CitationItem {
|
||||
url: string;
|
||||
|
||||
@@ -92,7 +92,7 @@ function RadarSetupRow({ setup, rank, reason, name, selected, onSelect }: RadarR
|
||||
className={`grid cursor-pointer grid-cols-[20px_minmax(92px,116px)_44px_1fr_auto] items-center gap-2.5 rounded-lg px-2 py-2.5 transition-colors ${
|
||||
selected ? 'bg-blue-400/[0.08]' : 'hover:bg-white/[0.03]'
|
||||
} ${qualified ? '' : 'opacity-60'}`}
|
||||
title={`gate: R:R ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · touch odds ${Math.round(prob)}%` : ''} (screening, not an exit) · click to focus`}
|
||||
title={`gate: R:R ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · reach probability ${Math.round(prob)}%` : ''} (screening, not an exit) · click to focus`}
|
||||
>
|
||||
<span className="num text-[11px] text-gray-500">{rank}</span>
|
||||
<span className="min-w-0">
|
||||
@@ -168,8 +168,8 @@ function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: {
|
||||
</div>
|
||||
</div>
|
||||
{/* The headline stat is the signal that actually selected this ticker.
|
||||
R:R and touch odds are gate inputs computed from an S/R level the
|
||||
trade never exits at — they get quiet, labelled treatment. */}
|
||||
R:R and reach probability are gate inputs computed from a GTL
|
||||
proposal the trade never exits at — they get quiet treatment. */}
|
||||
<div className="flex items-start gap-10 text-right">
|
||||
{setup.momentum_percentile != null && (
|
||||
<div title="Residual 12-1 month momentum percentile across the universe. This is why the ticker was selected.">
|
||||
@@ -188,12 +188,12 @@ function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: {
|
||||
)}
|
||||
<div
|
||||
className="max-w-[13rem]"
|
||||
title="Gate metrics. The reward/risk and touch odds of the nearest S/R level are what admitted this setup through the activation gate. The trade does NOT exit at that level — it exits on the trailing stop."
|
||||
title="Gate metrics. The reward/risk and reach probability of the headline Gate Target Ladder proposal are what admitted this setup. The trade does NOT exit there — it exits on the trailing stop."
|
||||
>
|
||||
<p className="section-index">gate metrics</p>
|
||||
<p className="num mt-1.5 text-sm text-gray-300">
|
||||
R:R {setup.rr_ratio.toFixed(1)}:1
|
||||
{prob != null && <> · touch {Math.round(prob)}%</>}
|
||||
{prob != null && <> · reach {Math.round(prob)}%</>}
|
||||
</p>
|
||||
<p className="mt-1 text-[10.5px] leading-relaxed text-gray-500">
|
||||
screening only — exits on the trailing stop, not at the level
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SentimentPanel } from '../components/ticker/SentimentPanel';
|
||||
import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel';
|
||||
import { IndicatorSelector } from '../components/ticker/IndicatorSelector';
|
||||
import { RecommendationPanel } from '../components/ticker/RecommendationPanel';
|
||||
import { ProductionRankStrip } from '../components/ticker/ProductionRankStrip';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
import { formatPrice } from '../lib/format';
|
||||
@@ -120,8 +121,17 @@ function DataFreshnessBar({
|
||||
|
||||
export default function TickerDetailPage() {
|
||||
const { symbol = '' } = useParams<{ symbol: string }>();
|
||||
const [showGateTraffic, setShowGateTraffic] = useState(false);
|
||||
const companyName = useTickerNames().get(symbol.toUpperCase());
|
||||
const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol);
|
||||
const {
|
||||
ohlcv,
|
||||
scores,
|
||||
srLevels,
|
||||
gateTargetLadder,
|
||||
sentiment,
|
||||
fundamentals,
|
||||
trades,
|
||||
} = useTickerDetail(symbol, showGateTraffic);
|
||||
const ingestion = useFetchSymbolData();
|
||||
const watchlist = useWatchlist();
|
||||
const addToWatchlist = useAddToWatchlist();
|
||||
@@ -438,13 +448,24 @@ export default function TickerDetailPage() {
|
||||
data={ohlcv.data}
|
||||
srLevels={srLevels.data?.levels}
|
||||
zones={srLevels.data?.zones}
|
||||
gateTargetLevels={gateTargetLadder.data?.levels}
|
||||
gateTargetLookbackBars={gateTargetLadder.data?.lookback_bars}
|
||||
gateTargetLoading={gateTargetLadder.isLoading && showGateTraffic}
|
||||
gateTargetError={gateTargetLadder.isError}
|
||||
showGateTraffic={showGateTraffic}
|
||||
onShowGateTrafficChange={setShowGateTraffic}
|
||||
tradeSetup={overlayWithTarget}
|
||||
currentPrice={priceInfo?.price}
|
||||
/>
|
||||
<p className="mt-2 text-[11px] text-gray-500">
|
||||
Only the nearest support & resistance are drawn. Full list in the S/R Levels tab.
|
||||
{srLevels.isError && ' S/R levels unavailable.'}
|
||||
{gateTargetLadder.isError && ' GTL diagnostic unavailable.'}
|
||||
</p>
|
||||
<ProductionRankStrip
|
||||
setup={longSetup ?? shortSetup}
|
||||
momentumGate={gateMomentum}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(longSetup || shortSetup) && (
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Backtest report index
|
||||
|
||||
Reports dated 2026-07-11 or earlier are the historical production research
|
||||
record and remain untouched.
|
||||
|
||||
The completed 2026-07-12/13 S/R and Gate Target Ladder research is preserved as
|
||||
compact decision evidence instead of full per-arm replay output:
|
||||
|
||||
- `sr-v2-validation-comparison.json` and `sr-v2-validation-cohorts.csv` record
|
||||
the held-out detector comparison.
|
||||
- `sr-full-production-vs-candidate-comparison.json` and its cohort CSV record
|
||||
the full-period clean-structure replacement decision.
|
||||
- `sr-explicit-target-ladder-comparison.json` and its cohort CSV record exact
|
||||
GTL parity: 202,765 candidates, 1,086 qualified setups, 321 book trades,
|
||||
Sharpe 2.03, CAGR 50.0%, and max drawdown 21.4% in both arms.
|
||||
- The three `backtest-20260713-gtl-*.json/.md` pairs record the tuning,
|
||||
confirmation, and strength-sensitivity decisions. No stable improvement was
|
||||
found, so the production GTL stayed frozen.
|
||||
|
||||
The large `backtest-sr-*.json` replay files were removed after consolidation.
|
||||
They duplicated hundreds of thousands of candidate rows while adding no
|
||||
decision information beyond the compact comparisons and the narrative in
|
||||
`docs/research/sr-levels-and-exits.md`. The original raw files remain available
|
||||
in Git history if a forensic reconstruction is ever necessary.
|
||||
|
||||
The initial untracked `backtest-20260712-sr-detector-rewrite.json` is local-only
|
||||
and is intentionally not part of the repository.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
# GTL confirmation/union matrix
|
||||
|
||||
Status: **complete**
|
||||
Holdout split: `2024-07-01`
|
||||
Completed arms: 13/13
|
||||
|
||||
| Arm | Mode | Qualified | Full Sharpe | CAGR | Max DD | Trades | Train Sharpe | Test Sharpe | Ex-top-5% R | Screen |
|
||||
|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| control | intersection | 1086 | 2.03 | 50.0 | 21.4 | 321 | 1.28 | 2.78 | 0.049 | 0/0 |
|
||||
| touch_intersection | intersection | 1049 | 1.94 | 47.1 | 21.7 | 323 | 1.26 | 2.59 | 0.060 | 2/6 |
|
||||
| touch_union | union | 1104 | 2.01 | 50.1 | 20.8 | 322 | 1.29 | 2.73 | 0.042 | 4/6 |
|
||||
| strength_intersection | intersection | 1037 | 2.06 | 50.7 | 21.7 | 316 | 1.30 | 2.82 | 0.054 | 5/6 |
|
||||
| strength_union | union | 1208 | 1.91 | 47.2 | 18.7 | 338 | 1.44 | 2.37 | 0.062 | 4/6 |
|
||||
| merge_intersection | intersection | 791 | 2.01 | 47.5 | 18.8 | 293 | 1.44 | 2.56 | 0.071 | 4/6 |
|
||||
| merge_union | union | 1418 | 1.72 | 41.0 | 19.7 | 342 | 1.40 | 1.89 | 0.042 | 4/6 |
|
||||
| grid_intersection | intersection | 429 | 1.60 | 29.8 | 13.6 | 224 | 1.48 | 1.68 | 0.069 | 3/6 |
|
||||
| grid_union | union | 1454 | 2.05 | 54.3 | 22.0 | 348 | 2.06 | 1.95 | 0.053 | 4/6 |
|
||||
| touch_strength_intersection | intersection | 1009 | 1.99 | 47.9 | 18.7 | 317 | 1.35 | 2.59 | 0.068 | 4/6 |
|
||||
| touch_merge_intersection | intersection | 767 | 1.79 | 40.5 | 22.7 | 297 | 1.18 | 2.31 | 0.070 | 2/6 |
|
||||
| strength_merge_intersection | intersection | 750 | 1.99 | 46.7 | 17.9 | 290 | 1.44 | 2.50 | 0.067 | 4/6 |
|
||||
| touch_strength_merge_intersection | intersection | 733 | 1.81 | 40.9 | 21.2 | 292 | 1.25 | 2.28 | 0.071 | 3/6 |
|
||||
|
||||
## Interpretation guardrail
|
||||
|
||||
Intersections test the retained control cohort; unions test control plus genuinely added setups. The post-2024 interval is a robustness check, not a pristine holdout. Passing does not authorize deployment.
|
||||
@@ -0,0 +1,912 @@
|
||||
{
|
||||
"status": "complete",
|
||||
"generated_at": "2026-07-13T13:48:09.363544+00:00",
|
||||
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/prod.sqlite",
|
||||
"workers": 12,
|
||||
"holdout_split": "2024-07-01",
|
||||
"arm_count": 9,
|
||||
"strength_scales": [
|
||||
625.0,
|
||||
750.0,
|
||||
875.0,
|
||||
1000.0,
|
||||
1125.0,
|
||||
1250.0,
|
||||
1500.0,
|
||||
2000.0
|
||||
],
|
||||
"arms": [
|
||||
{
|
||||
"name": "control",
|
||||
"config": {
|
||||
"name": "control",
|
||||
"mode": "intersection",
|
||||
"confirmations": []
|
||||
},
|
||||
"candidates": 202765,
|
||||
"qualified": 1086,
|
||||
"qualified_net_avg_r": 0.209,
|
||||
"qualified_net_avg_r_ex_top5": 0.049,
|
||||
"full_book": {
|
||||
"sharpe": 2.03,
|
||||
"cagr_pct": 50.0,
|
||||
"max_drawdown_pct": 21.4,
|
||||
"trades": 321,
|
||||
"win_rate": 37.4,
|
||||
"avg_hold_days": 15.3,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"holdout": {
|
||||
"train": {
|
||||
"sharpe": 1.28,
|
||||
"cagr_pct": 28.8,
|
||||
"max_drawdown_pct": 21.4,
|
||||
"trades": 175,
|
||||
"win_rate": 33.7,
|
||||
"avg_hold_days": 14.2,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"test": {
|
||||
"sharpe": 2.78,
|
||||
"cagr_pct": 73.3,
|
||||
"max_drawdown_pct": 11.7,
|
||||
"trades": 150,
|
||||
"win_rate": 42.0,
|
||||
"avg_hold_days": 16.6,
|
||||
"skipped_book_full": 0
|
||||
}
|
||||
},
|
||||
"gtl_diagnostics": {
|
||||
"variant": "gtl_confirmation",
|
||||
"candidate_count": 202765,
|
||||
"primary_source_counts": {
|
||||
"pivot_point": 196290,
|
||||
"range_grid": 180036
|
||||
},
|
||||
"primary_round_only": 0,
|
||||
"primary_strength_100": 138596,
|
||||
"avg_primary_strength": 80.109,
|
||||
"avg_primary_distance_atr": 2.293,
|
||||
"avg_primary_rejection_count": 41.908,
|
||||
"avg_raw_level_count": 53.204,
|
||||
"avg_gate_level_count": 53.204,
|
||||
"avg_range_504_log": 0.612,
|
||||
"range_factor_pass": 19566,
|
||||
"structural_overlay_rows": 0,
|
||||
"structural_overlay_pass": 0,
|
||||
"structural_overlay_weight": null,
|
||||
"gtl_confirmation_rows": 202765,
|
||||
"gtl_confirmation_pass": 202765,
|
||||
"gtl_confirmation_tuned_additions": 0
|
||||
},
|
||||
"cohort_vs_control": null,
|
||||
"description": "Frozen GTL composition-path parity control.",
|
||||
"screen": {
|
||||
"checks": {},
|
||||
"passed": 0,
|
||||
"total": 0,
|
||||
"advances": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "strength_625_intersection",
|
||||
"config": {
|
||||
"name": "strength_625_intersection",
|
||||
"mode": "intersection",
|
||||
"confirmations": [
|
||||
{
|
||||
"name": "strength_625",
|
||||
"strength_scale": 625.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"candidates": 202765,
|
||||
"qualified": 1070,
|
||||
"qualified_net_avg_r": 0.206,
|
||||
"qualified_net_avg_r_ex_top5": 0.047,
|
||||
"full_book": {
|
||||
"sharpe": 1.96,
|
||||
"cagr_pct": 47.6,
|
||||
"max_drawdown_pct": 21.4,
|
||||
"trades": 318,
|
||||
"win_rate": 37.7,
|
||||
"avg_hold_days": 15.4,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"holdout": {
|
||||
"train": {
|
||||
"sharpe": 1.22,
|
||||
"cagr_pct": 27.1,
|
||||
"max_drawdown_pct": 21.4,
|
||||
"trades": 173,
|
||||
"win_rate": 34.1,
|
||||
"avg_hold_days": 14.3,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"test": {
|
||||
"sharpe": 2.71,
|
||||
"cagr_pct": 70.3,
|
||||
"max_drawdown_pct": 11.7,
|
||||
"trades": 149,
|
||||
"win_rate": 42.3,
|
||||
"avg_hold_days": 16.6,
|
||||
"skipped_book_full": 0
|
||||
}
|
||||
},
|
||||
"gtl_diagnostics": {
|
||||
"variant": "gtl_confirmation",
|
||||
"candidate_count": 202765,
|
||||
"primary_source_counts": {
|
||||
"pivot_point": 196290,
|
||||
"range_grid": 180036
|
||||
},
|
||||
"primary_round_only": 0,
|
||||
"primary_strength_100": 138596,
|
||||
"avg_primary_strength": 80.109,
|
||||
"avg_primary_distance_atr": 2.293,
|
||||
"avg_primary_rejection_count": 41.908,
|
||||
"avg_raw_level_count": 53.204,
|
||||
"avg_gate_level_count": 53.204,
|
||||
"avg_range_504_log": 0.612,
|
||||
"range_factor_pass": 19566,
|
||||
"structural_overlay_rows": 0,
|
||||
"structural_overlay_pass": 0,
|
||||
"structural_overlay_weight": null,
|
||||
"gtl_confirmation_rows": 202765,
|
||||
"gtl_confirmation_pass": 14682,
|
||||
"gtl_confirmation_tuned_additions": 0
|
||||
},
|
||||
"cohort_vs_control": {
|
||||
"retained": {
|
||||
"count": 1070,
|
||||
"net_avg_r": 0.2063,
|
||||
"net_avg_r_ex_top5": 0.0468
|
||||
},
|
||||
"added": {
|
||||
"count": 0,
|
||||
"net_avg_r": null,
|
||||
"net_avg_r_ex_top5": null
|
||||
},
|
||||
"removed": {
|
||||
"count": 16,
|
||||
"net_avg_r": 0.3605,
|
||||
"net_avg_r_ex_top5": 0.2131
|
||||
}
|
||||
},
|
||||
"description": "Require control confirmation at traffic-strength scale 625.",
|
||||
"screen": {
|
||||
"checks": {
|
||||
"full_sharpe_not_worse": false,
|
||||
"train_sharpe_not_worse": false,
|
||||
"test_sharpe_not_worse": false,
|
||||
"drawdown_not_worse": true,
|
||||
"retains_80pct_trades": true,
|
||||
"robust_expectancy_positive": true
|
||||
},
|
||||
"passed": 3,
|
||||
"total": 6,
|
||||
"advances": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "strength_750_intersection",
|
||||
"config": {
|
||||
"name": "strength_750_intersection",
|
||||
"mode": "intersection",
|
||||
"confirmations": [
|
||||
{
|
||||
"name": "strength_750",
|
||||
"strength_scale": 750.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"candidates": 202765,
|
||||
"qualified": 1061,
|
||||
"qualified_net_avg_r": 0.209,
|
||||
"qualified_net_avg_r_ex_top5": 0.05,
|
||||
"full_book": {
|
||||
"sharpe": 2.03,
|
||||
"cagr_pct": 50.0,
|
||||
"max_drawdown_pct": 21.2,
|
||||
"trades": 316,
|
||||
"win_rate": 38.6,
|
||||
"avg_hold_days": 15.5,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"holdout": {
|
||||
"train": {
|
||||
"sharpe": 1.36,
|
||||
"cagr_pct": 31.0,
|
||||
"max_drawdown_pct": 21.2,
|
||||
"trades": 171,
|
||||
"win_rate": 35.7,
|
||||
"avg_hold_days": 14.6,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"test": {
|
||||
"sharpe": 2.71,
|
||||
"cagr_pct": 70.3,
|
||||
"max_drawdown_pct": 11.7,
|
||||
"trades": 149,
|
||||
"win_rate": 42.3,
|
||||
"avg_hold_days": 16.6,
|
||||
"skipped_book_full": 0
|
||||
}
|
||||
},
|
||||
"gtl_diagnostics": {
|
||||
"variant": "gtl_confirmation",
|
||||
"candidate_count": 202765,
|
||||
"primary_source_counts": {
|
||||
"pivot_point": 196290,
|
||||
"range_grid": 180036
|
||||
},
|
||||
"primary_round_only": 0,
|
||||
"primary_strength_100": 138596,
|
||||
"avg_primary_strength": 80.109,
|
||||
"avg_primary_distance_atr": 2.293,
|
||||
"avg_primary_rejection_count": 41.908,
|
||||
"avg_raw_level_count": 53.204,
|
||||
"avg_gate_level_count": 53.204,
|
||||
"avg_range_504_log": 0.612,
|
||||
"range_factor_pass": 19566,
|
||||
"structural_overlay_rows": 0,
|
||||
"structural_overlay_pass": 0,
|
||||
"structural_overlay_weight": null,
|
||||
"gtl_confirmation_rows": 202765,
|
||||
"gtl_confirmation_pass": 14738,
|
||||
"gtl_confirmation_tuned_additions": 0
|
||||
},
|
||||
"cohort_vs_control": {
|
||||
"retained": {
|
||||
"count": 1061,
|
||||
"net_avg_r": 0.2095,
|
||||
"net_avg_r_ex_top5": 0.0499
|
||||
},
|
||||
"added": {
|
||||
"count": 0,
|
||||
"net_avg_r": null,
|
||||
"net_avg_r_ex_top5": null
|
||||
},
|
||||
"removed": {
|
||||
"count": 25,
|
||||
"net_avg_r": 0.1722,
|
||||
"net_avg_r_ex_top5": -0.0891
|
||||
}
|
||||
},
|
||||
"description": "Require control confirmation at traffic-strength scale 750.",
|
||||
"screen": {
|
||||
"checks": {
|
||||
"full_sharpe_not_worse": true,
|
||||
"train_sharpe_not_worse": true,
|
||||
"test_sharpe_not_worse": false,
|
||||
"drawdown_not_worse": true,
|
||||
"retains_80pct_trades": true,
|
||||
"robust_expectancy_positive": true
|
||||
},
|
||||
"passed": 5,
|
||||
"total": 6,
|
||||
"advances": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "strength_875_intersection",
|
||||
"config": {
|
||||
"name": "strength_875_intersection",
|
||||
"mode": "intersection",
|
||||
"confirmations": [
|
||||
{
|
||||
"name": "strength_875",
|
||||
"strength_scale": 875.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"candidates": 202765,
|
||||
"qualified": 1044,
|
||||
"qualified_net_avg_r": 0.21,
|
||||
"qualified_net_avg_r_ex_top5": 0.051,
|
||||
"full_book": {
|
||||
"sharpe": 2.04,
|
||||
"cagr_pct": 49.7,
|
||||
"max_drawdown_pct": 21.2,
|
||||
"trades": 316,
|
||||
"win_rate": 38.3,
|
||||
"avg_hold_days": 15.4,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"holdout": {
|
||||
"train": {
|
||||
"sharpe": 1.31,
|
||||
"cagr_pct": 28.8,
|
||||
"max_drawdown_pct": 21.2,
|
||||
"trades": 171,
|
||||
"win_rate": 35.1,
|
||||
"avg_hold_days": 14.3,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"test": {
|
||||
"sharpe": 2.77,
|
||||
"cagr_pct": 72.6,
|
||||
"max_drawdown_pct": 11.7,
|
||||
"trades": 149,
|
||||
"win_rate": 42.3,
|
||||
"avg_hold_days": 16.6,
|
||||
"skipped_book_full": 0
|
||||
}
|
||||
},
|
||||
"gtl_diagnostics": {
|
||||
"variant": "gtl_confirmation",
|
||||
"candidate_count": 202765,
|
||||
"primary_source_counts": {
|
||||
"pivot_point": 196290,
|
||||
"range_grid": 180036
|
||||
},
|
||||
"primary_round_only": 0,
|
||||
"primary_strength_100": 138596,
|
||||
"avg_primary_strength": 80.109,
|
||||
"avg_primary_distance_atr": 2.293,
|
||||
"avg_primary_rejection_count": 41.908,
|
||||
"avg_raw_level_count": 53.204,
|
||||
"avg_gate_level_count": 53.204,
|
||||
"avg_range_504_log": 0.612,
|
||||
"range_factor_pass": 19566,
|
||||
"structural_overlay_rows": 0,
|
||||
"structural_overlay_pass": 0,
|
||||
"structural_overlay_weight": null,
|
||||
"gtl_confirmation_rows": 202765,
|
||||
"gtl_confirmation_pass": 14787,
|
||||
"gtl_confirmation_tuned_additions": 0
|
||||
},
|
||||
"cohort_vs_control": {
|
||||
"retained": {
|
||||
"count": 1044,
|
||||
"net_avg_r": 0.2103,
|
||||
"net_avg_r_ex_top5": 0.0506
|
||||
},
|
||||
"added": {
|
||||
"count": 0,
|
||||
"net_avg_r": null,
|
||||
"net_avg_r_ex_top5": null
|
||||
},
|
||||
"removed": {
|
||||
"count": 42,
|
||||
"net_avg_r": 0.1665,
|
||||
"net_avg_r_ex_top5": -0.0502
|
||||
}
|
||||
},
|
||||
"description": "Require control confirmation at traffic-strength scale 875.",
|
||||
"screen": {
|
||||
"checks": {
|
||||
"full_sharpe_not_worse": true,
|
||||
"train_sharpe_not_worse": true,
|
||||
"test_sharpe_not_worse": false,
|
||||
"drawdown_not_worse": true,
|
||||
"retains_80pct_trades": true,
|
||||
"robust_expectancy_positive": true
|
||||
},
|
||||
"passed": 5,
|
||||
"total": 6,
|
||||
"advances": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "strength_1000_intersection",
|
||||
"config": {
|
||||
"name": "strength_1000_intersection",
|
||||
"mode": "intersection",
|
||||
"confirmations": [
|
||||
{
|
||||
"name": "strength_1000",
|
||||
"strength_scale": 1000.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"candidates": 202765,
|
||||
"qualified": 1037,
|
||||
"qualified_net_avg_r": 0.212,
|
||||
"qualified_net_avg_r_ex_top5": 0.054,
|
||||
"full_book": {
|
||||
"sharpe": 2.06,
|
||||
"cagr_pct": 50.7,
|
||||
"max_drawdown_pct": 21.7,
|
||||
"trades": 316,
|
||||
"win_rate": 38.6,
|
||||
"avg_hold_days": 15.5,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"holdout": {
|
||||
"train": {
|
||||
"sharpe": 1.3,
|
||||
"cagr_pct": 28.7,
|
||||
"max_drawdown_pct": 21.7,
|
||||
"trades": 173,
|
||||
"win_rate": 35.3,
|
||||
"avg_hold_days": 14.4,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"test": {
|
||||
"sharpe": 2.82,
|
||||
"cagr_pct": 74.9,
|
||||
"max_drawdown_pct": 11.7,
|
||||
"trades": 147,
|
||||
"win_rate": 42.9,
|
||||
"avg_hold_days": 16.7,
|
||||
"skipped_book_full": 0
|
||||
}
|
||||
},
|
||||
"gtl_diagnostics": {
|
||||
"variant": "gtl_confirmation",
|
||||
"candidate_count": 202765,
|
||||
"primary_source_counts": {
|
||||
"pivot_point": 196290,
|
||||
"range_grid": 180036
|
||||
},
|
||||
"primary_round_only": 0,
|
||||
"primary_strength_100": 138596,
|
||||
"avg_primary_strength": 80.109,
|
||||
"avg_primary_distance_atr": 2.293,
|
||||
"avg_primary_rejection_count": 41.908,
|
||||
"avg_raw_level_count": 53.204,
|
||||
"avg_gate_level_count": 53.204,
|
||||
"avg_range_504_log": 0.612,
|
||||
"range_factor_pass": 19566,
|
||||
"structural_overlay_rows": 0,
|
||||
"structural_overlay_pass": 0,
|
||||
"structural_overlay_weight": null,
|
||||
"gtl_confirmation_rows": 202765,
|
||||
"gtl_confirmation_pass": 14821,
|
||||
"gtl_confirmation_tuned_additions": 0
|
||||
},
|
||||
"cohort_vs_control": {
|
||||
"retained": {
|
||||
"count": 1037,
|
||||
"net_avg_r": 0.2118,
|
||||
"net_avg_r_ex_top5": 0.0536
|
||||
},
|
||||
"added": {
|
||||
"count": 0,
|
||||
"net_avg_r": null,
|
||||
"net_avg_r_ex_top5": null
|
||||
},
|
||||
"removed": {
|
||||
"count": 49,
|
||||
"net_avg_r": 0.1417,
|
||||
"net_avg_r_ex_top5": -0.0437
|
||||
}
|
||||
},
|
||||
"description": "Require control confirmation at traffic-strength scale 1000.",
|
||||
"screen": {
|
||||
"checks": {
|
||||
"full_sharpe_not_worse": true,
|
||||
"train_sharpe_not_worse": true,
|
||||
"test_sharpe_not_worse": true,
|
||||
"drawdown_not_worse": false,
|
||||
"retains_80pct_trades": true,
|
||||
"robust_expectancy_positive": true
|
||||
},
|
||||
"passed": 5,
|
||||
"total": 6,
|
||||
"advances": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "strength_1125_intersection",
|
||||
"config": {
|
||||
"name": "strength_1125_intersection",
|
||||
"mode": "intersection",
|
||||
"confirmations": [
|
||||
{
|
||||
"name": "strength_1125",
|
||||
"strength_scale": 1125.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"candidates": 202765,
|
||||
"qualified": 1026,
|
||||
"qualified_net_avg_r": 0.206,
|
||||
"qualified_net_avg_r_ex_top5": 0.047,
|
||||
"full_book": {
|
||||
"sharpe": 2.0,
|
||||
"cagr_pct": 48.4,
|
||||
"max_drawdown_pct": 21.5,
|
||||
"trades": 316,
|
||||
"win_rate": 38.6,
|
||||
"avg_hold_days": 15.3,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"holdout": {
|
||||
"train": {
|
||||
"sharpe": 1.2,
|
||||
"cagr_pct": 26.0,
|
||||
"max_drawdown_pct": 21.5,
|
||||
"trades": 173,
|
||||
"win_rate": 35.3,
|
||||
"avg_hold_days": 14.3,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"test": {
|
||||
"sharpe": 2.82,
|
||||
"cagr_pct": 73.4,
|
||||
"max_drawdown_pct": 10.2,
|
||||
"trades": 146,
|
||||
"win_rate": 43.2,
|
||||
"avg_hold_days": 16.7,
|
||||
"skipped_book_full": 0
|
||||
}
|
||||
},
|
||||
"gtl_diagnostics": {
|
||||
"variant": "gtl_confirmation",
|
||||
"candidate_count": 202765,
|
||||
"primary_source_counts": {
|
||||
"pivot_point": 196290,
|
||||
"range_grid": 180036
|
||||
},
|
||||
"primary_round_only": 0,
|
||||
"primary_strength_100": 138596,
|
||||
"avg_primary_strength": 80.109,
|
||||
"avg_primary_distance_atr": 2.293,
|
||||
"avg_primary_rejection_count": 41.908,
|
||||
"avg_raw_level_count": 53.204,
|
||||
"avg_gate_level_count": 53.204,
|
||||
"avg_range_504_log": 0.612,
|
||||
"range_factor_pass": 19566,
|
||||
"structural_overlay_rows": 0,
|
||||
"structural_overlay_pass": 0,
|
||||
"structural_overlay_weight": null,
|
||||
"gtl_confirmation_rows": 202765,
|
||||
"gtl_confirmation_pass": 14845,
|
||||
"gtl_confirmation_tuned_additions": 0
|
||||
},
|
||||
"cohort_vs_control": {
|
||||
"retained": {
|
||||
"count": 1026,
|
||||
"net_avg_r": 0.2061,
|
||||
"net_avg_r_ex_top5": 0.0471
|
||||
},
|
||||
"added": {
|
||||
"count": 0,
|
||||
"net_avg_r": null,
|
||||
"net_avg_r_ex_top5": null
|
||||
},
|
||||
"removed": {
|
||||
"count": 60,
|
||||
"net_avg_r": 0.2517,
|
||||
"net_avg_r_ex_top5": 0.0922
|
||||
}
|
||||
},
|
||||
"description": "Require control confirmation at traffic-strength scale 1125.",
|
||||
"screen": {
|
||||
"checks": {
|
||||
"full_sharpe_not_worse": false,
|
||||
"train_sharpe_not_worse": false,
|
||||
"test_sharpe_not_worse": true,
|
||||
"drawdown_not_worse": false,
|
||||
"retains_80pct_trades": true,
|
||||
"robust_expectancy_positive": true
|
||||
},
|
||||
"passed": 3,
|
||||
"total": 6,
|
||||
"advances": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "strength_1250_intersection",
|
||||
"config": {
|
||||
"name": "strength_1250_intersection",
|
||||
"mode": "intersection",
|
||||
"confirmations": [
|
||||
{
|
||||
"name": "strength_1250",
|
||||
"strength_scale": 1250.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"candidates": 202765,
|
||||
"qualified": 1025,
|
||||
"qualified_net_avg_r": 0.205,
|
||||
"qualified_net_avg_r_ex_top5": 0.046,
|
||||
"full_book": {
|
||||
"sharpe": 2.01,
|
||||
"cagr_pct": 49.0,
|
||||
"max_drawdown_pct": 21.5,
|
||||
"trades": 316,
|
||||
"win_rate": 38.6,
|
||||
"avg_hold_days": 15.4,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"holdout": {
|
||||
"train": {
|
||||
"sharpe": 1.2,
|
||||
"cagr_pct": 26.0,
|
||||
"max_drawdown_pct": 21.5,
|
||||
"trades": 173,
|
||||
"win_rate": 35.3,
|
||||
"avg_hold_days": 14.3,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"test": {
|
||||
"sharpe": 2.82,
|
||||
"cagr_pct": 74.9,
|
||||
"max_drawdown_pct": 11.7,
|
||||
"trades": 147,
|
||||
"win_rate": 42.9,
|
||||
"avg_hold_days": 16.7,
|
||||
"skipped_book_full": 0
|
||||
}
|
||||
},
|
||||
"gtl_diagnostics": {
|
||||
"variant": "gtl_confirmation",
|
||||
"candidate_count": 202765,
|
||||
"primary_source_counts": {
|
||||
"pivot_point": 196290,
|
||||
"range_grid": 180036
|
||||
},
|
||||
"primary_round_only": 0,
|
||||
"primary_strength_100": 138596,
|
||||
"avg_primary_strength": 80.109,
|
||||
"avg_primary_distance_atr": 2.293,
|
||||
"avg_primary_rejection_count": 41.908,
|
||||
"avg_raw_level_count": 53.204,
|
||||
"avg_gate_level_count": 53.204,
|
||||
"avg_range_504_log": 0.612,
|
||||
"range_factor_pass": 19566,
|
||||
"structural_overlay_rows": 0,
|
||||
"structural_overlay_pass": 0,
|
||||
"structural_overlay_weight": null,
|
||||
"gtl_confirmation_rows": 202765,
|
||||
"gtl_confirmation_pass": 14846,
|
||||
"gtl_confirmation_tuned_additions": 0
|
||||
},
|
||||
"cohort_vs_control": {
|
||||
"retained": {
|
||||
"count": 1025,
|
||||
"net_avg_r": 0.2054,
|
||||
"net_avg_r_ex_top5": 0.0463
|
||||
},
|
||||
"added": {
|
||||
"count": 0,
|
||||
"net_avg_r": null,
|
||||
"net_avg_r_ex_top5": null
|
||||
},
|
||||
"removed": {
|
||||
"count": 61,
|
||||
"net_avg_r": 0.2622,
|
||||
"net_avg_r_ex_top5": 0.0578
|
||||
}
|
||||
},
|
||||
"description": "Require control confirmation at traffic-strength scale 1250.",
|
||||
"screen": {
|
||||
"checks": {
|
||||
"full_sharpe_not_worse": false,
|
||||
"train_sharpe_not_worse": false,
|
||||
"test_sharpe_not_worse": true,
|
||||
"drawdown_not_worse": false,
|
||||
"retains_80pct_trades": true,
|
||||
"robust_expectancy_positive": true
|
||||
},
|
||||
"passed": 3,
|
||||
"total": 6,
|
||||
"advances": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "strength_1500_intersection",
|
||||
"config": {
|
||||
"name": "strength_1500_intersection",
|
||||
"mode": "intersection",
|
||||
"confirmations": [
|
||||
{
|
||||
"name": "strength_1500",
|
||||
"strength_scale": 1500.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"candidates": 202765,
|
||||
"qualified": 1010,
|
||||
"qualified_net_avg_r": 0.188,
|
||||
"qualified_net_avg_r_ex_top5": 0.034,
|
||||
"full_book": {
|
||||
"sharpe": 2.03,
|
||||
"cagr_pct": 48.8,
|
||||
"max_drawdown_pct": 20.9,
|
||||
"trades": 313,
|
||||
"win_rate": 39.0,
|
||||
"avg_hold_days": 15.4,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"holdout": {
|
||||
"train": {
|
||||
"sharpe": 1.28,
|
||||
"cagr_pct": 27.9,
|
||||
"max_drawdown_pct": 20.9,
|
||||
"trades": 171,
|
||||
"win_rate": 35.7,
|
||||
"avg_hold_days": 14.5,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"test": {
|
||||
"sharpe": 2.79,
|
||||
"cagr_pct": 71.5,
|
||||
"max_drawdown_pct": 10.2,
|
||||
"trades": 145,
|
||||
"win_rate": 43.4,
|
||||
"avg_hold_days": 16.7,
|
||||
"skipped_book_full": 0
|
||||
}
|
||||
},
|
||||
"gtl_diagnostics": {
|
||||
"variant": "gtl_confirmation",
|
||||
"candidate_count": 202765,
|
||||
"primary_source_counts": {
|
||||
"pivot_point": 196290,
|
||||
"range_grid": 180036
|
||||
},
|
||||
"primary_round_only": 0,
|
||||
"primary_strength_100": 138596,
|
||||
"avg_primary_strength": 80.109,
|
||||
"avg_primary_distance_atr": 2.293,
|
||||
"avg_primary_rejection_count": 41.908,
|
||||
"avg_raw_level_count": 53.204,
|
||||
"avg_gate_level_count": 53.204,
|
||||
"avg_range_504_log": 0.612,
|
||||
"range_factor_pass": 19566,
|
||||
"structural_overlay_rows": 0,
|
||||
"structural_overlay_pass": 0,
|
||||
"structural_overlay_weight": null,
|
||||
"gtl_confirmation_rows": 202765,
|
||||
"gtl_confirmation_pass": 14866,
|
||||
"gtl_confirmation_tuned_additions": 0
|
||||
},
|
||||
"cohort_vs_control": {
|
||||
"retained": {
|
||||
"count": 1010,
|
||||
"net_avg_r": 0.1876,
|
||||
"net_avg_r_ex_top5": 0.0342
|
||||
},
|
||||
"added": {
|
||||
"count": 0,
|
||||
"net_avg_r": null,
|
||||
"net_avg_r_ex_top5": null
|
||||
},
|
||||
"removed": {
|
||||
"count": 76,
|
||||
"net_avg_r": 0.4876,
|
||||
"net_avg_r_ex_top5": 0.2554
|
||||
}
|
||||
},
|
||||
"description": "Require control confirmation at traffic-strength scale 1500.",
|
||||
"screen": {
|
||||
"checks": {
|
||||
"full_sharpe_not_worse": true,
|
||||
"train_sharpe_not_worse": true,
|
||||
"test_sharpe_not_worse": true,
|
||||
"drawdown_not_worse": true,
|
||||
"retains_80pct_trades": true,
|
||||
"robust_expectancy_positive": true
|
||||
},
|
||||
"passed": 6,
|
||||
"total": 6,
|
||||
"advances": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "strength_2000_intersection",
|
||||
"config": {
|
||||
"name": "strength_2000_intersection",
|
||||
"mode": "intersection",
|
||||
"confirmations": [
|
||||
{
|
||||
"name": "strength_2000",
|
||||
"strength_scale": 2000.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"candidates": 202765,
|
||||
"qualified": 990,
|
||||
"qualified_net_avg_r": 0.174,
|
||||
"qualified_net_avg_r_ex_top5": 0.02,
|
||||
"full_book": {
|
||||
"sharpe": 1.95,
|
||||
"cagr_pct": 46.3,
|
||||
"max_drawdown_pct": 18.0,
|
||||
"trades": 313,
|
||||
"win_rate": 38.3,
|
||||
"avg_hold_days": 15.4,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"holdout": {
|
||||
"train": {
|
||||
"sharpe": 1.23,
|
||||
"cagr_pct": 26.4,
|
||||
"max_drawdown_pct": 18.0,
|
||||
"trades": 170,
|
||||
"win_rate": 34.7,
|
||||
"avg_hold_days": 14.2,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"test": {
|
||||
"sharpe": 2.68,
|
||||
"cagr_pct": 67.9,
|
||||
"max_drawdown_pct": 10.2,
|
||||
"trades": 146,
|
||||
"win_rate": 43.2,
|
||||
"avg_hold_days": 16.8,
|
||||
"skipped_book_full": 0
|
||||
}
|
||||
},
|
||||
"gtl_diagnostics": {
|
||||
"variant": "gtl_confirmation",
|
||||
"candidate_count": 202765,
|
||||
"primary_source_counts": {
|
||||
"pivot_point": 196290,
|
||||
"range_grid": 180036
|
||||
},
|
||||
"primary_round_only": 0,
|
||||
"primary_strength_100": 138596,
|
||||
"avg_primary_strength": 80.109,
|
||||
"avg_primary_distance_atr": 2.293,
|
||||
"avg_primary_rejection_count": 41.908,
|
||||
"avg_raw_level_count": 53.204,
|
||||
"avg_gate_level_count": 53.204,
|
||||
"avg_range_504_log": 0.612,
|
||||
"range_factor_pass": 19566,
|
||||
"structural_overlay_rows": 0,
|
||||
"structural_overlay_pass": 0,
|
||||
"structural_overlay_weight": null,
|
||||
"gtl_confirmation_rows": 202765,
|
||||
"gtl_confirmation_pass": 14873,
|
||||
"gtl_confirmation_tuned_additions": 0
|
||||
},
|
||||
"cohort_vs_control": {
|
||||
"retained": {
|
||||
"count": 990,
|
||||
"net_avg_r": 0.174,
|
||||
"net_avg_r_ex_top5": 0.0196
|
||||
},
|
||||
"added": {
|
||||
"count": 0,
|
||||
"net_avg_r": null,
|
||||
"net_avg_r_ex_top5": null
|
||||
},
|
||||
"removed": {
|
||||
"count": 96,
|
||||
"net_avg_r": 0.5661,
|
||||
"net_avg_r_ex_top5": 0.3608
|
||||
}
|
||||
},
|
||||
"description": "Require control confirmation at traffic-strength scale 2000.",
|
||||
"screen": {
|
||||
"checks": {
|
||||
"full_sharpe_not_worse": false,
|
||||
"train_sharpe_not_worse": false,
|
||||
"test_sharpe_not_worse": false,
|
||||
"drawdown_not_worse": true,
|
||||
"retains_80pct_trades": true,
|
||||
"robust_expectancy_positive": true
|
||||
},
|
||||
"passed": 3,
|
||||
"total": 6,
|
||||
"advances": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"promotion_rule": "At least two adjacent non-control scales must pass all six original guardrails.",
|
||||
"control_parity": "pass",
|
||||
"completed_at": "2026-07-13T14:21:42.222381+00:00",
|
||||
"strength_1000_replication": "pass",
|
||||
"advancing_arms": [
|
||||
"strength_1500_intersection"
|
||||
],
|
||||
"stable_plateau_pairs": [],
|
||||
"stable_candidate": false,
|
||||
"ranking_by_full_sharpe": [
|
||||
"strength_1000_intersection",
|
||||
"strength_875_intersection",
|
||||
"control",
|
||||
"strength_750_intersection",
|
||||
"strength_1500_intersection",
|
||||
"strength_1250_intersection",
|
||||
"strength_1125_intersection",
|
||||
"strength_625_intersection",
|
||||
"strength_2000_intersection"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# GTL strength-confirmation sensitivity
|
||||
|
||||
Status: **complete**
|
||||
Holdout split: `2024-07-01`
|
||||
Completed arms: 9/9
|
||||
|
||||
| Arm | Qualified | Full Sharpe | CAGR | Max DD | Trades | Train Sharpe | Test Sharpe | Ex-top-5% R | Screen |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| control | 1086 | 2.03 | 50.0 | 21.4 | 321 | 1.28 | 2.78 | 0.049 | 0/0 |
|
||||
| strength_625_intersection | 1070 | 1.96 | 47.6 | 21.4 | 318 | 1.22 | 2.71 | 0.047 | 3/6 |
|
||||
| strength_750_intersection | 1061 | 2.03 | 50.0 | 21.2 | 316 | 1.36 | 2.71 | 0.050 | 5/6 |
|
||||
| strength_875_intersection | 1044 | 2.04 | 49.7 | 21.2 | 316 | 1.31 | 2.77 | 0.051 | 5/6 |
|
||||
| strength_1000_intersection | 1037 | 2.06 | 50.7 | 21.7 | 316 | 1.30 | 2.82 | 0.054 | 5/6 |
|
||||
| strength_1125_intersection | 1026 | 2.00 | 48.4 | 21.5 | 316 | 1.20 | 2.82 | 0.047 | 3/6 |
|
||||
| strength_1250_intersection | 1025 | 2.01 | 49.0 | 21.5 | 316 | 1.20 | 2.82 | 0.046 | 3/6 |
|
||||
| strength_1500_intersection | 1010 | 2.03 | 48.8 | 20.9 | 313 | 1.28 | 2.79 | 0.034 | 6/6 |
|
||||
| strength_2000_intersection | 990 | 1.95 | 46.3 | 18.0 | 313 | 1.23 | 2.68 | 0.020 | 3/6 |
|
||||
|
||||
## Pre-registered interpretation
|
||||
|
||||
The six original guardrails remain unchanged. A stable candidate requires at least two adjacent non-control scales to pass all six; an isolated passing scale is rejected as sensitivity, not promoted.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
# GTL tuning matrix
|
||||
|
||||
Status: **complete**
|
||||
Holdout split: `2024-07-01`
|
||||
Completed arms: 20/20
|
||||
|
||||
| Arm | Full Sharpe | CAGR | Max DD | Trades | Train Sharpe | Test Sharpe | Ex-top-5% R | Screen |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| control | 2.03 | 50.0 | 21.4 | 321 | 1.28 | 2.78 | 0.049 | 0/0 |
|
||||
| lookback_252 | 1.43 | 26.0 | 22.8 | 241 | 0.78 | 2.07 | 0.152 | 1/6 |
|
||||
| lookback_504 | 1.75 | 38.8 | 21.4 | 289 | 1.32 | 2.20 | 0.055 | 4/6 |
|
||||
| lookback_756 | 1.60 | 36.4 | 21.4 | 318 | 1.28 | 1.81 | 0.013 | 4/6 |
|
||||
| candidates_8 | 1.91 | 44.5 | 18.9 | 320 | 1.44 | 2.39 | 0.024 | 4/6 |
|
||||
| candidates_all | 1.74 | 39.0 | 18.9 | 322 | 1.15 | 2.31 | 0.014 | 3/6 |
|
||||
| max_atr_5_5 | 1.61 | 37.0 | 23.1 | 348 | 1.15 | 2.09 | -0.011 | 1/6 |
|
||||
| max_atr_8 | 1.89 | 46.3 | 19.7 | 348 | 1.51 | 2.27 | 0.003 | 4/6 |
|
||||
| touch_0 | 1.88 | 45.5 | 22.0 | 321 | 1.25 | 2.48 | 0.053 | 2/6 |
|
||||
| touch_0_25pct | 1.94 | 47.9 | 20.8 | 323 | 1.27 | 2.57 | 0.050 | 3/6 |
|
||||
| merge_0_25pct | 1.82 | 43.4 | 20.8 | 327 | 1.78 | 1.71 | 0.062 | 4/6 |
|
||||
| merge_1pct | 1.52 | 34.8 | 19.4 | 319 | 1.49 | 1.42 | 0.045 | 4/6 |
|
||||
| zones_1pct | 1.71 | 38.3 | 20.3 | 291 | 1.43 | 1.91 | -0.044 | 3/6 |
|
||||
| zones_3pct | 1.71 | 41.9 | 17.8 | 339 | 1.34 | 1.92 | -0.006 | 3/6 |
|
||||
| grid_12 | 1.74 | 42.7 | 22.0 | 337 | 1.25 | 2.16 | 0.023 | 2/6 |
|
||||
| grid_32 | 1.63 | 37.8 | 17.3 | 325 | 1.54 | 1.57 | 0.043 | 4/6 |
|
||||
| pivots_none | 1.89 | 44.7 | 17.3 | 309 | 1.72 | 1.96 | 0.077 | 4/6 |
|
||||
| pivots_11bar | 1.31 | 27.9 | 18.7 | 334 | 1.15 | 1.43 | 0.011 | 3/6 |
|
||||
| strength_250 | 1.82 | 45.1 | 18.9 | 326 | 1.29 | 2.32 | 0.056 | 4/6 |
|
||||
| strength_1000 | 1.88 | 46.7 | 18.5 | 338 | 1.42 | 2.32 | 0.080 | 4/6 |
|
||||
|
||||
## Interpretation guardrail
|
||||
|
||||
The post-2024 interval has already informed prior research. The train/test columns are robustness checks, not a pristine holdout. A passing arm is a candidate for forward paper validation, not automatic production promotion.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"control_report": "/Users/taathde3/git/lab/signal_platform/reports/backtest-sr-full-production_control.json",
|
||||
"variant_report": "/Users/taathde3/git/lab/signal_platform/reports/backtest-sr-full-explicit_target_ladder.json",
|
||||
"control_variant": "production_control",
|
||||
"variant": "explicit_target_ladder",
|
||||
"retained": {
|
||||
"count": 1086,
|
||||
"net_avg_r": 0.2086,
|
||||
"net_avg_r_ex_top5": 0.0492,
|
||||
"hold30_avg_r": 0.6307
|
||||
},
|
||||
"added": {
|
||||
"count": 0,
|
||||
"net_avg_r": null,
|
||||
"net_avg_r_ex_top5": null,
|
||||
"hold30_avg_r": null
|
||||
},
|
||||
"removed": {
|
||||
"count": 0,
|
||||
"net_avg_r": null,
|
||||
"net_avg_r_ex_top5": null,
|
||||
"hold30_avg_r": null
|
||||
},
|
||||
"control_book": {
|
||||
"sharpe": 2.03,
|
||||
"cagr_pct": 50.0,
|
||||
"max_drawdown_pct": 21.4,
|
||||
"trades": 321,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"variant_book": {
|
||||
"sharpe": 2.03,
|
||||
"cagr_pct": 50.0,
|
||||
"max_drawdown_pct": 21.4,
|
||||
"trades": 321,
|
||||
"skipped_book_full": 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"control_report": "/Users/taathde3/git/lab/signal_platform/reports/backtest-sr-full-production_control.json",
|
||||
"variant_report": "/Users/taathde3/git/lab/signal_platform/reports/backtest-sr-full-rewrite_range504_structural_legacy_primary.json",
|
||||
"control_variant": "production_control",
|
||||
"variant": "rewrite_range504_structural_legacy_primary",
|
||||
"retained": {
|
||||
"count": 115,
|
||||
"net_avg_r": 0.4775,
|
||||
"net_avg_r_ex_top5": 0.345,
|
||||
"hold30_avg_r": 1.2952
|
||||
},
|
||||
"added": {
|
||||
"count": 175,
|
||||
"net_avg_r": 0.279,
|
||||
"net_avg_r_ex_top5": 0.1477,
|
||||
"hold30_avg_r": 0.7703
|
||||
},
|
||||
"removed": {
|
||||
"count": 971,
|
||||
"net_avg_r": 0.1789,
|
||||
"net_avg_r_ex_top5": 0.024,
|
||||
"hold30_avg_r": 0.552
|
||||
},
|
||||
"control_book": {
|
||||
"sharpe": 2.03,
|
||||
"cagr_pct": 50.0,
|
||||
"max_drawdown_pct": 21.4,
|
||||
"trades": 321,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"variant_book": {
|
||||
"sharpe": 1.53,
|
||||
"cagr_pct": 28.8,
|
||||
"max_drawdown_pct": 14.3,
|
||||
"trades": 170,
|
||||
"skipped_book_full": 8
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
symbol,date,direction,cohort,control_rr,variant_rr,control_prob,variant_prob,control_sources,variant_sources,control_net_r,variant_net_r,control_hold30_r,variant_hold30_r
|
||||
HOOD,2024-07-01,long,removed,2.363534,1.008379,20.78,53.96,pivot_point,volume_profile,-1.047696,0.979879,-1.019196,-1.019196
|
||||
APP,2024-07-02,long,removed,2.593435,1.597531,31.89,40.48,pivot_point+volume_profile,volume_profile,-1.161328,-1.161328,-1.129295,-1.129295
|
||||
AXON,2024-07-02,long,removed,2.088883,1.689069,34.97,38.94,pivot_point+volume_profile,volume_profile,2.034275,1.634462,-1.260972,-1.260972
|
||||
CVNA,2024-07-02,long,added,1.620942,3.003727,35.47,25.26,pivot_point,volume_profile,1.59883,-0.022112,1.252546,1.252546
|
||||
KEY,2024-07-02,long,retained,2.194943,2.010299,36.34,34.33,pivot_point+volume_profile,volume_profile,-1.174915,-1.174915,-1.121839,-1.121839
|
||||
SMCI,2024-07-02,long,added,1.790945,2.609255,35.95,28.16,pivot_point+volume_profile,volume_profile,-1.018032,-1.018032,-1.0,-1.0
|
||||
STX,2024-07-02,long,added,1.933209,2.395527,38.34,30.07,pivot_point+volume_profile,volume_profile,1.879937,2.342254,-1.0,-1.0
|
||||
TPL,2024-07-02,long,retained,2.197984,2.138032,31.91,32.79,pivot_point+volume_profile,volume_profile,2.155173,2.095222,2.455028,2.455028
|
||||
VLO,2024-07-02,long,removed,2.313153,1.768764,22.49,37.69,pivot_point+volume_profile,volume_profile,-1.050398,-1.050398,-1.0,-1.0
|
||||
APP,2024-07-10,long,retained,2.366003,2.392551,33.96,30.1,pivot_point+volume_profile,volume_profile,-1.031019,-1.031019,-1.0,-1.0
|
||||
CRH,2024-07-10,long,retained,2.366265,2.349782,32.76,30.52,pivot_point+volume_profile,volume_profile,2.311975,2.295492,-1.0,-1.0
|
||||
CVNA,2024-07-10,long,retained,2.586053,2.786918,22.55,26.76,pivot_point+volume_profile,volume_profile,-1.066693,-1.066693,-1.043339,-1.043339
|
||||
DELL,2024-07-10,long,retained,2.070397,2.070397,23.99,33.59,volume_profile,volume_profile,-1.026336,-1.026336,-1.0,-1.0
|
||||
GEN,2024-07-10,long,removed,2.391085,1.689807,25.12,23.92,pivot_point+volume_profile,volume_profile,-0.064503,1.625303,1.507702,1.507702
|
||||
KEY,2024-07-10,long,removed,7.212904,1.563743,25.59,41.07,pivot_point,volume_profile,-1.520191,1.509962,-1.46641,-1.46641
|
||||
NVDA,2024-07-10,long,added,1.056776,2.988849,42.75,25.36,volume_profile,volume_profile,-1.028158,-1.028158,-1.0,-1.0
|
||||
QCOM,2024-07-10,long,retained,2.214523,2.214523,22.33,31.93,pivot_point+volume_profile,volume_profile,-1.049717,-1.049717,-1.0,-1.0
|
||||
SMCI,2024-07-10,long,added,1.803073,2.05868,31.57,33.73,pivot_point+volume_profile,volume_profile,-1.020646,-1.020646,-1.0,-1.0
|
||||
STX,2024-07-10,long,removed,2.259748,1.550674,26.04,41.31,pivot_point+volume_profile,volume_profile,-1.058327,-1.058327,-1.0,-1.0
|
||||
TPL,2024-07-10,long,removed,2.4469,1.856628,29.79,36.39,pivot_point+volume_profile,volume_profile,-1.047047,1.809581,-1.0,-1.0
|
||||
HOOD,2024-07-16,long,added,1.202989,2.283322,39.07,31.2,pivot_point,volume_profile,-1.030546,-1.030546,-1.0,-1.0
|
||||
COIN,2024-07-17,long,retained,2.024981,2.994532,27.15,25.32,pivot_point+volume_profile,volume_profile,-1.024552,-1.024552,-1.0,-1.0
|
||||
CVNA,2024-07-17,long,added,1.892159,2.345824,27.9,30.56,pivot_point,volume_profile,-1.217536,-1.217536,-1.195482,-1.195482
|
||||
PSX,2024-07-17,long,removed,2.638302,1.847648,24.12,36.52,pivot_point+volume_profile,volume_profile,-1.061989,-1.061989,-1.0,-1.0
|
||||
TPL,2024-07-17,long,removed,2.191527,1.564777,28.58,41.06,pivot_point+volume_profile,volume_profile,-1.051591,-1.051591,-1.0,-1.0
|
||||
COIN,2024-07-24,long,added,1.949161,2.798867,28.12,26.67,pivot_point+volume_profile,volume_profile,-1.021168,-1.021168,-1.0,-1.0
|
||||
T,2024-07-24,long,removed,2.024326,1.762354,26.56,22.78,pivot_point,volume_profile,1.959139,1.697168,2.53466,2.53466
|
||||
C,2024-07-31,long,removed,2.04091,0.679298,28.95,50.76,pivot_point+volume_profile,volume_profile,-1.056715,-1.056715,-1.0,-1.0
|
||||
CVNA,2024-07-31,long,added,1.764983,2.179133,29.74,32.32,pivot_point,volume_profile,-1.073779,-1.073779,-1.053721,-1.053721
|
||||
DECK,2024-07-31,long,retained,2.204131,2.230714,24.24,31.75,pivot_point+volume_profile,volume_profile,-1.035117,-1.035117,-1.0,-1.0
|
||||
IP,2024-07-31,long,retained,2.061675,2.896383,28.69,25.98,pivot_point+volume_profile,volume_profile,-1.414336,-1.414336,-1.363237,-1.363237
|
||||
MPC,2024-07-31,long,retained,2.070547,2.070547,26.39,33.59,pivot_point+volume_profile,volume_profile,-1.0517,-1.0517,-1.0,-1.0
|
||||
PSX,2024-07-31,long,removed,2.68204,1.71537,22.76,38.51,pivot_point+volume_profile,volume_profile,-1.054284,-1.054284,-1.0,-1.0
|
||||
RL,2024-07-31,long,removed,2.162234,1.801202,22.71,37.2,pivot_point,volume_profile,-1.203596,-1.203596,-1.157407,-1.157407
|
||||
LHX,2024-08-07,long,removed,2.486849,1.866229,24.22,21.26,pivot_point+volume_profile,volume_profile,-0.060235,-0.060235,0.481976,0.481976
|
||||
CVNA,2024-08-14,long,added,0.830134,2.688769,54.21,27.51,pivot_point,volume_profile,0.810487,-1.019647,-1.0,-1.0
|
||||
DECK,2024-08-14,long,retained,2.250336,2.276737,23.74,31.26,pivot_point+volume_profile,volume_profile,-1.034755,-1.034755,-1.0,-1.0
|
||||
IP,2024-08-14,long,added,1.832687,2.216827,46.74,31.9,pivot_point+volume_profile,volume_profile,1.780846,2.164986,1.646715,1.646715
|
||||
PANW,2024-08-14,long,retained,2.056851,2.056851,24.55,33.75,pivot_point+volume_profile,volume_profile,2.016184,2.016184,-0.070585,-0.070585
|
||||
UBER,2024-08-14,long,added,1.686961,2.109652,39.77,33.12,pivot_point+volume_profile,volume_profile,-1.03507,-1.03507,-1.0,-1.0
|
||||
VRT,2024-08-14,long,added,1.982701,2.095018,38.68,33.29,pivot_point+volume_profile,volume_profile,-1.020453,-1.020453,-1.0,-1.0
|
||||
CRWD,2024-08-21,long,added,1.638443,2.259128,49.77,31.45,pivot_point,volume_profile,-1.030148,-1.030148,-1.0,-1.0
|
||||
IFF,2024-08-21,long,retained,2.083267,2.056829,34.83,33.75,pivot_point+volume_profile,volume_profile,-0.05916,-0.05916,0.112124,0.112124
|
||||
IP,2024-08-21,long,retained,5.241123,2.180305,27.42,32.31,pivot_point+volume_profile,volume_profile,-1.059221,-1.059221,-1.0,-1.0
|
||||
TFC,2024-08-21,long,removed,2.364212,1.315501,25.38,31.04,pivot_point+volume_profile,volume_profile,-0.057957,1.257544,-0.413095,-0.413095
|
||||
VST,2024-08-21,long,added,1.802328,2.116052,35.18,33.04,pivot_point+volume_profile,volume_profile,-1.025569,-1.025569,-1.0,-1.0
|
||||
CEG,2024-08-27,long,retained,2.152078,2.134751,40.63,32.83,pivot_point+volume_profile,volume_profile,-1.03271,-1.03271,-1.0,-1.0
|
||||
HOOD,2024-08-27,long,added,1.976913,2.059984,26.16,33.71,pivot_point+volume_profile,volume_profile,-1.028923,-1.028923,-1.0,-1.0
|
||||
BAC,2024-08-28,long,removed,2.358212,1.561372,25.44,26.12,pivot_point+volume_profile,volume_profile,-1.062807,-1.062807,-1.0,-1.0
|
||||
CFG,2024-08-28,long,removed,2.897858,0.973092,20.97,40.03,pivot_point+volume_profile,volume_profile,-1.057175,-1.057175,-1.0,-1.0
|
||||
COF,2024-08-28,long,removed,2.031659,1.595906,29.06,25.5,pivot_point+volume_profile,volume_profile,-1.057914,-1.057914,-1.0,-1.0
|
||||
CRWD,2024-08-28,long,added,1.579228,3.019642,50.8,25.16,pivot_point+volume_profile,volume_profile,-1.03194,-1.03194,-1.0,-1.0
|
||||
CVNA,2024-08-28,long,added,1.787492,2.250672,27.8,31.54,pivot_point,volume_profile,-1.026143,-1.026143,-1.0,-1.0
|
||||
IP,2024-08-28,long,retained,5.632903,2.227531,26.85,31.79,pivot_point+volume_profile,volume_profile,-1.066284,-1.066284,-1.0,-1.0
|
||||
KEY,2024-08-28,long,added,1.718821,2.105375,48.46,33.17,pivot_point+volume_profile,volume_profile,-1.044658,-1.044658,-1.0,-1.0
|
||||
NVDA,2024-08-28,long,added,1.068723,2.960434,43.01,25.54,pivot_point+volume_profile,volume_profile,-1.025929,-1.025929,-1.0,-1.0
|
||||
SW,2024-08-28,long,retained,2.747627,2.079496,37.05,33.48,pivot_point+volume_profile,volume_profile,-1.045941,-1.045941,-1.0,-1.0
|
||||
TFC,2024-08-28,long,removed,2.90902,1.582712,20.89,25.74,pivot_point+volume_profile,volume_profile,-1.062366,-1.062366,-1.0,-1.0
|
||||
AMT,2024-09-05,long,retained,2.079796,3.058994,43.47,24.91,pivot_point+volume_profile,volume_profile,-1.166052,-1.166052,-1.099904,-1.099904
|
||||
APP,2024-09-05,long,retained,2.185527,2.355959,23.45,30.46,pivot_point+volume_profile,volume_profile,2.15693,2.327362,8.886403,8.886403
|
||||
FITB,2024-09-05,long,removed,2.073653,1.910131,43.55,35.65,pivot_point+volume_profile,volume_profile,-1.063196,-1.063196,-1.0,-1.0
|
||||
KKR,2024-09-05,long,removed,2.181887,1.723269,22.49,38.39,pivot_point,volume_profile,2.13195,1.673332,4.089721,4.089721
|
||||
NRG,2024-09-05,long,removed,2.25116,1.894631,21.73,35.86,pivot_point,volume_profile,2.212092,1.855563,1.85814,1.85814
|
||||
RL,2024-09-05,long,added,1.555085,2.24363,50.83,31.61,pivot_point,volume_profile,1.506102,2.194647,4.172316,4.172316
|
||||
RMD,2024-09-05,long,added,1.99921,2.840897,44.47,26.37,pivot_point+volume_profile,volume_profile,-1.226172,-1.226172,-1.174599,-1.174599
|
||||
CEG,2024-09-11,long,added,1.880922,2.172351,41.85,32.4,pivot_point+volume_profile,volume_profile,1.848098,2.139527,6.906646,6.906646
|
||||
HOOD,2024-09-11,long,retained,2.083757,2.549773,26.63,28.66,pivot_point,volume_profile,2.055784,2.5218,4.106526,4.106526
|
||||
AMT,2024-09-12,long,added,,2.5128,,28.99,,volume_profile,,-1.065809,,-1.0
|
||||
CMG,2024-09-12,long,retained,2.010624,2.010624,37.13,34.33,pivot_point+volume_profile,volume_profile,-0.045527,-0.045527,1.248258,1.248258
|
||||
DASH,2024-09-12,long,retained,2.285917,2.438839,25.57,29.66,pivot_point+volume_profile,volume_profile,2.239634,2.392556,4.088344,4.088344
|
||||
FITB,2024-09-12,long,removed,2.134405,1.061322,27.83,37.42,pivot_point+volume_profile,volume_profile,2.080604,1.007521,1.888333,1.888333
|
||||
RL,2024-09-12,long,added,,2.091438,,33.33,,volume_profile,,2.038762,,3.391383
|
||||
RMD,2024-09-12,long,retained,2.562837,2.163551,29.95,32.5,pivot_point+volume_profile,volume_profile,-1.808118,-1.808118,-1.756551,-1.756551
|
||||
VRT,2024-09-12,long,retained,2.005719,2.207135,25.99,32.01,pivot_point+volume_profile,volume_profile,1.980011,2.181427,3.447561,3.447561
|
||||
CFG,2024-09-19,long,added,1.562785,2.226657,51.09,31.8,pivot_point,volume_profile,-1.051285,-1.051285,-1.0,-1.0
|
||||
CPB,2024-09-19,long,removed,2.043159,0.894853,28.92,42.56,pivot_point+volume_profile,volume_profile,-1.131369,-1.131369,-1.069907,-1.069907
|
||||
CRWD,2024-09-19,long,added,1.654913,2.384252,49.5,30.18,pivot_point,volume_profile,1.618946,2.348286,1.263574,1.263574
|
||||
CVNA,2024-09-19,long,retained,2.373325,2.373325,20.69,30.29,volume_profile,volume_profile,2.345166,2.345166,6.312101,6.312101
|
||||
DASH,2024-09-19,long,retained,2.097487,2.973728,26.66,25.45,pivot_point,volume_profile,2.0506,2.926841,3.314464,3.314464
|
||||
DELL,2024-09-19,long,retained,2.078075,2.078075,31.49,33.49,pivot_point+volume_profile,volume_profile,2.04365,2.04365,0.856462,0.856462
|
||||
FITB,2024-09-19,long,retained,2.259068,2.276905,31.45,31.26,pivot_point+volume_profile,volume_profile,-1.057874,-1.057874,-1.0,-1.0
|
||||
IP,2024-09-19,long,retained,4.401095,2.251491,29.22,31.53,pivot_point+volume_profile,volume_profile,-1.065161,-1.065161,-1.0,-1.0
|
||||
RMD,2024-09-19,long,added,1.992176,2.758242,44.56,26.97,pivot_point+volume_profile,volume_profile,-1.046594,-1.046594,-1.0,-1.0
|
||||
TFC,2024-09-19,long,added,1.809614,2.2605,47.07,31.44,pivot_point+volume_profile,volume_profile,-1.0599,-1.0599,-1.0,-1.0
|
||||
VRT,2024-09-19,long,added,1.947838,2.250443,26.14,31.54,pivot_point+volume_profile,volume_profile,1.919599,2.222204,2.642618,2.642618
|
||||
VST,2024-09-19,long,retained,2.072291,2.072291,23.96,33.56,volume_profile,volume_profile,2.040867,2.040867,5.458704,5.458704
|
||||
CVNA,2024-09-26,long,retained,10.231888,2.474562,20.25,29.33,pivot_point+volume_profile,volume_profile,-0.029905,2.444657,6.135639,6.135639
|
||||
DASH,2024-09-26,long,retained,7.365516,2.10784,25.52,33.14,pivot_point+volume_profile,volume_profile,-0.0516,2.05624,4.973482,4.973482
|
||||
FITB,2024-09-26,long,removed,2.083968,1.931414,21.22,20.36,pivot_point+volume_profile,volume_profile,-1.060556,-1.060556,-1.0,-1.0
|
||||
RMD,2024-09-26,long,removed,2.186908,1.266636,27.23,32.15,pivot_point+volume_profile,volume_profile,-1.050048,-1.050048,-1.0,-1.0
|
||||
TGT,2024-09-26,long,removed,2.292853,1.776621,26.1,22.57,pivot_point,volume_profile,-1.06012,-1.06012,-1.0,-1.0
|
||||
WSM,2024-09-26,long,removed,2.402788,1.658261,20.4,39.44,pivot_point+volume_profile,volume_profile,-1.137583,-1.137583,-1.101101,-1.101101
|
||||
CVNA,2024-10-03,long,added,1.664246,2.109813,29.74,33.12,pivot_point,volume_profile,1.631671,2.077238,5.882489,5.882489
|
||||
DECK,2024-10-03,long,retained,2.168365,2.199366,24.44,32.1,pivot_point+volume_profile,volume_profile,2.126606,2.157607,2.672686,2.672686
|
||||
MSTR,2024-10-03,long,retained,2.022121,2.022121,24.38,34.18,volume_profile,volume_profile,2.001418,2.001418,10.405324,10.405324
|
||||
NEM,2024-10-03,long,removed,2.437475,1.6442,24.67,24.68,pivot_point+volume_profile,volume_profile,2.383997,1.590722,-1.0,-1.0
|
||||
CVNA,2024-10-10,long,retained,9.123898,2.511664,20.12,29.0,pivot_point+volume_profile,volume_profile,-0.036109,2.475555,5.358405,5.358405
|
||||
FITB,2024-10-10,long,retained,2.456651,2.29033,32.3,31.12,pivot_point+volume_profile,volume_profile,2.390999,2.224679,3.42334,3.42334
|
||||
NVDA,2024-10-10,long,added,0.7889,2.583282,51.54,28.38,pivot_point,volume_profile,0.753151,-0.035748,1.572496,1.572496
|
||||
PODD,2024-10-10,long,removed,2.231607,1.720271,26.74,23.44,pivot_point+volume_profile,volume_profile,2.180027,1.668691,3.435679,3.435679
|
||||
COF,2024-10-17,long,added,,2.025877,,34.14,,volume_profile,,-1.058059,,-1.0
|
||||
COIN,2024-10-17,long,added,1.642968,2.257497,49.7,31.47,pivot_point,volume_profile,-1.024139,-1.024139,-1.0,-1.0
|
||||
CVNA,2024-10-17,long,retained,9.492877,2.588273,20.09,28.33,pivot_point+volume_profile,volume_profile,-0.037775,2.550498,6.74196,6.74196
|
||||
DASH,2024-10-17,long,retained,4.392565,2.700376,26.45,27.42,pivot_point+volume_profile,volume_profile,4.334569,2.64238,5.569761,5.569761
|
||||
GM,2024-10-17,long,retained,2.503676,2.380907,39.07,30.21,pivot_point+volume_profile,volume_profile,2.451817,2.329049,3.26087,3.26087
|
||||
NVDA,2024-10-17,long,retained,2.240177,2.240177,21.85,31.65,volume_profile,volume_profile,-0.035332,-0.035332,0.170302,0.170302
|
||||
PNC,2024-10-17,long,removed,2.403014,1.107655,25.0,36.14,pivot_point+volume_profile,volume_profile,2.340479,1.045121,4.292634,4.292634
|
||||
CFG,2024-10-24,long,removed,2.405384,1.535265,23.18,26.59,pivot_point+volume_profile,volume_profile,2.352507,1.482388,3.350752,3.350752
|
||||
COF,2024-10-24,long,removed,2.345874,1.529143,25.56,26.7,pivot_point+volume_profile,volume_profile,2.288967,1.472236,6.44221,6.44221
|
||||
DASH,2024-10-24,long,retained,4.421262,2.573824,26.37,28.46,pivot_point+volume_profile,volume_profile,4.357196,2.509759,5.28825,5.28825
|
||||
RMD,2024-10-24,long,removed,2.039677,0.914596,28.96,41.9,pivot_point+volume_profile,volume_profile,1.980043,0.854962,0.294367,0.294367
|
||||
HOOD,2024-10-30,long,added,0.688621,2.735256,55.76,27.15,pivot_point,volume_profile,-1.531615,-1.531615,-1.493148,-1.493148
|
||||
CFG,2024-10-31,long,retained,2.172117,2.386818,40.6,30.16,pivot_point+volume_profile,volume_profile,2.118575,2.333276,2.2754,2.2754
|
||||
CVNA,2024-10-31,long,removed,2.780741,1.821707,31.41,36.9,pivot_point+volume_profile,volume_profile,-1.033504,-1.033504,-1.0,-1.0
|
||||
DASH,2024-10-31,long,removed,5.013156,1.624669,27.42,40.01,pivot_point+volume_profile,volume_profile,-0.057868,1.566801,3.395652,3.395652
|
||||
IP,2024-10-31,long,added,,2.088162,,33.37,,volume_profile,,2.038095,,0.0
|
||||
PODD,2024-10-31,long,removed,2.039063,1.924042,28.97,20.46,pivot_point+volume_profile,volume_profile,1.980028,1.865007,4.820368,4.820368
|
||||
RMD,2024-10-31,long,removed,2.182318,1.268032,27.29,32.12,pivot_point+volume_profile,volume_profile,-1.049721,1.218312,-1.0,-1.0
|
||||
HOOD,2024-11-06,long,added,1.731126,2.790779,30.87,26.73,pivot_point,volume_profile,1.70455,2.764204,3.150657,3.150657
|
||||
CFG,2024-11-07,long,added,1.952332,3.052252,41.48,24.95,pivot_point+volume_profile,volume_profile,-1.041993,-1.041993,-1.0,-1.0
|
||||
CVNA,2024-11-07,long,retained,3.046097,2.126585,29.59,32.92,pivot_point+volume_profile,volume_profile,-1.031364,-1.031364,-1.0,-1.0
|
||||
NTAP,2024-11-07,long,removed,2.609349,1.630808,20.16,39.9,pivot_point+volume_profile,volume_profile,-1.548219,-1.548219,-1.485722,-1.485722
|
||||
NVDA,2024-11-07,long,retained,2.116497,2.116497,23.24,33.04,volume_profile,volume_profile,-1.042958,-1.042958,-1.0,-1.0
|
||||
ZBRA,2024-11-07,long,retained,12.224687,2.898061,25.01,25.96,pivot_point+volume_profile,volume_profile,-1.058806,-1.058806,-1.0,-1.0
|
||||
HOOD,2024-11-13,long,removed,2.810736,1.620896,25.78,40.07,pivot_point+volume_profile,volume_profile,2.78623,1.596391,2.7301,2.7301
|
||||
CFG,2024-11-14,long,added,1.901703,2.258489,41.96,31.46,pivot_point+volume_profile,volume_profile,-1.047497,-1.047497,-1.0,-1.0
|
||||
CVNA,2024-11-14,long,retained,3.294474,2.320747,27.94,30.81,pivot_point+volume_profile,volume_profile,-1.033075,-1.033075,-1.0,-1.0
|
||||
FIS,2024-11-14,long,removed,2.102161,1.197044,28.21,33.82,pivot_point+volume_profile,volume_profile,-1.066206,-1.066206,-1.0,-1.0
|
||||
IP,2024-11-14,long,removed,2.506606,1.626125,21.04,39.98,pivot_point+volume_profile,volume_profile,-1.057094,1.569031,-1.0,-1.0
|
||||
NVDA,2024-11-14,long,added,1.135555,2.5309,40.59,28.83,volume_profile,volume_profile,-1.044243,-1.044243,-1.0,-1.0
|
||||
TSN,2024-11-14,long,retained,2.596128,2.001554,25.87,34.44,pivot_point+volume_profile,volume_profile,-1.055266,-1.055266,-1.0,-1.0
|
||||
ZBRA,2024-11-14,long,removed,15.069871,1.640643,25.0,39.74,pivot_point+volume_profile,volume_profile,-1.227627,-1.227627,-1.164907,-1.164907
|
||||
HOOD,2024-11-20,long,retained,2.234166,2.76471,28.52,26.93,pivot_point,volume_profile,2.208817,-0.025349,2.329013,2.329013
|
||||
CFG,2024-11-21,long,retained,2.044706,2.448911,40.1,29.57,pivot_point+volume_profile,volume_profile,-1.054029,-1.054029,-1.0,-1.0
|
||||
CVNA,2024-11-21,long,removed,2.887708,1.864361,30.44,36.28,pivot_point+volume_profile,volume_profile,-1.03591,-1.03591,-1.0,-1.0
|
||||
DASH,2024-11-21,long,added,1.571744,2.048168,50.13,33.86,pivot_point+volume_profile,volume_profile,-1.05097,-1.05097,-1.0,-1.0
|
||||
NVDA,2024-11-21,long,retained,2.121212,2.121212,23.18,32.98,volume_profile,volume_profile,-1.036861,-1.036861,-1.0,-1.0
|
||||
RMD,2024-11-21,long,retained,2.331234,2.277761,40.71,31.25,pivot_point+volume_profile,volume_profile,-1.056353,-1.056353,-1.0,-1.0
|
||||
TSN,2024-11-21,long,retained,9.528478,2.276611,25.09,31.27,pivot_point+volume_profile,volume_profile,-1.059542,-1.059542,-1.0,-1.0
|
||||
HOOD,2024-11-27,long,added,1.568666,2.063271,37.79,33.67,pivot_point,volume_profile,1.544006,-1.02466,-1.0,-1.0
|
||||
CFG,2024-11-29,long,removed,2.778313,1.74038,22.02,38.12,pivot_point+volume_profile,volume_profile,-1.058389,-1.058389,-1.0,-1.0
|
||||
CVNA,2024-11-29,long,retained,2.009852,2.333981,38.54,30.68,pivot_point+volume_profile,volume_profile,-1.03745,-1.03745,-1.0,-1.0
|
||||
DASH,2024-11-29,long,retained,2.259005,2.709896,37.65,27.35,pivot_point+volume_profile,volume_profile,-1.056211,-1.056211,-1.0,-1.0
|
||||
GM,2024-11-29,long,removed,2.328011,1.562034,28.34,41.1,pivot_point+volume_profile,volume_profile,-1.038933,-1.038933,-1.0,-1.0
|
||||
INCY,2024-11-29,long,removed,2.225995,1.892311,41.8,35.89,pivot_point+volume_profile,volume_profile,-1.142421,-1.142421,-1.102218,-1.102218
|
||||
NEE,2024-11-29,long,removed,2.084122,1.817583,28.42,21.96,pivot_point+volume_profile,volume_profile,-1.122428,-1.122428,-1.059965,-1.059965
|
||||
UHS,2024-11-29,long,added,1.577296,2.002636,36.23,34.43,pivot_point+volume_profile,volume_profile,-1.045223,-1.045223,-1.0,-1.0
|
||||
HOOD,2024-12-05,long,removed,2.527938,1.550767,22.45,41.31,pivot_point+volume_profile,volume_profile,-1.023997,-1.023997,-1.0,-1.0
|
||||
CFG,2024-12-06,long,removed,2.094709,1.448606,24.3,28.24,pivot_point+volume_profile,volume_profile,-1.06007,-1.06007,-1.0,-1.0
|
||||
HOOD,2024-12-12,long,retained,2.151963,2.356331,26.23,30.46,pivot_point+volume_profile,volume_profile,-1.187492,-1.187492,-1.165762,-1.165762
|
||||
EBAY,2024-12-20,long,removed,2.41583,1.503526,39.88,42.18,pivot_point+volume_profile,volume_profile,-1.0524,-1.0524,-1.0,-1.0
|
||||
HOOD,2024-12-27,long,retained,2.132954,2.327764,26.45,30.74,pivot_point+volume_profile,volume_profile,2.112404,2.307214,4.447604,4.447604
|
||||
HOOD,2025-01-06,long,retained,2.316894,2.534928,22.25,28.79,pivot_point+volume_profile,volume_profile,-1.023902,-1.023902,-1.0,-1.0
|
||||
TPL,2025-01-07,long,added,1.779801,2.801287,28.52,26.65,pivot_point+volume_profile,volume_profile,1.75231,-0.02749,0.953313,0.953313
|
||||
LDOS,2025-01-15,long,retained,2.022643,2.271702,42.78,31.32,pivot_point+volume_profile,volume_profile,-1.054629,-1.054629,-1.0,-1.0
|
||||
MTB,2025-01-15,long,added,1.506775,2.334803,37.12,30.67,pivot_point+volume_profile,volume_profile,-1.05847,-1.05847,-1.0,-1.0
|
||||
SATS,2025-01-15,long,removed,2.084923,1.904701,32.21,35.72,pivot_point,volume_profile,2.05013,1.869907,4.79693,4.79693
|
||||
HOOD,2025-01-22,long,added,1.485045,2.537047,32.93,28.77,volume_profile,volume_profile,1.45805,2.510052,-1.0,-1.0
|
||||
CBOE,2025-01-23,long,removed,2.406105,1.814548,29.77,37.0,pivot_point+volume_profile,volume_profile,2.340848,1.749291,1.843355,1.843355
|
||||
CHRW,2025-01-23,long,retained,2.066756,2.066756,36.03,33.63,pivot_point+volume_profile,volume_profile,-2.1852,-2.1852,-2.123782,-2.123782
|
||||
DASH,2025-01-23,long,added,1.755187,2.183821,43.69,32.27,pivot_point+volume_profile,volume_profile,-1.054221,-1.054221,-1.0,-1.0
|
||||
EBAY,2025-01-23,long,removed,2.148392,1.370809,27.67,29.84,pivot_point+volume_profile,volume_profile,2.103909,1.326326,-1.0,-1.0
|
||||
GM,2025-01-23,long,removed,2.219727,1.556927,41.87,41.2,pivot_point+volume_profile,volume_profile,-1.377279,-1.377279,-1.332675,-1.332675
|
||||
TPL,2025-01-23,long,retained,2.215306,2.215306,22.52,31.92,volume_profile,volume_profile,-1.034187,-1.034187,-1.0,-1.0
|
||||
ZBRA,2025-01-23,long,retained,10.101825,2.592492,25.05,28.3,pivot_point+volume_profile,volume_profile,-1.239258,-1.239258,-1.179004,-1.179004
|
||||
HOOD,2025-01-29,long,retained,2.182893,2.182893,22.68,32.28,volume_profile,volume_profile,2.156429,2.156429,-1.0,-1.0
|
||||
AEP,2025-01-30,long,removed,2.291606,1.611789,33.31,40.23,pivot_point+volume_profile,volume_profile,2.226497,1.54668,2.500623,2.500623
|
||||
D,2025-01-30,long,added,1.81942,2.484021,46.93,29.25,pivot_point+volume_profile,volume_profile,-1.062307,-1.062307,-1.0,-1.0
|
||||
DASH,2025-01-30,long,retained,3.032273,2.965588,24.48,25.51,pivot_point+volume_profile,volume_profile,2.976645,2.909961,-1.0,-1.0
|
||||
EBAY,2025-01-30,long,retained,2.31507,2.050187,40.87,33.83,pivot_point+volume_profile,volume_profile,-1.2003,-1.2003,-1.152131,-1.152131
|
||||
NEE,2025-01-30,long,removed,2.856326,1.678544,21.26,24.11,pivot_point+volume_profile,volume_profile,-1.048252,-1.048252,-1.0,-1.0
|
||||
PODD,2025-01-30,long,removed,3.011956,1.716997,35.21,38.49,pivot_point+volume_profile,volume_profile,-1.050309,-1.050309,-1.0,-1.0
|
||||
SO,2025-01-30,long,removed,2.663459,1.959193,22.11,34.99,pivot_point+volume_profile,volume_profile,2.597821,1.893554,2.106036,2.106036
|
||||
T,2025-01-30,long,retained,3.890785,2.286312,30.86,31.16,pivot_point+volume_profile,volume_profile,3.827951,2.223478,3.348375,3.348375
|
||||
HOOD,2025-02-05,long,added,1.411529,2.435669,34.39,29.69,volume_profile,volume_profile,1.383132,2.407272,-1.0,-1.0
|
||||
CFG,2025-02-06,long,retained,2.426162,2.426162,24.58,29.78,pivot_point+volume_profile,volume_profile,-1.053272,-1.053272,-1.0,-1.0
|
||||
EBAY,2025-02-06,long,retained,2.564372,2.271866,38.54,31.32,pivot_point+volume_profile,volume_profile,-1.317578,-1.317578,-1.264402,-1.264402
|
||||
FIX,2025-02-06,long,retained,2.001518,2.001518,24.84,34.44,volume_profile,volume_profile,-1.026408,-1.026408,-1.0,-1.0
|
||||
MTB,2025-02-06,long,retained,2.398987,2.405595,22.84,29.98,pivot_point+volume_profile,volume_profile,-1.064938,-1.064938,-1.0,-1.0
|
||||
PODD,2025-02-06,long,retained,2.44026,2.071586,39.65,33.57,pivot_point+volume_profile,volume_profile,-1.054228,-1.054228,-1.0,-1.0
|
||||
VTR,2025-02-06,long,removed,2.336742,1.660532,40.65,39.4,pivot_point+volume_profile,volume_profile,2.270724,1.594514,3.446489,3.446489
|
||||
CVNA,2025-02-13,long,retained,2.034467,2.549029,36.23,28.67,pivot_point+volume_profile,volume_profile,-1.035496,-1.035496,-1.0,-1.0
|
||||
D,2025-02-13,long,removed,2.394779,1.857229,40.08,36.38,pivot_point+volume_profile,volume_profile,-1.059337,-1.059337,-1.0,-1.0
|
||||
NVDA,2025-02-13,long,added,0.695595,2.97396,66.27,25.45,pivot_point+volume_profile,volume_profile,0.667121,-1.028474,-1.0,-1.0
|
||||
HOOD,2025-02-20,long,added,1.380515,2.831765,35.23,26.43,volume_profile,volume_profile,-1.02081,-1.02081,-1.0,-1.0
|
||||
DASH,2025-02-21,long,added,1.564233,2.063261,35.07,33.67,pivot_point,volume_profile,-1.042246,-1.042246,-1.0,-1.0
|
||||
EBAY,2025-02-21,long,removed,2.020731,1.695948,44.2,38.83,pivot_point+volume_profile,volume_profile,-2.291229,-2.291229,-2.230532,-2.230532
|
||||
CHRW,2025-02-28,long,removed,2.524211,1.154679,23.89,34.89,pivot_point+volume_profile,volume_profile,-1.056592,-1.056592,-1.0,-1.0
|
||||
DASH,2025-02-28,long,added,1.553079,2.002972,35.27,34.42,pivot_point,volume_profile,-1.0378,-1.0378,-1.0,-1.0
|
||||
CHRW,2025-03-07,long,removed,2.160874,1.608008,42.53,40.29,pivot_point+volume_profile,volume_profile,-1.053765,-1.053765,-1.0,-1.0
|
||||
NEE,2025-03-07,long,added,1.87582,2.31029,46.12,30.92,pivot_point+volume_profile,volume_profile,-1.058424,-1.058424,-1.0,-1.0
|
||||
NEM,2025-03-07,long,removed,2.53317,1.950455,23.81,20.11,pivot_point,volume_profile,2.491007,1.908292,5.449434,5.449434
|
||||
T,2025-03-07,long,retained,2.028456,2.028456,25.3,34.1,pivot_point+volume_profile,volume_profile,-1.060056,-1.060056,-1.0,-1.0
|
||||
KMI,2025-03-14,long,retained,2.170945,2.170945,22.81,32.41,volume_profile,volume_profile,-1.050685,-1.050685,-1.0,-1.0
|
||||
NEM,2025-03-14,long,removed,2.932488,0.767514,20.73,47.16,pivot_point+volume_profile,volume_profile,-1.04203,0.725485,-1.0,-1.0
|
||||
AXON,2025-03-21,long,removed,2.475348,1.752563,20.72,37.93,volume_profile,volume_profile,-1.025557,-1.025557,-1.0,-1.0
|
||||
GDDY,2025-03-21,long,retained,2.701088,2.199113,32.01,32.1,pivot_point+volume_profile,volume_profile,-1.047347,-1.047347,-1.0,-1.0
|
||||
NEM,2025-03-21,long,removed,2.205772,1.643242,42.03,39.69,pivot_point+volume_profile,volume_profile,-1.04556,-1.04556,-1.0,-1.0
|
||||
AXON,2025-04-11,long,retained,2.030874,2.030874,25.47,34.07,volume_profile,volume_profile,2.007918,2.007918,3.59907,3.59907
|
||||
CVNA,2025-04-11,long,removed,2.353936,1.948265,36.68,35.14,pivot_point+volume_profile,volume_profile,2.342119,1.936448,3.057394,3.057394
|
||||
IBKR,2025-04-11,long,removed,2.24209,1.915206,24.63,35.58,pivot_point+volume_profile,volume_profile,-1.019948,-1.019948,-1.0,-1.0
|
||||
TYL,2025-04-11,long,added,1.851443,2.070885,43.47,33.58,pivot_point+volume_profile,volume_profile,-1.036647,-1.036647,-1.0,-1.0
|
||||
WMT,2025-04-11,long,retained,2.018185,2.122872,26.03,32.96,pivot_point+volume_profile,volume_profile,-0.036331,-0.036331,0.935686,0.935686
|
||||
WMT,2025-04-21,long,removed,2.218352,1.642866,23.69,39.7,pivot_point+volume_profile,volume_profile,-0.038317,1.604549,1.569432,1.569432
|
||||
APP,2025-04-28,long,removed,2.449157,1.806118,22.57,37.13,pivot_point+volume_profile,volume_profile,2.434952,1.791913,2.458197,2.458197
|
||||
CHTR,2025-04-28,long,added,1.788035,2.03155,47.39,34.06,pivot_point+volume_profile,volume_profile,1.757898,2.001414,1.195488,1.195488
|
||||
DASH,2025-04-28,long,added,1.53196,3.055881,40.85,24.93,pivot_point+volume_profile,volume_profile,1.504868,-0.027092,1.953171,1.953171
|
||||
FICO,2025-04-28,long,removed,2.589579,1.804765,20.32,37.15,pivot_point+volume_profile,volume_profile,-1.034812,1.769953,-1.0,-1.0
|
||||
GDDY,2025-04-28,long,added,1.783221,2.20482,41.07,32.04,pivot_point+volume_profile,volume_profile,-1.4347,-1.4347,-1.393018,-1.393018
|
||||
GEN,2025-04-28,long,added,1.995704,2.618785,44.52,28.08,pivot_point+volume_profile,volume_profile,1.955837,2.578918,3.132201,3.132201
|
||||
ISRG,2025-04-28,long,retained,2.051044,2.044118,33.02,33.91,pivot_point+volume_profile,volume_profile,-0.030325,-0.030325,0.459884,0.459884
|
||||
LITE,2025-04-28,long,removed,2.424966,1.902659,39.79,35.75,pivot_point+volume_profile,volume_profile,2.408499,1.886192,2.998472,2.998472
|
||||
LYV,2025-04-28,long,removed,2.22777,1.63153,23.59,39.89,pivot_point+volume_profile,volume_profile,-0.033994,1.597536,1.331497,1.331497
|
||||
MAA,2025-04-28,long,removed,2.30841,1.614858,25.94,25.18,pivot_point+volume_profile,volume_profile,-1.050537,-1.050537,-1.0,-1.0
|
||||
MSTR,2025-04-28,long,added,1.849639,2.909547,27.49,25.89,pivot_point,volume_profile,-0.019982,-0.019982,0.593368,0.593368
|
||||
TPR,2025-04-28,long,retained,2.39514,2.600039,21.48,28.23,pivot_point+volume_profile,volume_profile,2.368427,2.573326,2.053804,2.053804
|
||||
VST,2025-04-28,long,added,1.987113,2.84257,28.83,26.35,pivot_point+volume_profile,volume_profile,1.96779,2.823248,2.610974,2.610974
|
||||
KVUE,2025-05-01,long,retained,2.541159,2.399402,34.74,30.04,pivot_point+volume_profile,volume_profile,-1.044749,-1.044749,-1.0,-1.0
|
||||
HOOD,2025-05-02,long,added,1.579848,2.71337,31.59,27.32,pivot_point,volume_profile,1.56216,2.695683,5.125405,5.125405
|
||||
ABBV,2025-05-05,long,added,1.695505,2.083795,32.43,33.43,pivot_point+volume_profile,volume_profile,-1.041348,-1.041348,-1.0,-1.0
|
||||
AMT,2025-05-05,long,removed,2.615411,1.760432,38.11,37.81,pivot_point+volume_profile,volume_profile,-1.047871,-1.047871,-1.0,-1.0
|
||||
APP,2025-05-05,long,retained,2.13066,2.13066,25.87,32.87,pivot_point+volume_profile,volume_profile,2.11486,2.11486,1.53399,1.53399
|
||||
AXON,2025-05-05,long,added,1.907457,2.204538,27.28,32.04,pivot_point+volume_profile,volume_profile,1.872667,2.169748,4.37086,4.37086
|
||||
BKR,2025-05-05,long,removed,2.586196,,38.35,,pivot_point+volume_profile,,-0.031936,,1.23835,
|
||||
CBRE,2025-05-05,long,removed,2.028372,1.675005,39.7,39.17,pivot_point+volume_profile,volume_profile,-1.037015,-1.037015,-1.0,-1.0
|
||||
CHTR,2025-05-05,long,retained,2.244983,2.782606,41.6,26.79,pivot_point+volume_profile,volume_profile,-1.034872,-1.034872,-1.0,-1.0
|
||||
CVNA,2025-05-05,long,added,1.798961,2.121305,39.23,32.98,pivot_point+volume_profile,volume_profile,1.777774,2.100117,1.406108,1.406108
|
||||
CVS,2025-05-05,long,added,1.677853,2.460807,49.12,29.46,pivot_point+volume_profile,volume_profile,-1.038343,-1.038343,-1.0,-1.0
|
||||
EBAY,2025-05-05,long,added,1.690447,2.034162,38.11,34.03,pivot_point+volume_profile,volume_profile,1.65223,1.995945,1.926892,1.926892
|
||||
EXC,2025-05-05,long,removed,2.478172,1.852594,20.3,21.45,pivot_point+volume_profile,volume_profile,-1.144068,-1.144068,-1.085003,-1.085003
|
||||
FICO,2025-05-05,long,added,1.960447,2.3875,25.97,30.15,pivot_point,volume_profile,-1.036717,-1.036717,-1.0,-1.0
|
||||
GDDY,2025-05-05,long,retained,2.20301,2.176304,28.66,32.35,pivot_point+volume_profile,volume_profile,-0.035309,-0.035309,-0.402143,-0.402143
|
||||
GEN,2025-05-05,long,retained,2.047797,2.756485,43.86,26.99,pivot_point+volume_profile,volume_profile,2.002008,2.710697,3.55366,3.55366
|
||||
IBKR,2025-05-05,long,removed,2.42902,1.979107,22.75,34.73,pivot_point+volume_profile,volume_profile,2.40025,1.950336,2.291226,2.291226
|
||||
MAA,2025-05-05,long,removed,2.985414,1.645458,35.38,39.66,pivot_point+volume_profile,volume_profile,-1.048771,-1.048771,-1.0,-1.0
|
||||
MMM,2025-05-05,long,removed,5.279289,1.693847,27.36,38.86,pivot_point+volume_profile,volume_profile,-0.038725,1.655122,0.193898,0.193898
|
||||
PODD,2025-05-05,long,removed,2.035338,1.903346,44.02,35.74,pivot_point+volume_profile,volume_profile,2.002335,1.870343,2.907644,2.907644
|
||||
SBAC,2025-05-05,long,removed,8.952552,1.863751,25.14,36.29,pivot_point+volume_profile,volume_profile,-1.046235,-1.046235,-1.0,-1.0
|
||||
TPR,2025-05-05,long,added,1.840942,2.074077,28.02,33.54,pivot_point+volume_profile,volume_profile,1.808779,2.041914,2.077916,2.077916
|
||||
VST,2025-05-05,long,removed,2.036511,1.522944,34.0,41.82,pivot_point+volume_profile,volume_profile,2.013574,1.500006,3.09245,3.09245
|
||||
XEL,2025-05-05,long,removed,2.229859,1.692521,25.96,38.88,pivot_point+volume_profile,volume_profile,-1.053867,-1.053867,-1.0,-1.0
|
||||
CBRE,2025-05-12,long,retained,2.072185,2.07875,25.97,33.49,pivot_point+volume_profile,volume_profile,-1.042494,-1.042494,-1.0,-1.0
|
||||
CHTR,2025-05-12,long,retained,2.190034,2.816722,42.2,26.54,pivot_point+volume_profile,volume_profile,-1.041502,-1.041502,-1.0,-1.0
|
||||
CVNA,2025-05-12,long,added,1.932575,2.069082,35.54,33.6,pivot_point+volume_profile,volume_profile,1.909703,2.04621,1.478573,1.478573
|
||||
EBAY,2025-05-12,long,removed,2.158189,1.865706,31.76,36.26,pivot_point+volume_profile,volume_profile,2.117684,1.825201,1.569592,1.569592
|
||||
FFIV,2025-05-12,long,retained,2.168648,2.182334,25.84,32.29,pivot_point+volume_profile,volume_profile,-0.043699,-0.043699,1.095796,1.095796
|
||||
FICO,2025-05-12,long,added,1.700282,2.177095,29.76,32.34,pivot_point,volume_profile,-1.041973,-1.041973,-1.0,-1.0
|
||||
GDDY,2025-05-12,long,removed,2.020186,1.989107,30.81,34.6,pivot_point+volume_profile,volume_profile,-1.042178,-1.042178,-1.0,-1.0
|
||||
GLW,2025-05-12,long,added,1.836647,2.081473,30.28,33.45,pivot_point+volume_profile,volume_profile,1.794151,2.038977,1.999502,1.999502
|
||||
IBKR,2025-05-12,long,added,1.896594,2.218736,28.43,31.88,pivot_point+volume_profile,volume_profile,-0.034383,-0.034383,1.028484,1.028484
|
||||
IP,2025-05-12,long,removed,2.044365,1.784897,43.91,37.44,pivot_point+volume_profile,volume_profile,-1.030962,-1.030962,-1.0,-1.0
|
||||
KMI,2025-05-12,long,removed,2.392809,1.721495,20.5,38.42,volume_profile,volume_profile,-0.044531,-0.044531,0.815295,0.815295
|
||||
LITE,2025-05-12,long,added,1.998667,3.054193,44.48,24.94,pivot_point+volume_profile,volume_profile,1.977419,3.032945,2.968097,2.968097
|
||||
LYV,2025-05-12,long,retained,2.131057,2.131057,23.47,32.87,pivot_point+volume_profile,volume_profile,-0.041161,-0.041161,0.783299,0.783299
|
||||
MMM,2025-05-12,long,retained,3.434618,2.991348,32.62,25.34,pivot_point+volume_profile,volume_profile,-1.043943,-1.043943,-1.0,-1.0
|
||||
MSTR,2025-05-12,long,retained,2.148607,2.148607,22.87,32.67,volume_profile,volume_profile,-1.024205,-1.024205,-1.0,-1.0
|
||||
NVDA,2025-05-12,long,removed,2.065139,1.589846,36.85,40.61,pivot_point,volume_profile,2.034535,1.559242,3.895151,3.895151
|
||||
SBUX,2025-05-12,long,retained,2.738132,2.531213,37.13,28.82,pivot_point+volume_profile,volume_profile,-0.033023,-0.033023,1.046545,1.046545
|
||||
TRGP,2025-05-12,long,removed,2.362374,1.831072,40.4,36.76,pivot_point+volume_profile,volume_profile,-0.029157,-0.029157,0.377208,0.377208
|
||||
UAL,2025-05-12,long,retained,2.451227,2.649603,31.95,27.83,pivot_point+volume_profile,volume_profile,-1.022953,-1.022953,-1.0,-1.0
|
||||
VST,2025-05-12,long,removed,2.151812,1.71222,25.63,38.56,pivot_point+volume_profile,volume_profile,2.128723,1.689131,3.179119,3.179119
|
||||
BKR,2025-05-19,long,removed,2.61815,1.744687,23.08,23.06,pivot_point+volume_profile,volume_profile,-1.041425,-1.041425,-1.0,-1.0
|
||||
CBRE,2025-05-19,long,added,1.697692,2.018194,44.4,34.23,pivot_point+volume_profile,volume_profile,-1.04777,-1.04777,-1.0,-1.0
|
||||
CCL,2025-05-19,long,removed,2.213689,1.88525,35.94,35.99,pivot_point,volume_profile,-1.035223,-1.035223,-1.0,-1.0
|
||||
CHTR,2025-05-19,long,removed,2.534289,1.918738,38.8,35.53,pivot_point,volume_profile,-1.041697,-1.041697,-1.0,-1.0
|
||||
CIEN,2025-05-19,long,added,1.711175,2.748682,31.18,27.05,pivot_point+volume_profile,volume_profile,-1.035445,-1.035445,-1.0,-1.0
|
||||
DASH,2025-05-19,long,added,1.948308,2.354426,27.54,30.47,pivot_point+volume_profile,volume_profile,1.914409,2.320527,3.07001,3.07001
|
||||
EXPE,2025-05-19,long,added,1.628529,2.673372,49.94,27.63,pivot_point,volume_profile,-0.030995,-0.030995,0.516934,0.516934
|
||||
FFIV,2025-05-19,long,retained,2.044528,2.060502,27.3,33.71,pivot_point+volume_profile,volume_profile,-0.051978,-0.051978,0.947872,0.947872
|
||||
GDDY,2025-05-19,long,retained,2.043438,2.006004,30.52,34.39,pivot_point+volume_profile,volume_profile,-1.051582,-1.051582,-1.0,-1.0
|
||||
GLW,2025-05-19,long,removed,2.537345,1.747245,20.57,38.02,pivot_point+volume_profile,volume_profile,-0.048875,1.69837,2.330541,2.330541
|
||||
IBKR,2025-05-19,long,retained,2.295831,2.295831,23.07,31.07,pivot_point+volume_profile,volume_profile,-1.039941,-1.039941,-1.0,-1.0
|
||||
IP,2025-05-19,long,removed,2.023784,1.657927,44.16,39.45,pivot_point+volume_profile,volume_profile,-1.153729,-1.153729,-1.117559,-1.117559
|
||||
KMI,2025-05-19,long,retained,2.104627,2.104627,23.58,33.18,volume_profile,volume_profile,-0.052501,-0.052501,0.468757,0.468757
|
||||
NVDA,2025-05-19,long,retained,2.272964,2.440243,22.3,29.65,pivot_point+volume_profile,volume_profile,2.237626,2.404906,2.825566,2.825566
|
||||
UAL,2025-05-19,long,added,1.512611,2.803624,34.81,26.64,pivot_point+volume_profile,volume_profile,-1.024287,-1.024287,-1.0,-1.0
|
||||
HOOD,2025-05-23,long,removed,2.422622,1.637018,20.01,39.8,volume_profile,volume_profile,2.397235,1.611631,6.303564,6.303564
|
||||
ADSK,2025-05-27,long,retained,2.568575,2.143189,25.1,32.73,pivot_point+volume_profile,volume_profile,-1.060214,-1.060214,-1.0,-1.0
|
||||
CCL,2025-05-27,long,removed,2.020875,1.697979,38.2,38.79,pivot_point,volume_profile,-1.03494,-1.03494,-1.0,-1.0
|
||||
CHTR,2025-05-27,long,retained,2.369859,2.594641,40.32,28.28,pivot_point+volume_profile,volume_profile,-1.046352,-1.046352,-1.0,-1.0
|
||||
COHR,2025-05-27,long,retained,2.170188,2.896921,38.02,25.97,pivot_point+volume_profile,volume_profile,-1.027459,-1.027459,-1.0,-1.0
|
||||
CVNA,2025-05-27,long,removed,2.294516,1.643299,22.28,39.69,pivot_point+volume_profile,volume_profile,-1.028604,1.614695,-1.0,-1.0
|
||||
DASH,2025-05-27,long,added,1.752993,2.166633,30.33,32.46,pivot_point+volume_profile,volume_profile,1.718003,2.131643,2.847653,2.847653
|
||||
EBAY,2025-05-27,long,added,1.535804,2.010702,40.78,34.33,pivot_point+volume_profile,volume_profile,1.481401,1.956299,1.794901,1.794901
|
||||
FFIV,2025-05-27,long,retained,2.182513,2.200268,25.68,32.09,pivot_point+volume_profile,volume_profile,-0.057954,-0.057954,1.344759,1.344759
|
||||
FIX,2025-05-27,long,retained,2.155536,2.155536,22.99,32.59,volume_profile,volume_profile,2.117869,2.117869,1.876737,1.876737
|
||||
KMI,2025-05-27,long,retained,2.146558,2.146558,23.09,32.69,volume_profile,volume_profile,-1.058895,-1.058895,-1.0,-1.0
|
||||
LITE,2025-05-27,long,removed,2.151883,1.901772,42.63,35.76,pivot_point+volume_profile,volume_profile,-1.028929,-1.028929,-1.0,-1.0
|
||||
MMM,2025-05-27,long,removed,4.201405,1.643381,29.6,39.69,pivot_point+volume_profile,volume_profile,-1.051608,-1.051608,-1.0,-1.0
|
||||
NVDA,2025-05-27,long,retained,2.432299,2.610589,20.72,28.15,pivot_point+volume_profile,volume_profile,2.394655,2.572945,3.972802,3.972802
|
||||
VST,2025-05-27,long,retained,2.098277,2.098277,23.45,33.25,volume_profile,volume_profile,2.067344,2.067344,3.012884,3.012884
|
||||
KVUE,2025-05-30,long,removed,2.050369,0.768132,28.83,47.13,pivot_point+volume_profile,volume_profile,-1.057804,-1.057804,-1.0,-1.0
|
||||
HOOD,2025-06-02,long,retained,2.309116,2.309116,21.13,30.93,volume_profile,volume_profile,2.280974,2.280974,7.300464,7.300464
|
||||
ADSK,2025-06-03,long,added,1.692598,2.410998,45.68,29.92,pivot_point+volume_profile,volume_profile,1.627602,-1.064996,-1.0,-1.0
|
||||
AON,2025-06-03,long,retained,2.431709,2.105846,22.53,33.16,pivot_point+volume_profile,volume_profile,-1.065022,-1.065022,-1.0,-1.0
|
||||
CIEN,2025-06-03,long,removed,2.142464,1.785979,24.54,37.43,pivot_point+volume_profile,volume_profile,-1.646023,-1.646023,-1.604345,-1.604345
|
||||
EXPE,2025-06-03,long,added,1.567645,2.794189,51.0,26.71,pivot_point,volume_profile,1.530571,-0.037074,1.479698,1.479698
|
||||
IBKR,2025-06-03,long,removed,2.607373,1.605808,20.17,40.33,pivot_point+volume_profile,volume_profile,-1.045192,-1.045192,-1.0,-1.0
|
||||
LH,2025-06-03,long,removed,2.572651,1.68287,23.47,24.04,pivot_point+volume_profile,volume_profile,-0.062688,1.620182,-0.409144,-0.409144
|
||||
LITE,2025-06-03,long,retained,2.233028,2.651965,41.73,27.81,pivot_point+volume_profile,volume_profile,2.205013,2.62395,4.507942,4.507942
|
||||
MMM,2025-06-03,long,added,1.551705,2.053951,33.49,33.79,pivot_point+volume_profile,volume_profile,-1.055926,-1.055926,-1.0,-1.0
|
||||
MSTR,2025-06-03,long,added,1.02871,2.142162,48.56,32.74,pivot_point+volume_profile,volume_profile,1.002307,2.115759,2.177754,2.177754
|
||||
TRGP,2025-06-03,long,removed,3.733429,1.741375,31.48,38.11,pivot_point+volume_profile,volume_profile,-0.043686,-0.043686,0.205279,0.205279
|
||||
UAL,2025-06-03,long,retained,2.628249,2.877458,30.4,26.11,pivot_point+volume_profile,volume_profile,-1.479331,-1.479331,-1.449594,-1.449594
|
||||
XEL,2025-06-03,long,removed,2.225926,1.355463,26.81,30.16,pivot_point+volume_profile,volume_profile,-1.064194,-1.064194,-1.0,-1.0
|
||||
APP,2025-06-10,long,removed,2.343753,1.558826,20.98,41.16,volume_profile,volume_profile,-1.023341,-1.023341,-1.0,-1.0
|
||||
CBRE,2025-06-10,long,removed,2.125347,1.829022,26.34,36.79,pivot_point+volume_profile,volume_profile,2.069269,1.772945,2.477813,2.477813
|
||||
CHTR,2025-06-10,long,removed,2.977021,1.341616,35.43,45.46,pivot_point+volume_profile,volume_profile,-1.052197,-1.052197,-1.0,-1.0
|
||||
DASH,2025-06-10,long,added,1.013584,2.53042,46.2,28.83,pivot_point+volume_profile,volume_profile,0.971883,2.488718,2.785659,2.785659
|
||||
FIX,2025-06-10,long,retained,2.108728,2.108728,23.53,33.13,volume_profile,volume_profile,2.070098,2.070098,2.975038,2.975038
|
||||
LITE,2025-06-10,long,removed,2.14058,1.840276,42.76,36.63,pivot_point+volume_profile,volume_profile,2.11155,1.811246,3.699571,3.699571
|
||||
LYV,2025-06-10,long,retained,2.026039,2.026039,24.73,34.13,pivot_point+volume_profile,volume_profile,-1.049761,-1.049761,-1.0,-1.0
|
||||
DASH,2025-06-17,long,retained,2.272546,2.272546,21.71,31.31,volume_profile,volume_profile,2.226528,2.226528,3.236531,3.236531
|
||||
LITE,2025-06-17,long,removed,2.35311,1.666804,36.49,39.3,pivot_point+volume_profile,volume_profile,2.323386,1.63708,4.085339,4.085339
|
||||
SYF,2025-06-17,long,removed,2.052238,1.13273,28.81,35.47,pivot_point+volume_profile,volume_profile,2.004993,1.085485,3.679755,3.679755
|
||||
TPR,2025-06-17,long,removed,2.183713,1.746025,22.27,38.03,pivot_point,volume_profile,2.139034,1.701347,6.821065,6.821065
|
||||
TRGP,2025-06-17,long,retained,3.056467,2.267724,34.92,31.36,pivot_point+volume_profile,volume_profile,-1.044187,-1.044187,-1.0,-1.0
|
||||
CBRE,2025-06-25,long,retained,2.179995,2.18858,24.11,32.22,pivot_point+volume_profile,volume_profile,2.123366,2.131951,4.007789,4.007789
|
||||
CVNA,2025-06-25,long,removed,2.240201,1.56613,22.45,41.03,pivot_point+volume_profile,volume_profile,2.210323,1.536252,1.987084,1.987084
|
||||
FIX,2025-06-25,long,removed,2.04309,1.498263,24.12,42.28,pivot_point,volume_profile,1.997489,1.452662,8.392007,8.392007
|
||||
LITE,2025-06-25,long,added,1.526208,2.14951,47.36,32.66,pivot_point+volume_profile,volume_profile,1.492238,2.11554,3.583195,3.583195
|
||||
MOS,2025-06-25,long,removed,2.178066,1.800481,27.33,22.21,pivot_point,volume_profile,-2.542438,1.750255,-2.492212,-2.492212
|
||||
MSTR,2025-06-25,long,removed,2.443067,1.525476,20.22,41.77,pivot_point+volume_profile,volume_profile,2.40932,1.491729,0.579133,0.579133
|
||||
SOLV,2025-06-25,long,removed,2.051014,1.621831,29.02,40.06,pivot_point+volume_profile,volume_profile,-1.058064,-1.058064,-1.0,-1.0
|
||||
SYF,2025-06-25,long,added,,2.006152,,34.38,,volume_profile,,1.952892,,1.443658
|
||||
TRGP,2025-06-25,long,retained,2.870589,2.102316,36.16,33.21,pivot_point+volume_profile,volume_profile,-1.043253,-1.043253,-1.0,-1.0
|
||||
VRT,2025-06-25,long,added,,2.179303,,32.32,,volume_profile,,-1.033791,,-1.0
|
||||
CF,2025-07-02,long,added,1.72572,2.203327,48.35,32.05,pivot_point+volume_profile,volume_profile,-1.04834,-1.04834,-1.0,-1.0
|
||||
EXPE,2025-07-02,long,added,,2.249806,,31.55,,volume_profile,,2.202485,,5.007195
|
||||
MOS,2025-07-02,long,added,,2.12156,,32.98,,volume_profile,,-1.052618,,-1.0
|
||||
NEM,2025-07-02,long,retained,2.051223,2.10406,38.42,33.18,pivot_point+volume_profile,volume_profile,-1.051019,-1.051019,-1.0,-1.0
|
||||
CF,2025-07-10,long,removed,2.079072,1.709908,43.48,38.6,pivot_point+volume_profile,volume_profile,-1.04928,-1.04928,-1.0,-1.0
|
||||
CIEN,2025-07-10,long,removed,2.074956,1.510562,31.53,42.05,pivot_point+volume_profile,volume_profile,2.032894,1.4685,2.388612,2.388612
|
||||
EXPE,2025-07-10,long,added,1.731643,2.279927,33.66,31.23,pivot_point,volume_profile,-1.046783,-1.046783,-1.0,-1.0
|
||||
LITE,2025-07-10,long,retained,2.071854,2.071854,30.17,33.57,pivot_point+volume_profile,volume_profile,2.036198,2.036198,4.775516,4.775516
|
||||
MOS,2025-07-10,long,removed,2.030196,1.394129,29.08,29.35,pivot_point,volume_profile,-2.731686,-2.731686,-2.683345,-2.683345
|
||||
MSTR,2025-07-10,long,retained,2.254602,2.254602,21.7,31.5,volume_profile,volume_profile,-1.03455,-1.03455,-1.0,-1.0
|
||||
UAL,2025-07-10,long,removed,2.166151,1.597686,28.47,40.47,pivot_point+volume_profile,volume_profile,-1.094723,-1.094723,-1.063476,-1.063476
|
||||
WBD,2025-07-11,long,added,1.689693,2.64646,48.93,27.85,pivot_point,volume_profile,1.652945,2.609712,-1.0,-1.0
|
||||
APP,2025-07-17,long,removed,2.361064,1.639012,20.81,39.76,pivot_point+volume_profile,volume_profile,2.334722,1.612669,4.343765,4.343765
|
||||
CIEN,2025-07-17,long,added,1.862108,2.321977,27.72,30.8,pivot_point+volume_profile,volume_profile,1.817051,2.27692,3.474519,3.474519
|
||||
DASH,2025-07-17,long,removed,2.259481,1.710911,21.45,38.59,pivot_point,volume_profile,2.211705,1.663136,1.251207,1.251207
|
||||
MSTR,2025-07-17,long,retained,2.161028,2.161028,22.72,32.52,volume_profile,volume_profile,-1.037565,-1.037565,-1.0,-1.0
|
||||
SBUX,2025-07-17,long,removed,2.694036,1.637838,22.47,24.78,pivot_point+volume_profile,volume_profile,-1.054383,1.583456,-1.0,-1.0
|
||||
TSLA,2025-07-17,long,added,1.693777,2.98586,48.86,25.37,pivot_point+volume_profile,volume_profile,-1.030383,-1.030383,-1.0,-1.0
|
||||
UAL,2025-07-17,long,removed,2.242615,1.674207,27.63,39.18,pivot_point+volume_profile,volume_profile,-1.03109,-1.03109,-1.0,-1.0
|
||||
WBD,2025-07-18,long,added,1.69778,2.631175,48.8,27.98,pivot_point,volume_profile,1.6586,-1.03918,-1.0,-1.0
|
||||
CF,2025-07-24,long,retained,2.027676,2.561976,44.11,28.56,pivot_point+volume_profile,volume_profile,-1.053798,-1.053798,-1.0,-1.0
|
||||
CIEN,2025-07-24,long,removed,2.121631,1.775805,23.18,37.58,pivot_point,volume_profile,2.072951,1.727125,8.235278,8.235278
|
||||
NEM,2025-07-24,long,retained,2.370375,2.370375,22.52,30.32,volume_profile,volume_profile,2.324462,2.324462,5.471272,5.471272
|
||||
TMUS,2025-07-24,long,retained,2.008083,2.000731,39.96,34.45,pivot_point+volume_profile,volume_profile,-1.062418,-1.062418,-1.0,-1.0
|
||||
UAL,2025-07-24,long,added,1.895682,2.090516,34.45,33.35,pivot_point+volume_profile,volume_profile,-1.033158,-1.033158,-1.0,-1.0
|
||||
WBD,2025-07-25,long,removed,2.007694,1.558868,44.36,41.16,pivot_point,volume_profile,-1.043248,-1.043248,-1.0,-1.0
|
||||
AXON,2025-07-31,long,removed,2.0208,1.515251,24.4,41.96,pivot_point,volume_profile,1.979936,1.474387,-1.0,-1.0
|
||||
NEM,2025-07-31,long,added,1.550636,2.927566,40.31,25.76,pivot_point+volume_profile,volume_profile,1.5084,2.88533,5.832143,5.832143
|
||||
NVDA,2025-07-31,long,added,,2.187503,,32.23,,volume_profile,,-1.057851,,-1.0
|
||||
UAL,2025-07-31,long,removed,2.346512,1.699916,29.15,38.76,pivot_point+volume_profile,volume_profile,-1.035483,-1.035483,-1.0,-1.0
|
||||
APP,2025-08-07,long,retained,2.2387,2.2387,21.87,31.67,volume_profile,volume_profile,-1.02614,-1.02614,-1.0,-1.0
|
||||
PODD,2025-08-07,long,added,1.665084,2.014836,49.33,34.27,pivot_point+volume_profile,volume_profile,1.61996,1.969712,2.028378,2.028378
|
||||
TSLA,2025-08-07,long,added,,2.112593,,33.08,,volume_profile,,2.07904,,5.403464
|
||||
ULTA,2025-08-07,long,added,1.994095,2.160126,38.54,32.54,pivot_point+volume_profile,volume_profile,-1.061824,-1.061824,-1.0,-1.0
|
||||
NVDA,2025-08-14,long,removed,2.160695,1.442764,22.53,43.36,pivot_point,volume_profile,-1.056467,-1.056467,-1.0,-1.0
|
||||
TSLA,2025-08-14,long,added,1.98307,2.435478,43.28,29.69,pivot_point+volume_profile,volume_profile,-1.03507,-1.03507,-1.0,-1.0
|
||||
UAL,2025-08-14,long,added,1.895586,2.046469,30.65,33.88,pivot_point+volume_profile,volume_profile,1.856758,2.007641,-0.240865,-0.240865
|
||||
WYNN,2025-08-14,long,removed,2.010866,1.572488,25.12,40.92,pivot_point+volume_profile,volume_profile,1.957667,1.519288,4.200848,4.200848
|
||||
WBD,2025-08-15,long,removed,2.585147,0.67591,23.36,50.91,pivot_point,volume_profile,2.554721,0.645484,9.005713,9.005713
|
||||
AXON,2025-08-21,long,retained,2.129759,2.129759,23.28,32.88,volume_profile,volume_profile,-1.031785,-1.031785,-1.0,-1.0
|
||||
UAL,2025-08-21,long,removed,2.369461,1.656179,25.13,39.48,pivot_point+volume_profile,volume_profile,2.329877,1.616595,-0.37777,-0.37777
|
||||
WSM,2025-08-21,long,added,1.667664,2.310824,32.89,30.91,pivot_point+volume_profile,volume_profile,-1.04916,-1.04916,-1.0,-1.0
|
||||
AXON,2025-08-28,long,retained,2.008032,2.008032,24.76,34.36,volume_profile,volume_profile,-1.187978,-1.187978,-1.150564,-1.150564
|
||||
DLTR,2025-08-28,long,removed,2.61381,0.922035,23.12,41.66,pivot_point+volume_profile,volume_profile,-1.060616,-1.060616,-1.0,-1.0
|
||||
MOS,2025-08-28,long,removed,2.660654,0.871184,22.74,43.36,pivot_point+volume_profile,volume_profile,-1.143849,-1.143849,-1.095243,-1.095243
|
||||
NEM,2025-08-28,long,added,,2.558169,,28.59,,volume_profile,,2.498731,,4.956524
|
||||
NFLX,2025-08-28,long,retained,2.015068,2.015068,24.47,34.27,volume_profile,volume_profile,-1.061855,-1.061855,-1.0,-1.0
|
||||
TRMB,2025-08-28,long,removed,2.444595,1.624024,24.61,25.02,pivot_point+volume_profile,volume_profile,-1.061855,-1.061855,-1.0,-1.0
|
||||
TSLA,2025-08-28,long,added,1.505925,3.036081,50.54,25.05,pivot_point+volume_profile,volume_profile,-1.037822,-1.037822,-1.0,-1.0
|
||||
ULTA,2025-08-28,long,added,1.433885,2.155983,39.14,32.58,pivot_point+volume_profile,volume_profile,-1.061037,-1.061037,-1.0,-1.0
|
||||
NEM,2025-09-05,long,added,1.519842,2.224862,32.88,31.82,pivot_point,volume_profile,1.462674,2.167693,5.478938,5.478938
|
||||
TSLA,2025-09-05,long,removed,2.734901,1.679832,24.55,39.09,pivot_point+volume_profile,volume_profile,2.697302,1.642233,4.740624,4.740624
|
||||
COIN,2025-09-12,long,retained,2.013059,2.324843,27.1,30.77,pivot_point+volume_profile,volume_profile,1.9826,2.294384,1.481272,1.481272
|
||||
CRWD,2025-09-12,long,added,1.902125,2.491779,34.36,29.18,pivot_point+volume_profile,volume_profile,1.858615,2.448269,4.550534,4.550534
|
||||
EXE,2025-09-12,long,removed,2.819644,1.77956,21.52,22.52,pivot_point+volume_profile,volume_profile,2.76661,1.726526,2.080664,2.080664
|
||||
NEM,2025-09-12,long,retained,2.090577,2.090577,23.54,33.34,pivot_point+volume_profile,volume_profile,2.032407,2.032407,1.512065,1.512065
|
||||
PWR,2025-09-12,long,retained,2.222758,2.222758,22.64,31.84,pivot_point+volume_profile,volume_profile,2.172589,2.172589,3.829571,3.829571
|
||||
SMCI,2025-09-12,long,retained,2.923759,2.911473,20.19,25.87,pivot_point+volume_profile,volume_profile,2.895037,2.882751,1.049944,1.049944
|
||||
TSLA,2025-09-12,long,retained,2.13463,2.145752,24.03,32.7,pivot_point+volume_profile,volume_profile,2.096238,2.10736,1.831651,1.831651
|
||||
CEG,2025-09-18,long,removed,2.066235,1.591977,23.84,40.57,pivot_point,volume_profile,2.027344,1.553086,3.6016,3.6016
|
||||
COIN,2025-09-19,long,retained,2.267824,2.267824,22.36,31.36,volume_profile,volume_profile,-1.030729,-1.030729,-1.0,-1.0
|
||||
CVS,2025-09-19,long,removed,2.286544,1.63835,41.16,39.78,pivot_point+volume_profile,volume_profile,2.227776,1.579582,1.266816,1.266816
|
||||
EXE,2025-09-19,long,retained,2.161204,2.156746,42.52,32.57,pivot_point+volume_profile,volume_profile,2.107659,2.103202,1.307349,1.307349
|
||||
MSTR,2025-09-19,long,added,1.664773,2.275601,49.33,31.28,pivot_point,volume_profile,-1.247909,-1.247909,-1.218063,-1.218063
|
||||
NFLX,2025-09-19,long,retained,2.035308,2.035308,24.22,34.02,volume_profile,volume_profile,-1.058942,-1.058942,-1.0,-1.0
|
||||
SMCI,2025-09-19,long,retained,2.927459,2.913963,20.16,25.85,pivot_point+volume_profile,volume_profile,2.895343,2.881848,2.155739,2.155739
|
||||
TRMB,2025-09-19,long,added,,2.441876,,29.63,,volume_profile,,-1.061322,,-1.0
|
||||
TSLA,2025-09-19,long,retained,2.359784,2.359784,20.62,30.42,volume_profile,volume_profile,-0.038083,-0.038083,1.362642,1.362642
|
||||
WBD,2025-09-22,long,added,1.899743,2.758206,26.59,26.97,pivot_point+volume_profile,volume_profile,-1.025516,-1.025516,-1.0,-1.0
|
||||
CAH,2025-09-26,long,retained,2.096286,2.096286,26.08,33.28,pivot_point+volume_profile,volume_profile,2.041041,2.041041,8.978022,8.978022
|
||||
CVS,2025-09-26,long,added,1.894977,2.553957,45.86,28.63,pivot_point+volume_profile,volume_profile,1.837314,2.496294,1.225266,1.225266
|
||||
EQT,2025-09-26,long,retained,2.019186,2.441163,26.62,29.64,pivot_point+volume_profile,volume_profile,-1.045293,-1.045293,-1.0,-1.0
|
||||
SMCI,2025-09-26,long,retained,2.71762,2.705077,21.49,27.38,pivot_point+volume_profile,volume_profile,2.687765,2.675222,-1.0,-1.0
|
||||
WBD,2025-09-29,long,added,0.868167,2.816363,49.47,26.54,pivot_point,volume_profile,-1.02696,-1.02696,-1.0,-1.0
|
||||
COIN,2025-10-03,long,retained,2.365012,2.365012,20.57,30.37,volume_profile,volume_profile,-1.033105,-1.033105,-1.0,-1.0
|
||||
CVS,2025-10-03,long,removed,2.295793,1.839922,41.07,36.63,pivot_point,volume_profile,2.238669,1.782798,0.117948,0.117948
|
||||
SMCI,2025-10-03,long,retained,2.211226,2.420371,25.37,29.84,pivot_point+volume_profile,volume_profile,-1.030886,-1.030886,-1.0,-1.0
|
||||
WBD,2025-10-06,long,added,0.82203,3.063296,51.11,24.88,pivot_point,volume_profile,-1.031369,-1.031369,-1.0,-1.0
|
||||
NFLX,2025-10-10,long,retained,2.215083,2.215083,22.12,31.92,volume_profile,volume_profile,-1.058985,-1.058985,-1.0,-1.0
|
||||
DG,2025-10-17,long,removed,2.231381,1.14135,41.75,50.24,pivot_point,volume_profile,-1.050929,-1.050929,-1.0,-1.0
|
||||
EQT,2025-10-17,long,added,1.678733,2.031022,31.5,34.07,pivot_point+volume_profile,volume_profile,-1.037827,-1.037827,-1.0,-1.0
|
||||
INTC,2025-10-17,long,added,1.778496,2.790407,41.94,26.73,pivot_point+volume_profile,volume_profile,1.751902,-1.026595,-1.0,-1.0
|
||||
KR,2025-10-17,long,retained,2.129798,2.323374,27.88,30.78,pivot_point+volume_profile,volume_profile,-1.079151,-1.079151,-1.014635,-1.014635
|
||||
STX,2025-10-17,long,retained,2.174568,2.174568,22.77,32.37,volume_profile,volume_profile,-1.028836,-1.028836,-1.0,-1.0
|
||||
AXON,2025-10-24,long,added,1.76636,2.241583,30.12,31.64,pivot_point+volume_profile,volume_profile,-4.337363,-4.337363,-4.300584,-4.300584
|
||||
COIN,2025-10-24,long,retained,2.105666,2.105666,23.57,33.17,volume_profile,volume_profile,-1.025214,-1.025214,-1.0,-1.0
|
||||
DLTR,2025-10-24,long,retained,2.80395,2.292387,36.63,31.1,pivot_point,volume_profile,2.762807,2.251244,4.41966,4.41966
|
||||
FSLR,2025-10-24,long,retained,2.30447,2.30447,22.18,30.98,volume_profile,volume_profile,2.272186,2.272186,0.96756,0.96756
|
||||
WBD,2025-10-27,long,retained,2.101189,2.101189,24.02,33.22,volume_profile,volume_profile,2.069718,2.069718,5.399746,5.399746
|
||||
DLTR,2025-10-31,long,retained,2.786721,2.275651,36.76,31.28,pivot_point,volume_profile,2.745588,2.234518,6.650067,6.650067
|
||||
WBD,2025-11-03,long,retained,2.085838,2.085838,24.2,33.4,volume_profile,volume_profile,2.050108,2.050108,5.297748,5.297748
|
||||
APP,2025-11-07,long,retained,2.123208,2.123208,23.16,32.96,volume_profile,volume_profile,-1.024541,-1.024541,-1.0,-1.0
|
||||
APTV,2025-11-07,long,retained,2.072254,2.042395,43.57,33.93,pivot_point+volume_profile,volume_profile,-1.229377,-1.229377,-1.180253,-1.180253
|
||||
DLTR,2025-11-07,long,removed,2.012478,1.9107,44.3,35.64,pivot_point+volume_profile,volume_profile,-1.039683,-1.039683,-1.0,-1.0
|
||||
WSM,2025-11-07,long,removed,2.170939,1.955635,22.61,35.04,pivot_point,volume_profile,-1.040974,-1.040974,-1.0,-1.0
|
||||
DG,2025-11-14,long,removed,2.670283,1.541305,22.66,26.48,pivot_point,volume_profile,-1.05203,-1.05203,-1.0,-1.0
|
||||
DLTR,2025-11-14,long,added,1.552266,2.22325,51.28,31.83,pivot_point,volume_profile,-1.041689,-1.041689,-1.0,-1.0
|
||||
DLTR,2025-11-21,long,removed,2.793575,1.57706,21.71,25.84,pivot_point+volume_profile,volume_profile,2.75473,1.538215,5.673028,5.673028
|
||||
CVS,2025-12-01,long,removed,2.300687,1.241893,26.02,32.73,pivot_point+volume_profile,volume_profile,-1.058096,-1.058096,-1.0,-1.0
|
||||
DG,2025-12-01,long,retained,2.506071,2.506071,35.05,29.05,pivot_point+volume_profile,volume_profile,2.455923,2.455923,9.542155,9.542155
|
||||
DLTR,2025-12-01,long,retained,2.303197,2.220009,40.99,31.87,pivot_point+volume_profile,volume_profile,2.262084,2.178895,5.686814,5.686814
|
||||
FSLR,2025-12-01,long,retained,2.073126,2.073126,23.75,33.55,volume_profile,volume_profile,-1.029492,-1.029492,-1.0,-1.0
|
||||
KR,2025-12-01,long,added,1.843739,2.203724,46.58,32.05,pivot_point+volume_profile,volume_profile,-2.012949,-2.012949,-1.947183,-1.947183
|
||||
BA,2025-12-08,long,added,1.583825,2.232845,50.72,31.73,pivot_point+volume_profile,volume_profile,1.534769,2.183788,5.367771,5.367771
|
||||
DG,2025-12-08,long,added,1.912045,2.232293,45.62,31.74,pivot_point+volume_profile,volume_profile,1.876424,2.196672,2.913693,2.913693
|
||||
F,2025-12-08,long,removed,2.047935,2.245957,28.86,16.59,pivot_point+volume_profile,volume_profile,1.986783,2.184805,1.326353,1.326353
|
||||
INTC,2025-12-08,long,added,1.892965,2.697443,45.88,27.44,pivot_point+volume_profile,volume_profile,-1.025599,-1.025599,-1.0,-1.0
|
||||
MPWR,2025-12-08,long,retained,2.044393,2.044393,24.31,33.91,pivot_point+volume_profile,volume_profile,-1.033722,-1.033722,-1.0,-1.0
|
||||
CVS,2025-12-15,long,added,1.866009,2.030843,46.26,34.07,pivot_point+volume_profile,volume_profile,-1.466298,-1.466298,-1.413892,-1.413892
|
||||
DG,2025-12-15,long,retained,2.542671,2.503657,27.32,29.07,pivot_point+volume_profile,volume_profile,2.502824,2.46381,1.326439,1.326439
|
||||
DLTR,2025-12-15,long,retained,2.748539,2.992958,37.05,25.33,pivot_point+volume_profile,volume_profile,-1.03973,-1.03973,-1.0,-1.0
|
||||
F,2025-12-15,long,removed,2.281559,1.05922,41.21,52.48,pivot_point+volume_profile,volume_profile,-1.063525,-1.063525,-1.0,-1.0
|
||||
PM,2025-12-15,long,removed,2.502584,1.899821,29.28,35.79,pivot_point+volume_profile,volume_profile,2.443037,1.840274,3.66131,3.66131
|
||||
ALB,2025-12-22,long,retained,2.72812,2.697474,31.8,27.44,pivot_point+volume_profile,volume_profile,2.697029,2.666382,1.186944,1.186944
|
||||
CVS,2025-12-22,long,removed,2.484968,1.462176,24.24,27.98,pivot_point+volume_profile,volume_profile,-1.109179,1.40658,-1.053584,-1.053584
|
||||
DG,2025-12-22,long,retained,2.083862,2.040028,32.03,33.96,pivot_point+volume_profile,volume_profile,2.037546,1.993712,1.242769,1.242769
|
||||
DLTR,2025-12-22,long,removed,2.826398,1.690429,21.47,23.91,pivot_point,volume_profile,2.788535,1.652567,-0.44666,-0.44666
|
||||
EBAY,2025-12-22,long,removed,2.169577,1.859804,27.63,36.35,pivot_point,volume_profile,2.115095,1.805322,0.81724,0.81724
|
||||
F,2025-12-22,long,removed,2.77107,1.527462,21.88,26.74,pivot_point+volume_profile,volume_profile,-0.063731,1.463731,0.61553,0.61553
|
||||
HAS,2025-12-22,long,removed,2.964001,1.319322,20.52,30.95,pivot_point+volume_profile,volume_profile,2.905396,1.260717,4.983114,4.983114
|
||||
MPWR,2025-12-22,long,added,1.682915,2.036712,29.44,34.0,pivot_point+volume_profile,volume_profile,1.649894,2.003691,3.682887,3.682887
|
||||
NVDA,2025-12-22,long,added,0.816308,2.602582,51.52,28.21,pivot_point,volume_profile,0.775562,-1.040747,-1.0,-1.0
|
||||
ALB,2025-12-30,long,removed,8.749341,1.607977,25.17,40.29,pivot_point,volume_profile,-0.031848,1.576129,1.897265,1.897265
|
||||
DG,2025-12-30,long,retained,2.61558,2.567207,26.5,28.51,pivot_point+volume_profile,volume_profile,2.5651,2.516727,2.367546,2.367546
|
||||
DLTR,2025-12-30,long,removed,2.83934,1.593978,36.38,40.54,pivot_point,volume_profile,2.797313,1.551951,-1.0,-1.0
|
||||
EBAY,2025-12-30,long,retained,2.183133,2.060311,31.68,33.71,pivot_point+volume_profile,volume_profile,2.122264,1.999441,-1.0,-1.0
|
||||
ALB,2026-01-07,long,retained,2.828773,2.393536,36.45,30.09,pivot_point,volume_profile,2.795567,2.36033,0.703918,0.703918
|
||||
CVS,2026-01-07,long,retained,2.269166,2.473208,41.34,29.34,pivot_point+volume_profile,volume_profile,-1.855864,-1.855864,-1.790911,-1.790911
|
||||
DG,2026-01-07,long,retained,2.447888,2.677215,29.38,27.6,pivot_point+volume_profile,volume_profile,-0.048288,-0.048288,1.199549,1.199549
|
||||
DLTR,2026-01-07,long,added,1.507532,2.214072,52.11,31.93,pivot_point,volume_profile,1.464498,-1.043034,-1.0,-1.0
|
||||
HAS,2026-01-07,long,removed,2.805479,1.686841,36.62,38.97,pivot_point+volume_profile,volume_profile,2.740518,1.62188,5.389769,5.389769
|
||||
INTC,2026-01-07,long,added,1.877971,2.856229,46.09,26.26,pivot_point+volume_profile,volume_profile,1.848168,2.826426,0.517338,0.517338
|
||||
NOC,2026-01-07,long,retained,2.002685,2.002685,25.23,34.43,volume_profile,volume_profile,1.947249,1.947249,7.039869,7.039869
|
||||
ALB,2026-01-14,long,added,1.905518,2.113028,45.71,33.08,pivot_point+volume_profile,volume_profile,-1.146749,-1.146749,-1.11196,-1.11196
|
||||
AMD,2026-01-14,long,retained,2.061087,2.018284,25.3,34.23,pivot_point+volume_profile,volume_profile,2.028316,1.985512,-1.0,-1.0
|
||||
CVS,2026-01-14,long,removed,2.438088,1.2733,24.67,31.99,pivot_point+volume_profile,volume_profile,-1.655824,1.209203,-1.591726,-1.591726
|
||||
DG,2026-01-14,long,added,1.993034,2.993141,44.55,25.33,pivot_point,volume_profile,-1.049482,-1.049482,-1.0,-1.0
|
||||
EL,2026-01-14,long,removed,5.505627,0.961465,27.02,55.39,pivot_point,volume_profile,-2.498226,-2.498226,-2.451114,-2.451114
|
||||
ES,2026-01-14,long,retained,2.158663,2.915613,42.55,25.84,pivot_point+volume_profile,volume_profile,-1.062037,-1.062037,-1.0,-1.0
|
||||
MPWR,2026-01-14,long,removed,2.353425,1.54253,20.88,41.46,pivot_point+volume_profile,volume_profile,2.314598,1.503703,3.140984,3.140984
|
||||
PM,2026-01-14,long,removed,2.516942,1.521953,24.35,41.84,pivot_point+volume_profile,volume_profile,-1.066575,-1.066575,-1.0,-1.0
|
||||
ALB,2026-01-22,long,retained,2.66612,2.106006,37.69,33.16,pivot_point,volume_profile,-1.78932,-1.78932,-1.75766,-1.75766
|
||||
BG,2026-01-22,long,added,,2.031162,,34.07,,volume_profile,,1.974765,,1.003692
|
||||
COR,2026-01-22,long,removed,2.369274,1.740334,20.53,38.12,pivot_point,volume_profile,-1.066621,-1.066621,-1.0,-1.0
|
||||
CVS,2026-01-22,long,retained,2.782609,2.259288,36.79,31.45,pivot_point+volume_profile,volume_profile,-2.563433,-2.563433,-2.506576,-2.506576
|
||||
DG,2026-01-22,long,removed,2.984281,0.811673,20.39,45.49,pivot_point,volume_profile,-0.046626,0.765046,0.275695,0.275695
|
||||
EL,2026-01-22,long,retained,2.222014,3.841923,27.85,21.05,pivot_point,volume_profile,-1.049674,-1.049674,-1.0,-1.0
|
||||
EW,2026-01-22,long,removed,2.009026,1.960466,29.35,19.97,pivot_point+volume_profile,volume_profile,-1.060973,-1.060973,-1.0,-1.0
|
||||
HAS,2026-01-22,long,retained,2.033706,2.088676,44.04,33.37,pivot_point+volume_profile,volume_profile,1.97132,2.02629,2.017433,2.017433
|
||||
HSY,2026-01-22,long,removed,3.035643,1.089931,20.05,36.62,pivot_point+volume_profile,volume_profile,2.980952,1.035241,4.925416,4.925416
|
||||
NUE,2026-01-22,long,retained,2.1872,2.156377,28.43,32.58,pivot_point+volume_profile,volume_profile,-1.254102,-1.254102,-1.198898,-1.198898
|
||||
PM,2026-01-22,long,removed,2.010398,1.552206,34.53,41.28,pivot_point+volume_profile,volume_profile,1.950761,1.49257,-0.012275,-0.012275
|
||||
AES,2026-01-29,long,removed,2.745556,1.501027,37.07,42.23,pivot_point+volume_profile,volume_profile,2.702953,1.458424,-1.0,-1.0
|
||||
ALB,2026-01-29,long,added,1.653071,2.58279,49.53,28.38,pivot_point,volume_profile,-1.079102,-1.079102,-1.050535,-1.050535
|
||||
BIIB,2026-01-29,long,removed,2.414377,0.794286,24.89,46.14,pivot_point+volume_profile,volume_profile,2.367156,0.747065,0.719641,0.719641
|
||||
CRL,2026-01-29,long,removed,2.311041,1.831363,25.91,21.76,pivot_point,volume_profile,-1.04234,-1.04234,-1.0,-1.0
|
||||
F,2026-01-29,long,retained,2.223061,2.234271,38.44,31.72,pivot_point+volume_profile,volume_profile,-1.064059,-1.064059,-1.0,-1.0
|
||||
HSY,2026-01-29,long,retained,2.125632,2.186276,42.93,32.24,pivot_point+volume_profile,volume_profile,2.068357,2.129002,3.990351,3.990351
|
||||
ADM,2026-02-05,long,added,1.849616,2.96559,46.49,25.51,pivot_point+volume_profile,volume_profile,1.805728,-0.043888,0.248181,0.248181
|
||||
BIIB,2026-02-05,long,retained,2.2605,3.004306,41.44,25.26,pivot_point,volume_profile,-0.048519,-0.048519,-0.510424,-0.510424
|
||||
DG,2026-02-05,long,removed,2.78933,0.725195,21.74,48.84,pivot_point,volume_profile,-2.089286,0.680805,-2.044896,-2.044896
|
||||
F,2026-02-05,long,removed,2.105446,0.890739,28.17,42.7,pivot_point+volume_profile,volume_profile,2.041993,0.827286,-1.0,-1.0
|
||||
WELL,2026-02-05,long,retained,2.414449,2.388452,21.29,30.14,pivot_point+volume_profile,volume_profile,2.350069,2.324072,0.822192,0.822192
|
||||
AES,2026-02-12,long,added,1.819168,2.479199,46.93,29.29,pivot_point+volume_profile,volume_profile,1.77782,-2.408627,-2.367279,-2.367279
|
||||
BIIB,2026-02-12,long,added,1.64856,2.768015,49.6,26.9,pivot_point+volume_profile,volume_profile,-1.041757,-1.041757,-1.0,-1.0
|
||||
DHI,2026-02-12,long,added,1.684037,2.107728,41.62,33.14,pivot_point+volume_profile,volume_profile,-1.039232,-1.039232,-1.0,-1.0
|
||||
EL,2026-02-12,long,removed,2.135451,1.855177,20.42,21.41,pivot_point,volume_profile,-1.151489,-1.151489,-1.125617,-1.125617
|
||||
F,2026-02-12,long,removed,2.536476,2.192144,23.78,17.18,pivot_point,volume_profile,-1.062851,-1.062851,-1.0,-1.0
|
||||
HSY,2026-02-12,long,retained,3.280295,3.005439,29.42,25.25,pivot_point+volume_profile,volume_profile,-1.050958,-1.050958,-1.0,-1.0
|
||||
MRK,2026-02-12,long,removed,2.301212,1.577912,41.01,40.82,pivot_point+volume_profile,volume_profile,-1.050697,-1.050697,-1.0,-1.0
|
||||
ADM,2026-02-20,long,removed,2.857416,1.214979,21.25,33.38,pivot_point+volume_profile,volume_profile,-0.047161,1.167818,1.910618,1.910618
|
||||
AES,2026-02-20,long,added,1.553838,2.274272,51.25,31.29,pivot_point+volume_profile,volume_profile,1.507842,-3.061736,-3.01574,-3.01574
|
||||
BIIB,2026-02-20,long,removed,2.280691,1.964439,41.22,34.92,pivot_point+volume_profile,volume_profile,-1.046329,-1.046329,-1.0,-1.0
|
||||
DG,2026-02-20,long,added,1.807443,2.691872,47.11,27.49,pivot_point,volume_profile,-1.043669,-1.043669,-1.0,-1.0
|
||||
DHI,2026-02-20,long,added,1.86088,2.313992,38.93,30.88,pivot_point+volume_profile,volume_profile,-1.041837,-1.041837,-1.0,-1.0
|
||||
DLTR,2026-02-20,long,removed,2.168662,1.550498,42.44,41.31,pivot_point+volume_profile,volume_profile,-1.039397,-1.039397,-1.0,-1.0
|
||||
F,2026-02-20,long,removed,2.452913,2.116951,24.53,18.03,pivot_point,volume_profile,-1.061367,-1.061367,-1.0,-1.0
|
||||
HSY,2026-02-20,long,added,1.777447,2.741403,47.55,27.1,pivot_point+volume_profile,volume_profile,1.727495,-1.049952,-1.0,-1.0
|
||||
ADM,2026-02-27,long,removed,2.405563,1.781489,39.98,37.49,pivot_point+volume_profile,volume_profile,-1.047012,-1.047012,-1.0,-1.0
|
||||
AES,2026-02-27,long,added,1.714359,2.425565,48.53,29.79,pivot_point,volume_profile,-3.822565,-3.822565,-3.778078,-3.778078
|
||||
ALB,2026-02-27,long,added,1.50417,2.275291,52.17,31.28,pivot_point,volume_profile,-1.023428,-1.023428,-1.0,-1.0
|
||||
BIIB,2026-02-27,long,removed,2.258936,1.949142,41.45,35.12,pivot_point+volume_profile,volume_profile,-1.045333,-1.045333,-1.0,-1.0
|
||||
CF,2026-02-27,long,added,1.72868,2.073351,48.3,33.55,pivot_point+volume_profile,volume_profile,1.689394,2.034064,4.369061,4.369061
|
||||
DG,2026-02-27,long,removed,2.034541,1.979068,35.83,34.73,pivot_point+volume_profile,volume_profile,-1.047674,-1.047674,-1.0,-1.0
|
||||
LVS,2026-02-27,long,removed,2.116964,1.550234,32.63,41.32,pivot_point+volume_profile,volume_profile,-1.040159,-1.040159,-1.0,-1.0
|
||||
MRK,2026-02-27,long,added,,2.042053,,33.93,,volume_profile,,-1.054868,,-1.0
|
||||
APA,2026-03-06,long,added,1.831683,2.405864,46.75,29.97,pivot_point,volume_profile,1.799478,2.373659,1.618625,1.618625
|
||||
PLTR,2026-03-06,long,retained,2.165026,2.159874,36.08,32.54,pivot_point+volume_profile,volume_profile,-1.026994,-1.026994,-1.0,-1.0
|
||||
ADM,2026-03-13,long,removed,2.868953,1.581314,36.17,40.76,pivot_point,volume_profile,-1.043884,-1.043884,-1.0,-1.0
|
||||
AMD,2026-03-20,long,added,1.748995,2.062867,37.79,33.68,pivot_point+volume_profile,volume_profile,1.719912,2.033784,10.127012,10.127012
|
||||
PLTR,2026-03-20,long,retained,2.218591,2.235503,41.89,31.7,pivot_point+volume_profile,volume_profile,-1.031268,-1.031268,-1.0,-1.0
|
||||
ADM,2026-03-27,long,added,1.847544,3.062681,46.52,24.89,pivot_point+volume_profile,volume_profile,-1.041243,-1.041243,-1.0,-1.0
|
||||
ALB,2026-03-27,long,added,1.555558,2.38109,51.22,30.21,pivot_point,volume_profile,1.530367,2.355899,2.143564,2.143564
|
||||
MRK,2026-03-27,long,removed,2.642683,1.781011,22.88,22.5,pivot_point+volume_profile,volume_profile,-1.060594,-1.060594,-1.0,-1.0
|
||||
ADM,2026-04-06,long,added,1.618843,2.913662,50.11,25.86,pivot_point+volume_profile,volume_profile,-1.431888,-1.431888,-1.387241,-1.387241
|
||||
AMD,2026-04-06,long,removed,2.050665,1.983637,28.23,34.67,pivot_point+volume_profile,volume_profile,2.022452,1.955424,12.865385,12.865385
|
||||
CMI,2026-04-06,long,removed,2.030737,1.993522,25.28,34.55,pivot_point+volume_profile,volume_profile,1.99083,1.953614,4.550433,4.550433
|
||||
FSLR,2026-04-06,long,added,1.901145,2.416746,45.77,29.87,pivot_point,volume_profile,1.870523,2.386124,2.980447,2.980447
|
||||
GOOG,2026-04-06,long,removed,2.156045,1.620573,31.78,40.08,pivot_point+volume_profile,volume_profile,2.105673,1.570201,8.076424,8.076424
|
||||
GOOGL,2026-04-06,long,added,1.817453,2.344971,36.76,30.57,pivot_point+volume_profile,volume_profile,1.76908,2.296599,7.816437,7.816437
|
||||
IBKR,2026-04-06,long,added,1.654872,2.122334,31.7,32.97,pivot_point,volume_profile,1.62217,2.089632,4.169943,4.169943
|
||||
MRK,2026-04-06,long,retained,2.348703,2.293801,40.53,31.09,pivot_point+volume_profile,volume_profile,-1.061605,-1.061605,-1.0,-1.0
|
||||
NVDA,2026-04-06,long,retained,2.017951,2.397549,36.84,30.05,pivot_point+volume_profile,volume_profile,1.974149,2.353746,5.508603,5.508603
|
||||
TEL,2026-04-06,long,removed,2.030485,1.949998,44.08,35.11,pivot_point+volume_profile,volume_profile,1.994454,1.913968,-1.0,-1.0
|
||||
WMT,2026-04-06,long,removed,2.064388,1.439845,23.86,43.42,pivot_point,volume_profile,-1.066264,-1.066264,-1.0,-1.0
|
||||
ALB,2026-04-13,long,added,1.632672,2.971695,49.87,25.47,pivot_point+volume_profile,volume_profile,1.606556,-1.026116,-1.0,-1.0
|
||||
APH,2026-04-13,long,retained,2.078347,2.078347,23.49,33.49,volume_profile,volume_profile,-1.03288,-1.03288,-1.0,-1.0
|
||||
BALL,2026-04-13,long,added,1.793025,2.156109,47.32,32.58,pivot_point+volume_profile,volume_profile,-1.050157,-1.050157,-1.0,-1.0
|
||||
FSLR,2026-04-13,long,added,1.548381,3.052353,51.35,24.95,pivot_point,volume_profile,-1.031811,-1.031811,-1.0,-1.0
|
||||
GNRC,2026-04-13,long,added,1.579353,2.951036,46.4,25.6,pivot_point,volume_profile,1.550083,2.921766,4.969183,4.969183
|
||||
GOOG,2026-04-13,long,added,1.829459,2.019351,27.58,34.22,pivot_point+volume_profile,volume_profile,1.776345,1.966238,5.460089,5.460089
|
||||
IVZ,2026-04-13,long,added,1.746362,2.119681,47.03,33.0,pivot_point+volume_profile,volume_profile,1.712682,2.086001,2.349272,2.349272
|
||||
LVS,2026-04-13,long,removed,2.407554,1.725531,24.96,23.35,pivot_point+volume_profile,volume_profile,-1.49832,-1.49832,-1.453044,-1.453044
|
||||
MRK,2026-04-13,long,removed,2.334923,1.53367,25.67,26.62,pivot_point+volume_profile,volume_profile,-1.05659,-1.05659,-1.0,-1.0
|
||||
NEM,2026-04-13,long,retained,2.111857,2.129673,23.49,32.88,pivot_point+volume_profile,volume_profile,-1.031519,-1.031519,-1.0,-1.0
|
||||
ADM,2026-04-20,long,removed,2.378361,1.814786,25.24,22.0,pivot_point+volume_profile,volume_profile,2.336319,1.772744,4.332119,4.332119
|
||||
ALB,2026-04-20,long,added,1.704982,2.891679,48.68,26.01,pivot_point+volume_profile,volume_profile,-1.023945,-1.023945,-1.0,-1.0
|
||||
APP,2026-04-20,long,added,1.597975,2.981364,35.07,25.4,pivot_point,volume_profile,-1.023277,-1.023277,-1.0,-1.0
|
||||
BIIB,2026-04-20,long,retained,2.318083,2.990785,40.84,25.34,pivot_point,volume_profile,2.274173,-0.04391,0.657434,0.657434
|
||||
DG,2026-04-20,long,added,1.764519,2.118746,47.75,33.01,pivot_point+volume_profile,volume_profile,-1.040034,-1.040034,-1.0,-1.0
|
||||
DLTR,2026-04-20,long,retained,2.99618,2.358253,35.31,30.44,pivot_point+volume_profile,volume_profile,-1.034868,-1.034868,-1.0,-1.0
|
||||
GM,2026-04-20,long,removed,2.031536,1.627283,24.47,39.96,pivot_point,volume_profile,-1.047255,-1.047255,-1.0,-1.0
|
||||
GNRC,2026-04-20,long,retained,2.536521,2.128052,26.38,32.9,pivot_point,volume_profile,2.504928,2.096459,4.894647,4.894647
|
||||
IVZ,2026-04-20,long,removed,2.0925,1.716515,29.72,38.5,pivot_point+volume_profile,volume_profile,2.056939,1.680955,1.874221,1.874221
|
||||
LUV,2026-04-20,long,removed,2.068451,1.627587,43.61,39.96,pivot_point+volume_profile,volume_profile,-1.178575,-1.178575,-1.151594,-1.151594
|
||||
MCHP,2026-04-20,long,added,1.53215,2.09942,51.65,33.24,pivot_point+volume_profile,volume_profile,1.492661,2.059932,4.069653,4.069653
|
||||
MNST,2026-04-20,long,retained,2.298378,2.464221,23.04,29.43,pivot_point+volume_profile,volume_profile,-1.06053,-1.06053,-1.0,-1.0
|
||||
ON,2026-04-20,long,removed,2.205227,1.655239,37.03,39.49,pivot_point+volume_profile,volume_profile,2.168538,1.61855,9.236509,9.236509
|
||||
ULTA,2026-04-20,long,removed,3.177791,1.895238,21.4,35.85,pivot_point+volume_profile,volume_profile,-1.042209,-1.042209,-1.0,-1.0
|
||||
VTRS,2026-04-20,long,added,1.906578,2.159994,28.9,32.54,pivot_point+volume_profile,volume_profile,1.859326,2.112742,1.302828,1.302828
|
||||
FSLR,2026-04-27,long,added,1.762167,2.280875,47.79,31.22,pivot_point,volume_profile,1.73106,2.249768,5.09658,5.09658
|
||||
GNRC,2026-04-27,long,retained,2.431647,2.002688,26.93,34.43,pivot_point,volume_profile,2.398006,1.969046,3.106866,3.106866
|
||||
HAS,2026-04-27,long,retained,2.080713,2.116798,25.86,33.03,pivot_point+volume_profile,volume_profile,-1.163106,-1.163106,-1.125577,-1.125577
|
||||
INCY,2026-04-27,long,added,1.54251,2.311158,51.46,30.91,pivot_point+volume_profile,volume_profile,1.492363,2.261011,1.968643,1.968643
|
||||
IVZ,2026-04-27,long,added,1.879775,2.17108,32.47,32.41,pivot_point+volume_profile,volume_profile,1.839966,2.131271,1.898289,1.898289
|
||||
NEM,2026-04-27,long,retained,2.157639,2.175364,22.96,32.36,pivot_point+volume_profile,volume_profile,-1.098504,-1.098504,-1.067259,-1.067259
|
||||
ON,2026-04-27,long,retained,2.093542,2.093542,23.51,33.31,pivot_point+volume_profile,volume_profile,-1.03632,-1.03632,-1.0,-1.0
|
||||
VTRS,2026-04-27,long,removed,2.04878,1.526016,27.05,41.76,pivot_point+volume_profile,volume_profile,2.000618,1.477854,2.260163,2.260163
|
||||
ADM,2026-05-04,long,added,1.68048,2.194278,49.08,32.15,pivot_point,volume_profile,1.63043,2.144227,0.574203,0.574203
|
||||
ALB,2026-05-04,long,added,1.610684,2.377,50.25,30.25,pivot_point,volume_profile,1.586143,-1.024541,-1.0,-1.0
|
||||
BIIB,2026-05-04,long,added,1.742645,2.374724,48.09,30.27,pivot_point,volume_profile,1.700516,-0.04213,0.945164,0.945164
|
||||
C,2026-05-04,long,removed,2.012374,1.506573,24.31,42.12,pivot_point,volume_profile,-1.052343,-1.052343,-1.0,-1.0
|
||||
DOW,2026-05-04,long,removed,2.152554,1.517886,41.82,41.91,pivot_point+volume_profile,volume_profile,-1.028888,-1.028888,-1.0,-1.0
|
||||
FSLR,2026-05-04,long,removed,2.07299,1.925998,43.56,35.43,pivot_point,volume_profile,2.043281,1.896289,3.722247,3.722247
|
||||
GNRC,2026-05-04,long,retained,2.620386,2.216863,25.66,31.9,pivot_point,volume_profile,-1.033618,-1.033618,-1.0,-1.0
|
||||
IVZ,2026-05-04,long,removed,2.325783,1.684083,22.56,39.02,pivot_point+volume_profile,volume_profile,2.286261,1.644561,2.398027,2.398027
|
||||
OXY,2026-05-04,long,removed,2.45361,1.418712,24.52,28.84,pivot_point+volume_profile,volume_profile,-1.581139,-1.581139,-1.542008,-1.542008
|
||||
ADM,2026-05-11,long,removed,2.143232,1.69859,42.73,38.78,pivot_point+volume_profile,volume_profile,-1.045111,-1.045111,-1.0,-1.0
|
||||
BIIB,2026-05-11,long,added,1.569096,2.835602,50.98,26.4,pivot_point+volume_profile,volume_profile,-1.047834,-1.047834,-1.0,-1.0
|
||||
FSLR,2026-05-11,long,added,1.847053,2.025176,45.73,34.14,pivot_point+volume_profile,volume_profile,1.815814,1.993938,1.010405,1.010405
|
||||
GNRC,2026-05-11,long,added,1.769106,2.872088,35.28,26.14,pivot_point,volume_profile,-1.037299,-1.037299,-1.0,-1.0
|
||||
HAS,2026-05-11,long,removed,2.341129,1.630847,23.01,39.9,pivot_point+volume_profile,volume_profile,-1.491403,-1.491403,-1.446832,-1.446832
|
||||
MRNA,2026-05-11,long,removed,4.417063,0.908038,21.18,57.12,pivot_point,volume_profile,-1.019052,-1.019052,-1.0,-1.0
|
||||
ADM,2026-05-18,long,added,1.860181,2.237304,46.34,31.68,pivot_point+volume_profile,volume_profile,-1.046193,-1.046193,-1.0,-1.0
|
||||
APA,2026-05-18,long,removed,2.025758,1.981919,44.14,34.69,pivot_point+volume_profile,volume_profile,-1.031291,-1.031291,-1.0,-1.0
|
||||
BIIB,2026-05-18,long,removed,2.090766,1.802678,28.34,22.18,pivot_point+volume_profile,volume_profile,2.048589,1.760502,1.959265,1.959265
|
||||
CAH,2026-05-18,long,added,1.751088,2.249334,47.96,31.55,pivot_point+volume_profile,volume_profile,1.705581,2.203827,4.322422,4.322422
|
||||
COP,2026-05-18,long,added,1.777297,2.206045,31.36,32.02,pivot_point+volume_profile,volume_profile,-1.131613,-1.131613,-1.08494,-1.08494
|
||||
CVS,2026-05-18,long,removed,2.060862,1.843965,43.7,36.57,pivot_point+volume_profile,volume_profile,-1.052069,-1.052069,-1.0,-1.0
|
||||
CVX,2026-05-18,long,added,1.961291,2.230118,25.56,31.76,pivot_point+volume_profile,volume_profile,-1.056276,-1.056276,-1.0,-1.0
|
||||
DVN,2026-05-18,long,added,1.796719,2.467367,47.26,29.4,pivot_point,volume_profile,-1.039565,-1.039565,-1.0,-1.0
|
||||
OXY,2026-05-18,long,removed,2.716157,1.65086,37.3,39.57,pivot_point+volume_profile,volume_profile,-1.075823,-1.075823,-1.035923,-1.035923
|
||||
|
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"control_report": "/Users/taathde3/git/lab/signal_platform/reports/backtest-sr-v2-validation-production_control.json",
|
||||
"variant_report": "/Users/taathde3/git/lab/signal_platform/reports/backtest-sr-v2-validation-legacy_range_grid_neutral.json",
|
||||
"control_variant": "production_control",
|
||||
"variant": "legacy_range_grid_neutral",
|
||||
"retained": {
|
||||
"count": 193,
|
||||
"net_avg_r": 0.23,
|
||||
"net_avg_r_ex_top5": 0.0924,
|
||||
"hold30_avg_r": 0.7591
|
||||
},
|
||||
"added": {
|
||||
"count": 179,
|
||||
"net_avg_r": 0.1223,
|
||||
"net_avg_r_ex_top5": -0.0191,
|
||||
"hold30_avg_r": 0.4528
|
||||
},
|
||||
"removed": {
|
||||
"count": 217,
|
||||
"net_avg_r": 0.1318,
|
||||
"net_avg_r_ex_top5": -0.0093,
|
||||
"hold30_avg_r": 0.527
|
||||
},
|
||||
"control_book": {
|
||||
"sharpe": 2.78,
|
||||
"cagr_pct": 73.3,
|
||||
"max_drawdown_pct": 11.7,
|
||||
"trades": 150,
|
||||
"skipped_book_full": 0
|
||||
},
|
||||
"variant_book": {
|
||||
"sharpe": 1.85,
|
||||
"cagr_pct": 43.2,
|
||||
"max_drawdown_pct": 15.2,
|
||||
"trades": 154,
|
||||
"skipped_book_full": 0
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -46,6 +46,20 @@ def _parse_args() -> argparse.Namespace:
|
||||
help="Allow spawn multiprocessing for offline CLI runs, useful on Windows.",
|
||||
)
|
||||
parser.add_argument("--quiet", action="store_true", help="Hide progress output.")
|
||||
parser.add_argument(
|
||||
"--target-model",
|
||||
choices=("production_gtl", "structural_sr"),
|
||||
default="production_gtl",
|
||||
help=(
|
||||
"Target source: production_gtl matches the live scanner; "
|
||||
"structural_sr is a comparison-only chart-S/R model."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--holdout-split",
|
||||
default=None,
|
||||
help="Add a disjoint train/test portfolio report split at YYYY-MM-DD.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -128,6 +142,24 @@ def _print_summary(report: dict) -> None:
|
||||
f"trades {row.get('trades')}"
|
||||
)
|
||||
|
||||
monitor_rows = [
|
||||
row
|
||||
for row in ((report.get("portfolio_monitor") or {}).get("runs") or [])
|
||||
if row.get("lookback") == "all"
|
||||
and row.get("is_production")
|
||||
]
|
||||
if monitor_rows:
|
||||
print(" live-path full-period comparison:")
|
||||
for row in monitor_rows:
|
||||
print(
|
||||
" "
|
||||
f"{row.get('strategy')}: "
|
||||
f"Sharpe {row.get('sharpe')}, "
|
||||
f"CAGR {_pct(row.get('cagr_pct'))}, "
|
||||
f"DD {_drawdown_pct(row.get('max_drawdown_pct'))}, "
|
||||
f"trades {row.get('trades')}"
|
||||
)
|
||||
|
||||
|
||||
async def _main() -> None:
|
||||
args = _parse_args()
|
||||
@@ -138,6 +170,12 @@ async def _main() -> None:
|
||||
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
||||
if args.allow_spawn:
|
||||
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
|
||||
if args.holdout_split:
|
||||
try:
|
||||
date.fromisoformat(args.holdout_split)
|
||||
except ValueError as exc:
|
||||
raise SystemExit("--holdout-split must use YYYY-MM-DD") from exc
|
||||
os.environ["BACKTEST_HOLDOUT_SPLIT"] = args.holdout_split
|
||||
|
||||
from app.config import settings
|
||||
from app.services.backtest_service import run_backtest
|
||||
@@ -166,7 +204,11 @@ async def _main() -> None:
|
||||
|
||||
try:
|
||||
async with Session() as db:
|
||||
report = await run_backtest(db, progress_cb=progress)
|
||||
report = await run_backtest(
|
||||
db,
|
||||
progress_cb=progress,
|
||||
target_model=args.target_model,
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
+5
-1
@@ -213,7 +213,11 @@ def sr_levels(draw: st.DrawFn) -> dict[str, Any]:
|
||||
"price_level": draw(st.floats(min_value=0.01, max_value=10000.0, allow_nan=False, allow_infinity=False)),
|
||||
"type": draw(st.sampled_from(["support", "resistance"])),
|
||||
"strength": draw(st.integers(min_value=0, max_value=100)),
|
||||
"detection_method": draw(st.sampled_from(["volume_profile", "pivot_point", "merged"])),
|
||||
"detection_method": draw(
|
||||
st.sampled_from(
|
||||
["volume_profile", "pivot_point", "merged", "round_number"]
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -604,7 +604,6 @@ class TestSimulatePortfolio:
|
||||
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
|
||||
@@ -730,6 +729,80 @@ def test_window_setups_too_short_returns_empty():
|
||||
assert bt._window_setups([], {}, {}) == []
|
||||
|
||||
|
||||
def test_backtest_target_model_is_small_and_validated():
|
||||
assert bt.validate_backtest_target_model(" PRODUCTION_GTL ") == "production_gtl"
|
||||
assert bt.validate_backtest_target_model("structural_sr") == "structural_sr"
|
||||
with pytest.raises(ValueError, match="Unknown backtest target model"):
|
||||
bt.validate_backtest_target_model("legacy_range_grid_touch")
|
||||
|
||||
|
||||
def _flat_window_records():
|
||||
return [
|
||||
SimpleNamespace(
|
||||
date=date(2024, 1, 1) + timedelta(days=i),
|
||||
open=100.0,
|
||||
high=101.0,
|
||||
low=99.0,
|
||||
close=100.0,
|
||||
volume=1_000_000,
|
||||
)
|
||||
for i in range(bt.MIN_LOOKBACK)
|
||||
]
|
||||
|
||||
|
||||
def test_window_setups_routes_production_gtl_by_default(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_detector(highs, lows, closes):
|
||||
captured.update({"highs": highs, "lows": lows, "closes": closes})
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(bt, "detect_gate_target_ladder", fake_detector)
|
||||
assert bt._window_setups(_flat_window_records(), {}, {}) == []
|
||||
assert captured == {
|
||||
"highs": [101.0] * bt.MIN_LOOKBACK,
|
||||
"lows": [99.0] * bt.MIN_LOOKBACK,
|
||||
"closes": [100.0] * bt.MIN_LOOKBACK,
|
||||
}
|
||||
|
||||
|
||||
def test_window_setups_routes_structural_comparison(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_detector(highs, lows, closes, volumes):
|
||||
captured.update({
|
||||
"highs": highs,
|
||||
"lows": lows,
|
||||
"closes": closes,
|
||||
"volumes": volumes,
|
||||
})
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(bt, "detect_sr_levels", fake_detector)
|
||||
assert bt._window_setups(
|
||||
_flat_window_records(),
|
||||
{},
|
||||
{},
|
||||
target_model=bt.STRUCTURAL_SR_TARGET_MODEL,
|
||||
) == []
|
||||
assert captured == {
|
||||
"highs": [101.0] * bt.MIN_LOOKBACK,
|
||||
"lows": [99.0] * bt.MIN_LOOKBACK,
|
||||
"closes": [100.0] * bt.MIN_LOOKBACK,
|
||||
"volumes": [1_000_000] * bt.MIN_LOOKBACK,
|
||||
}
|
||||
|
||||
|
||||
def test_window_setups_rejects_removed_research_arm():
|
||||
with pytest.raises(ValueError, match="Unknown backtest target model"):
|
||||
bt._window_setups(
|
||||
_flat_window_records(),
|
||||
{},
|
||||
{},
|
||||
target_model="production_control",
|
||||
)
|
||||
|
||||
|
||||
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)."""
|
||||
@@ -755,6 +828,7 @@ def test_replay_ticker_candidates_carry_gate_fields():
|
||||
for c in cands:
|
||||
assert c.get("action") is not None
|
||||
assert "risk_level" in c
|
||||
assert c["target_model"] == bt.PRODUCTION_GTL_TARGET_MODEL
|
||||
|
||||
|
||||
async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None:
|
||||
@@ -794,6 +868,8 @@ async def test_run_backtest_smoke(session):
|
||||
|
||||
# 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 report["params"]["target_model"] == bt.PRODUCTION_GTL_TARGET_MODEL
|
||||
assert report["params"]["is_production_target_model"] is True
|
||||
assert "net_avg_r" in report["overall_all"]
|
||||
|
||||
# ablation baseline reproduces the qualified set exactly, and every row
|
||||
|
||||
@@ -85,6 +85,33 @@ class TestClusterSrZonesStrength:
|
||||
zones = cluster_sr_zones(levels, current_price=200.0, tolerance=0.02)
|
||||
assert zones[0]["strength"] == 30
|
||||
|
||||
def test_soft_strength_uses_max_plus_confluence(self):
|
||||
levels = [
|
||||
{
|
||||
"price_level": 100.0,
|
||||
"strength": 60,
|
||||
"detection_method": "pivot_point",
|
||||
"sources": ["pivot_point"],
|
||||
"rejection_count": 3,
|
||||
},
|
||||
{
|
||||
"price_level": 100.5,
|
||||
"strength": 60,
|
||||
"detection_method": "round_number",
|
||||
"sources": ["round_number"],
|
||||
"rejection_count": 1,
|
||||
},
|
||||
]
|
||||
zones = cluster_sr_zones(
|
||||
levels,
|
||||
current_price=200.0,
|
||||
tolerance=0.02,
|
||||
strength_mode="soft",
|
||||
)
|
||||
assert zones[0]["strength"] == 65
|
||||
assert set(zones[0]["sources"]) == {"pivot_point", "round_number"}
|
||||
assert zones[0]["rejection_count"] == 3
|
||||
|
||||
|
||||
class TestClusterSrZonesTypeTagging:
|
||||
"""Support vs resistance tagging."""
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Unit tests for detect_sr_levels and related pure helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.sr_service import (
|
||||
MAX_LEVELS,
|
||||
_bar_respect_weight,
|
||||
_cap_levels,
|
||||
_gate_target_range_centers,
|
||||
_merge_levels,
|
||||
_round_number_candidates,
|
||||
_strength_from_respects,
|
||||
detect_gate_target_ladder,
|
||||
detect_sr_levels,
|
||||
)
|
||||
|
||||
|
||||
def _make_series(
|
||||
n: int = 300,
|
||||
*,
|
||||
base: float = 100.0,
|
||||
support: float = 95.0,
|
||||
resistance: float = 110.0,
|
||||
) -> tuple[list[float], list[float], list[float], list[int]]:
|
||||
"""Synthetic OHLCV that repeatedly tests support/resistance."""
|
||||
highs: list[float] = []
|
||||
lows: list[float] = []
|
||||
closes: list[float] = []
|
||||
volumes: list[int] = []
|
||||
|
||||
price = base
|
||||
for i in range(n):
|
||||
phase = i % 40
|
||||
if phase < 15:
|
||||
# Drift down toward support, bounce
|
||||
target = support
|
||||
price = price + (target - price) * 0.25
|
||||
low = min(price, support) - 0.3
|
||||
high = price + 1.0
|
||||
close = max(price, support + 0.5) if phase > 12 else price
|
||||
elif phase < 30:
|
||||
# Drift up toward resistance, reject
|
||||
target = resistance
|
||||
price = price + (target - price) * 0.25
|
||||
high = max(price, resistance) + 0.3
|
||||
low = price - 1.0
|
||||
close = min(price, resistance - 0.5) if phase > 27 else price
|
||||
else:
|
||||
price = base + (i % 7) * 0.2
|
||||
high = price + 1.0
|
||||
low = price - 1.0
|
||||
close = price
|
||||
|
||||
# Occasional clear swing extremes
|
||||
if i % 55 == 25:
|
||||
high = resistance + 1.0
|
||||
close = resistance - 1.0
|
||||
low = close - 1.0
|
||||
if i % 55 == 50:
|
||||
low = support - 1.0
|
||||
close = support + 1.0
|
||||
high = close + 1.0
|
||||
|
||||
highs.append(high)
|
||||
lows.append(low)
|
||||
closes.append(close)
|
||||
volumes.append(1000 + (i % 10) * 50)
|
||||
price = close
|
||||
|
||||
return highs, lows, closes, volumes
|
||||
|
||||
|
||||
class TestBarRespectWeight:
|
||||
def test_no_interaction(self):
|
||||
assert _bar_respect_weight(100.0, 90.0, 85.0, 88.0, 87.0, 0.005) == 0.0
|
||||
|
||||
def test_support_rejection(self):
|
||||
# Low probes at 100, closes above with recovery wick
|
||||
w = _bar_respect_weight(100.0, 103.0, 99.8, 102.0, 101.0, 0.005)
|
||||
assert w >= 0.9
|
||||
|
||||
def test_resistance_rejection(self):
|
||||
# High probes at 100, closes below
|
||||
w = _bar_respect_weight(100.0, 100.2, 97.0, 98.0, 99.0, 0.005)
|
||||
assert w >= 0.9
|
||||
|
||||
def test_pass_through_lower_weight(self):
|
||||
# Prev below, close above, bar spans through without probing extremes at level
|
||||
w = _bar_respect_weight(100.0, 105.0, 95.0, 104.0, 96.0, 0.005)
|
||||
assert w < 0.5
|
||||
|
||||
|
||||
class TestStrengthFromRespects:
|
||||
def test_pass_through_not_maximal(self):
|
||||
"""Central pass-through levels should not pin at strength 100."""
|
||||
n = 200
|
||||
# Trending series that passes through 100 many times
|
||||
closes = [80.0 + i * 0.25 for i in range(n)]
|
||||
highs = [c + 1.0 for c in closes]
|
||||
lows = [c - 1.0 for c in closes]
|
||||
strength = _strength_from_respects(100.0, highs, lows, closes, 0.005)
|
||||
assert strength < 100
|
||||
|
||||
def test_repeated_rejection_stronger_than_no_touch(self):
|
||||
n = 120
|
||||
level = 100.0
|
||||
# Bars that repeatedly probe support (low near level) and close above
|
||||
highs = [103.0] * n
|
||||
lows = [99.8] * n
|
||||
closes = [102.0] * n
|
||||
strong = _strength_from_respects(level, highs, lows, closes, 0.01, base=10)
|
||||
|
||||
far_highs = [120.0] * n
|
||||
far_lows = [118.0] * n
|
||||
far_closes = [119.0] * n
|
||||
weak = _strength_from_respects(level, far_highs, far_lows, far_closes, 0.01, base=10)
|
||||
assert strong > weak
|
||||
|
||||
|
||||
class TestRoundNumbers:
|
||||
def test_near_spot(self):
|
||||
levels = _round_number_candidates(103.0)
|
||||
assert levels
|
||||
assert all(abs(p - 103.0) / 103.0 <= 0.15 + 1e-9 for p in levels)
|
||||
assert len(levels) <= 8
|
||||
|
||||
def test_non_positive_price(self):
|
||||
assert _round_number_candidates(0.0) == []
|
||||
assert _round_number_candidates(-5.0) == []
|
||||
|
||||
|
||||
class TestCapLevels:
|
||||
def test_interleaves_sides(self):
|
||||
levels = [
|
||||
{"price_level": 90.0, "type": "support", "strength": 80, "detection_method": "x"},
|
||||
{"price_level": 91.0, "type": "support", "strength": 70, "detection_method": "x"},
|
||||
{"price_level": 92.0, "type": "support", "strength": 60, "detection_method": "x"},
|
||||
{"price_level": 110.0, "type": "resistance", "strength": 50, "detection_method": "x"},
|
||||
{"price_level": 111.0, "type": "resistance", "strength": 40, "detection_method": "x"},
|
||||
]
|
||||
capped = _cap_levels(levels, max_levels=4)
|
||||
assert len(capped) == 4
|
||||
types = {lvl["type"] for lvl in capped}
|
||||
assert "support" in types
|
||||
assert "resistance" in types
|
||||
|
||||
|
||||
class TestLevelEvidence:
|
||||
def test_merge_preserves_sources_and_rejection_evidence(self):
|
||||
levels = [
|
||||
{
|
||||
"price_level": 100.0,
|
||||
"type": "",
|
||||
"strength": 55,
|
||||
"detection_method": "pivot_point",
|
||||
"sources": ["pivot_point"],
|
||||
"rejection_count": 3,
|
||||
"last_rejection_age": 12,
|
||||
"weighted_respects": 1.5,
|
||||
},
|
||||
{
|
||||
"price_level": 100.3,
|
||||
"type": "",
|
||||
"strength": 40,
|
||||
"detection_method": "round_number",
|
||||
"sources": ["round_number"],
|
||||
"rejection_count": 1,
|
||||
"last_rejection_age": 4,
|
||||
"weighted_respects": 0.5,
|
||||
},
|
||||
]
|
||||
merged = _merge_levels(levels, tolerance=0.005)
|
||||
assert len(merged) == 1
|
||||
assert set(merged[0]["sources"]) == {"pivot_point", "round_number"}
|
||||
assert merged[0]["rejection_count"] == 3
|
||||
assert merged[0]["last_rejection_age"] == 4
|
||||
|
||||
|
||||
class TestDetectSrLevels:
|
||||
def test_returns_capped_tagged_levels(self):
|
||||
highs, lows, closes, volumes = _make_series()
|
||||
levels = detect_sr_levels(highs, lows, closes, volumes)
|
||||
assert levels
|
||||
assert len(levels) <= MAX_LEVELS
|
||||
for lvl in levels:
|
||||
assert lvl["type"] in ("support", "resistance")
|
||||
assert 0 <= lvl["strength"] <= 100
|
||||
assert lvl["detection_method"] in (
|
||||
"volume_profile",
|
||||
"pivot_point",
|
||||
"merged",
|
||||
"round_number",
|
||||
)
|
||||
assert lvl["price_level"] > 0
|
||||
assert lvl["sources"]
|
||||
assert lvl["rejection_count"] >= 0
|
||||
# Sorted by strength desc
|
||||
strengths = [lvl["strength"] for lvl in levels]
|
||||
assert strengths == sorted(strengths, reverse=True)
|
||||
|
||||
def test_far_fewer_than_old_grid(self):
|
||||
"""Should not produce a near-1%-spacing grid of ~70 levels."""
|
||||
highs, lows, closes, volumes = _make_series(n=500)
|
||||
levels = detect_sr_levels(highs, lows, closes, volumes)
|
||||
assert len(levels) <= MAX_LEVELS
|
||||
|
||||
def test_empty_input(self):
|
||||
assert detect_sr_levels([], [], [], []) == []
|
||||
|
||||
def test_explicit_tolerance(self):
|
||||
highs, lows, closes, volumes = _make_series()
|
||||
tight = detect_sr_levels(highs, lows, closes, volumes, tolerance=0.001)
|
||||
wide = detect_sr_levels(highs, lows, closes, volumes, tolerance=0.05)
|
||||
# Wider merge should not produce more levels
|
||||
assert len(wide) <= len(tight) + 2 # allow small jitter from scoring
|
||||
|
||||
def test_levels_near_structural_areas(self):
|
||||
"""At least some levels should land near the synthetic S/R band."""
|
||||
highs, lows, closes, volumes = _make_series(
|
||||
n=400, support=95.0, resistance=110.0
|
||||
)
|
||||
levels = detect_sr_levels(highs, lows, closes, volumes)
|
||||
prices = [lvl["price_level"] for lvl in levels]
|
||||
near_support = any(abs(p - 95.0) / 95.0 < 0.05 for p in prices)
|
||||
near_resist = any(abs(p - 110.0) / 110.0 < 0.05 for p in prices)
|
||||
# Round numbers / VP may dominate; require at least one structural band hit
|
||||
assert near_support or near_resist or any(
|
||||
abs(p - 100.0) / 100.0 < 0.08 for p in prices
|
||||
)
|
||||
|
||||
def test_strength_not_all_pinned_at_100(self):
|
||||
highs, lows, closes, volumes = _make_series(n=400)
|
||||
levels = detect_sr_levels(highs, lows, closes, volumes)
|
||||
if len(levels) >= 3:
|
||||
pinned = sum(1 for lvl in levels if lvl["strength"] == 100)
|
||||
assert pinned < len(levels)
|
||||
|
||||
def test_gate_target_range_centers_cover_the_observed_range(self):
|
||||
highs, lows, closes, _ = _make_series(n=500)
|
||||
centers = _gate_target_range_centers(highs, lows, closes)
|
||||
|
||||
assert len(centers) == 20
|
||||
assert centers == sorted(centers)
|
||||
assert min(lows) < centers[0] < centers[-1] < max(highs)
|
||||
|
||||
def test_gate_target_ladder_is_dense_transient_price_traffic(self):
|
||||
highs, lows, closes, _ = _make_series(n=500)
|
||||
levels = detect_gate_target_ladder(highs, lows, closes)
|
||||
|
||||
assert len(levels) > MAX_LEVELS
|
||||
assert any("range_grid" in level["sources"] for level in levels)
|
||||
assert all("volume_profile" not in level["sources"] for level in levels)
|
||||
assert all(0 <= level["strength"] <= 100 for level in levels)
|
||||
assert all(level["rejection_count"] >= 0 for level in levels)
|
||||
|
||||
def test_gate_target_ladder_is_deterministic(self):
|
||||
highs, lows, closes, _ = _make_series(n=500)
|
||||
assert detect_gate_target_ladder(highs, lows, closes) == (
|
||||
detect_gate_target_ladder(list(highs), list(lows), list(closes))
|
||||
)
|
||||
@@ -164,6 +164,34 @@ class TestComputeVolumeProfile:
|
||||
with pytest.raises(ValidationError, match="Volume Profile requires"):
|
||||
compute_volume_profile(highs, lows, closes, volumes)
|
||||
|
||||
def test_close_bin_volume_no_double_count(self):
|
||||
"""Each bar's volume is counted once (close bin), not per span."""
|
||||
# Wide bars that would span many bins under the old algorithm
|
||||
n = 25
|
||||
closes = [100.0 + (i % 5) for i in range(n)]
|
||||
highs = [c + 20 for c in closes] # wide range
|
||||
lows = [c - 20 for c in closes]
|
||||
volumes = [1000] * n
|
||||
result = compute_volume_profile(highs, lows, closes, volumes, num_bins=20)
|
||||
# Binned total equals true volume (close-bin assignment)
|
||||
# We only expose poc/hvn; reconstruct by checking score fields exist
|
||||
assert result["poc"] > 0
|
||||
# With volume concentrated on a few close prices, HVNs should be few local peaks
|
||||
assert len(result["hvn"]) < 20
|
||||
|
||||
def test_hvn_are_local_peaks_not_all_above_mean(self):
|
||||
"""HVN should be local histogram peaks, not every above-mean bin."""
|
||||
# Two clusters of closes → two volume peaks
|
||||
closes = [80.0] * 10 + [120.0] * 10 + [100.0] * 5
|
||||
highs = [c + 1 for c in closes]
|
||||
lows = [c - 1 for c in closes]
|
||||
volumes = [1000] * len(closes)
|
||||
result = compute_volume_profile(highs, lows, closes, volumes, num_bins=20)
|
||||
# At most a handful of local peaks (not ~half of 20 bins)
|
||||
assert len(result["hvn"]) <= 6
|
||||
# POC should land near one of the high-volume clusters
|
||||
assert result["poc"] < 95 or result["poc"] > 105
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pivot Points
|
||||
@@ -184,6 +212,26 @@ class TestComputePivotPoints:
|
||||
with pytest.raises(ValidationError, match="Pivot Points requires"):
|
||||
compute_pivot_points([1, 2], [0, 1], [0.5, 1.5])
|
||||
|
||||
def test_prominence_filters_tiny_swings(self):
|
||||
# Mix of a large swing (depth ~10) and tiny fractal noise (depth ~1)
|
||||
closes = [
|
||||
10, 10.2, 10.5, 10.2, 10, # tiny high around idx 2
|
||||
10, 15, 20, 15, 10, # large high around idx 7
|
||||
10, 10.3, 10.6, 10.3, 10, # tiny high around idx 12
|
||||
]
|
||||
highs = list(closes)
|
||||
lows = [c - 0.5 for c in closes]
|
||||
highs[2] = 10.8
|
||||
highs[7] = 20.5
|
||||
highs[12] = 10.9
|
||||
lows[7] = 10.0 # large window range at major swing
|
||||
unfiltered = compute_pivot_points(highs, lows, closes, min_prominence=None)
|
||||
filtered = compute_pivot_points(highs, lows, closes, min_prominence=5.0)
|
||||
assert unfiltered["pivot_count"] > 0
|
||||
assert filtered["pivot_count"] < unfiltered["pivot_count"]
|
||||
# Major swing high should survive
|
||||
assert any(h >= 20.0 for h in filtered["swing_highs"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EMA Cross
|
||||
|
||||
@@ -110,7 +110,7 @@ def test_primary_target_is_most_likely_worthwhile_not_lottery():
|
||||
{"price": 120.0, "rr_ratio": 3.5, "probability": 50.0},
|
||||
{"price": 140.0, "rr_ratio": 6.0, "probability": 15.0}, # far lottery — not chosen
|
||||
]
|
||||
primary = _select_primary_target(targets)
|
||||
primary = _select_primary_target(targets, min_rr=1.5)
|
||||
assert primary is not None
|
||||
assert primary["price"] == 110.0
|
||||
|
||||
@@ -120,13 +120,13 @@ def test_primary_target_skips_sub_threshold_rr():
|
||||
{"price": 102.0, "rr_ratio": 1.0, "probability": 95.0}, # high prob but trivial R:R — skipped
|
||||
{"price": 115.0, "rr_ratio": 2.5, "probability": 60.0}, # most likely above the R:R floor ← primary
|
||||
]
|
||||
primary = _select_primary_target(targets)
|
||||
primary = _select_primary_target(targets, min_rr=1.5)
|
||||
assert primary is not None
|
||||
assert primary["price"] == 115.0
|
||||
|
||||
|
||||
def test_primary_target_none_when_empty():
|
||||
assert _select_primary_target([]) is None
|
||||
assert _select_primary_target([], min_rr=1.5) is None
|
||||
|
||||
|
||||
def test_primary_target_never_headlines_a_lottery():
|
||||
@@ -138,7 +138,7 @@ def test_primary_target_never_headlines_a_lottery():
|
||||
{"price": 101.0, "rr_ratio": 0.9, "probability": 55.0}, # likely, no asymmetry
|
||||
{"price": 140.0, "rr_ratio": 5.0, "probability": 3.0}, # asymmetric lottery
|
||||
]
|
||||
primary = _select_primary_target(targets)
|
||||
primary = _select_primary_target(targets, min_rr=1.5)
|
||||
assert primary is not None
|
||||
assert primary["price"] == 101.0
|
||||
|
||||
@@ -150,7 +150,17 @@ def test_primary_target_requires_probability_floor():
|
||||
{"price": 130.0, "rr_ratio": 4.0, "probability": 12.0}, # asymmetric but unlikely
|
||||
{"price": 112.0, "rr_ratio": 1.8, "probability": 38.0}, # clears both floors ← primary
|
||||
]
|
||||
primary = _select_primary_target(targets)
|
||||
primary = _select_primary_target(targets, min_rr=1.5)
|
||||
assert primary is not None
|
||||
assert primary["price"] == 112.0
|
||||
|
||||
|
||||
def test_primary_target_uses_activation_rr_not_scanner_floor():
|
||||
targets = [
|
||||
{"price": 108.0, "rr_ratio": 1.6, "probability": 60.0},
|
||||
{"price": 112.0, "rr_ratio": 2.2, "probability": 35.0},
|
||||
]
|
||||
primary = _select_primary_target(targets, min_rr=2.0)
|
||||
assert primary is not None
|
||||
assert primary["price"] == 112.0
|
||||
|
||||
@@ -318,3 +328,28 @@ def test_zone_representative_levels_singletons_unchanged():
|
||||
reps = _zone_representative_levels(levels, entry_price=100.0)
|
||||
assert len(reps) == 2
|
||||
assert {round(r.price_level) for r in reps} == {120, 150}
|
||||
|
||||
|
||||
def test_zone_representative_levels_soft_strength_avoids_resaturation():
|
||||
from types import SimpleNamespace
|
||||
from app.services.recommendation_service import _zone_representative_levels
|
||||
|
||||
levels = [
|
||||
SimpleNamespace(
|
||||
id=1, price_level=183.0, type="resistance", strength=60,
|
||||
detection_method="pivot_point", sources=["pivot_point"],
|
||||
rejection_count=3, last_rejection_age=5,
|
||||
),
|
||||
SimpleNamespace(
|
||||
id=2, price_level=185.0, type="resistance", strength=60,
|
||||
detection_method="round_number", sources=["round_number"],
|
||||
rejection_count=1, last_rejection_age=10,
|
||||
),
|
||||
]
|
||||
reps = _zone_representative_levels(
|
||||
levels, entry_price=180.0, strength_mode="soft"
|
||||
)
|
||||
assert len(reps) == 1
|
||||
assert reps[0].strength == 65
|
||||
assert set(reps[0].sources) == {"pivot_point", "round_number"}
|
||||
assert reps[0].rejection_count == 3
|
||||
|
||||
@@ -110,7 +110,12 @@ async def test_long_prefers_strong_near_over_weak_far(scan_session: AsyncSession
|
||||
scan_session.add_all([near_level, far_level])
|
||||
await scan_session.flush()
|
||||
|
||||
setups = await scan_ticker(scan_session, "EXPLR", rr_threshold=1.5)
|
||||
setups = await scan_ticker(
|
||||
scan_session,
|
||||
"EXPLR",
|
||||
rr_threshold=1.5,
|
||||
gate_levels_override=[near_level, far_level],
|
||||
)
|
||||
|
||||
long_setups = [s for s in setups if s.direction == "long"]
|
||||
assert len(long_setups) == 1, "Expected exactly one long setup"
|
||||
@@ -162,7 +167,12 @@ async def test_short_prefers_strong_near_over_weak_far(scan_session: AsyncSessio
|
||||
scan_session.add_all([near_level, far_level])
|
||||
await scan_session.flush()
|
||||
|
||||
setups = await scan_ticker(scan_session, "EXPLS", rr_threshold=1.5)
|
||||
setups = await scan_ticker(
|
||||
scan_session,
|
||||
"EXPLS",
|
||||
rr_threshold=1.5,
|
||||
gate_levels_override=[near_level, far_level],
|
||||
)
|
||||
|
||||
short_setups = [s for s in setups if s.direction == "short"]
|
||||
assert len(short_setups) == 1, "Expected exactly one short setup"
|
||||
@@ -256,7 +266,12 @@ async def test_property_scanner_does_not_always_pick_most_distant(
|
||||
session.add_all([near_level, far_level])
|
||||
await session.commit()
|
||||
|
||||
setups = await scan_ticker(session, "PROP", rr_threshold=1.5)
|
||||
setups = await scan_ticker(
|
||||
session,
|
||||
"PROP",
|
||||
rr_threshold=1.5,
|
||||
gate_levels_override=[near_level, far_level],
|
||||
)
|
||||
|
||||
long_setups = [s for s in setups if s.direction == "long"]
|
||||
assert len(long_setups) == 1, "Expected exactly one long setup"
|
||||
|
||||
@@ -169,7 +169,12 @@ async def test_property_long_selects_highest_quality(
|
||||
session.add_all(sr_levels)
|
||||
await session.commit()
|
||||
|
||||
setups = await scan_ticker(session, "FIXL", rr_threshold=1.5)
|
||||
setups = await scan_ticker(
|
||||
session,
|
||||
"FIXL",
|
||||
rr_threshold=1.5,
|
||||
gate_levels_override=sr_levels,
|
||||
)
|
||||
|
||||
long_setups = [s for s in setups if s.direction == "long"]
|
||||
assert len(long_setups) == 1, "Expected exactly one long setup"
|
||||
@@ -225,7 +230,12 @@ async def test_property_short_selects_highest_quality(
|
||||
session.add_all(sr_levels)
|
||||
await session.commit()
|
||||
|
||||
setups = await scan_ticker(session, "FIXS", rr_threshold=1.5)
|
||||
setups = await scan_ticker(
|
||||
session,
|
||||
"FIXS",
|
||||
rr_threshold=1.5,
|
||||
gate_levels_override=sr_levels,
|
||||
)
|
||||
|
||||
short_setups = [s for s in setups if s.direction == "short"]
|
||||
assert len(short_setups) == 1, "Expected exactly one short setup"
|
||||
@@ -283,7 +293,12 @@ async def test_deterministic_long_three_levels(scan_session: AsyncSession):
|
||||
scan_session.add_all([level_a, level_b, level_c])
|
||||
await scan_session.flush()
|
||||
|
||||
setups = await scan_ticker(scan_session, "DET3L", rr_threshold=1.5)
|
||||
setups = await scan_ticker(
|
||||
scan_session,
|
||||
"DET3L",
|
||||
rr_threshold=1.5,
|
||||
gate_levels_override=[level_a, level_b, level_c],
|
||||
)
|
||||
|
||||
long_setups = [s for s in setups if s.direction == "long"]
|
||||
assert len(long_setups) == 1, "Expected exactly one long setup"
|
||||
@@ -341,7 +356,12 @@ async def test_deterministic_short_three_levels(scan_session: AsyncSession):
|
||||
scan_session.add_all([level_a, level_b, level_c])
|
||||
await scan_session.flush()
|
||||
|
||||
setups = await scan_ticker(scan_session, "DET3S", rr_threshold=1.5)
|
||||
setups = await scan_ticker(
|
||||
scan_session,
|
||||
"DET3S",
|
||||
rr_threshold=1.5,
|
||||
gate_levels_override=[level_a, level_b, level_c],
|
||||
)
|
||||
|
||||
short_setups = [s for s in setups if s.direction == "short"]
|
||||
assert len(short_setups) == 1, "Expected exactly one short setup"
|
||||
|
||||
@@ -8,6 +8,7 @@ correct TradeSetup field population, and database persistence.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
@@ -152,7 +153,13 @@ async def test_scan_ticker_full_flow_quality_selection_and_persistence(
|
||||
assert len(pre_setups) == 1, "Dummy old setup should exist before scan"
|
||||
|
||||
# -- Act: run scan_ticker --
|
||||
setups = await scan_ticker(scan_session, "INTEG", rr_threshold=1.5, atr_multiplier=1.5)
|
||||
setups = await scan_ticker(
|
||||
scan_session,
|
||||
"INTEG",
|
||||
rr_threshold=1.5,
|
||||
atr_multiplier=1.5,
|
||||
gate_levels_override=sr_levels,
|
||||
)
|
||||
|
||||
# -- Assert: both directions produced --
|
||||
assert len(setups) == 2, f"Expected 2 setups (long + short), got {len(setups)}"
|
||||
@@ -255,3 +262,61 @@ async def test_scan_ticker_full_flow_quality_selection_and_persistence(
|
||||
assert persisted_short.entry_price == short_setup.entry_price
|
||||
assert persisted_short.stop_loss == short_setup.stop_loss
|
||||
assert persisted_short.composite_score == short_setup.composite_score
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_ticker_uses_transient_ladder_not_persisted_chart_levels(
|
||||
scan_session: AsyncSession,
|
||||
monkeypatch,
|
||||
):
|
||||
ticker = Ticker(symbol="DUAL")
|
||||
scan_session.add(ticker)
|
||||
await scan_session.flush()
|
||||
scan_session.add_all(_make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0))
|
||||
scan_session.add(SRLevel(
|
||||
ticker_id=ticker.id,
|
||||
price_level=130.0,
|
||||
type="resistance",
|
||||
strength=100,
|
||||
detection_method="pivot_point",
|
||||
))
|
||||
await scan_session.commit()
|
||||
|
||||
ladder = [
|
||||
{
|
||||
"price_level": 105.0,
|
||||
"type": "resistance",
|
||||
"strength": 90,
|
||||
"detection_method": "range_grid",
|
||||
"sources": ["range_grid"],
|
||||
"rejection_count": 5,
|
||||
"last_rejection_age": None,
|
||||
},
|
||||
{
|
||||
"price_level": 95.0,
|
||||
"type": "support",
|
||||
"strength": 85,
|
||||
"detection_method": "range_grid",
|
||||
"sources": ["range_grid"],
|
||||
"rejection_count": 4,
|
||||
"last_rejection_age": None,
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"app.services.rr_scanner_service.detect_gate_target_ladder",
|
||||
lambda highs, lows, closes: ladder,
|
||||
)
|
||||
|
||||
setups = await scan_ticker(
|
||||
scan_session,
|
||||
"DUAL",
|
||||
rr_threshold=1.5,
|
||||
)
|
||||
|
||||
long_setup = next(setup for setup in setups if setup.direction == "long")
|
||||
assert long_setup.target == pytest.approx(105.0, abs=0.01)
|
||||
assert long_setup.target != pytest.approx(130.0, abs=0.01)
|
||||
targets = json.loads(long_setup.targets_json or "[]")
|
||||
assert targets
|
||||
assert all(target["sr_level_id"] < 0 for target in targets)
|
||||
assert all(target["sr_sources"] == ["range_grid"] for target in targets)
|
||||
|
||||
@@ -197,17 +197,25 @@ async def test_property_zero_candidates_produce_no_setup(
|
||||
bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0)
|
||||
session.add_all(bars)
|
||||
|
||||
gate_levels = []
|
||||
for lv_data in scenario.get("levels", []):
|
||||
session.add(SRLevel(
|
||||
level = SRLevel(
|
||||
ticker_id=ticker.id,
|
||||
price_level=lv_data["price"],
|
||||
type=lv_data["type"],
|
||||
strength=lv_data["strength"],
|
||||
detection_method="volume_profile",
|
||||
))
|
||||
)
|
||||
session.add(level)
|
||||
gate_levels.append(level)
|
||||
await session.commit()
|
||||
|
||||
setups = await scan_ticker(session, "PRSV0", rr_threshold=1.5)
|
||||
setups = await scan_ticker(
|
||||
session,
|
||||
"PRSV0",
|
||||
rr_threshold=1.5,
|
||||
gate_levels_override=gate_levels,
|
||||
)
|
||||
|
||||
assert setups == [], (
|
||||
f"Expected no setups for zero-candidate scenario "
|
||||
@@ -247,16 +255,22 @@ async def test_property_single_candidate_selected_unchanged(
|
||||
session.add_all(bars)
|
||||
|
||||
lv = scenario["level"]
|
||||
session.add(SRLevel(
|
||||
level = SRLevel(
|
||||
ticker_id=ticker.id,
|
||||
price_level=lv["price"],
|
||||
type=lv["type"],
|
||||
strength=lv["strength"],
|
||||
detection_method="volume_profile",
|
||||
))
|
||||
)
|
||||
session.add(level)
|
||||
await session.commit()
|
||||
|
||||
setups = await scan_ticker(session, "PRSV1", rr_threshold=1.5)
|
||||
setups = await scan_ticker(
|
||||
session,
|
||||
"PRSV1",
|
||||
rr_threshold=1.5,
|
||||
gate_levels_override=[level],
|
||||
)
|
||||
|
||||
direction = scenario["direction"]
|
||||
dir_setups = [s for s in setups if s.direction == direction]
|
||||
@@ -292,7 +306,12 @@ async def test_no_sr_levels_produces_no_setup(scan_session: AsyncSession):
|
||||
scan_session.add_all(bars)
|
||||
await scan_session.flush()
|
||||
|
||||
setups = await scan_ticker(scan_session, "NOSRL", rr_threshold=1.5)
|
||||
setups = await scan_ticker(
|
||||
scan_session,
|
||||
"NOSRL",
|
||||
rr_threshold=1.5,
|
||||
gate_levels_override=[],
|
||||
)
|
||||
|
||||
assert setups == [], (
|
||||
f"Expected no setups when no SR levels exist, got {len(setups)}"
|
||||
@@ -329,7 +348,12 @@ async def test_single_resistance_above_threshold_selected(scan_session: AsyncSes
|
||||
scan_session.add(level)
|
||||
await scan_session.flush()
|
||||
|
||||
setups = await scan_ticker(scan_session, "SINGL", rr_threshold=1.5)
|
||||
setups = await scan_ticker(
|
||||
scan_session,
|
||||
"SINGL",
|
||||
rr_threshold=1.5,
|
||||
gate_levels_override=[level],
|
||||
)
|
||||
|
||||
long_setups = [s for s in setups if s.direction == "long"]
|
||||
assert len(long_setups) == 1, (
|
||||
@@ -366,7 +390,12 @@ async def test_single_support_below_threshold_selected(scan_session: AsyncSessio
|
||||
scan_session.add(level)
|
||||
await scan_session.flush()
|
||||
|
||||
setups = await scan_ticker(scan_session, "SINGS", rr_threshold=1.5)
|
||||
setups = await scan_ticker(
|
||||
scan_session,
|
||||
"SINGS",
|
||||
rr_threshold=1.5,
|
||||
gate_levels_override=[level],
|
||||
)
|
||||
|
||||
short_setups = [s for s in setups if s.direction == "short"]
|
||||
assert len(short_setups) == 1, (
|
||||
|
||||
@@ -31,9 +31,11 @@ async def test_scan_proceeds_when_score_refresh_fails(session, monkeypatch):
|
||||
raise RuntimeError("scoring unavailable")
|
||||
|
||||
scanned: list[str] = []
|
||||
primary_floors: list[float] = []
|
||||
|
||||
async def _fake_scan_ticker(db, symbol, *args, **kwargs):
|
||||
scanned.append(symbol)
|
||||
primary_floors.append(kwargs["primary_min_rr"])
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(scoring_service, "compute_all_dimensions", _boom)
|
||||
@@ -42,6 +44,7 @@ async def test_scan_proceeds_when_score_refresh_fails(session, monkeypatch):
|
||||
setups = await rr_scanner_service.scan_all_tickers(session)
|
||||
|
||||
assert scanned == ["AAA"]
|
||||
assert primary_floors == [rr_scanner_service.PRIMARY_TARGET_MIN_RR]
|
||||
assert setups == []
|
||||
|
||||
|
||||
|
||||
@@ -3,15 +3,27 @@
|
||||
import pytest
|
||||
|
||||
from app.scheduler import (
|
||||
_is_job_enabled,
|
||||
_consume_backtest_target_model,
|
||||
_parse_frequency,
|
||||
_resume_tickers,
|
||||
_last_successful,
|
||||
configure_scheduler,
|
||||
queue_backtest_target_model,
|
||||
scheduler,
|
||||
)
|
||||
|
||||
|
||||
def test_manual_backtest_target_model_is_one_shot():
|
||||
assert queue_backtest_target_model("structural_sr") == "structural_sr"
|
||||
assert _consume_backtest_target_model() == "structural_sr"
|
||||
assert _consume_backtest_target_model() == "production_gtl"
|
||||
|
||||
|
||||
def test_manual_backtest_target_model_rejects_removed_research_arms():
|
||||
with pytest.raises(ValueError, match="Unknown backtest target model"):
|
||||
queue_backtest_target_model("production_control")
|
||||
|
||||
|
||||
class TestParseFrequency:
|
||||
def test_hourly(self):
|
||||
assert _parse_frequency("hourly") == {"hours": 1}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -29,10 +28,12 @@ class _FakeLevel:
|
||||
|
||||
|
||||
class _FakeOHLCV:
|
||||
"""Mimics an OHLCVRecord with a close attribute."""
|
||||
"""Mimics an OHLCVRecord with price attributes."""
|
||||
|
||||
def __init__(self, close: float):
|
||||
def __init__(self, close: float, high: float | None = None, low: float | None = None):
|
||||
self.close = close
|
||||
self.high = high if high is not None else close + 1.0
|
||||
self.low = low if low is not None else close - 1.0
|
||||
|
||||
|
||||
def _make_app() -> FastAPI:
|
||||
@@ -62,6 +63,71 @@ SAMPLE_LEVELS = [
|
||||
SAMPLE_OHLCV = [_FakeOHLCV(100.0)]
|
||||
|
||||
|
||||
class TestGateTargetLadderRouter:
|
||||
@patch("app.routers.sr_levels.detect_gate_target_ladder")
|
||||
@patch("app.routers.sr_levels.query_ohlcv", new_callable=AsyncMock)
|
||||
def test_returns_transient_price_traffic_proposals(self, mock_ohlcv, mock_detect):
|
||||
mock_ohlcv.return_value = [
|
||||
_FakeOHLCV(100.0, high=101.0, low=99.0),
|
||||
_FakeOHLCV(102.0, high=103.0, low=100.0),
|
||||
]
|
||||
mock_detect.return_value = [
|
||||
{
|
||||
"price_level": 105.0,
|
||||
"type": "resistance",
|
||||
"strength": 80,
|
||||
"detection_method": "merged",
|
||||
"sources": ["pivot_point", "range_grid"],
|
||||
"rejection_count": 7,
|
||||
},
|
||||
{
|
||||
"price_level": 95.0,
|
||||
"type": "support",
|
||||
"strength": 60,
|
||||
"detection_method": "range_grid",
|
||||
"sources": ["range_grid"],
|
||||
"rejection_count": 4,
|
||||
},
|
||||
]
|
||||
|
||||
response = TestClient(_make_app()).get("/api/v1/gate-target-ladder/aapl")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert data["symbol"] == "AAPL"
|
||||
assert data["lookback_bars"] == 2
|
||||
assert [level["price_level"] for level in data["levels"]] == [95.0, 105.0]
|
||||
assert data["levels"][1] == {
|
||||
"price_level": 105.0,
|
||||
"type": "resistance",
|
||||
"strength": 80,
|
||||
"detection_method": "merged",
|
||||
"sources": ["pivot_point", "range_grid"],
|
||||
"traffic_count": 7,
|
||||
}
|
||||
mock_detect.assert_called_once_with(
|
||||
[101.0, 103.0],
|
||||
[99.0, 100.0],
|
||||
[100.0, 102.0],
|
||||
)
|
||||
|
||||
@patch("app.routers.sr_levels.detect_gate_target_ladder")
|
||||
@patch("app.routers.sr_levels.query_ohlcv", new_callable=AsyncMock)
|
||||
def test_empty_history_returns_empty_ladder(self, mock_ohlcv, mock_detect):
|
||||
mock_ohlcv.return_value = []
|
||||
|
||||
response = TestClient(_make_app()).get("/api/v1/gate-target-ladder/AAPL")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"] == {
|
||||
"symbol": "AAPL",
|
||||
"levels": [],
|
||||
"count": 0,
|
||||
"lookback_bars": 0,
|
||||
}
|
||||
mock_detect.assert_not_called()
|
||||
|
||||
|
||||
class TestSRLevelsRouterZones:
|
||||
"""Tests for max_zones parameter and zone inclusion in response."""
|
||||
|
||||
@@ -207,7 +273,7 @@ class TestSRLevelsRouterVisibleLevels:
|
||||
), f"visible level price {price} not within any zone bounds"
|
||||
|
||||
# visible_levels must be a subset of levels (by id)
|
||||
level_ids = {l["id"] for l in data["levels"]}
|
||||
level_ids = {level["id"] for level in data["levels"]}
|
||||
for lvl in visible:
|
||||
assert lvl["id"] in level_ids
|
||||
|
||||
|
||||
Reference in New Issue
Block a user