diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index 4dfd80b..bc2619d 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -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() { + {user && !user.onboarded && api.auth.me().then(setUser)} />} diff --git a/app/src/components/ChartLabPanel.tsx b/app/src/components/ChartLabPanel.tsx new file mode 100644 index 0000000..9c1090b --- /dev/null +++ b/app/src/components/ChartLabPanel.tsx @@ -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 = { + 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([]); + const [indicators, setIndicators] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+

{UI_STRINGS.chartLabTitle}

+

{UI_STRINGS.loadingSnapshot}

+
+ ); + } + + if (error) { + return ( +
+

{UI_STRINGS.chartLabTitle}

+

{error}

+
+ ); + } + + if (candles.length === 0) { + return ( +
+

{UI_STRINGS.chartLabTitle}

+

{UI_STRINGS.noCandleData}

+
+ ); + } + + // Build chart data by merging candles with indicator overlays. + const rsiSeries = indicators?.rsi ?? []; + const relvolSeries = indicators?.relvol ?? []; + const emaMap: Record> = {}; + if (indicators?.ema) { + for (const [period, entries] of Object.entries(indicators.ema)) { + const m = new Map(); + 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 ( + + + + ); + }; + + return ( +
+

{UI_STRINGS.chartLabTitle}

+ + {/* Price chart with EMA overlays */} +
+
+

{UI_STRINGS.priceChartCaption}

+
+ + + + + + `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]; + }} + /> + { + 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}`}`; + }} + /> + + {Object.entries(EMA_COLORS).map(([period, color]) => ( + + ))} + + +
+
+ {UI_STRINGS.emaTooltip} +
+
+
+ + {/* Volume sub-chart */} +
+
+

{UI_STRINGS.volumeLabel}

+
+ + + + + + [`${value.toLocaleString()}`, UI_STRINGS.volumeLabel]} + /> + + {chartData.map((entry, index) => ( + = entry.o ? upColor : downColor} opacity={0.5} /> + ))} + + + +
+
+ {UI_STRINGS.volumeCaption} +
+
+
+ + {/* RSI sub-chart */} +
+
+

{UI_STRINGS.rsiLabel}

+
+ + + + + + [`${value?.toFixed(2)}`, UI_STRINGS.rsiLabel]} + /> + + + +
+
+ {UI_STRINGS.rsiTooltip} +
+
+
+ + {/* Relative Volume sub-chart */} +
+
+

{UI_STRINGS.relvolLabel}

+
+ + + + + + { + const label = value > 1 ? "above typical" : value < 1 ? "below typical" : "at typical"; + return [`${label} (${value?.toFixed(2)}x)`, UI_STRINGS.relvolLabel]; + }} + /> + + {chartData.map((entry, index) => { + const rv = entry.relvol ?? 1; + const fill = rv > 1 ? upColor : rv < 1 ? downColor : "#6b7280"; + return ; + })} + + + +
+
+ {UI_STRINGS.relvolTooltip} +
+
+
+
+ ); +} diff --git a/app/src/lib/strings.ts b/app/src/lib/strings.ts index 19680d1..79b705d 100644 --- a/app/src/lib/strings.ts +++ b/app/src/lib/strings.ts @@ -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); diff --git a/app/src/lib/trpc.ts b/app/src/lib/trpc.ts index 8e96571..42d1c5c 100644 --- a/app/src/lib/trpc.ts +++ b/app/src/lib/trpc.ts @@ -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>; + rsi?: Array<{ ts: string; value: number }>; + relvol?: Array<{ ts: string; value: number }>; +} + export const api = { - market: { snapshot: (symbol: string) => trpcQuery("market.snapshot", { symbol }) }, + market: { + snapshot: (symbol: string) => trpcQuery("market.snapshot", { symbol }), + candles: (symbol: string, timeframe = "1d") => + trpcQuery("market.candles", { symbol, timeframe }), + indicators: (symbol: string, opts: IndicatorOptions) => + trpcQuery("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 } : {}) }),