feat: deliver multi-asset analytics, OCR exchange selection, and heatmap UX

Add asset-type aware market/overview flows, portfolio OCR reverse-engineering with exchange overrides, and interactive index heatmap features. Update README with recent updates and wire backend/frontend APIs for FX matrix, exchange options, and improved portfolio editing flows.

Made-with: Cursor
This commit is contained in:
shawnkim1997
2026-03-21 17:08:00 +00:00
parent e225c05cc8
commit 38c56a5a43
35 changed files with 3224 additions and 287 deletions
@@ -0,0 +1,119 @@
"use client";
import { useEffect, useMemo, useState } from "react";
type HeatmapStock = {
ticker: string;
name: string;
sector: string;
market_cap: number;
change_pct: number;
};
const INDEX_OPTIONS = [
{ id: "sp500", label: "S&P 500" },
{ id: "nasdaq100", label: "NASDAQ 100" },
{ id: "kospi", label: "KOSPI" },
{ id: "ftse100", label: "FTSE 100" },
];
export function HeatmapSection({
selectedIndex,
onSelectIndex,
}: {
selectedIndex: string;
onSelectIndex: (v: string) => void;
}) {
const [stocks, setStocks] = useState<HeatmapStock[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetch(`/api/markets/heatmap/${selectedIndex}?top_n=50`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => setStocks(Array.isArray(d?.stocks) ? d.stocks : []))
.finally(() => setLoading(false));
}, [selectedIndex]);
const grouped = useMemo(() => {
const sectors: Record<string, HeatmapStock[]> = {};
for (const s of stocks) {
const k = s.sector || "Other";
if (!sectors[k]) sectors[k] = [];
sectors[k].push(s);
}
return sectors;
}, [stocks]);
const totalMcap = stocks.reduce((sum, s) => sum + (s.market_cap || 0), 0);
return (
<div id="heatmap-section" className="bg-bg-card border border-border rounded-lg p-4">
<div className="heatmap-header">
<h3 className="text-text-secondary text-sm font-semibold">Stock Heatmap</h3>
<div className="index-toggle">
{INDEX_OPTIONS.map((idx) => (
<button
key={idx.id}
className={`index-btn ${selectedIndex === idx.id ? "active" : ""}`}
onClick={() => onSelectIndex(idx.id)}
>
{idx.label}
</button>
))}
</div>
</div>
{loading ? (
<div className="heatmap-loading">Loading heatmap...</div>
) : (
<div className="treemap-container">
{Object.entries(grouped).map(([sector, sectorStocks]) => {
const sectorMcap = sectorStocks.reduce((s, st) => s + (st.market_cap || 0), 0);
const sectorPct = totalMcap > 0 ? (sectorMcap / totalMcap) * 100 : 0;
return (
<div
key={sector}
className="treemap-sector"
style={{ flexBasis: `${Math.max(sectorPct, 8)}%`, flexGrow: Math.max(sectorPct, 8) }}
>
<div className="treemap-sector-label">{sector}</div>
<div className="treemap-stocks">
{sectorStocks.map((stock) => {
const stockPct = sectorMcap > 0 ? (stock.market_cap / sectorMcap) * 100 : 0;
const intensity = Math.min(Math.abs(stock.change_pct) / 4, 1);
const isPositive = stock.change_pct >= 0;
const primaryLabel = selectedIndex === "kospi" ? (stock.name || stock.ticker) : stock.ticker;
const secondaryLabel = selectedIndex === "kospi" ? stock.ticker : "";
return (
<div
key={stock.ticker}
className="treemap-cell"
style={{
flexBasis: `${Math.max(stockPct, 8)}%`,
flexGrow: Math.max(stockPct, 8),
backgroundColor: isPositive
? `rgba(0, 212, 170, ${0.15 + intensity * 0.6})`
: `rgba(255, 71, 87, ${0.15 + intensity * 0.6})`,
}}
title={`${stock.name}\n${stock.change_pct >= 0 ? "+" : ""}${stock.change_pct}%`}
>
<span className="treemap-ticker">{primaryLabel}</span>
{secondaryLabel ? <span className="treemap-subticker">{secondaryLabel}</span> : null}
<span className="treemap-change">
{stock.change_pct >= 0 ? "+" : ""}
{stock.change_pct}%
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,88 @@
"use client";
interface CommodityOverviewProps {
ticker: string;
data: Record<string, unknown>;
}
export function CommodityOverview({ ticker, data }: CommodityOverviewProps) {
const seasonal = data?.seasonal_pattern || {};
const correlations = data?.correlation_matrix || {};
const related = Array.isArray(data?.related_assets) ? data.related_assets : [];
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">
<span className="text-accent-green">{ticker}</span> Commodity Overview
</h1>
<div className="bg-bg-card border border-border rounded-lg p-5">
<div className="text-text-primary font-semibold text-lg">{data?.name || ticker}</div>
<div className="text-3xl font-mono font-bold text-text-primary mt-1">
{data?.price != null ? `$${Number(data.price).toFixed(2)}` : "—"}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: "Open Interest", value: data?.open_interest?.toLocaleString?.() || "—" },
{ label: "Volume", value: data?.volume?.toLocaleString?.() || "—" },
{ label: "52W High", value: data?.high_52w != null ? `$${Number(data.high_52w).toFixed(2)}` : "—" },
{ label: "52W Low", value: data?.low_52w != null ? `$${Number(data.low_52w).toFixed(2)}` : "—" },
].map((m) => (
<div key={m.label} className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs">{m.label}</div>
<div className="text-text-primary font-mono font-semibold mt-1">{m.value}</div>
</div>
))}
</div>
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Seasonal Pattern (10Y avg monthly)</h3>
<div className="grid grid-cols-3 lg:grid-cols-6 gap-2 text-xs">
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => {
const v = seasonal?.[m] ?? 0;
return (
<div key={m} className="bg-bg-primary border border-border rounded p-2">
<div className="text-text-muted">M{m}</div>
<div className={`font-mono ${v >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{v >= 0 ? "+" : ""}
{v}%
</div>
</div>
);
})}
</div>
</div>
{related.length > 0 && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Related Assets</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
{related.map((r: Record<string, unknown>) => (
<div key={r.symbol} className="bg-bg-primary border border-border rounded p-3">
<div className="text-text-secondary text-xs">{r.symbol}</div>
<div className="text-text-primary font-mono">{r.price != null ? `$${Number(r.price).toFixed(2)}` : "—"}</div>
<div className={`text-xs font-mono ${Number(r.change_pct || 0) >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{Number(r.change_pct || 0) >= 0 ? "+" : ""}
{r.change_pct ?? 0}%
</div>
</div>
))}
</div>
</div>
)}
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Correlation Matrix (1Y)</h3>
<div className="space-y-1 text-sm">
{Object.keys(correlations).length === 0 && <div className="text-text-muted">No correlation data</div>}
{Object.entries(correlations).map(([k, v]) => (
<div key={k} className="flex justify-between">
<span className="text-text-secondary">{k}</span>
<span className="text-text-primary font-mono">{String(v)}</span>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,84 @@
"use client";
interface ETFOverviewProps {
ticker: string;
data: Record<string, unknown>;
}
export function ETFOverview({ ticker, data }: ETFOverviewProps) {
const returns = data?.returns || {};
const risk = data?.risk || {};
const holdings = Array.isArray(data?.holdings) ? data.holdings : [];
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">
<span className="text-accent-green">{ticker}</span> ETF Overview
</h1>
<div className="bg-bg-card border border-border rounded-lg p-5">
<div className="text-text-primary font-semibold text-lg">{data?.name || ticker}</div>
<div className="text-3xl font-mono font-bold text-text-primary mt-1">
{data?.price != null ? `$${Number(data.price).toFixed(2)}` : "—"}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: "Category", value: data?.category || "N/A" },
{ label: "AUM", value: data?.aum ? `$${(Number(data.aum) / 1e9).toFixed(1)}B` : "—" },
{ label: "Expense Ratio", value: data?.expense_ratio != null ? `${(Number(data.expense_ratio) * 100).toFixed(2)}%` : "—" },
{ label: "NAV", value: data?.nav != null ? `$${Number(data.nav).toFixed(2)}` : "—" },
{ label: "52W High", value: data?.high_52w != null ? `$${Number(data.high_52w).toFixed(2)}` : "—" },
{ label: "52W Low", value: data?.low_52w != null ? `$${Number(data.low_52w).toFixed(2)}` : "—" },
{ label: "1Y Return", value: returns?.["1y"] != null ? `${returns["1y"]}%` : "—" },
{ label: "YTD Return", value: returns?.ytd != null ? `${returns.ytd}%` : "—" },
].map((m) => (
<div key={m.label} className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs">{m.label}</div>
<div className="text-text-primary font-mono font-semibold mt-1">{m.value}</div>
</div>
))}
</div>
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Performance</h3>
<div className="flex flex-wrap gap-2 text-sm">
{["1m", "3m", "6m", "ytd", "1y", "3y", "5y"].map((k) => (
<span key={k} className="bg-bg-primary border border-border rounded px-2 py-1 font-mono text-text-primary">
{k.toUpperCase()}: {returns?.[k] != null ? `${returns[k]}%` : "—"}
</span>
))}
</div>
</div>
{holdings.length > 0 && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Top Holdings</h3>
<div className="space-y-2">
{holdings.slice(0, 10).map((h: Record<string, unknown>, i: number) => (
<div key={`${h.symbol || h.name}-${i}`} className="flex justify-between text-sm">
<span className="text-text-primary">{h.symbol || h.name || "—"}</span>
<span className="text-text-muted font-mono">
{h.weight_pct != null ? `${(Number(h.weight_pct) * 100).toFixed(2)}%` : "—"}
</span>
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: "Sharpe", value: risk?.sharpe },
{ label: "Sortino", value: risk?.sortino },
{ label: "Max DD", value: risk?.max_drawdown != null ? `${risk.max_drawdown}%` : null },
{ label: "Volatility", value: risk?.volatility != null ? `${risk.volatility}%` : null },
].map((r) => (
<div key={r.label} className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs">{r.label}</div>
<div className="text-text-primary font-mono font-semibold mt-1">{r.value ?? "—"}</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,74 @@
"use client";
interface EquityOverviewProps {
ticker: string;
sector: Record<string, unknown> | null;
health: Record<string, unknown> | null;
}
export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) {
const metrics = [
{ label: "Sector", value: sector?.sector || "—" },
{ label: "Industry", value: sector?.industry || "—" },
{ label: "Market Cap", value: sector?.market_cap ? `$${(Number(sector.market_cap) / 1e9).toFixed(1)}B` : "—" },
{ label: "P/E Ratio", value: sector?.pe_ratio != null ? Number(sector.pe_ratio).toFixed(1) : "—" },
{ label: "Beta", value: sector?.beta != null ? Number(sector.beta).toFixed(2) : "—" },
{ label: "Div Yield", value: sector?.dividend_yield != null ? `${Number(sector.dividend_yield).toFixed(2)}%` : "—" },
{ label: "52W High", value: sector?.fifty_two_week_high != null ? `$${Number(sector.fifty_two_week_high).toFixed(2)}` : "—" },
{ label: "52W Low", value: sector?.fifty_two_week_low != null ? `$${Number(sector.fifty_two_week_low).toFixed(2)}` : "—" },
];
return (
<div>
<h1 className="text-2xl font-bold mb-1">
<span className="text-accent-green">{ticker}</span> Overview
</h1>
{sector?.current_price != null && (
<p className="text-3xl font-mono font-bold text-text-primary mb-6">${Number(sector.current_price).toFixed(2)}</p>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{metrics.map((m) => (
<div key={m.label} className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">{m.label}</div>
<div className="text-text-primary font-semibold">{m.value}</div>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-4">
<Card title="Altman Z-Score" value={health?.altman_z != null ? Number(health.altman_z).toFixed(2) : "—"} />
<Card title="Current Ratio" value={health?.current_ratio != null ? Number(health.current_ratio).toFixed(2) : "—"} />
<Card title="Interest Cov." value={health?.interest_coverage != null ? `${Number(health.interest_coverage).toFixed(1)}x` : "—"} />
<Card title="D/E Ratio" value={health?.debt_to_equity != null ? Number(health.debt_to_equity).toFixed(2) : "—"} />
</div>
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">DuPont Analysis</h3>
{!!health?.dupont ? (
<div className="space-y-2">
{[
{ label: "ROE", value: health.dupont.roe },
{ label: "Net Profit Margin", value: health.dupont.npm },
{ label: "Asset Turnover", value: health.dupont.asset_turnover },
{ label: "Equity Multiplier", value: health.dupont.equity_multiplier },
].map((d) => (
<div key={d.label} className="flex justify-between">
<span className="text-text-muted text-sm">{d.label}</span>
<span className="text-text-primary font-mono">{d.value != null ? Number(d.value).toFixed(2) : "—"}</span>
</div>
))}
</div>
) : (
<div className="text-text-muted">No data</div>
)}
</div>
</div>
);
}
function Card({ title, value }: { title: string; value: string }) {
return (
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">{title}</h3>
<div className="text-3xl font-mono font-bold text-text-primary">{value}</div>
</div>
);
}
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect } from "react";
import { normalizeTickerInput } from "../lib/ticker-alias";
const NAV_ITEMS = [
{ href: "/", label: "Overview", icon: "📊" },
@@ -11,6 +12,7 @@ const NAV_ITEMS = [
{ href: "/markets", label: "Markets", icon: "🌍" },
{ href: "/earnings", label: "Earnings", icon: "📅" },
{ href: "/news", label: "News", icon: "📰" },
{ href: "/screener", label: "Screener", icon: "🎯" },
{ href: "/portfolio", label: "Portfolio", icon: "💼" },
{ href: "/filings", label: "Filings", icon: "📑" },
];
@@ -33,7 +35,7 @@ export function Sidebar() {
}, []);
function handleSearch() {
const val = input.trim().toUpperCase();
const val = normalizeTickerInput(input);
if (val) {
setTickerLocal(val);
localStorage.setItem("atlas_active_ticker", val);