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 { useActiveSymbol } from '@/stores/active-symbol-store';
|
||||||
import { CollapsibleSection } from '@/components/CollapsibleSection';
|
import { CollapsibleSection } from '@/components/CollapsibleSection';
|
||||||
import { SymbolAutocomplete } from '@/components/SymbolAutocomplete';
|
import { SymbolAutocomplete } from '@/components/SymbolAutocomplete';
|
||||||
|
import { ConfluenceChart } from '@/components/ConfluenceChart';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types (subset of server types for display)
|
// Types (subset of server types for display)
|
||||||
@@ -210,6 +211,13 @@ export function ConfluencePanel() {
|
|||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Signal Timeline chart */}
|
||||||
|
{symbol && (
|
||||||
|
<CollapsibleSection title="Signal Timeline" defaultOpen>
|
||||||
|
<ConfluenceChart symbol={symbol} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Scorecard */}
|
{/* Scorecard */}
|
||||||
{scorecard && scorecard.slots.length > 0 && (
|
{scorecard && scorecard.slots.length > 0 && (
|
||||||
<CollapsibleSection title="Reliability Scorecard">
|
<CollapsibleSection title="Reliability Scorecard">
|
||||||
|
|||||||
Reference in New Issue
Block a user