'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]; // Deduplicate by slot name (a slot can fire multiple times on the same day) const seen = new Set(); const uniqueFires = allFires.filter((f) => { if (seen.has(f.slotName)) return false; seen.add(f.slotName); return true; }); return (
{formatDateFull(point.date)}
Close: ${point.close.toFixed(2)}
{uniqueFires.length > 0 && (
{uniqueFires.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) ?? []; // Deduplicate by slot name per day (a slot can fire multiple times) const seenSlots = new Set(); const uniqueDayFires = dayFires.filter((f) => { if (seenSlots.has(f.slotId)) return false; seenSlots.add(f.slotId); return true; }); return { date: c.ts, dateShort: formatDate(c.ts), close: c.c, bullFires: uniqueDayFires.filter((f) => f.body === 'bull'), bearFires: uniqueDayFires.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 }} />
); }