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
+2
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { OverviewPanel } from "@/components/OverviewPanel"; import { OverviewPanel } from "@/components/OverviewPanel";
import { ChartLabPanel } from "@/components/ChartLabPanel";
import { useActiveSymbol } from "@/stores/active-symbol-store"; import { useActiveSymbol } from "@/stores/active-symbol-store";
import { api, type AuthUser } from "@/lib/trpc"; import { api, type AuthUser } from "@/lib/trpc";
import { UI_STRINGS } from "@/lib/strings"; import { UI_STRINGS } from "@/lib/strings";
@@ -19,6 +20,7 @@ export default function Page() {
<button type="submit" className="px-4 py-2 rounded-md text-sm font-medium bg-[#1a1b24] border border-[#2a2b3a] text-[#e6e7ec] hover:border-[#4f8cff]">Open</button> <button type="submit" className="px-4 py-2 rounded-md text-sm font-medium bg-[#1a1b24] border border-[#2a2b3a] text-[#e6e7ec] hover:border-[#4f8cff]">Open</button>
</form> </form>
<OverviewPanel /> <OverviewPanel />
<ChartLabPanel />
{user && !user.onboarded && <OnboardingSection onDone={() => api.auth.me().then(setUser)} />} {user && !user.onboarded && <OnboardingSection onDone={() => api.auth.me().then(setUser)} />}
<AuthSection user={user} setUser={setUser} /> <AuthSection user={user} setUser={setUser} />
<PrimaryRuleFooter /> <PrimaryRuleFooter />
+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>
);
}
+26
View File
@@ -42,6 +42,32 @@ export const UI_STRINGS = {
drawdownNote: "A default max-drawdown tolerance is set from your experience level (you can change it later).", drawdownNote: "A default max-drawdown tolerance is set from your experience level (you can change it later).",
completeOnboardingButton: "Finish setup", completeOnboardingButton: "Finish setup",
onboardingDone: "Setup complete — your starter watchlist is tracking.", onboardingDone: "Setup complete — your starter watchlist is tracking.",
onboardingDone: "Setup complete — your starter watchlist is tracking.",
// Chart Lab (Slice 5c)
chartLabTitle: "Chart Lab — price, volume, and indicator overlays",
priceChartCaption:
"Daily price range (bar) with closing-price line — visual context, not a signal.",
volumeLabel: "Volume",
volumeCaption:
"Number of shares traded per day — relative size, not a directional cue.",
rsiLabel: "Relative Strength Index (RSI)",
rsiCaption:
"Momentum oscillator comparing gain and loss magnitude over a rolling window.",
rsiTooltip:
"RSI reads the speed and change of price movements; values near extremes can mark overextended stretches, but do not imply a direction.",
emaTooltip:
"Average price over the last N days — a smoothing filter that tracks the prevailing trend direction.",
ema9Label: "EMA 9",
ema21Label: "EMA 21",
ema50Label: "EMA 50",
ema200Label: "EMA 200",
relvolLabel: "Relative Volume",
relvolCaption:
"Today's volume compared to the rolling average — above typical or below typical.",
relvolTooltip:
"Relative volume shows whether trading activity is above or below the recent average; it does not predict direction.",
noCandleData: "No candle data available for this symbol/timeframe.",
} as const; } as const;
export const UI_STRING_LIST: string[] = Object.values(UI_STRINGS); export const UI_STRING_LIST: string[] = Object.values(UI_STRINGS);
+18 -1
View File
@@ -67,8 +67,25 @@ export type Complexity = "beginner" | "intermediate" | "advanced";
export interface StarterEntry { symbol: string; tickerKind: string; reason: string; } export interface StarterEntry { symbol: string; tickerKind: string; reason: string; }
export interface PortfolioEntry { symbol: string; qty: number; avgCost: number; acquiredAt: string; } export interface PortfolioEntry { symbol: string; qty: number; avgCost: number; acquiredAt: string; }
export interface IndicatorOptions {
ema?: number[];
rsi?: { period?: number };
relvol?: { period?: number };
}
export interface IndicatorResult {
ema?: Record<number, Array<{ ts: string; value: number }>>;
rsi?: Array<{ ts: string; value: number }>;
relvol?: Array<{ ts: string; value: number }>;
}
export const api = { export const api = {
market: { snapshot: (symbol: string) => trpcQuery<Snapshot>("market.snapshot", { symbol }) }, market: {
snapshot: (symbol: string) => trpcQuery<Snapshot>("market.snapshot", { symbol }),
candles: (symbol: string, timeframe = "1d") =>
trpcQuery<PriceCandle[]>("market.candles", { symbol, timeframe }),
indicators: (symbol: string, opts: IndicatorOptions) =>
trpcQuery<IndicatorResult>("market.indicators", { symbol, ...opts }),
},
auth: { auth: {
signup: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.signup", { email, password }), signup: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.signup", { email, password }),
login: (email: string, password: string, totp?: string) => trpcMutate<{ userId: string }>("auth.login", { email, password, ...(totp ? { totp } : {}) }), login: (email: string, password: string, totp?: string) => trpcMutate<{ userId: string }>("auth.login", { email, password, ...(totp ? { totp } : {}) }),