feat: add Signal Timeline chart to Signal Confluence panel

- New ConfluenceChart component renders daily price as area chart
- Overlays colored dot markers at each signal fire date (green=bull,
  red=bear, amber=exit)
- Detects entry zones (2+ bull signals within 5 days) and exit zones
  (2+ bear/exit signals) and shades them on the chart
- Custom tooltip shows price + which signals fired on hover
- Fetches candles and signal history in parallel on mount
- Placed between Slot Evidence and Reliability Scorecard sections
This commit is contained in:
Investor Flow Build
2026-08-11 23:15:19 -04:00
parent 44f8342f6e
commit 762d6612f5
2 changed files with 322 additions and 0 deletions
+314
View File
@@ -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<string, string> = {
bull: '#22c55e',
bear: '#ef4444',
exit: '#f59e0b',
};
const BODY_LABEL: Record<string, string> = {
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 (
<div className="rounded border border-line bg-surface-raised px-3 py-2 text-xs shadow-lg">
<div className="font-medium text-fg mb-1">{formatDateFull(point.date)}</div>
<div className="text-fg-muted">
Close: <span className="text-fg font-medium">${point.close.toFixed(2)}</span>
</div>
{allFires.length > 0 && (
<div className="mt-1.5 space-y-0.5 border-t border-line/50 pt-1.5">
{allFires.map((f, i) => (
<div key={i} className="flex items-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full" style={{ backgroundColor: BODY_COLOR[f.body] }} />
<span className="text-fg">{f.slotName}</span>
<span className="text-fg-muted ml-auto">{BODY_LABEL[f.body]}</span>
</div>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<g>
{markers.map((f, i) => (
<circle
key={i}
cx={cx}
cy={cy - (i * 7)}
r={3.5}
fill={BODY_COLOR[f.body]}
fillOpacity={0.9}
stroke={BODY_COLOR[f.body]}
strokeWidth={1}
strokeOpacity={0.4}
/>
))}
</g>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface ConfluenceChartProps {
symbol: string;
}
export function ConfluenceChart({ symbol }: ConfluenceChartProps) {
const [candles, setCandles] = useState<PriceCandle[]>([]);
const [fires, setFires] = useState<SignalFire[]>([]);
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<string, SignalFire[]>();
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 (
<div className="flex items-center justify-center py-12 text-xs text-fg-muted">
Loading chart…
</div>
);
}
if (!chartData.length) {
return (
<div className="flex items-center justify-center py-12 text-xs text-fg-muted">
No price data available for {symbol}.
</div>
);
}
return (
<div className="space-y-3">
{/* Legend */}
<div className="flex flex-wrap items-center gap-3 text-[10px] text-fg-muted">
<span className="font-medium text-fg">Signal markers:</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-green-500" /> Bull
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-red-500" /> Bear
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-amber-500" /> Exit
</span>
<span className="mx-1">|</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-2 rounded-sm bg-green-500/20 border border-green-500/40" /> Entry zone
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-2 rounded-sm bg-red-500/20 border border-red-500/40" /> Exit zone
</span>
</div>
{/* Chart */}
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="confluencePriceGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART.accent} stopOpacity={0.3} />
<stop offset="100%" stopColor={CHART.accent} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke={CHART.grid} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="dateShort"
tick={{ fontSize: 10, fill: CHART.axis }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={40}
/>
<YAxis
tick={{ fontSize: 10, fill: CHART.axis }}
tickLine={false}
axisLine={false}
domain={['auto', 'auto']}
tickFormatter={(v: number) => `$${v}`}
width={55}
/>
<Tooltip content={<ChartTooltip />} />
{/* Entry/exit zone shading */}
{zones.map((z, i) => (
<ReferenceArea
key={i}
x1={chartData[z.startIdx]?.dateShort}
x2={chartData[z.endIdx]?.dateShort}
fill={z.type === 'entry' ? '#22c55e' : '#ef4444'}
fillOpacity={0.08}
stroke={z.type === 'entry' ? '#22c55e' : '#ef4444'}
strokeOpacity={0.2}
strokeDasharray="3 3"
/>
))}
{/* Price area with signal markers */}
<Area
type="monotone"
dataKey="close"
stroke={CHART.accent}
strokeWidth={1.5}
fill="url(#confluencePriceGradient)"
dot={<SignalDot />}
activeDot={{ r: 3, fill: CHART.accent }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
+8
View File
@@ -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() {
</CollapsibleSection>
)}
{/* Signal Timeline chart */}
{symbol && (
<CollapsibleSection title="Signal Timeline" defaultOpen>
<ConfluenceChart symbol={symbol} />
</CollapsibleSection>
)}
{/* Scorecard */}
{scorecard && scorecard.slots.length > 0 && (
<CollapsibleSection title="Reliability Scorecard">