Add GTL price-traffic chart diagnostic

This commit is contained in:
2026-07-13 12:08:10 +02:00
parent dc1570877c
commit 4730d19694
14 changed files with 433 additions and 44 deletions
+10
View File
@@ -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 (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). [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 ### Daily Load — the full refresh
Once a day (default 07:00). Steps run **in dependency order**, each consuming the previous step's fresh output: Once a day (default 07:00). Steps run **in dependency order**, each consuming the previous step's fresh output:
@@ -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 - 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 - 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) - 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 - Data freshness bar showing availability and recency of each data source
- Watchlist with composite scores, R:R ratios, and S/R summaries - Watchlist with composite scores, R:R ratios, and S/R summaries
- Ticker detail page: chart, scores, sentiment breakdown, fundamentals, technical indicators, S/R table - 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}` | | Ingestion | `POST /ingestion/fetch/{symbol}` |
| Indicators | `GET /indicators/{symbol}/{type}`, `GET /indicators/{symbol}/ema-cross` | | Indicators | `GET /indicators/{symbol}/{type}`, `GET /indicators/{symbol}/ema-cross` |
| S/R Levels | `GET /sr-levels/{symbol}` | | S/R Levels | `GET /sr-levels/{symbol}` |
| Gate Target Ladder | `GET /gate-target-ladder/{symbol}` |
| Sentiment | `GET /sentiment/{symbol}` | | Sentiment | `GET /sentiment/{symbol}` |
| Fundamentals | `GET /fundamentals/{symbol}` | | Fundamentals | `GET /fundamentals/{symbol}` |
| Scores | `GET /scores/{symbol}`, `GET /rankings`, `PUT /scores/weights` | | Scores | `GET /scores/{symbol}`, `GET /rankings`, `PUT /scores/weights` |
+57 -2
View File
@@ -5,13 +5,68 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access from app.dependencies import get_db, require_access
from app.schemas.common import APIEnvelope 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.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 = 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) @router.get("/sr-levels/{symbol}", response_model=APIEnvelope)
async def read_sr_levels( async def read_sr_levels(
symbol: str, symbol: str,
+20
View File
@@ -40,3 +40,23 @@ class SRLevelResponse(BaseModel):
zones: list[SRZoneResult] = [] zones: list[SRZoneResult] = []
visible_levels: list[SRLevelResult] = [] visible_levels: list[SRLevelResult] = []
count: int 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
+18
View File
@@ -746,6 +746,24 @@ Step by step:
20%, plus the residual-momentum and direction rules. A traded setup still 20%, plus the residual-momentum and direction rules. A traded setup still
exits only through the ATR stop/trail or maximum hold. 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 The `explicit_target_ladder` arm therefore replaces only the irrelevant volume
pass with the complete range grid. It retains pivots, touch strength, merge pass with the complete range grid. It retains pivots, touch strength, merge
geometry, primary selection, qualification, ranking, and exit behavior. Grid geometry, primary selection, qualification, ranking, and exit behavior. Grid
+7 -1
View File
@@ -1,8 +1,14 @@
import apiClient from './client'; import apiClient from './client';
import type { SRLevelResponse } from '../lib/types'; import type { GateTargetLadderResponse, SRLevelResponse } from '../lib/types';
export function getLevels(symbol: string) { export function getLevels(symbol: string) {
return apiClient return apiClient
.get<SRLevelResponse>(`sr-levels/${symbol}`) .get<SRLevelResponse>(`sr-levels/${symbol}`)
.then((r) => r.data); .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 { 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'; import { formatPrice, formatDate, formatLargeNumber } from '../../lib/format';
interface CandlestickChartProps { interface CandlestickChartProps {
data: OHLCVBar[]; data: OHLCVBar[];
srLevels?: SRLevel[]; srLevels?: SRLevel[];
zones?: SRZone[]; zones?: SRZone[];
gateTargetLevels?: GateTargetLevel[];
gateTargetLookbackBars?: number;
gateTargetLoading?: boolean;
gateTargetError?: boolean;
showGateTraffic?: boolean;
onShowGateTrafficChange?: (visible: boolean) => void;
tradeSetup?: TradeSetup; tradeSetup?: TradeSetup;
currentPrice?: number; currentPrice?: number;
} }
@@ -74,7 +86,19 @@ function startIndexForPreset(data: OHLCVBar[], preset: RangePreset): number {
return idx < 0 ? 0 : idx; 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 canvasRef = useRef<HTMLCanvasElement>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement>(null); const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(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); 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) // Nearest support/resistance only (band if it came from a zone)
markers.forEach((m) => { markers.forEach((m) => {
const isSupport = m.role === 'support'; const isSupport = m.role === 'support';
@@ -267,15 +342,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
// Take-profit zone: green semi-transparent rectangle between entry and target // Gate target: a diagnostic marker, not a take-profit zone. Violet ties
const tpTop = Math.min(entryY, targetY); // it to the GTL profile without implying that the trade exits here.
const tpHeight = Math.max(Math.abs(targetY - entryY), 1); ctx.strokeStyle = 'rgba(139, 92, 246, 0.65)';
ctx.fillStyle = 'rgba(47, 157, 178, 0.13)';
ctx.fillRect(ml, tpTop, cw, tpHeight);
// Target border
ctx.strokeStyle = 'rgba(47, 157, 178, 0.45)';
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.setLineDash([4, 3]); ctx.setLineDash([2, 3]);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(ml, targetY); ctx.moveTo(ml, targetY);
ctx.lineTo(ml + cw, 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.fillText(`Entry ${formatPrice(tradeSetup.entry_price)}`, ml + cw + 4, entryY + 3);
ctx.fillStyle = 'rgba(239, 145, 130, 0.9)'; ctx.fillStyle = 'rgba(239, 145, 130, 0.9)';
ctx.fillText(`SL ${formatPrice(tradeSetup.stop_loss)}`, ml + cw + 4, stopY + 3); ctx.fillText(`SL ${formatPrice(tradeSetup.stop_loss)}`, ml + cw + 4, stopY + 3);
ctx.fillStyle = 'rgba(110, 201, 219, 0.9)'; ctx.fillStyle = 'rgba(196, 181, 253, 0.95)';
ctx.fillText(`TP ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3); ctx.fillText(`Gate ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3);
} }
// Current price line — the anchor for everything else (drawn on top) // Current price line — the anchor for everything else (drawn on top)
@@ -367,6 +438,13 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
volumeTop, volumeTop,
volumeH, volumeH,
volumeBottom, volumeBottom,
gateProfile: gateProfileRows.length > 0
? {
startX: gateProfileEndX - gateProfileMaxWidth,
endX: gateProfileEndX,
rows: gateProfileRows,
}
: null,
}; };
// Size the overlay canvas to match // Size the overlay canvas to match
@@ -377,7 +455,16 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
overlay.style.width = `${W}px`; overlay.style.width = `${W}px`;
overlay.style.height = `${H}px`; overlay.style.height = `${H}px`;
} }
}, [data, srLevels, visibleRange, zones, tradeSetup, currentPrice]); }, [
currentPrice,
data,
gateTargetLevels,
showGateTraffic,
srLevels,
tradeSetup,
visibleRange,
zones,
]);
const drawCrosshair = useCallback(() => { const drawCrosshair = useCallback(() => {
const overlay = overlayCanvasRef.current; 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.left = `${Math.min(mx + 14, rect.width - 180)}px`;
tip.style.top = `${Math.max(my - 80, 8)}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 // Check if cursor is near trade overlay zone
let tradeTooltipHtml = ''; let tradeTooltipHtml = '';
if (tradeSetup && meta.yScale) { 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>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>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>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> <span>R:R</span><span class="text-right text-gray-200">${tradeSetup.rr_ratio.toFixed(2)}</span>
</div>`; </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>Low</span><span class="text-right text-gray-200">${formatPrice(bar.low)}</span>
<span>Close</span><span class="text-right text-gray-200">${formatPrice(bar.close)}</span> <span>Close</span><span class="text-right text-gray-200">${formatPrice(bar.close)}</span>
<span>Vol</span><span class="text-right text-gray-200" title="${bar.volume.toLocaleString()}">${formatLargeNumber(bar.volume)}</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 { } else {
tip.style.display = 'none'; tip.style.display = 'none';
} }
@@ -733,6 +857,29 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
</button> </button>
))} ))}
<span className="ml-1 text-[10px] text-gray-600">scroll to zoom · drag to pan</span> <span className="ml-1 text-[10px] text-gray-600">scroll to zoom · drag to pan</span>
<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>
<div ref={containerRef} className="relative w-full" style={{ height: CHART_HEIGHT }}> <div ref={containerRef} className="relative w-full" style={{ height: CHART_HEIGHT }}>
<canvas <canvas
@@ -756,6 +903,25 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
</div> </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> </div>
); );
} }
@@ -118,7 +118,7 @@ function TargetTable({ setup, selectedPrice, onSelect, honorsTarget }: {
honorsTarget: boolean; honorsTarget: boolean;
}) { }) {
if (!setup.targets || setup.targets.length === 0) { 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 ( return (
@@ -129,7 +129,7 @@ function TargetTable({ setup, selectedPrice, onSelect, honorsTarget }: {
aria-label={ aria-label={
honorsTarget honorsTarget
? 'Choose the take-profit level for the rail and paper trade' ? '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> <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."> <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 Gate R:R
</th> </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."> <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.">
Touch odds Reach probability
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -455,7 +455,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
> >
{setup.targets.map((t) => ( {setup.targets.map((t) => (
<option key={`${t.sr_level_id}-${t.price}`} value={t.price} className="bg-[#14161f]"> <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> </option>
))} ))}
</select> </select>
@@ -482,21 +482,21 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
document.body, document.body,
)} )}
{/* Levels ladder — still fully explorable (clicking a row drives the rail {/* GTL targets remain explorable (clicking a row drives the rail and
and the candlestick overlay), but framed as what it is: overhead candlestick marker), but they screen the setup rather than defining
structure used to screen the setup, not a menu of exits. */} production exits. */}
{setup.targets && setup.targets.length > 0 && ( {setup.targets && setup.targets.length > 0 && (
<details className="mt-3" open> <details className="mt-3" open>
<summary className="cursor-pointer text-[11px] font-medium text-gray-500 transition-colors hover:text-gray-300"> <summary className="cursor-pointer text-[11px] font-medium text-gray-500 transition-colors hover:text-gray-300">
{honorsTarget {honorsTarget
? `Take-profit levels (${setup.targets.length}) · select one to preview it and use it when taking` ? `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> </summary>
{!honorsTarget && ( {!honorsTarget && (
<p className="mt-1.5 text-[11px] leading-relaxed text-gray-600"> <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 Gate Target Ladder proposals used by the scanner. Their headline R:R and reach
through the gate but the trade exits on the trailing stop, so price reaching one of probability determine gate eligibility, but the trade exits on the trailing stop;
these is not a sell signal. Clicking only moves the marker. reaching one is not a sell signal. Clicking only moves the marker.
</p> </p>
)} )}
<div className="mt-2"> <div className="mt-2">
+1
View File
@@ -39,6 +39,7 @@ export function useFetchSymbolData(options: UseFetchSymbolDataOptions = {}) {
queryClient.invalidateQueries({ queryKey: ['sentiment', symbol] }); queryClient.invalidateQueries({ queryKey: ['sentiment', symbol] });
queryClient.invalidateQueries({ queryKey: ['fundamentals', symbol] }); queryClient.invalidateQueries({ queryKey: ['fundamentals', symbol] });
queryClient.invalidateQueries({ queryKey: ['sr-levels', symbol] }); queryClient.invalidateQueries({ queryKey: ['sr-levels', symbol] });
queryClient.invalidateQueries({ queryKey: ['gate-target-ladder', symbol] });
queryClient.invalidateQueries({ queryKey: ['scores', symbol] }); queryClient.invalidateQueries({ queryKey: ['scores', symbol] });
// Fetch re-runs the scanner → setups/confidence change. Refresh both the // Fetch re-runs the scanner → setups/confidence change. Refresh both the
// per-ticker trades (['trades', symbol]) and the Overview list (['trades']). // per-ticker trades (['trades', symbol]) and the Overview list (['trades']).
+17 -3
View File
@@ -1,12 +1,12 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getOHLCV } from '../api/ohlcv'; import { getOHLCV } from '../api/ohlcv';
import { getScores } from '../api/scores'; import { getScores } from '../api/scores';
import { getLevels } from '../api/sr-levels'; import { getGateTargetLadder, getLevels } from '../api/sr-levels';
import { getSentiment } from '../api/sentiment'; import { getSentiment } from '../api/sentiment';
import { getFundamentals } from '../api/fundamentals'; import { getFundamentals } from '../api/fundamentals';
import * as tradesApi from '../api/trades'; import * as tradesApi from '../api/trades';
export function useTickerDetail(symbol: string) { export function useTickerDetail(symbol: string, includeGateTargetLadder = false) {
const ohlcv = useQuery({ const ohlcv = useQuery({
queryKey: ['ohlcv', symbol], queryKey: ['ohlcv', symbol],
queryFn: () => getOHLCV(symbol), queryFn: () => getOHLCV(symbol),
@@ -25,6 +25,12 @@ export function useTickerDetail(symbol: string) {
enabled: !!symbol, enabled: !!symbol,
}); });
const gateTargetLadder = useQuery({
queryKey: ['gate-target-ladder', symbol],
queryFn: () => getGateTargetLadder(symbol),
enabled: !!symbol && includeGateTargetLadder,
});
const sentiment = useQuery({ const sentiment = useQuery({
queryKey: ['sentiment', symbol], queryKey: ['sentiment', symbol],
queryFn: () => getSentiment(symbol), queryFn: () => getSentiment(symbol),
@@ -43,5 +49,13 @@ export function useTickerDetail(symbol: string) {
enabled: !!symbol, enabled: !!symbol,
}); });
return { ohlcv, scores, srLevels, sentiment, fundamentals, trades }; return {
ohlcv,
scores,
srLevels,
gateTargetLadder,
sentiment,
fundamentals,
trades,
};
} }
+2 -1
View File
@@ -2,7 +2,8 @@
* What actually closes a trade. * What actually closes a trade.
* *
* The setup's `target` is NOT an exit under the production policy: it is a * 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 * probability that admit the setup through the activation gate. The live exit
* (`paper_trade_service.resolve_open_trades`) never reads it; `atr_trailing` * (`paper_trade_service.resolve_open_trades`) never reads it; `atr_trailing`
* closes on the initial stop, a trailing stop, or the max hold. * closes on the initial stop, a trailing stop, or the max hold.
+16
View File
@@ -604,6 +604,22 @@ export interface SRLevelResponse {
count: number; 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 // Sentiment
export interface CitationItem { export interface CitationItem {
url: string; url: string;
+5 -5
View File
@@ -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 ${ 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]' selected ? 'bg-blue-400/[0.08]' : 'hover:bg-white/[0.03]'
} ${qualified ? '' : 'opacity-60'}`} } ${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="num text-[11px] text-gray-500">{rank}</span>
<span className="min-w-0"> <span className="min-w-0">
@@ -168,8 +168,8 @@ function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: {
</div> </div>
</div> </div>
{/* The headline stat is the signal that actually selected this ticker. {/* 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 R:R and reach probability are gate inputs computed from a GTL
trade never exits at — they get quiet, labelled treatment. */} proposal the trade never exits at — they get quiet treatment. */}
<div className="flex items-start gap-10 text-right"> <div className="flex items-start gap-10 text-right">
{setup.momentum_percentile != null && ( {setup.momentum_percentile != null && (
<div title="Residual 12-1 month momentum percentile across the universe. This is why the ticker was selected."> <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 <div
className="max-w-[13rem]" 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="section-index">gate metrics</p>
<p className="num mt-1.5 text-sm text-gray-300"> <p className="num mt-1.5 text-sm text-gray-300">
R:R {setup.rr_ratio.toFixed(1)}:1 R:R {setup.rr_ratio.toFixed(1)}:1
{prob != null && <> · touch {Math.round(prob)}%</>} {prob != null && <> · reach {Math.round(prob)}%</>}
</p> </p>
<p className="mt-1 text-[10.5px] leading-relaxed text-gray-500"> <p className="mt-1 text-[10.5px] leading-relaxed text-gray-500">
screening only exits on the trailing stop, not at the level screening only exits on the trailing stop, not at the level
+17 -1
View File
@@ -120,8 +120,17 @@ function DataFreshnessBar({
export default function TickerDetailPage() { export default function TickerDetailPage() {
const { symbol = '' } = useParams<{ symbol: string }>(); const { symbol = '' } = useParams<{ symbol: string }>();
const [showGateTraffic, setShowGateTraffic] = useState(false);
const companyName = useTickerNames().get(symbol.toUpperCase()); 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 ingestion = useFetchSymbolData();
const watchlist = useWatchlist(); const watchlist = useWatchlist();
const addToWatchlist = useAddToWatchlist(); const addToWatchlist = useAddToWatchlist();
@@ -438,12 +447,19 @@ export default function TickerDetailPage() {
data={ohlcv.data} data={ohlcv.data}
srLevels={srLevels.data?.levels} srLevels={srLevels.data?.levels}
zones={srLevels.data?.zones} 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} tradeSetup={overlayWithTarget}
currentPrice={priceInfo?.price} currentPrice={priceInfo?.price}
/> />
<p className="mt-2 text-[11px] text-gray-500"> <p className="mt-2 text-[11px] text-gray-500">
Only the nearest support &amp; resistance are drawn. Full list in the S/R Levels tab. Only the nearest support &amp; resistance are drawn. Full list in the S/R Levels tab.
{srLevels.isError && ' S/R levels unavailable.'} {srLevels.isError && ' S/R levels unavailable.'}
{gateTargetLadder.isError && ' GTL diagnostic unavailable.'}
</p> </p>
</> </>
)} )}
+70 -4
View File
@@ -3,7 +3,6 @@
from datetime import datetime from datetime import datetime
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -29,10 +28,12 @@ class _FakeLevel:
class _FakeOHLCV: 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.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: def _make_app() -> FastAPI:
@@ -62,6 +63,71 @@ SAMPLE_LEVELS = [
SAMPLE_OHLCV = [_FakeOHLCV(100.0)] 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: class TestSRLevelsRouterZones:
"""Tests for max_zones parameter and zone inclusion in response.""" """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" ), f"visible level price {price} not within any zone bounds"
# visible_levels must be a subset of levels (by id) # 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: for lvl in visible:
assert lvl["id"] in level_ids assert lvl["id"] in level_ids