major update
This commit is contained in:
@@ -21,7 +21,7 @@ export class ApiError extends Error {
|
||||
*/
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api/v1/',
|
||||
timeout: 30_000,
|
||||
timeout: 120_000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,6 @@ export function getRankings() {
|
||||
|
||||
export function updateWeights(weights: Record<string, number>) {
|
||||
return apiClient
|
||||
.put<{ message: string }>('scores/weights', weights)
|
||||
.put<{ message: string }>('scores/weights', { weights })
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import type { OHLCVBar, SRLevel } from '../../lib/types';
|
||||
import { useMemo, useRef, useEffect, useCallback, useState } from 'react';
|
||||
import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types';
|
||||
import { formatPrice, formatDate } from '../../lib/format';
|
||||
|
||||
interface CandlestickChartProps {
|
||||
data: OHLCVBar[];
|
||||
srLevels?: SRLevel[];
|
||||
maxSRLevels?: number;
|
||||
zones?: SRZone[];
|
||||
tradeSetup?: TradeSetup;
|
||||
}
|
||||
|
||||
function filterTopSRLevels(levels: SRLevel[], max: number): SRLevel[] {
|
||||
@@ -20,12 +22,29 @@ interface TooltipState {
|
||||
bar: OHLCVBar | null;
|
||||
}
|
||||
|
||||
export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: CandlestickChartProps) {
|
||||
const MIN_VISIBLE_BARS = 10;
|
||||
|
||||
export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones = [], tradeSetup }: CandlestickChartProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const tooltipState = useRef<TooltipState>({ visible: false, x: 0, y: 0, bar: null });
|
||||
const crosshairRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const animFrame = useRef<number>(0);
|
||||
const zoomFrame = useRef<number>(0);
|
||||
const isPanningRef = useRef<boolean>(false);
|
||||
const panStartXRef = useRef<number>(0);
|
||||
|
||||
const [visibleRange, setVisibleRange] = useState<{ start: number; end: number }>({
|
||||
start: 0,
|
||||
end: data.length,
|
||||
});
|
||||
|
||||
// Reset visible range when data changes
|
||||
useEffect(() => {
|
||||
setVisibleRange({ start: 0, end: data.length });
|
||||
}, [data]);
|
||||
|
||||
const topLevels = useMemo(() => filterTopSRLevels(srLevels, maxSRLevels), [srLevels, maxSRLevels]);
|
||||
|
||||
@@ -34,6 +53,12 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container || data.length === 0) return;
|
||||
|
||||
// Clamp visible range to valid bounds
|
||||
const start = Math.max(0, visibleRange.start);
|
||||
const end = Math.min(data.length, visibleRange.end);
|
||||
const visibleData = data.slice(start, end);
|
||||
if (visibleData.length === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const W = rect.width;
|
||||
@@ -54,10 +79,12 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
|
||||
const cw = W - ml - mr;
|
||||
const ch = H - mt - mb;
|
||||
|
||||
// Price range
|
||||
const allPrices = data.flatMap((b) => [b.high, b.low]);
|
||||
// Price range from visible data
|
||||
const allPrices = visibleData.flatMap((b) => [b.high, b.low]);
|
||||
const srPrices = topLevels.map((l) => l.price_level);
|
||||
const allVals = [...allPrices, ...srPrices];
|
||||
const zonePrices = zones.flatMap((z) => [z.low, z.high]);
|
||||
const tradePrices = tradeSetup ? [tradeSetup.entry_price, tradeSetup.stop_loss, tradeSetup.target] : [];
|
||||
const allVals = [...allPrices, ...srPrices, ...zonePrices, ...tradePrices];
|
||||
const minP = Math.min(...allVals);
|
||||
const maxP = Math.max(...allVals);
|
||||
const pad = (maxP - minP) * 0.06 || 1;
|
||||
@@ -65,7 +92,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
|
||||
const hi = maxP + pad;
|
||||
|
||||
const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
|
||||
const barW = cw / data.length;
|
||||
const barW = cw / visibleData.length;
|
||||
const candleW = Math.max(barW * 0.65, 1);
|
||||
|
||||
// Grid lines (horizontal)
|
||||
@@ -87,43 +114,142 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
|
||||
|
||||
// X-axis labels
|
||||
ctx.textAlign = 'center';
|
||||
const labelInterval = Math.max(Math.floor(data.length / 8), 1);
|
||||
for (let i = 0; i < data.length; i += labelInterval) {
|
||||
const labelInterval = Math.max(Math.floor(visibleData.length / 8), 1);
|
||||
for (let i = 0; i < visibleData.length; i += labelInterval) {
|
||||
const x = ml + i * barW + barW / 2;
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.fillText(formatDate(data[i].date), x, H - 6);
|
||||
ctx.fillText(formatDate(visibleData[i].date), x, H - 6);
|
||||
}
|
||||
|
||||
// S/R levels
|
||||
topLevels.forEach((level) => {
|
||||
const y = yScale(level.price_level);
|
||||
const isSupport = level.type === 'support';
|
||||
const color = isSupport ? '#10b981' : '#ef4444';
|
||||
// S/R levels (only when no zones are provided)
|
||||
if (zones.length === 0) {
|
||||
topLevels.forEach((level) => {
|
||||
const y = yScale(level.price_level);
|
||||
const isSupport = level.type === 'support';
|
||||
const color = isSupport ? '#10b981' : '#ef4444';
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.setLineDash([6, 3]);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.setLineDash([6, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, y);
|
||||
ctx.lineTo(ml + cw, y);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '10px Inter, system-ui, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(
|
||||
`${level.type[0].toUpperCase()} ${formatPrice(level.price_level)}`,
|
||||
ml + cw + 4,
|
||||
y + 3
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// S/R Zone rectangles (drawn before candles so candles render on top)
|
||||
zones.forEach((zone) => {
|
||||
const isSupport = zone.type === 'support';
|
||||
const fillColor = isSupport ? 'rgba(16, 185, 129, 0.15)' : 'rgba(239, 68, 68, 0.15)';
|
||||
const borderColor = isSupport ? 'rgba(16, 185, 129, 0.35)' : 'rgba(239, 68, 68, 0.35)';
|
||||
const labelColor = isSupport ? '#10b981' : '#ef4444';
|
||||
|
||||
const yTop = yScale(zone.high);
|
||||
const yBottom = yScale(zone.low);
|
||||
// Handle single-level zones (low == high) as thin 2px-height rectangles
|
||||
const rectHeight = Math.max(yBottom - yTop, 2);
|
||||
|
||||
// Shaded rectangle spanning full chart width
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fillRect(ml, yTop, cw, rectHeight);
|
||||
|
||||
// Border lines at top and bottom of zone
|
||||
ctx.strokeStyle = borderColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, y);
|
||||
ctx.lineTo(ml + cw, y);
|
||||
ctx.moveTo(ml, yTop);
|
||||
ctx.lineTo(ml + cw, yTop);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, yTop + rectHeight);
|
||||
ctx.lineTo(ml + cw, yTop + rectHeight);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = color;
|
||||
// Label with midpoint price and strength score
|
||||
const yMid = yTop + rectHeight / 2;
|
||||
ctx.fillStyle = labelColor;
|
||||
ctx.font = '10px Inter, system-ui, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(
|
||||
`${level.type[0].toUpperCase()} ${formatPrice(level.price_level)}`,
|
||||
`${zone.type[0].toUpperCase()} ${formatPrice(zone.midpoint)} (${zone.strength})`,
|
||||
ml + cw + 4,
|
||||
y + 3
|
||||
yMid + 3
|
||||
);
|
||||
});
|
||||
|
||||
// Trade setup overlay (drawn before candles so candles render on top)
|
||||
if (tradeSetup) {
|
||||
const entryY = yScale(tradeSetup.entry_price);
|
||||
const stopY = yScale(tradeSetup.stop_loss);
|
||||
const targetY = yScale(tradeSetup.target);
|
||||
|
||||
// Stop-loss zone: red semi-transparent rectangle between entry and stop-loss
|
||||
const slTop = Math.min(entryY, stopY);
|
||||
const slHeight = Math.max(Math.abs(stopY - entryY), 1);
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.13)';
|
||||
ctx.fillRect(ml, slTop, cw, slHeight);
|
||||
// Stop-loss border
|
||||
ctx.strokeStyle = 'rgba(239, 68, 68, 0.4)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, stopY);
|
||||
ctx.lineTo(ml + cw, stopY);
|
||||
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(16, 185, 129, 0.13)';
|
||||
ctx.fillRect(ml, tpTop, cw, tpHeight);
|
||||
// Target border
|
||||
ctx.strokeStyle = 'rgba(16, 185, 129, 0.4)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, targetY);
|
||||
ctx.lineTo(ml + cw, targetY);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Entry price: dashed horizontal line (blue/white)
|
||||
ctx.strokeStyle = 'rgba(96, 165, 250, 0.9)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([6, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, entryY);
|
||||
ctx.lineTo(ml + cw, entryY);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Labels on right side
|
||||
ctx.font = '10px Inter, system-ui, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = 'rgba(96, 165, 250, 0.9)';
|
||||
ctx.fillText(`Entry ${formatPrice(tradeSetup.entry_price)}`, ml + cw + 4, entryY + 3);
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.8)';
|
||||
ctx.fillText(`SL ${formatPrice(tradeSetup.stop_loss)}`, ml + cw + 4, stopY + 3);
|
||||
ctx.fillStyle = 'rgba(16, 185, 129, 0.8)';
|
||||
ctx.fillText(`TP ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3);
|
||||
}
|
||||
|
||||
// Candles
|
||||
data.forEach((bar, i) => {
|
||||
visibleData.forEach((bar, i) => {
|
||||
const x = ml + i * barW + barW / 2;
|
||||
const bullish = bar.close >= bar.open;
|
||||
const color = bullish ? '#10b981' : '#ef4444';
|
||||
@@ -148,42 +274,307 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
|
||||
ctx.fillRect(x - candleW / 2, bodyTop, candleW, bodyH);
|
||||
});
|
||||
|
||||
// Store geometry for hit testing
|
||||
(canvas as any).__chartMeta = { ml, mr, mt, mb, cw, ch, barW, lo, hi, yScale };
|
||||
}, [data, topLevels]);
|
||||
// Store geometry for hit testing (includes visibleRange offset)
|
||||
(canvas as any).__chartMeta = { ml, mr, mt, mb, cw, ch, barW, lo, hi, yScale, visibleStart: start };
|
||||
|
||||
// Size the overlay canvas to match
|
||||
const overlay = overlayCanvasRef.current;
|
||||
if (overlay) {
|
||||
overlay.width = W * dpr;
|
||||
overlay.height = H * dpr;
|
||||
overlay.style.width = `${W}px`;
|
||||
overlay.style.height = `${H}px`;
|
||||
}
|
||||
}, [data, topLevels, visibleRange, zones, tradeSetup]);
|
||||
|
||||
const drawCrosshair = useCallback(() => {
|
||||
const overlay = overlayCanvasRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!overlay || !canvas) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const ctx = overlay.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
||||
|
||||
const pos = crosshairRef.current;
|
||||
if (!pos) return;
|
||||
|
||||
const meta = (canvas as any).__chartMeta;
|
||||
if (!meta) return;
|
||||
|
||||
const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart } = meta;
|
||||
const H = overlay.height / dpr;
|
||||
|
||||
// Clamp crosshair to chart area
|
||||
const cx = Math.max(ml, Math.min(ml + cw, pos.x));
|
||||
const cy = Math.max(mt, Math.min(mt + ch, pos.y));
|
||||
|
||||
// Dashed crosshair lines
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
||||
ctx.lineWidth = 0.75;
|
||||
ctx.setLineDash([4, 3]);
|
||||
|
||||
// Vertical line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, mt);
|
||||
ctx.lineTo(cx, mt + ch);
|
||||
ctx.stroke();
|
||||
|
||||
// Horizontal line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, cy);
|
||||
ctx.lineTo(ml + cw, cy);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Price label on y-axis (right side)
|
||||
const price = hi - ((cy - mt) / ch) * (hi - lo);
|
||||
const priceText = formatPrice(price);
|
||||
ctx.font = '11px Inter, system-ui, sans-serif';
|
||||
const priceMetrics = ctx.measureText(priceText);
|
||||
const labelPadX = 5;
|
||||
const labelPadY = 3;
|
||||
const labelW = priceMetrics.width + labelPadX * 2;
|
||||
const labelH = 16 + labelPadY * 2;
|
||||
const labelX = ml + cw + 2;
|
||||
const labelY = cy - labelH / 2;
|
||||
|
||||
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(labelX, labelY, labelW, labelH, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#e5e7eb';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(priceText, labelX + labelPadX, cy);
|
||||
|
||||
// Date label on x-axis (bottom)
|
||||
const localIdx = Math.floor((cx - ml) / barW);
|
||||
const absIdx = (visibleStart ?? 0) + localIdx;
|
||||
if (absIdx >= 0 && absIdx < data.length) {
|
||||
const dateText = formatDate(data[absIdx].date);
|
||||
const dateMetrics = ctx.measureText(dateText);
|
||||
const dateLabelW = dateMetrics.width + labelPadX * 2;
|
||||
const dateLabelH = 16 + labelPadY * 2;
|
||||
const dateLabelX = cx - dateLabelW / 2;
|
||||
const dateLabelY = H - mb + 2;
|
||||
|
||||
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(dateLabelX, dateLabelY, dateLabelW, dateLabelH, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#e5e7eb';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(dateText, cx, dateLabelY + dateLabelH / 2);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
const onResize = () => {
|
||||
cancelAnimationFrame(animFrame.current);
|
||||
animFrame.current = requestAnimationFrame(draw);
|
||||
animFrame.current = requestAnimationFrame(() => {
|
||||
draw();
|
||||
drawCrosshair();
|
||||
});
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
cancelAnimationFrame(animFrame.current);
|
||||
};
|
||||
}, [draw]);
|
||||
}, [draw, drawCrosshair]);
|
||||
|
||||
// Prevent default scroll on wheel events over the overlay canvas
|
||||
useEffect(() => {
|
||||
const overlay = overlayCanvasRef.current;
|
||||
if (!overlay) return;
|
||||
const preventScroll = (e: WheelEvent) => {
|
||||
if (data.length >= MIN_VISIBLE_BARS) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
overlay.addEventListener('wheel', preventScroll, { passive: false });
|
||||
return () => overlay.removeEventListener('wheel', preventScroll);
|
||||
}, [data.length]);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent<HTMLCanvasElement>) => {
|
||||
// Disable zoom if dataset has fewer than 10 bars
|
||||
if (data.length < MIN_VISIBLE_BARS) return;
|
||||
|
||||
cancelAnimationFrame(zoomFrame.current);
|
||||
zoomFrame.current = requestAnimationFrame(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const meta = (canvas as any).__chartMeta;
|
||||
if (!meta) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
|
||||
// Determine cursor position as fraction within the visible chart area
|
||||
const cursorFraction = Math.max(0, Math.min(1, (mx - meta.ml) / meta.cw));
|
||||
|
||||
setVisibleRange((prev) => {
|
||||
const currentWidth = prev.end - prev.start;
|
||||
const zoomFactor = 0.1;
|
||||
let delta: number;
|
||||
|
||||
if (e.deltaY > 0) {
|
||||
// Scroll down → zoom out (widen range)
|
||||
delta = Math.max(1, Math.round(currentWidth * zoomFactor));
|
||||
} else {
|
||||
// Scroll up → zoom in (narrow range)
|
||||
delta = -Math.max(1, Math.round(currentWidth * zoomFactor));
|
||||
}
|
||||
|
||||
const newWidth = currentWidth + delta;
|
||||
|
||||
// Clamp to min 10 bars, max full dataset
|
||||
const clampedWidth = Math.max(MIN_VISIBLE_BARS, Math.min(data.length, newWidth));
|
||||
|
||||
if (clampedWidth === currentWidth) return prev;
|
||||
|
||||
const widthChange = clampedWidth - currentWidth;
|
||||
|
||||
// Distribute the change around the cursor position
|
||||
const leftChange = Math.round(widthChange * cursorFraction);
|
||||
const rightChange = widthChange - leftChange;
|
||||
|
||||
let newStart = prev.start - leftChange;
|
||||
let newEnd = prev.end + rightChange;
|
||||
|
||||
// Clamp to dataset bounds
|
||||
if (newStart < 0) {
|
||||
newEnd -= newStart;
|
||||
newStart = 0;
|
||||
}
|
||||
if (newEnd > data.length) {
|
||||
newStart -= newEnd - data.length;
|
||||
newEnd = data.length;
|
||||
}
|
||||
newStart = Math.max(0, newStart);
|
||||
newEnd = Math.min(data.length, newEnd);
|
||||
|
||||
return { start: newStart, end: newEnd };
|
||||
});
|
||||
});
|
||||
},
|
||||
[data.length]
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
// Pan only when zoomed in (visible range < full dataset)
|
||||
const currentWidth = visibleRange.end - visibleRange.start;
|
||||
if (currentWidth >= data.length) return;
|
||||
|
||||
isPanningRef.current = true;
|
||||
panStartXRef.current = e.clientX;
|
||||
},
|
||||
[data.length, visibleRange]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
// Handle panning
|
||||
if (isPanningRef.current) {
|
||||
const meta = (canvas as any).__chartMeta;
|
||||
if (!meta) return;
|
||||
|
||||
const dx = e.clientX - panStartXRef.current;
|
||||
// Convert pixel drag to bar count: negative dx = drag left = shift range right (see later bars)
|
||||
const barShift = -Math.round(dx / meta.barW);
|
||||
|
||||
if (barShift !== 0) {
|
||||
panStartXRef.current = e.clientX;
|
||||
|
||||
setVisibleRange((prev) => {
|
||||
const width = prev.end - prev.start;
|
||||
let newStart = prev.start + barShift;
|
||||
let newEnd = prev.end + barShift;
|
||||
|
||||
// Clamp to dataset bounds
|
||||
if (newStart < 0) {
|
||||
newStart = 0;
|
||||
newEnd = width;
|
||||
}
|
||||
if (newEnd > data.length) {
|
||||
newEnd = data.length;
|
||||
newStart = data.length - width;
|
||||
}
|
||||
|
||||
if (newStart === prev.start && newEnd === prev.end) return prev;
|
||||
return { start: newStart, end: newEnd };
|
||||
});
|
||||
}
|
||||
|
||||
// Clear crosshair while panning
|
||||
crosshairRef.current = null;
|
||||
drawCrosshair();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update crosshair position and draw
|
||||
crosshairRef.current = { x: mx, y: my };
|
||||
drawCrosshair();
|
||||
|
||||
// Tooltip logic
|
||||
const tip = tooltipRef.current;
|
||||
if (!canvas || !tip || data.length === 0) return;
|
||||
if (!tip || data.length === 0) return;
|
||||
|
||||
const meta = (canvas as any).__chartMeta;
|
||||
if (!meta) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const idx = Math.floor((mx - meta.ml) / meta.barW);
|
||||
const localIdx = Math.floor((mx - meta.ml) / meta.barW);
|
||||
|
||||
if (idx >= 0 && idx < data.length) {
|
||||
const bar = data[idx];
|
||||
tooltipState.current = { visible: true, x: e.clientX - rect.left, y: e.clientY - rect.top, bar };
|
||||
// Map local index to the visible data slice
|
||||
const visibleStart = meta.visibleStart ?? 0;
|
||||
const visibleEnd = Math.min(data.length, visibleStart + Math.round(meta.cw / meta.barW));
|
||||
const absIdx = visibleStart + localIdx;
|
||||
|
||||
if (localIdx >= 0 && absIdx < visibleEnd && absIdx < data.length) {
|
||||
const bar = data[absIdx];
|
||||
tooltipState.current = { visible: true, x: mx, y: my, bar };
|
||||
tip.style.display = 'block';
|
||||
tip.style.left = `${Math.min(mx + 14, rect.width - 180)}px`;
|
||||
tip.style.top = `${Math.max(e.clientY - rect.top - 80, 8)}px`;
|
||||
tip.style.top = `${Math.max(my - 80, 8)}px`;
|
||||
|
||||
// Check if cursor is near trade overlay zone
|
||||
let tradeTooltipHtml = '';
|
||||
if (tradeSetup && meta.yScale) {
|
||||
const entryY = meta.yScale(tradeSetup.entry_price);
|
||||
const stopY = meta.yScale(tradeSetup.stop_loss);
|
||||
const targetY = meta.yScale(tradeSetup.target);
|
||||
const tradeTop = Math.min(entryY, stopY, targetY);
|
||||
const tradeBottom = Math.max(entryY, stopY, targetY);
|
||||
if (my >= tradeTop - 10 && my <= tradeBottom + 10) {
|
||||
tradeTooltipHtml = `
|
||||
<div class="border-t border-gray-600 mt-1.5 pt-1.5 text-gray-300 font-medium mb-1">Trade Setup</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-gray-400">
|
||||
<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-400">${formatPrice(tradeSetup.stop_loss)}</span>
|
||||
<span>Target</span><span class="text-right text-green-400">${formatPrice(tradeSetup.target)}</span>
|
||||
<span>R:R</span><span class="text-right text-gray-200">${tradeSetup.rr_ratio.toFixed(2)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
tip.innerHTML = `
|
||||
<div class="text-gray-300 font-medium mb-1">${formatDate(bar.date)}</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-gray-400">
|
||||
@@ -192,18 +583,25 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
|
||||
<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">${bar.volume.toLocaleString()}</span>
|
||||
</div>`;
|
||||
</div>${tradeTooltipHtml}`;
|
||||
} else {
|
||||
tip.style.display = 'none';
|
||||
}
|
||||
},
|
||||
[data]
|
||||
[data, drawCrosshair, tradeSetup]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isPanningRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
isPanningRef.current = false;
|
||||
crosshairRef.current = null;
|
||||
drawCrosshair();
|
||||
const tip = tooltipRef.current;
|
||||
if (tip) tip.style.display = 'none';
|
||||
}, []);
|
||||
}, [drawCrosshair]);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
@@ -217,10 +615,18 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
|
||||
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full cursor-crosshair"
|
||||
className="w-full"
|
||||
style={{ height: 400 }}
|
||||
/>
|
||||
<canvas
|
||||
ref={overlayCanvasRef}
|
||||
className="absolute top-0 left-0 w-full cursor-crosshair"
|
||||
style={{ height: 400 }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onWheel={handleWheel}
|
||||
/>
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { RankingEntry } from '../../lib/types';
|
||||
|
||||
@@ -5,18 +6,31 @@ interface RankingsTableProps {
|
||||
rankings: RankingEntry[];
|
||||
}
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
function scoreColor(score: number | null): string {
|
||||
if (score === null || score === undefined) return 'text-gray-600';
|
||||
if (score > 70) return 'text-emerald-400';
|
||||
if (score >= 40) return 'text-amber-400';
|
||||
return 'text-red-400';
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
export function RankingsTable({ rankings }: RankingsTableProps) {
|
||||
if (rankings.length === 0) {
|
||||
return <p className="py-8 text-center text-sm text-gray-500">No rankings available.</p>;
|
||||
}
|
||||
|
||||
const dimensionNames = rankings.length > 0 ? rankings[0].dimensions.map((d) => d.dimension) : [];
|
||||
const dimensionNames = useMemo(() => {
|
||||
const nameSet = new Set<string>();
|
||||
for (const entry of rankings) {
|
||||
for (const d of entry.dimensions) {
|
||||
nameSet.add(d.dimension);
|
||||
}
|
||||
}
|
||||
return Array.from(nameSet);
|
||||
}, [rankings]);
|
||||
|
||||
return (
|
||||
<div className="glass overflow-x-auto">
|
||||
@@ -27,7 +41,7 @@ export function RankingsTable({ rankings }: RankingsTableProps) {
|
||||
<th className="px-4 py-3">Symbol</th>
|
||||
<th className="px-4 py-3">Composite</th>
|
||||
{dimensionNames.map((dim) => (
|
||||
<th key={dim} className="px-4 py-3">{dim}</th>
|
||||
<th key={dim} className="px-4 py-3">{capitalize(dim)}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -43,11 +57,15 @@ export function RankingsTable({ rankings }: RankingsTableProps) {
|
||||
<td className={`px-4 py-3.5 font-semibold ${scoreColor(entry.composite_score)}`}>
|
||||
{Math.round(entry.composite_score)}
|
||||
</td>
|
||||
{entry.dimensions.map((dim) => (
|
||||
<td key={dim.dimension} className={`px-4 py-3.5 font-mono ${scoreColor(dim.score)}`}>
|
||||
{Math.round(dim.score)}
|
||||
</td>
|
||||
))}
|
||||
{dimensionNames.map((dim) => {
|
||||
const found = entry.dimensions.find((d) => d.dimension === dim);
|
||||
const score = found ? found.score : null;
|
||||
return (
|
||||
<td key={dim} className={`px-4 py-3.5 font-mono ${scoreColor(score)}`}>
|
||||
{score !== null ? Math.round(score) : 'N/A'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useState, useMemo, type FormEvent } from 'react';
|
||||
import { useUpdateWeights } from '../../hooks/useScores';
|
||||
|
||||
interface WeightsFormProps {
|
||||
@@ -6,17 +6,33 @@ interface WeightsFormProps {
|
||||
}
|
||||
|
||||
export function WeightsForm({ weights }: WeightsFormProps) {
|
||||
const [localWeights, setLocalWeights] = useState<Record<string, number>>(weights);
|
||||
// Convert API decimal weights (0-1) to 0-100 integer scale on mount
|
||||
const [sliderValues, setSliderValues] = useState<Record<string, number>>(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(weights).map(([key, w]) => [key, Math.round(w * 100)])
|
||||
)
|
||||
);
|
||||
const updateWeights = useUpdateWeights();
|
||||
|
||||
const allZero = useMemo(
|
||||
() => Object.values(sliderValues).every((v) => v === 0),
|
||||
[sliderValues]
|
||||
);
|
||||
|
||||
const handleChange = (key: string, value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num)) setLocalWeights((prev) => ({ ...prev, [key]: num }));
|
||||
const num = parseInt(value, 10);
|
||||
if (!isNaN(num)) setSliderValues((prev) => ({ ...prev, [key]: num }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateWeights.mutate(localWeights);
|
||||
if (allZero) return;
|
||||
|
||||
const total = Object.values(sliderValues).reduce((sum, v) => sum + v, 0);
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(sliderValues).map(([key, v]) => [key, v / total])
|
||||
);
|
||||
updateWeights.mutate(normalized);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -24,23 +40,35 @@ export function WeightsForm({ weights }: WeightsFormProps) {
|
||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-widest text-gray-500">
|
||||
Scoring Weights
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Object.keys(weights).map((key) => (
|
||||
<label key={key} className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-gray-400 capitalize">{key.replace(/_/g, ' ')}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={localWeights[key] ?? 0}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
className="input-glass px-2.5 py-1.5 text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={sliderValues[key] ?? 0}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-700 accent-indigo-500"
|
||||
/>
|
||||
<span className="min-w-[2ch] text-right text-sm font-medium text-gray-300">
|
||||
{sliderValues[key] ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{allZero && (
|
||||
<p className="mt-3 text-xs text-red-400">
|
||||
At least one weight must be greater than zero
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateWeights.isPending}
|
||||
disabled={updateWeights.isPending || allZero}
|
||||
className="mt-4 btn-gradient px-4 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
<span>{updateWeights.isPending ? 'Updating…' : 'Update Weights'}</span>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { TradeSetup } from '../../lib/types';
|
||||
import { formatPrice, formatDateTime } from '../../lib/format';
|
||||
import { formatPrice, formatPercent, formatDateTime } from '../../lib/format';
|
||||
|
||||
export type SortColumn = 'symbol' | 'direction' | 'entry_price' | 'stop_loss' | 'target' | 'rr_ratio' | 'composite_score' | 'detected_at';
|
||||
export type SortColumn = 'symbol' | 'direction' | 'entry_price' | 'stop_loss' | 'target' | 'risk_amount' | 'reward_amount' | 'rr_ratio' | 'stop_pct' | 'target_pct' | 'composite_score' | 'detected_at';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
interface TradeTableProps {
|
||||
@@ -18,11 +18,36 @@ const columns: { key: SortColumn; label: string }[] = [
|
||||
{ key: 'entry_price', label: 'Entry' },
|
||||
{ key: 'stop_loss', label: 'Stop Loss' },
|
||||
{ key: 'target', label: 'Target' },
|
||||
{ key: 'risk_amount', label: 'Risk $' },
|
||||
{ key: 'reward_amount', label: 'Reward $' },
|
||||
{ key: 'rr_ratio', label: 'R:R' },
|
||||
{ key: 'stop_pct', label: '% to Stop' },
|
||||
{ key: 'target_pct', label: '% to Target' },
|
||||
{ key: 'composite_score', label: 'Score' },
|
||||
{ key: 'detected_at', label: 'Detected' },
|
||||
];
|
||||
|
||||
export interface TradeAnalysis {
|
||||
risk_amount: number;
|
||||
reward_amount: number;
|
||||
stop_pct: number;
|
||||
target_pct: number;
|
||||
}
|
||||
|
||||
export function computeTradeAnalysis(trade: TradeSetup): TradeAnalysis {
|
||||
const risk_amount = Math.abs(trade.entry_price - trade.stop_loss);
|
||||
const reward_amount = Math.abs(trade.target - trade.entry_price);
|
||||
const stop_pct = (risk_amount / trade.entry_price) * 100;
|
||||
const target_pct = (reward_amount / trade.entry_price) * 100;
|
||||
return { risk_amount, reward_amount, stop_pct, target_pct };
|
||||
}
|
||||
|
||||
function rrColorClass(rr: number): string {
|
||||
if (rr >= 3.0) return 'text-green-400';
|
||||
if (rr >= 2.0) return 'text-amber-400';
|
||||
return 'text-red-400';
|
||||
}
|
||||
|
||||
function sortIndicator(column: SortColumn, active: SortColumn, dir: SortDirection) {
|
||||
if (column !== active) return '';
|
||||
return dir === 'asc' ? ' ▲' : ' ▼';
|
||||
@@ -50,30 +75,37 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map((trade) => (
|
||||
<tr key={trade.id} className="border-b border-white/[0.04] transition-all duration-200 hover:bg-white/[0.03]">
|
||||
<td className="px-4 py-3.5">
|
||||
<Link to={`/ticker/${trade.symbol}`} className="font-medium text-blue-400 hover:text-blue-300 transition-colors duration-150">
|
||||
{trade.symbol}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={trade.direction === 'long' ? 'font-medium text-emerald-400' : 'font-medium text-red-400'}>
|
||||
{trade.direction}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.entry_price)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.stop_loss)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.target)}</td>
|
||||
<td className="px-4 py-3.5 font-mono font-semibold text-gray-200">{trade.rr_ratio.toFixed(2)}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`font-semibold ${trade.composite_score > 70 ? 'text-emerald-400' : trade.composite_score >= 40 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||
{Math.round(trade.composite_score)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-gray-400">{formatDateTime(trade.detected_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{trades.map((trade) => {
|
||||
const analysis = computeTradeAnalysis(trade);
|
||||
return (
|
||||
<tr key={trade.id} className="border-b border-white/[0.04] transition-all duration-200 hover:bg-white/[0.03]">
|
||||
<td className="px-4 py-3.5">
|
||||
<Link to={`/ticker/${trade.symbol}`} className="font-medium text-blue-400 hover:text-blue-300 transition-colors duration-150">
|
||||
{trade.symbol}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={trade.direction === 'long' ? 'font-medium text-emerald-400' : 'font-medium text-red-400'}>
|
||||
{trade.direction}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.entry_price)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.stop_loss)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.target)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(analysis.risk_amount)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(analysis.reward_amount)}</td>
|
||||
<td className={`px-4 py-3.5 font-mono font-semibold ${rrColorClass(trade.rr_ratio)}`}>{trade.rr_ratio.toFixed(2)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPercent(analysis.stop_pct)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPercent(analysis.target_pct)}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`font-semibold ${trade.composite_score > 70 ? 'text-emerald-400' : trade.composite_score >= 40 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||
{Math.round(trade.composite_score)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-gray-400">{formatDateTime(trade.detected_at)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
66
frontend/src/components/ticker/DimensionBreakdownPanel.tsx
Normal file
66
frontend/src/components/ticker/DimensionBreakdownPanel.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { ScoreBreakdown } from '../../lib/types';
|
||||
|
||||
interface DimensionBreakdownPanelProps {
|
||||
breakdown: ScoreBreakdown;
|
||||
}
|
||||
|
||||
function formatWeight(weight: number): string {
|
||||
return `${Math.round(weight * 100)}%`;
|
||||
}
|
||||
|
||||
function formatRawValue(value: number | string | null): string {
|
||||
if (value === null) return '—';
|
||||
if (typeof value === 'string') return value;
|
||||
return Number.isInteger(value) ? value.toString() : value.toFixed(2);
|
||||
}
|
||||
|
||||
export function DimensionBreakdownPanel({ breakdown }: DimensionBreakdownPanelProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Sub-score rows */}
|
||||
{breakdown.sub_scores.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{breakdown.sub_scores.map((sub) => (
|
||||
<div
|
||||
key={sub.name}
|
||||
data-testid="sub-score-row"
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span className="text-gray-400 min-w-0 truncate">{sub.name}</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-gray-200 tabular-nums">{sub.score.toFixed(1)}</span>
|
||||
<span className="rounded bg-white/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400">
|
||||
{formatWeight(sub.weight)}
|
||||
</span>
|
||||
<span className="text-gray-500 text-xs tabular-nums w-16 text-right">
|
||||
{formatRawValue(sub.raw_value)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formula description */}
|
||||
{breakdown.formula && (
|
||||
<p className="text-xs text-gray-500 leading-relaxed">{breakdown.formula}</p>
|
||||
)}
|
||||
|
||||
{/* Unavailable sub-scores */}
|
||||
{breakdown.unavailable.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{breakdown.unavailable.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
data-testid="unavailable-row"
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="text-gray-600">{item.name}</span>
|
||||
<span className="text-gray-600 text-xs italic">{item.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { formatPercent, formatLargeNumber } from '../../lib/format';
|
||||
import type { FundamentalResponse } from '../../lib/types';
|
||||
|
||||
@@ -5,30 +6,106 @@ interface FundamentalsPanelProps {
|
||||
data: FundamentalResponse;
|
||||
}
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
pe_ratio: 'P/E Ratio',
|
||||
revenue_growth: 'Revenue Growth',
|
||||
earnings_surprise: 'Earnings Surprise',
|
||||
market_cap: 'Market Cap',
|
||||
};
|
||||
|
||||
export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
|
||||
const [expanded, setExpanded] = useState<boolean>(false);
|
||||
|
||||
const items = [
|
||||
{ label: 'P/E Ratio', value: data.pe_ratio !== null ? data.pe_ratio.toFixed(2) : '—' },
|
||||
{ label: 'Revenue Growth', value: data.revenue_growth !== null ? formatPercent(data.revenue_growth) : '—' },
|
||||
{ label: 'Earnings Surprise', value: data.earnings_surprise !== null ? formatPercent(data.earnings_surprise) : '—' },
|
||||
{ label: 'Market Cap', value: data.market_cap !== null ? formatLargeNumber(data.market_cap) : '—' },
|
||||
{ key: 'pe_ratio', label: 'P/E Ratio', value: data.pe_ratio, format: (v: number) => v.toFixed(2) },
|
||||
{ key: 'revenue_growth', label: 'Revenue Growth', value: data.revenue_growth, format: formatPercent },
|
||||
{ key: 'earnings_surprise', label: 'Earnings Surprise', value: data.earnings_surprise, format: formatPercent },
|
||||
{ key: 'market_cap', label: 'Market Cap', value: data.market_cap, format: formatLargeNumber },
|
||||
];
|
||||
|
||||
const unavailableEntries = Object.entries(data.unavailable_fields ?? {});
|
||||
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Fundamentals</h3>
|
||||
<div className="space-y-2.5 text-sm">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex justify-between">
|
||||
<span className="text-gray-400">{item.label}</span>
|
||||
<span className="text-gray-200">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fetched_at && (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Updated {new Date(data.fetched_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
{items.map((item) => {
|
||||
const reason = data.unavailable_fields?.[item.key];
|
||||
let display: React.ReactNode;
|
||||
let valueClass = 'text-gray-200';
|
||||
|
||||
if (item.value !== null) {
|
||||
display = item.format(item.value);
|
||||
} else if (reason) {
|
||||
display = reason;
|
||||
valueClass = 'text-amber-400';
|
||||
} else {
|
||||
display = '—';
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={item.key} className="flex justify-between">
|
||||
<span className="text-gray-400">{item.label}</span>
|
||||
<span className={valueClass}>{display}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? 'Collapse details' : 'Expand details'}
|
||||
>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 space-y-3 border-t border-white/10 pt-3">
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Data Source</span>
|
||||
<span className="text-gray-300">FMP</span>
|
||||
</div>
|
||||
{data.fetched_at && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Fetched</span>
|
||||
<span className="text-gray-300">{new Date(data.fetched_at).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unavailableEntries.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium uppercase tracking-widest text-gray-500">Unavailable Fields</span>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{unavailableEntries.map(([field, reason]) => (
|
||||
<li key={field} className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">{FIELD_LABELS[field] ?? field}</span>
|
||||
<span className="text-amber-400">{reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!expanded && data.fetched_at && (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Updated {new Date(data.fetched_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { formatPercent } from '../../lib/format';
|
||||
import type { SentimentResponse } from '../../lib/types';
|
||||
|
||||
@@ -12,32 +13,84 @@ const classificationColors: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function SentimentPanel({ data }: SentimentPanelProps) {
|
||||
const [expanded, setExpanded] = useState<boolean>(false);
|
||||
const latest = data.scores[0];
|
||||
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Sentiment</h3>
|
||||
{latest ? (
|
||||
<div className="space-y-2.5 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Classification</span>
|
||||
<span className={classificationColors[latest.classification] ?? 'text-gray-300'}>
|
||||
{latest.classification}
|
||||
</span>
|
||||
<>
|
||||
<div className="space-y-2.5 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Classification</span>
|
||||
<span className={classificationColors[latest.classification] ?? 'text-gray-300'}>
|
||||
{latest.classification}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Confidence</span>
|
||||
<span className="text-gray-200">{formatPercent(latest.confidence)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Dimension Score</span>
|
||||
<span className="text-gray-200">{data.dimension_score !== null ? Math.round(data.dimension_score) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Sources</span>
|
||||
<span className="text-gray-200">{data.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Confidence</span>
|
||||
<span className="text-gray-200">{formatPercent(latest.confidence)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Dimension Score</span>
|
||||
<span className="text-gray-200">{data.dimension_score !== null ? Math.round(data.dimension_score) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Sources</span>
|
||||
<span className="text-gray-200">{data.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? 'Collapse details' : 'Expand details'}
|
||||
>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 space-y-3 border-t border-white/10 pt-3">
|
||||
<div>
|
||||
<span className="text-xs font-medium uppercase tracking-widest text-gray-500">Reasoning</span>
|
||||
<p className="mt-1 text-sm text-gray-300">
|
||||
{latest.reasoning || 'No reasoning available'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{latest.citations.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium uppercase tracking-widest text-gray-500">Citations</span>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{latest.citations.map((citation, idx) => (
|
||||
<li key={idx}>
|
||||
<a
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-400 hover:text-blue-300 underline break-all"
|
||||
>
|
||||
{citation.title || citation.url}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No sentiment data available</p>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { DimensionBreakdownPanel } from '../ticker/DimensionBreakdownPanel';
|
||||
import type { DimensionScoreDetail, CompositeBreakdown } from '../../lib/types';
|
||||
|
||||
interface ScoreCardProps {
|
||||
compositeScore: number | null;
|
||||
dimensions: { dimension: string; score: number }[];
|
||||
dimensions: DimensionScoreDetail[];
|
||||
compositeBreakdown?: CompositeBreakdown;
|
||||
}
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
@@ -46,7 +51,13 @@ function ScoreRing({ score }: { score: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ScoreCard({ compositeScore, dimensions }: ScoreCardProps) {
|
||||
export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: ScoreCardProps) {
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleExpand = (dimension: string) => {
|
||||
setExpanded((prev) => ({ ...prev, [dimension]: !prev[dimension] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -60,28 +71,79 @@ export function ScoreCard({ compositeScore, dimensions }: ScoreCardProps) {
|
||||
<p className={`text-2xl font-bold ${compositeScore !== null ? scoreColor(compositeScore) : 'text-gray-500'}`}>
|
||||
{compositeScore !== null ? Math.round(compositeScore) : '—'}
|
||||
</p>
|
||||
{compositeBreakdown && (
|
||||
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[200px]" data-testid="renorm-explanation">
|
||||
Weighted average of available dimensions with re-normalized weights.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dimensions.length > 0 && (
|
||||
<div className="mt-5 space-y-2.5">
|
||||
<div className="mt-5 space-y-1">
|
||||
<p className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Dimensions</p>
|
||||
{dimensions.map((d) => (
|
||||
<div key={d.dimension} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-300 capitalize">{d.dimension}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
|
||||
<div
|
||||
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, d.score))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`w-8 text-right font-medium text-xs ${scoreColor(d.score)}`}>
|
||||
{Math.round(d.score)}
|
||||
</span>
|
||||
{dimensions.map((d) => {
|
||||
const isExpanded = expanded[d.dimension] ?? false;
|
||||
const weight = compositeBreakdown?.renormalized_weights?.[d.dimension]
|
||||
?? compositeBreakdown?.weights?.[d.dimension];
|
||||
|
||||
return (
|
||||
<div key={d.dimension}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between text-sm py-1 hover:bg-white/[0.03] rounded transition-colors"
|
||||
onClick={() => d.breakdown && toggleExpand(d.dimension)}
|
||||
data-testid={`dimension-row-${d.dimension}`}
|
||||
>
|
||||
<span className="text-gray-300 capitalize flex items-center gap-1.5">
|
||||
{d.breakdown && (
|
||||
<span className="text-gray-500 text-[10px]">{isExpanded ? '▾' : '▸'}</span>
|
||||
)}
|
||||
{d.dimension}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{weight != null && (
|
||||
<span className="text-[10px] text-gray-500 tabular-nums" data-testid={`weight-${d.dimension}`}>
|
||||
{Math.round(weight * 100)}%
|
||||
</span>
|
||||
)}
|
||||
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
|
||||
<div
|
||||
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, d.score))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`w-8 text-right font-medium text-xs ${scoreColor(d.score)}`}>
|
||||
{Math.round(d.score)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && d.breakdown && (
|
||||
<div className="ml-4 mt-1 mb-2 pl-3 border-l border-white/[0.06]">
|
||||
<DimensionBreakdownPanel breakdown={d.breakdown} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Missing dimensions */}
|
||||
{compositeBreakdown && compositeBreakdown.missing_dimensions.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{compositeBreakdown.missing_dimensions
|
||||
.filter((dim) => !dimensions.some((d) => d.dimension === dim))
|
||||
.map((dim) => (
|
||||
<div
|
||||
key={dim}
|
||||
className="flex items-center justify-between text-sm py-1 opacity-40"
|
||||
data-testid={`missing-dimension-${dim}`}
|
||||
>
|
||||
<span className="text-gray-500 capitalize">{dim}</span>
|
||||
<span className="text-[10px] text-gray-600 italic">redistributed</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getScores } from '../api/scores';
|
||||
import { 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) {
|
||||
const ohlcv = useQuery({
|
||||
@@ -36,5 +37,11 @@ export function useTickerDetail(symbol: string) {
|
||||
enabled: !!symbol,
|
||||
});
|
||||
|
||||
return { ohlcv, scores, srLevels, sentiment, fundamentals };
|
||||
const trades = useQuery({
|
||||
queryKey: ['trades'],
|
||||
queryFn: () => tradesApi.list(),
|
||||
enabled: !!symbol,
|
||||
});
|
||||
|
||||
return { ohlcv, scores, srLevels, sentiment, fundamentals, trades };
|
||||
}
|
||||
|
||||
@@ -34,6 +34,16 @@ export interface SRLevelSummary {
|
||||
strength: number;
|
||||
}
|
||||
|
||||
// S/R Zone
|
||||
export interface SRZone {
|
||||
low: number;
|
||||
high: number;
|
||||
midpoint: number;
|
||||
strength: number;
|
||||
type: 'support' | 'resistance';
|
||||
level_count: number;
|
||||
}
|
||||
|
||||
// OHLCV
|
||||
export interface OHLCVBar {
|
||||
id: number;
|
||||
@@ -48,6 +58,28 @@ export interface OHLCVBar {
|
||||
}
|
||||
|
||||
// Scores
|
||||
export interface SubScore {
|
||||
name: string;
|
||||
score: number;
|
||||
weight: number;
|
||||
raw_value: number | string | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ScoreBreakdown {
|
||||
sub_scores: SubScore[];
|
||||
formula: string;
|
||||
unavailable: { name: string; reason: string }[];
|
||||
}
|
||||
|
||||
export interface CompositeBreakdown {
|
||||
weights: Record<string, number>;
|
||||
available_dimensions: string[];
|
||||
missing_dimensions: string[];
|
||||
renormalized_weights: Record<string, number>;
|
||||
formula: string;
|
||||
}
|
||||
|
||||
export interface ScoreResponse {
|
||||
symbol: string;
|
||||
composite_score: number | null;
|
||||
@@ -56,6 +88,7 @@ export interface ScoreResponse {
|
||||
dimensions: DimensionScoreDetail[];
|
||||
missing_dimensions: string[];
|
||||
computed_at: string | null;
|
||||
composite_breakdown?: CompositeBreakdown;
|
||||
}
|
||||
|
||||
export interface DimensionScoreDetail {
|
||||
@@ -63,6 +96,7 @@ export interface DimensionScoreDetail {
|
||||
score: number;
|
||||
is_stale: boolean;
|
||||
computed_at: string | null;
|
||||
breakdown?: ScoreBreakdown;
|
||||
}
|
||||
|
||||
export interface RankingEntry {
|
||||
@@ -102,16 +136,25 @@ export interface SRLevel {
|
||||
export interface SRLevelResponse {
|
||||
symbol: string;
|
||||
levels: SRLevel[];
|
||||
zones: SRZone[];
|
||||
visible_levels: SRLevel[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Sentiment
|
||||
export interface CitationItem {
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface SentimentScore {
|
||||
id: number;
|
||||
classification: 'bullish' | 'bearish' | 'neutral';
|
||||
confidence: number;
|
||||
source: string;
|
||||
timestamp: string;
|
||||
reasoning: string;
|
||||
citations: CitationItem[];
|
||||
}
|
||||
|
||||
export interface SentimentResponse {
|
||||
@@ -130,6 +173,7 @@ export interface FundamentalResponse {
|
||||
earnings_surprise: number | null;
|
||||
market_cap: number | null;
|
||||
fetched_at: string | null;
|
||||
unavailable_fields: Record<string, string>;
|
||||
}
|
||||
|
||||
// Indicators
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTrades } from '../hooks/useTrades';
|
||||
import { TradeTable, type SortColumn, type SortDirection } from '../components/scanner/TradeTable';
|
||||
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../components/scanner/TradeTable';
|
||||
import { SkeletonTable } from '../components/ui/Skeleton';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { triggerJob } from '../api/admin';
|
||||
import type { TradeSetup } from '../lib/types';
|
||||
|
||||
type DirectionFilter = 'both' | 'long' | 'short';
|
||||
@@ -18,6 +21,17 @@ function filterTrades(
|
||||
});
|
||||
}
|
||||
|
||||
function getComputedValue(trade: TradeSetup, column: SortColumn): number {
|
||||
const analysis = computeTradeAnalysis(trade);
|
||||
switch (column) {
|
||||
case 'risk_amount': return analysis.risk_amount;
|
||||
case 'reward_amount': return analysis.reward_amount;
|
||||
case 'stop_pct': return analysis.stop_pct;
|
||||
case 'target_pct': return analysis.target_pct;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function sortTrades(
|
||||
trades: TradeSetup[],
|
||||
column: SortColumn,
|
||||
@@ -35,8 +49,19 @@ function sortTrades(
|
||||
case 'detected_at':
|
||||
cmp = new Date(a.detected_at).getTime() - new Date(b.detected_at).getTime();
|
||||
break;
|
||||
default:
|
||||
cmp = (a[column] as number) - (b[column] as number);
|
||||
case 'risk_amount':
|
||||
case 'reward_amount':
|
||||
case 'stop_pct':
|
||||
case 'target_pct':
|
||||
cmp = getComputedValue(a, column) - getComputedValue(b, column);
|
||||
break;
|
||||
case 'entry_price':
|
||||
case 'stop_loss':
|
||||
case 'target':
|
||||
case 'rr_ratio':
|
||||
case 'composite_score':
|
||||
cmp = a[column] - b[column];
|
||||
break;
|
||||
}
|
||||
return direction === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
@@ -45,12 +70,25 @@ function sortTrades(
|
||||
|
||||
export default function ScannerPage() {
|
||||
const { data: trades, isLoading, isError, error } = useTrades();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
const [minRR, setMinRR] = useState(0);
|
||||
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both');
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('rr_ratio');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: () => triggerJob('rr_scanner'),
|
||||
onSuccess: () => {
|
||||
toast.addToast('success', 'Scanner triggered. Results will refresh shortly.');
|
||||
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['trades'] }), 3000);
|
||||
},
|
||||
onError: () => {
|
||||
toast.addToast('error', 'Failed to trigger scanner');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSort = (column: SortColumn) => {
|
||||
if (column === sortColumn) {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
@@ -68,23 +106,44 @@ export default function ScannerPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Trade Scanner</h1>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Trade Scanner</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scanMutation.mutate()}
|
||||
disabled={scanMutation.isPending}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50 transition-colors duration-150"
|
||||
>
|
||||
{scanMutation.isPending ? 'Scanning...' : 'Run Scanner'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Explainer banner */}
|
||||
<div className="rounded-lg border border-blue-500/20 bg-blue-500/10 px-4 py-3 text-sm text-blue-300">
|
||||
The scanner identifies asymmetric risk-reward trade setups by analyzing S/R levels
|
||||
as price targets and using ATR-based stops to define risk.
|
||||
Click <span className="font-medium">Run Scanner</span> to scan all tickers now,
|
||||
or wait for the scheduled run.
|
||||
</div>
|
||||
|
||||
{/* Filter controls */}
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label htmlFor="min-rr" className="mb-1 block text-xs text-gray-400">
|
||||
Min R:R
|
||||
Min Risk:Reward
|
||||
</label>
|
||||
<input
|
||||
id="min-rr"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={minRR}
|
||||
onChange={(e) => setMinRR(Number(e.target.value) || 0)}
|
||||
className="w-24 rounded border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-blue-500 focus:outline-none transition-colors duration-150"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm text-gray-400">1 :</span>
|
||||
<input
|
||||
id="min-rr"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={minRR}
|
||||
onChange={(e) => setMinRR(Number(e.target.value) || 0)}
|
||||
className="w-20 rounded border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-blue-500 focus:outline-none transition-colors duration-150"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="direction" className="mb-1 block text-xs text-gray-400">
|
||||
@@ -112,7 +171,13 @@ export default function ScannerPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trades && (
|
||||
{trades && processed.length === 0 && !isLoading && (
|
||||
<div className="rounded-lg border border-gray-700 bg-gray-800/50 px-4 py-8 text-center text-sm text-gray-400">
|
||||
No trade setups match the current filters. Try lowering the Min R:R or click Run Scanner to refresh.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trades && processed.length > 0 && (
|
||||
<TradeTable
|
||||
trades={processed}
|
||||
sortColumn={sortColumn}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTickerDetail } from '../hooks/useTickerDetail';
|
||||
@@ -11,6 +11,7 @@ import { IndicatorSelector } from '../components/ticker/IndicatorSelector';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { fetchData } from '../api/ingestion';
|
||||
import { formatPrice } from '../lib/format';
|
||||
import type { TradeSetup } from '../lib/types';
|
||||
|
||||
function SectionError({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||
return (
|
||||
@@ -65,7 +66,7 @@ function DataFreshnessBar({ items }: { items: DataStatusItem[] }) {
|
||||
|
||||
export default function TickerDetailPage() {
|
||||
const { symbol = '' } = useParams<{ symbol: string }>();
|
||||
const { ohlcv, scores, srLevels, sentiment, fundamentals } = useTickerDetail(symbol);
|
||||
const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol);
|
||||
const queryClient = useQueryClient();
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -132,10 +133,29 @@ export default function TickerDetailPage() {
|
||||
},
|
||||
], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]);
|
||||
|
||||
// Sort S/R levels by strength for the table
|
||||
// Log trades API errors but don't disrupt the page
|
||||
useEffect(() => {
|
||||
if (trades.error) {
|
||||
console.error('Failed to fetch trade setups:', trades.error);
|
||||
}
|
||||
}, [trades.error]);
|
||||
|
||||
// Pick the latest trade setup for the current symbol
|
||||
const tradeSetup: TradeSetup | undefined = useMemo(() => {
|
||||
if (trades.error || !trades.data) return undefined;
|
||||
const matching = trades.data.filter(
|
||||
(t) => t.symbol.toUpperCase() === symbol.toUpperCase(),
|
||||
);
|
||||
if (matching.length === 0) return undefined;
|
||||
return matching.reduce((latest, t) =>
|
||||
new Date(t.detected_at) > new Date(latest.detected_at) ? t : latest,
|
||||
);
|
||||
}, [trades.data, trades.error, symbol]);
|
||||
|
||||
// Sort visible S/R levels by strength for the table (only levels within chart zones)
|
||||
const sortedLevels = useMemo(() => {
|
||||
if (!srLevels.data?.levels) return [];
|
||||
return [...srLevels.data.levels].sort((a, b) => b.strength - a.strength);
|
||||
if (!srLevels.data?.visible_levels) return [];
|
||||
return [...srLevels.data.visible_levels].sort((a, b) => b.strength - a.strength);
|
||||
}, [srLevels.data]);
|
||||
|
||||
return (
|
||||
@@ -176,7 +196,7 @@ export default function TickerDetailPage() {
|
||||
)}
|
||||
{ohlcv.data && (
|
||||
<div className="glass p-5">
|
||||
<CandlestickChart data={ohlcv.data} srLevels={srLevels.data?.levels} />
|
||||
<CandlestickChart data={ohlcv.data} srLevels={srLevels.data?.levels} zones={srLevels.data?.zones} tradeSetup={tradeSetup} />
|
||||
{srLevels.isError && (
|
||||
<p className="mt-2 text-xs text-yellow-500/80">S/R levels unavailable — chart shown without overlays</p>
|
||||
)}
|
||||
@@ -184,6 +204,39 @@ export default function TickerDetailPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Trade Setup Summary Card */}
|
||||
{tradeSetup && (
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Trade Setup</h2>
|
||||
<div className="glass p-5">
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Direction</span>
|
||||
<span className={`text-sm font-semibold ${tradeSetup.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{tradeSetup.direction.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Entry</span>
|
||||
<span className="text-sm font-mono text-blue-300">{formatPrice(tradeSetup.entry_price)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Stop</span>
|
||||
<span className="text-sm font-mono text-red-400">{formatPrice(tradeSetup.stop_loss)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Target</span>
|
||||
<span className="text-sm font-mono text-emerald-400">{formatPrice(tradeSetup.target)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">R:R</span>
|
||||
<span className="text-sm font-semibold text-gray-200">{tradeSetup.rr_ratio.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Scores + Side Panels */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<section>
|
||||
@@ -193,7 +246,7 @@ export default function TickerDetailPage() {
|
||||
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
|
||||
)}
|
||||
{scores.data && (
|
||||
<ScoreCard compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions.map((d) => ({ dimension: d.dimension, score: d.score }))} />
|
||||
<ScoreCard compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions} compositeBreakdown={scores.data.composite_breakdown} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/ohlcv.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/loginpage.tsx","./src/pages/rankingspage.tsx","./src/pages/registerpage.tsx","./src/pages/scannerpage.tsx","./src/pages/tickerdetailpage.tsx","./src/pages/watchlistpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/ohlcv.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/loginpage.tsx","./src/pages/rankingspage.tsx","./src/pages/registerpage.tsx","./src/pages/scannerpage.tsx","./src/pages/tickerdetailpage.tsx","./src/pages/watchlistpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
||||
Reference in New Issue
Block a user