Add GTL price-traffic chart diagnostic
This commit is contained in:
@@ -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` |
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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']).
|
||||||
|
|||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 & resistance are drawn. Full list in the S/R Levels tab.
|
Only the nearest support & 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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user