diff --git a/README.md b/README.md index 33a2dbc..e97d2ad 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,14 @@ 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. + ### Daily Load — the full refresh Once a day (default 07:00). Steps run **in dependency order**, each consuming the previous step's fresh output: @@ -275,6 +283,7 @@ 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) - 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 @@ -313,6 +322,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` | diff --git a/app/routers/sr_levels.py b/app/routers/sr_levels.py index 716649e..beaf381 100644 --- a/app/routers/sr_levels.py +++ b/app/routers/sr_levels.py @@ -5,13 +5,68 @@ 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, diff --git a/app/schemas/sr_level.py b/app/schemas/sr_level.py index 49461fe..be2981a 100644 --- a/app/schemas/sr_level.py +++ b/app/schemas/sr_level.py @@ -40,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 diff --git a/docs/research/sr-levels-and-exits.md b/docs/research/sr-levels-and-exits.md index ff8a6d9..b609b55 100644 --- a/docs/research/sr-levels-and-exits.md +++ b/docs/research/sr-levels-and-exits.md @@ -746,6 +746,24 @@ Step by step: 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 `explicit_target_ladder` arm therefore 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 diff --git a/frontend/src/api/sr-levels.ts b/frontend/src/api/sr-levels.ts index 3a1d3b7..a20728a 100644 --- a/frontend/src/api/sr-levels.ts +++ b/frontend/src/api/sr-levels.ts @@ -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(`sr-levels/${symbol}`) .then((r) => r.data); } + +export function getGateTargetLadder(symbol: string) { + return apiClient + .get(`gate-target-ladder/${symbol}`) + .then((r) => r.data); +} diff --git a/frontend/src/components/charts/CandlestickChart.tsx b/frontend/src/components/charts/CandlestickChart.tsx index 87847c9..fd77fa0 100644 --- a/frontend/src/components/charts/CandlestickChart.tsx +++ b/frontend/src/components/charts/CandlestickChart.tsx @@ -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(null); const overlayCanvasRef = useRef(null); const containerRef = useRef(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 = ` +
GTL price traffic · not volume
+
+ Price${formatPrice(level.price_level)} + Crossings${level.traffic_count} + Strength${level.strength} + Side${level.type} + Source${sources} +
`; + } + } + // 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, Direction${tradeSetup.direction} Entry${formatPrice(tradeSetup.entry_price)} Stop${formatPrice(tradeSetup.stop_loss)} - Target${formatPrice(tradeSetup.target)} + Gate target${formatPrice(tradeSetup.target)} R:R${tradeSetup.rr_ratio.toFixed(2)} `; } @@ -684,7 +808,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, Low${formatPrice(bar.low)} Close${formatPrice(bar.close)} Vol${formatLargeNumber(bar.volume)} - ${tradeTooltipHtml}`; + ${gateTooltipHtml}${tradeTooltipHtml}`; } else { tip.style.display = 'none'; } @@ -733,6 +857,29 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, ))} scroll to zoom · drag to pan +
+ {showGateTraffic && gateTargetLevels.length > 0 && ( +
+ + GTL price traffic + {' · '}bar width = relative historical crossings{' · '}not volume + + + {gateTargetLevels.length} proposals + {gateTargetLookbackBars > 0 ? ` · ${gateTargetLookbackBars} bars` : ''} + +
+ )} + {showGateTraffic && !gateTargetLoading && gateTargetLevels.length === 0 && ( +

+ {gateTargetError + ? 'Gate Target Ladder diagnostic could not be loaded.' + : 'No Gate Target Ladder proposals are available for this history.'} +

+ )} ); } diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index ac40e8d..efbc3f8 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -118,7 +118,7 @@ function TargetTable({ setup, selectedPrice, onSelect, honorsTarget }: { honorsTarget: boolean; }) { if (!setup.targets || setup.targets.length === 0) { - return

No overhead levels detected.

; + return

No gate target proposals detected.

; } 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)' } > @@ -140,8 +140,8 @@ function TargetTable({ setup, selectedPrice, onSelect, honorsTarget }: { Gate R:R - - Touch odds + + Reach probability @@ -455,7 +455,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele > {setup.targets.map((t) => ( ))} @@ -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 && (
{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`} {!honorsTarget && (

- 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.

)}
diff --git a/frontend/src/hooks/useFetchSymbolData.ts b/frontend/src/hooks/useFetchSymbolData.ts index 2e70b4b..84ec47c 100644 --- a/frontend/src/hooks/useFetchSymbolData.ts +++ b/frontend/src/hooks/useFetchSymbolData.ts @@ -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']). diff --git a/frontend/src/hooks/useTickerDetail.ts b/frontend/src/hooks/useTickerDetail.ts index 15abb55..97b789b 100644 --- a/frontend/src/hooks/useTickerDetail.ts +++ b/frontend/src/hooks/useTickerDetail.ts @@ -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, + }; } diff --git a/frontend/src/lib/exitPlan.ts b/frontend/src/lib/exitPlan.ts index f78f6d8..dc6dc8b 100644 --- a/frontend/src/lib/exitPlan.ts +++ b/frontend/src/lib/exitPlan.ts @@ -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. diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 7e61de0..60e75e2 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -604,6 +604,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; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index b8aaef1..6d35a98 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -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`} > {rank} @@ -168,8 +168,8 @@ function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: {
{/* 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. */}
{setup.momentum_percentile != null && (
@@ -188,12 +188,12 @@ function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: { )}

gate metrics

R:R {setup.rr_ratio.toFixed(1)}:1 - {prob != null && <> · touch {Math.round(prob)}%} + {prob != null && <> · reach {Math.round(prob)}%}

screening only — exits on the trailing stop, not at the level diff --git a/frontend/src/pages/TickerDetailPage.tsx b/frontend/src/pages/TickerDetailPage.tsx index 5bac164..ab71c73 100644 --- a/frontend/src/pages/TickerDetailPage.tsx +++ b/frontend/src/pages/TickerDetailPage.tsx @@ -120,8 +120,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,12 +447,19 @@ 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} />

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.'}

)} diff --git a/tests/unit/test_sr_levels_router.py b/tests/unit/test_sr_levels_router.py index a0cd2ff..8620f51 100644 --- a/tests/unit/test_sr_levels_router.py +++ b/tests/unit/test_sr_levels_router.py @@ -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