diff --git a/app/src/components/ConfluenceChart.tsx b/app/src/components/ConfluenceChart.tsx new file mode 100644 index 0000000..240ad2a --- /dev/null +++ b/app/src/components/ConfluenceChart.tsx @@ -0,0 +1,314 @@ +'use client'; +import { useEffect, useMemo, useState } from 'react'; +import { api, type PriceCandle } from '@/lib/trpc'; +import { chart as CHART } from '@/lib/chart-theme'; +import { + Area, + AreaChart, + CartesianGrid, + ReferenceArea, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface SignalFire { + slotId: string; + slotName: string; + body: 'bull' | 'bear' | 'exit'; + firedAt: string; + qualityAtFire: string; +} + +interface ChartPoint { + date: string; + dateShort: string; + close: number; + bullFires: SignalFire[]; + bearFires: SignalFire[]; +} + +interface EntryExitZone { + startIdx: number; + endIdx: number; + type: 'entry' | 'exit'; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function toDateKey(ts: string): string { + return ts.slice(0, 10); +} + +function formatDate(ts: string): string { + try { + return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } catch { + return ts.slice(5, 10); + } +} + +function formatDateFull(ts: string): string { + try { + return new Date(ts).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + } catch { + return ts.slice(0, 10); + } +} + +const BODY_COLOR: Record = { + bull: '#22c55e', + bear: '#ef4444', + exit: '#f59e0b', +}; + +const BODY_LABEL: Record = { + bull: 'Bull', + bear: 'Bear', + exit: 'Exit', +}; + +// --------------------------------------------------------------------------- +// Custom tooltip +// --------------------------------------------------------------------------- + +function ChartTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: ChartPoint }> }) { + if (!active || !payload?.length) return null; + const point = payload[0].payload; + const allFires = [...point.bullFires, ...point.bearFires]; + return ( +
+
{formatDateFull(point.date)}
+
+ Close: ${point.close.toFixed(2)} +
+ {allFires.length > 0 && ( +
+ {allFires.map((f, i) => ( +
+ + {f.slotName} + {BODY_LABEL[f.body]} +
+ ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Custom dot renderer - renders colored markers for signal fires +// --------------------------------------------------------------------------- + +function SignalDot(props: { cx?: number; cy?: number; payload?: ChartPoint; index?: number }) { + const { cx, cy, payload } = props; + if (cx == null || cy == null || !payload) return null; + + const allFires = [...payload.bullFires, ...payload.bearFires]; + if (allFires.length === 0) return null; + + // Stack multiple markers vertically if there are several signals + const markers = allFires.slice(0, 4); // cap at 4 visible markers + return ( + + {markers.map((f, i) => ( + + ))} + + ); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +interface ConfluenceChartProps { + symbol: string; +} + +export function ConfluenceChart({ symbol }: ConfluenceChartProps) { + const [candles, setCandles] = useState([]); + const [fires, setFires] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setLoading(true); + + Promise.all([ + api.market.candles(symbol, '1d'), + api.confluence.signalHistory(symbol), + ]).then(([candleData, fireData]) => { + if (cancelled) return; + const cutoff = new Date(); + cutoff.setMonth(cutoff.getMonth() - 6); + const cutoffStr = cutoff.toISOString(); + setCandles(candleData.filter((c) => c.ts >= cutoffStr)); + setFires(fireData.fires); + setLoading(false); + }).catch(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, [symbol]); + + const { chartData, zones } = useMemo(() => { + if (!candles.length) return { chartData: [], zones: [] }; + + const firesByDate = new Map(); + for (const f of fires) { + const key = toDateKey(f.firedAt); + const arr = firesByDate.get(key) ?? []; + arr.push(f); + firesByDate.set(key, arr); + } + + const data: ChartPoint[] = candles.map((c) => { + const key = toDateKey(c.ts); + const dayFires = firesByDate.get(key) ?? []; + return { + date: c.ts, + dateShort: formatDate(c.ts), + close: c.c, + bullFires: dayFires.filter((f) => f.body === 'bull'), + bearFires: dayFires.filter((f) => f.body === 'bear' || f.body === 'exit'), + }; + }); + + // Detect entry/exit zones: 2+ signals of same direction within 5 trading days + const windowSize = 5; + const rawZones: EntryExitZone[] = []; + for (let i = 0; i < data.length; i++) { + const windowEnd = Math.min(i + windowSize, data.length); + let bullCount = 0; + let bearCount = 0; + for (let j = i; j < windowEnd; j++) { + bullCount += data[j].bullFires.length; + bearCount += data[j].bearFires.length; + } + if (bullCount >= 2 && !rawZones.some((z) => z.type === 'entry' && i >= z.startIdx && i <= z.endIdx)) { + rawZones.push({ startIdx: i, endIdx: Math.min(i + windowSize - 1, data.length - 1), type: 'entry' }); + } + if (bearCount >= 2 && !rawZones.some((z) => z.type === 'exit' && i >= z.startIdx && i <= z.endIdx)) { + rawZones.push({ startIdx: i, endIdx: Math.min(i + windowSize - 1, data.length - 1), type: 'exit' }); + } + } + + return { chartData: data, zones: rawZones }; + }, [candles, fires]); + + if (loading) { + return ( +
+ Loading chart… +
+ ); + } + + if (!chartData.length) { + return ( +
+ No price data available for {symbol}. +
+ ); + } + + return ( +
+ {/* Legend */} +
+ Signal markers: + + Bull + + + Bear + + + Exit + + | + + Entry zone + + + Exit zone + +
+ + {/* Chart */} + + + + + + + + + + + `$${v}`} + width={55} + /> + } /> + + {/* Entry/exit zone shading */} + {zones.map((z, i) => ( + + ))} + + {/* Price area with signal markers */} + } + activeDot={{ r: 3, fill: CHART.accent }} + /> + + +
+ ); +} diff --git a/app/src/components/ConfluencePanel.tsx b/app/src/components/ConfluencePanel.tsx index 6990e0f..a374fb5 100644 --- a/app/src/components/ConfluencePanel.tsx +++ b/app/src/components/ConfluencePanel.tsx @@ -4,6 +4,7 @@ import { api } from '@/lib/trpc'; import { useActiveSymbol } from '@/stores/active-symbol-store'; import { CollapsibleSection } from '@/components/CollapsibleSection'; import { SymbolAutocomplete } from '@/components/SymbolAutocomplete'; +import { ConfluenceChart } from '@/components/ConfluenceChart'; // --------------------------------------------------------------------------- // Types (subset of server types for display) @@ -210,6 +211,13 @@ export function ConfluencePanel() { )} + {/* Signal Timeline chart */} + {symbol && ( + + + + )} + {/* Scorecard */} {scorecard && scorecard.slots.length > 0 && (