mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-26 08:48:05 +00:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user