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
+7 -1
View File
@@ -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>
);
}
@@ -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">
+1
View File
@@ -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']).
+17 -3
View File
@@ -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 -1
View File
@@ -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.
+16
View File
@@ -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;
+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 ${
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 -1
View File
@@ -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}
/>
<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.
{srLevels.isError && ' S/R levels unavailable.'}
{gateTargetLadder.isError && ' GTL diagnostic unavailable.'}
</p>
</>
)}