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:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { OverviewPanel } from "@/components/OverviewPanel";
|
||||
import { ChartLabPanel } from "@/components/ChartLabPanel";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api, type AuthUser } from "@/lib/trpc";
|
||||
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>
|
||||
</form>
|
||||
<OverviewPanel />
|
||||
<ChartLabPanel />
|
||||
{user && !user.onboarded && <OnboardingSection onDone={() => api.auth.me().then(setUser)} />}
|
||||
<AuthSection user={user} setUser={setUser} />
|
||||
<PrimaryRuleFooter />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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).",
|
||||
completeOnboardingButton: "Finish setup",
|
||||
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;
|
||||
|
||||
export const UI_STRING_LIST: string[] = Object.values(UI_STRINGS);
|
||||
|
||||
+18
-1
@@ -67,8 +67,25 @@ export type Complexity = "beginner" | "intermediate" | "advanced";
|
||||
export interface StarterEntry { symbol: string; tickerKind: string; reason: 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 = {
|
||||
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: {
|
||||
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 } : {}) }),
|
||||
|
||||
Reference in New Issue
Block a user