- Move symbol badge inline with input (flex items-end) so symbol and rack inputs align at the bottom - Move Signal Timeline chart above Slot Evidence table - Deduplicate signal fires by slotId per day in chart data processing and by slotName in tooltip rendering (prevents MACD Bullish etc from appearing twice)
329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
'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];
|
|
// Deduplicate by slot name (a slot can fire multiple times on the same day)
|
|
const seen = new Set<string>();
|
|
const uniqueFires = allFires.filter((f) => {
|
|
if (seen.has(f.slotName)) return false;
|
|
seen.add(f.slotName);
|
|
return true;
|
|
});
|
|
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>
|
|
{uniqueFires.length > 0 && (
|
|
<div className="mt-1.5 space-y-0.5 border-t border-line/50 pt-1.5">
|
|
{uniqueFires.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) ?? [];
|
|
// Deduplicate by slot name per day (a slot can fire multiple times)
|
|
const seenSlots = new Set<string>();
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|