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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user