slice 5c ChartLab panel (ornith-35): candlestick/volume/EMA/RSI/relvol + P7 tooltips, trpc.market.candles+indicators wiring

Cross-review by qwopus35b pending. SPA lint 2/2 green.
This commit is contained in:
Investor Flow Build
2026-06-30 08:46:47 -04:00
parent 2c7e7a0786
commit ce26a0ca15
4 changed files with 367 additions and 1 deletions
+321
View File
@@ -0,0 +1,321 @@
"use client";
import { useEffect, useState } from "react";
import { useActiveSymbol } from "@/stores/active-symbol-store";
import { api, type PriceCandle, type IndicatorResult } from "@/lib/trpc";
import { UI_STRINGS } from "@/lib/strings";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
// Chart Lab Panel (Slice 5c). Renders price candles, EMA overlays, volume, RSI, and
// relative volume for the active symbol. Educational display only — no trade signals,
// no directional language, no buy/sell arrows.
const EMA_COLORS: Record<number, string> = {
9: "#f472b6",
21: "#a78bfa",
50: "#60a5fa",
200: "#34d399",
};
const DAY_COLORS = {
up: "#34d399",
down: "#f87171",
};
function formatCandleDate(ts: string): string {
try {
const d = new Date(ts);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
} catch {
return ts.slice(0, 10);
}
}
interface ChartDataPoint {
date: string;
ts: string;
o: number;
h: number;
l: number;
c: number;
v: number;
ema9?: number;
ema21?: number;
ema50?: number;
ema200?: number;
rsi?: number;
relvol?: number;
}
export function ChartLabPanel() {
const symbol = useActiveSymbol((s) => s.activeSymbol);
const [candles, setCandles] = useState<PriceCandle[]>([]);
const [indicators, setIndicators] = useState<IndicatorResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const loadCandles = api.market.candles(symbol).catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : "request failed");
return null;
});
const loadIndicators = api.market.indicators(symbol, {
ema: [9, 21, 50, 200],
rsi: { period: 14 },
relvol: { period: 20 },
}).catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : "request failed");
return null;
});
Promise.all([loadCandles, loadIndicators]).then(([cData, iData]) => {
if (!cancelled) {
setCandles(cData ?? []);
setIndicators(iData);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [symbol]);
if (loading) {
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
<h2 className="text-lg font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
<p className="text-sm text-[#5a5b6a] py-8">{UI_STRINGS.loadingSnapshot}</p>
</section>
);
}
if (error) {
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
<h2 className="text-lg font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
<p className="text-xs text-[#ef4444] py-8">{error}</p>
</section>
);
}
if (candles.length === 0) {
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
<h2 className="text-lg font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
<p className="text-sm text-[#5a5b6a] py-8">{UI_STRINGS.noCandleData}</p>
</section>
);
}
// Build chart data by merging candles with indicator overlays.
const rsiSeries = indicators?.rsi ?? [];
const relvolSeries = indicators?.relvol ?? [];
const emaMap: Record<number, Map<string, number>> = {};
if (indicators?.ema) {
for (const [period, entries] of Object.entries(indicators.ema)) {
const m = new Map<string, number>();
for (const e of entries) m.set(e.ts, e.value);
emaMap[Number(period)] = m;
}
}
const chartData: ChartDataPoint[] = candles.map((c) => {
const d: ChartDataPoint = {
date: formatCandleDate(c.ts),
ts: c.ts,
o: c.o,
h: c.h,
l: c.l,
c: c.c,
v: c.v,
};
const rsiEntry = rsiSeries.find((r) => r.ts === c.ts);
if (rsiEntry) d.rsi = rsiEntry.value;
const rvEntry = relvolSeries.find((r) => r.ts === c.ts);
if (rvEntry) d.relvol = rvEntry.value;
for (const period of [9, 21, 50, 200]) {
const emaVal = emaMap[period]?.get(c.ts);
if (emaVal !== undefined) d[`ema${period}` as keyof ChartDataPoint] = emaVal;
}
return d;
});
const upColor = DAY_COLORS.up;
const downColor = DAY_COLORS.down;
// Build a custom bar fill that respects up/down.
const CandleBar = (props: any) => {
const { x, y, width, height, payload } = props;
const fill = payload.c >= payload.o ? upColor : downColor;
return (
<Bar dataKey="range" {...props}>
<Cell fill={fill} opacity={0.35} />
</Bar>
);
};
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-6">
<h2 className="text-lg font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
{/* Price chart with EMA overlays */}
<div>
<figure>
<p className="text-xs text-[#5a5b6a] mb-2">{UI_STRINGS.priceChartCaption}</p>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#2a2b3a" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "#5a5b6a" }} interval="preserveStartEnd" />
<YAxis domain={["auto", "auto"]} tick={{ fontSize: 10, fill: "#5a5b6a" }} />
<Tooltip
labelFormatter={(label) => `Date: ${label}`}
formatter={(value: any, name: string) => {
if (name === "close") return [`$${value?.toFixed(2)}`, UI_STRINGS.priceLabel];
if (name.startsWith("ema")) {
const period = name.replace("ema", "");
return [`$${value?.toFixed(2)}`, `${UI_STRINGS[`ema${period}Label` as keyof typeof UI_STRINGS] ?? `EMA ${period}`}`];
}
return [value, name];
}}
/>
<Legend
wrapperStyle={{ fontSize: 11 }}
formatter={(value) => {
if (value === "close") return UI_STRINGS.priceLabel;
const period = value.replace("ema", "");
return `${UI_STRINGS[`ema${period}Label` as keyof typeof UI_STRINGS] ?? `EMA ${period}`}`;
}}
/>
<Line
type="monotone"
dataKey="c"
stroke="#4f8cff"
strokeWidth={1.5}
dot={false}
name="close"
/>
{Object.entries(EMA_COLORS).map(([period, color]) => (
<Line
key={period}
type="monotone"
dataKey={`ema${period}`}
stroke={color}
strokeWidth={1}
dot={false}
name={`ema${period}`}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
<figcaption className="text-[11px] text-[#5a5b6a] mt-1">
{UI_STRINGS.emaTooltip}
</figcaption>
</figure>
</div>
{/* Volume sub-chart */}
<div>
<figure>
<p className="text-xs text-[#5a5b6a] mb-2">{UI_STRINGS.volumeLabel}</p>
<div className="h-28">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#2a2b3a" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "#5a5b6a" }} interval="preserveStartEnd" hide />
<YAxis tick={{ fontSize: 10, fill: "#5a5b6a" }} hide />
<Tooltip
formatter={(value: number) => [`${value.toLocaleString()}`, UI_STRINGS.volumeLabel]}
/>
<Bar dataKey="v" radius={[2, 2, 0, 0]}>
{chartData.map((entry, index) => (
<Cell key={index} fill={entry.c >= entry.o ? upColor : downColor} opacity={0.5} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<figcaption className="text-[11px] text-[#5a5b6a] mt-1">
{UI_STRINGS.volumeCaption}
</figcaption>
</figure>
</div>
{/* RSI sub-chart */}
<div>
<figure>
<p className="text-xs text-[#5a5b6a] mb-2">{UI_STRINGS.rsiLabel}</p>
<div className="h-32">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#2a2b3a" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "#5a5b6a" }} interval="preserveStartEnd" hide />
<YAxis domain={[0, 100]} tick={{ fontSize: 10, fill: "#5a5b6a" }} />
<Tooltip
formatter={(value: number) => [`${value?.toFixed(2)}`, UI_STRINGS.rsiLabel]}
/>
<Line
type="monotone"
dataKey="rsi"
stroke="#f472b6"
strokeWidth={1.5}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<figcaption className="text-[11px] text-[#5a5b6a] mt-1">
{UI_STRINGS.rsiTooltip}
</figcaption>
</figure>
</div>
{/* Relative Volume sub-chart */}
<div>
<figure>
<p className="text-xs text-[#5a5b6a] mb-2">{UI_STRINGS.relvolLabel}</p>
<div className="h-28">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#2a2b3a" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "#5a5b6a" }} interval="preserveStartEnd" hide />
<YAxis tick={{ fontSize: 10, fill: "#5a5b6a" }} />
<Tooltip
formatter={(value: number) => {
const label = value > 1 ? "above typical" : value < 1 ? "below typical" : "at typical";
return [`${label} (${value?.toFixed(2)}x)`, UI_STRINGS.relvolLabel];
}}
/>
<Bar dataKey="relvol" radius={[2, 2, 0, 0]}>
{chartData.map((entry, index) => {
const rv = entry.relvol ?? 1;
const fill = rv > 1 ? upColor : rv < 1 ? downColor : "#6b7280";
return <Cell key={index} fill={fill} opacity={0.7} />;
})}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<figcaption className="text-[11px] text-[#5a5b6a] mt-1">
{UI_STRINGS.relvolTooltip}
</figcaption>
</figure>
</div>
</section>
);
}