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";
|
"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 />
|
||||||
|
|||||||
@@ -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).",
|
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
@@ -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 } : {}) }),
|
||||||
|
|||||||
Reference in New Issue
Block a user