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);
@@ -23,6 +23,7 @@ interface QuarterlyData {
export default function EarningsPage() {
const { ticker } = useTicker();
const [assetType, setAssetType] = useState<string>("equity");
const [history, setHistory] = useState<EarningsRecord[]>([]);
const [calendar, setCalendar] = useState<CalendarData | null>(null);
const [quarterly, setQuarterly] = useState<QuarterlyData[]>([]);
@@ -34,16 +35,31 @@ export default function EarningsPage() {
fetch(`/api/earnings/${ticker}/history`).then((r) => r.ok ? r.json() : null),
fetch(`/api/earnings/${ticker}/calendar`).then((r) => r.ok ? r.json() : null),
fetch(`/api/earnings/${ticker}/quarterly`).then((r) => r.ok ? r.json() : null),
]).then(([h, c, q]) => {
fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null),
]).then(([h, c, q, o]) => {
setHistory(h?.history || []);
setCalendar(c);
setQuarterly(q?.quarterly || []);
setAssetType(o?.asset_type || "equity");
setLoading(false);
}).catch(() => setLoading(false));
}, [ticker]);
if (loading) return <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
if (assetType !== "equity") {
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<span className="text-accent-green">{ticker}</span> Earnings
</h1>
<div className="bg-bg-card border border-border rounded-lg p-5 text-text-secondary text-sm">
({assetType}) Earnings .
</div>
</div>
);
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">
+199
View File
@@ -38,3 +38,202 @@ body {
input::placeholder {
color: #6B7280;
}
.action-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
}
.edit-input {
width: 100%;
max-width: 120px;
background: #0A0A0F;
border: 1px solid #4DA6FF;
border-radius: 6px;
padding: 6px 10px;
color: #F3F4F6;
font-family: "JetBrains Mono", monospace;
font-size: 13px;
outline: none;
}
.delete-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.portfolio-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.currency-toggle {
display: flex;
gap: 4px;
background: #1A1A26;
border: 1px solid #2A2A3A;
border-radius: 8px;
padding: 3px;
}
.currency-btn {
padding: 6px 14px;
border: none;
border-radius: 6px;
background: transparent;
color: #9CA3AF;
font-family: "JetBrains Mono", monospace;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.currency-btn:hover {
color: #F3F4F6;
background: #252536;
}
.currency-btn.active {
background: #00D4AA;
color: #0A0A0F;
}
.heatmap-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.index-toggle {
display: flex;
gap: 4px;
background: #1A1A26;
border: 1px solid #2A2A3A;
border-radius: 8px;
padding: 3px;
}
.index-btn {
padding: 6px 16px;
border: none;
border-radius: 6px;
background: transparent;
color: #9CA3AF;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.index-btn:hover {
color: #F3F4F6;
background: #252536;
}
.index-btn.active {
background: #00D4AA;
color: #0A0A0F;
}
.treemap-container {
display: flex;
flex-wrap: wrap;
gap: 2px;
border-radius: 8px;
overflow: hidden;
min-height: 400px;
background: #0A0A0F;
}
.treemap-sector {
display: flex;
flex-direction: column;
min-width: 80px;
position: relative;
}
.treemap-sector-label {
font-size: 10px;
font-weight: 600;
color: #9CA3AF;
padding: 4px 6px;
background: rgba(0, 0, 0, 0.3);
position: absolute;
top: 0;
left: 0;
z-index: 1;
border-radius: 4px 0 4px 0;
pointer-events: none;
}
.treemap-stocks {
display: flex;
flex-wrap: wrap;
flex: 1;
gap: 1px;
}
.treemap-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 60px;
min-height: 50px;
padding: 4px;
cursor: pointer;
transition: filter 0.15s, transform 0.1s;
border-radius: 2px;
}
.treemap-cell:hover {
filter: brightness(1.15);
transform: scale(1.02);
z-index: 2;
}
.treemap-ticker {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
font-weight: 700;
color: #F3F4F6;
}
.treemap-change {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
font-weight: 600;
color: #F3F4F6;
}
.treemap-subticker {
font-family: "JetBrains Mono", monospace;
font-size: 9px;
color: #9CA3AF;
}
.heatmap-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
color: #9CA3AF;
font-size: 14px;
}
@@ -0,0 +1,36 @@
const TICKER_ALIASES: Record<string, string> = {
// Commodities (natural language -> futures ticker)
gold: "GC=F",
silver: "SI=F",
platinum: "PL=F",
palladium: "PA=F",
oil: "CL=F",
crude: "CL=F",
"crude oil": "CL=F",
brent: "BZ=F",
gas: "NG=F",
"natural gas": "NG=F",
copper: "HG=F",
wheat: "ZW=F",
corn: "ZC=F",
soybeans: "ZS=F",
coffee: "KC=F",
sugar: "SB=F",
// Popular commodity ETFs
gld: "GLD",
slv: "SLV",
uso: "USO",
ung: "UNG",
dbc: "DBC",
gsg: "GSG",
};
export function normalizeTickerInput(raw: string): string {
const cleaned = raw.trim();
if (!cleaned) return "";
const key = cleaned.toLowerCase();
const mapped = TICKER_ALIASES[key] || cleaned;
return mapped.toUpperCase();
}
@@ -1,5 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { normalizeTickerInput } from "./ticker-alias";
const DEFAULT_TICKER = "AAPL";
const STORAGE_KEY = "atlas_active_ticker";
@@ -7,7 +8,8 @@ const EVENT_NAME = "atlas-ticker-change";
function getInitialTicker(): string {
if (typeof window === "undefined") return DEFAULT_TICKER;
return localStorage.getItem(STORAGE_KEY) || DEFAULT_TICKER;
const saved = localStorage.getItem(STORAGE_KEY) || DEFAULT_TICKER;
return normalizeTickerInput(saved);
}
export function useTicker() {
@@ -23,7 +25,7 @@ export function useTicker() {
}, []);
const setTicker = useCallback((val: string) => {
const upper = val.trim().toUpperCase();
const upper = normalizeTickerInput(val);
if (!upper) return;
setTickerState(upper);
localStorage.setItem(STORAGE_KEY, upper);
@@ -1,8 +1,10 @@
"use client";
import { useEffect, useState } from "react";
import { useTicker } from "../lib/use-ticker";
import { HeatmapSection } from "../components/markets/HeatmapSection";
type StatementType = "income_statement" | "balance_sheet" | "cash_flow";
type ViewTab = "overview" | "statements";
interface FinancialStatements {
income_statement?: Record<string, unknown>[];
@@ -10,6 +12,28 @@ interface FinancialStatements {
cash_flow?: Record<string, unknown>[];
}
interface OverviewItem {
name: string;
symbol: string;
price: number;
change_pct: number;
}
interface MarketOverviewResponse {
indices?: OverviewItem[];
commodities?: OverviewItem[];
bonds?: OverviewItem[];
crypto?: OverviewItem[];
fx?: OverviewItem[];
popular_etfs?: OverviewItem[];
}
interface SectorHeat {
sector: string;
etf: string;
change_pct: number;
}
const TABS: { key: StatementType; label: string }[] = [
{ key: "income_statement", label: "Income Statement" },
{ key: "balance_sheet", label: "Balance Sheet" },
@@ -107,6 +131,10 @@ export default function MarketsPage() {
const { ticker } = useTicker();
const [data, setData] = useState<FinancialStatements | null>(null);
const [tab, setTab] = useState<StatementType>("income_statement");
const [viewTab, setViewTab] = useState<ViewTab>("overview");
const [overview, setOverview] = useState<MarketOverviewResponse | null>(null);
const [sectors, setSectors] = useState<SectorHeat[]>([]);
const [selectedHeatmapIndex, setSelectedHeatmapIndex] = useState("sp500");
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -120,6 +148,17 @@ export default function MarketsPage() {
.catch(() => setLoading(false));
}, [ticker]);
useEffect(() => {
fetch("/api/market/overview")
.then((r) => (r.ok ? r.json() : null))
.then((d) => setOverview(d))
.catch(() => setOverview(null));
fetch("/api/market/sectors")
.then((r) => (r.ok ? r.json() : []))
.then((d) => setSectors(Array.isArray(d) ? d : []))
.catch(() => setSectors([]));
}, []);
if (loading)
return (
<div className="flex items-center justify-center h-64">
@@ -137,7 +176,7 @@ export default function MarketsPage() {
return vals.some(([, v]) => v != null);
})
.map((r) => ({
date: String(r.asOfDate || "").slice(0, 10),
date: String((r.asOfDate || r.period || "")).slice(0, 10),
periodType: String(r.periodType || ""),
data: r,
}))
@@ -223,16 +262,107 @@ export default function MarketsPage() {
const filteredRows = rowDefs.filter(rowHasData);
function renderOverviewCard(title: string, items: OverviewItem[] | undefined) {
const indexMap: Record<string, string> = {
"S&P 500": "sp500",
NASDAQ: "nasdaq100",
KOSPI: "kospi",
"FTSE 100": "ftse100",
};
return (
<div className="bg-bg-card border border-border rounded-lg p-4">
<h3 className="text-text-secondary text-sm font-semibold mb-3">{title}</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
{(items || []).map((item) => (
<div
key={`${title}-${item.symbol}`}
className="bg-bg-primary border border-border rounded-md p-3"
style={{ cursor: title === "Global Indices" ? "pointer" : "default" }}
onClick={() => {
if (title !== "Global Indices") return;
const target = indexMap[item.name];
if (target) {
setSelectedHeatmapIndex(target);
document.getElementById("heatmap-section")?.scrollIntoView({ behavior: "smooth" });
}
}}
>
<div className="text-text-muted text-xs">{item.name}</div>
<div className="text-text-primary font-mono font-semibold">{item.price?.toLocaleString?.() ?? "—"}</div>
<div className={`font-mono text-xs ${item.change_pct >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{item.change_pct >= 0 ? "+" : ""}
{item.change_pct?.toFixed?.(2)}%
</div>
</div>
))}
</div>
</div>
);
}
return (
<div>
<h1 className="text-2xl font-bold mb-4">
<span className="text-accent-green">{ticker}</span> Financial Statements
<span className="text-accent-green">{ticker}</span> Markets
</h1>
{/* Top Tabs */}
<div className="flex gap-1 mb-4 bg-bg-card rounded-lg p-1 w-fit">
{[
{ key: "overview", label: "Market Overview" },
{ key: "statements", label: "Financial Statements" },
].map((t) => (
<button
key={t.key}
onClick={() => setViewTab(t.key as ViewTab)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
viewTab === t.key ? "bg-accent-green text-bg-primary" : "text-text-secondary hover:text-text-primary"
}`}
>
{t.label}
</button>
))}
</div>
{viewTab === "overview" ? (
<div className="space-y-4">
{renderOverviewCard("Global Indices", overview?.indices)}
<HeatmapSection selectedIndex={selectedHeatmapIndex} onSelectIndex={setSelectedHeatmapIndex} />
<div className="bg-bg-card border border-border rounded-lg p-4">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Sector Performance</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
{sectors.map((sector) => (
<div
key={sector.etf}
className="rounded-md p-3 flex flex-col items-center justify-center border border-border"
style={{
background:
sector.change_pct >= 0
? `rgba(0, 212, 170, ${Math.min(Math.abs(sector.change_pct) / 5, 0.6)})`
: `rgba(255, 71, 87, ${Math.min(Math.abs(sector.change_pct) / 5, 0.6)})`,
}}
>
<span className="text-xs font-semibold text-text-primary">{sector.sector}</span>
<span className={`text-sm font-mono font-bold ${sector.change_pct >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{sector.change_pct >= 0 ? "+" : ""}
{sector.change_pct}%
</span>
</div>
))}
</div>
</div>
{renderOverviewCard("Commodities", overview?.commodities)}
{renderOverviewCard("Popular ETFs", overview?.popular_etfs)}
{renderOverviewCard("Bonds", overview?.bonds)}
{renderOverviewCard("Crypto", overview?.crypto)}
{renderOverviewCard("FX", overview?.fx)}
</div>
) : (
<>
{/* Unit Note */}
<div className="text-text-muted text-xs mb-3 font-mono">Unit: Millions USD (except per-share data)</div>
{/* Tabs */}
{/* Statement Tabs */}
<div className="flex gap-1 mb-4 bg-bg-card rounded-lg p-1 w-fit">
{TABS.map((t) => (
<button
@@ -259,14 +389,14 @@ export default function MarketsPage() {
</th>
{periods.map((p, i) => (
<th key={i} className="text-right px-4 py-3 text-text-muted font-semibold whitespace-nowrap min-w-[110px]">
<div className="text-text-secondary">{p.date.slice(0, 4)}</div>
<div className="text-text-muted text-[10px]">{p.date}</div>
<div className="text-text-secondary">{p.date && p.date !== "—" ? p.date.slice(0, 4) : `P${i + 1}`}</div>
<div className="text-text-muted text-[10px]">{p.date || "—"}</div>
</th>
))}
</tr>
</thead>
<tbody>
{filteredRows.map((rowDef, ri) => {
{filteredRows.map((rowDef) => {
const isGrowth = rowDef.isGrowth;
return (
<tr
@@ -329,6 +459,8 @@ export default function MarketsPage() {
No financial data available for {ticker}
</div>
)}
</>
)}
</div>
);
}
+18 -109
View File
@@ -1,29 +1,16 @@
"use client";
import { useEffect, useState } from "react";
import { useTicker } from "./lib/use-ticker";
interface HealthData {
dupont?: { roe?: number; npm?: number; asset_turnover?: number; equity_multiplier?: number };
altman_z?: number;
red_flags?: string[];
}
interface SectorData {
sector?: string;
industry?: string;
market_cap?: number;
pe_ratio?: number;
dividend_yield?: number;
beta?: number;
fifty_two_week_high?: number;
fifty_two_week_low?: number;
current_price?: number;
}
import { EquityOverview } from "./components/overview/EquityOverview";
import { ETFOverview } from "./components/overview/ETFOverview";
import { CommodityOverview } from "./components/overview/CommodityOverview";
export default function OverviewPage() {
const { ticker } = useTicker();
const [sector, setSector] = useState<SectorData | null>(null);
const [health, setHealth] = useState<HealthData | null>(null);
const [sector, setSector] = useState<Record<string, unknown> | null>(null);
const [health, setHealth] = useState<Record<string, unknown> | null>(null);
const [overview, setOverview] = useState<Record<string, unknown> | null>(null);
const [assetType, setAssetType] = useState<string>("equity");
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -31,103 +18,25 @@ export default function OverviewPage() {
Promise.all([
fetch(`/api/market/sector/${ticker}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/market/health/${ticker}`).then((r) => r.ok ? r.json() : null),
]).then(([s, h]) => {
fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null),
]).then(([s, h, d]) => {
setSector(s);
setHealth(h);
setAssetType(d?.asset_type || "equity");
setOverview(d?.data || null);
setLoading(false);
}).catch(() => setLoading(false));
}, [ticker]);
if (loading) return <LoadingState />;
const metrics = [
{ label: "Sector", value: sector?.sector || "—" },
{ label: "Industry", value: sector?.industry || "—" },
{ label: "Market Cap", value: sector?.market_cap ? `$${(sector.market_cap / 1e9).toFixed(1)}B` : "—" },
{ label: "P/E Ratio", value: sector?.pe_ratio?.toFixed(1) || "—" },
{ label: "Beta", value: sector?.beta?.toFixed(2) || "—" },
{ label: "Div Yield", value: sector?.dividend_yield ? `${sector.dividend_yield.toFixed(2)}%` : "—" },
{ label: "52W High", value: sector?.fifty_two_week_high ? `$${sector.fifty_two_week_high.toFixed(2)}` : "—" },
{ label: "52W Low", value: sector?.fifty_two_week_low ? `$${sector.fifty_two_week_low.toFixed(2)}` : "—" },
];
const zScore = health?.altman_z;
const zColor = zScore && zScore > 2.99 ? "text-accent-green" : zScore && zScore > 1.81 ? "text-accent-yellow" : "text-accent-red";
return (
<div>
<h1 className="text-2xl font-bold mb-1">
<span className="text-accent-green">{ticker}</span> Overview
</h1>
{sector?.current_price && (
<p className="text-3xl font-mono font-bold text-text-primary mb-6">
${sector.current_price.toFixed(2)}
</p>
)}
{/* Key Metrics Grid */}
<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>
{/* Health Section */}
<div className="grid grid-cols-2 gap-4">
{/* Altman Z-Score */}
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Altman Z-Score</h3>
<div className={`text-4xl font-mono font-bold ${zColor}`}>
{zScore?.toFixed(2) || "—"}
</div>
<div className="text-text-muted text-xs mt-2">
{zScore && zScore > 2.99 ? "Safe Zone" : zScore && zScore > 1.81 ? "Grey Zone" : "Distress Zone"}
</div>
</div>
{/* DuPont Analysis */}
<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 items-center">
<span className="text-text-muted text-sm">{d.label}</span>
<span className="text-text-primary font-mono font-semibold">
{d.value?.toFixed(2) || "—"}
</span>
</div>
))}
</div>
) : (
<div className="text-text-muted">No data</div>
)}
</div>
</div>
{/* Red Flags */}
{health?.red_flags && health.red_flags.length > 0 && (
<div className="mt-4 bg-bg-card border border-accent-red/30 rounded-lg p-5">
<h3 className="text-accent-red text-sm font-semibold mb-3">Red Flags</h3>
<ul className="space-y-1.5">
{health.red_flags.map((f, i) => (
<li key={i} className="text-text-secondary text-sm flex items-start gap-2">
<span className="text-accent-red mt-0.5"></span> {f}
</li>
))}
</ul>
</div>
)}
</div>
);
if (assetType === "etf") {
return <ETFOverview ticker={ticker} data={overview || {}} />;
}
if (assetType === "commodity_future") {
return <CommodityOverview ticker={ticker} data={overview || {}} />;
}
return <EquityOverview ticker={ticker} sector={sector} health={health} />;
}
function LoadingState() {
@@ -11,17 +11,62 @@ interface Position {
market_value?: number;
pnl?: number;
pnl_pct?: number;
confidence?: string;
method?: string;
avg_price_currency?: string;
stock_currency?: string;
account_currency?: string;
current_value_account?: number;
total_pnl?: number;
yf_ticker?: string;
avg_method?: string;
exchange?: string;
}
export default function PortfolioPage() {
const [positions, setPositions] = useState<Position[]>([]);
const [form, setForm] = useState({ ticker: "", quantity: "", avg_price: "" });
const [loading, setLoading] = useState(true);
const [isDragging, setIsDragging] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [ocrPositions, setOcrPositions] = useState<Position[]>([]);
const [ocrError, setOcrError] = useState<string>("");
const [ocrWarnings, setOcrWarnings] = useState<string[]>([]);
const [ocrAccountCurrency, setOcrAccountCurrency] = useState<string>("USD");
const [editingId, setEditingId] = useState<string | null>(null);
const [editValues, setEditValues] = useState({ qty: "", avgPrice: "" });
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [displayCurrency, setDisplayCurrency] = useState("USD");
const [fxRates, setFxRates] = useState<Record<string, number>>({});
const [exchangeSelections, setExchangeSelections] = useState<Record<string, string>>({});
const [exchangeOptions, setExchangeOptions] = useState<Record<string, { exchange: string; yf_ticker: string; currency: string; default?: boolean }[]>>({});
useEffect(() => {
fetchPortfolio();
fetch("/api/fx/rates")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (!data) return;
setFxRates(data.rates || data);
})
.catch(() => {});
}, []);
useEffect(() => {
if (!ocrPositions.length) return;
ocrPositions.forEach(async (pos, idx) => {
const rowKey = `${pos.ticker}-${idx}`;
const res = await fetch(`/api/portfolio/exchange-options/${pos.ticker}`);
const data = await res.json().catch(() => null);
const opts = Array.isArray(data?.options) ? data.options : [];
if (opts.length > 0) {
setExchangeOptions((prev) => ({ ...prev, [rowKey]: opts }));
const def = opts.find((o) => o.default) || opts[0];
setExchangeSelections((prev) => ({ ...prev, [rowKey]: def.exchange }));
}
});
}, [ocrPositions]);
async function fetchPortfolio() {
setLoading(true);
try {
@@ -64,28 +109,209 @@ export default function PortfolioPage() {
}
}
const totalValue = positions.reduce((s, p) => s + (p.market_value || p.quantity * (p.current_price || p.avg_price)), 0);
const totalCost = positions.reduce((s, p) => s + p.quantity * p.avg_price, 0);
async function uploadForOcr(file: File) {
setIsProcessing(true);
setOcrError("");
try {
const geminiKey = localStorage.getItem("atlas_gemini_key") || "";
if (!geminiKey.trim()) {
setOcrPositions([]);
setOcrError("Gemini API 키가 없습니다. Settings에서 Gemini API Key를 먼저 저장하세요.");
return;
}
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/portfolio/ocr", {
method: "POST",
body: fd,
headers: geminiKey ? { "x-gemini-api-key": geminiKey } : undefined,
});
const data = await res.json().catch(() => null);
const message =
data?.error ||
data?.detail ||
(Array.isArray(data?.detail) ? data.detail.map((d: { msg?: string }) => d?.msg).filter(Boolean).join(", ") : "") ||
(res.ok ? "" : `HTTP ${res.status}`);
if (!res.ok || data?.error) {
setOcrPositions([]);
setOcrError(message || "OCR failed");
} else {
setOcrPositions(Array.isArray(data?.positions) ? data.positions : []);
setOcrWarnings(Array.isArray(data?.warnings) ? data.warnings : []);
setOcrAccountCurrency(data?.account_currency || data?.total_value?.currency || "USD");
if (!Array.isArray(data?.positions) || data.positions.length === 0) {
setOcrError("OCR은 완료됐지만 포지션을 찾지 못했습니다. 표가 선명하게 보이는 스크린샷으로 다시 시도하세요.");
}
}
} catch {
setOcrPositions([]);
setOcrError("Network error during OCR upload");
} finally {
setIsProcessing(false);
}
}
async function importOcrPositions() {
if (!ocrPositions.length) return;
const failed: string[] = [];
for (let idx = 0; idx < ocrPositions.length; idx += 1) {
const p = ocrPositions[idx];
try {
const rowKey = `${p.ticker}-${idx}`;
const res = await fetch("/api/portfolio/positions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ticker: p.ticker,
company_name: p.company_name || "",
quantity: p.quantity,
avg_price: p.avg_price,
currency: p.avg_price_currency || p.stock_currency || "USD",
exchange: exchangeSelections[rowKey] || p.exchange || "",
source: "ocr",
}),
});
if (!res.ok) failed.push(p.ticker);
} catch {
failed.push(p.ticker);
}
}
if (failed.length > 0) {
setOcrError(`일부 저장 실패: ${failed.join(", ")}. Import Results를 유지합니다.`);
return;
}
setOcrPositions([]);
setOcrWarnings([]);
setExchangeSelections({});
setExchangeOptions({});
setOcrError("");
await fetchPortfolio();
}
function updateOcrPosition(idx: number, key: "quantity" | "avg_price", value: string) {
setOcrPositions((prev) =>
prev.map((p, i) => {
if (i !== idx) return p;
const n = Number(value);
if (Number.isNaN(n)) return p;
return { ...p, [key]: n };
}),
);
}
async function handleExchangeChange(idx: number, exchange: string) {
const pos = ocrPositions[idx];
if (!pos) return;
const rowKey = `${pos.ticker}-${idx}`;
setExchangeSelections((prev) => ({ ...prev, [rowKey]: exchange }));
const res = await fetch("/api/portfolio/ocr/recalculate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
account_currency: ocrAccountCurrency,
position: pos,
selected_exchange: exchange,
}),
});
const data = await res.json().catch(() => null);
if (res.ok && data?.position) {
setOcrPositions((prev) => prev.map((p, i) => (i === idx ? { ...p, ...data.position, exchange } : p)));
}
}
async function handleDelete(positionId: string) {
try {
const res = await fetch(`/api/portfolio/positions/${positionId}`, { method: "DELETE" });
if (res.ok) {
setPositions((prev) => prev.filter((p) => p.id !== positionId));
}
} finally {
setDeleteConfirmId(null);
}
}
async function handleSaveEdit(positionId: string) {
const qty = parseFloat(editValues.qty);
const avgPrice = parseFloat(editValues.avgPrice);
if (isNaN(qty) || isNaN(avgPrice)) return;
const res = await fetch(`/api/portfolio/positions/${positionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quantity: qty, avg_price: avgPrice }),
});
if (res.ok) {
setPositions((prev) =>
prev.map((p) => (p.id === positionId ? { ...p, quantity: qty, avg_price: avgPrice } : p)),
);
setEditingId(null);
await fetchPortfolio();
}
}
function convertAmount(amount: number, fromCurrency: string, toCurrency: string): number {
if (!amount || Number.isNaN(amount)) return 0;
const from = (fromCurrency || "USD").toUpperCase();
const to = (toCurrency || "USD").toUpperCase();
if (from === to) return amount;
const key = `${from}_${to}`;
const rate = fxRates[key] || 1;
return amount * rate;
}
function getCurrencySymbol(currency: string): string {
const symbols: Record<string, string> = { USD: "$", GBP: "£", KRW: "₩", EUR: "€", JPY: "¥", CNY: "¥" };
return symbols[currency] || currency;
}
function formatCurrencyValue(amount: number, currency: string): string {
const symbol = getCurrencySymbol(currency);
if (currency === "KRW" || currency === "JPY") return `${symbol}${Math.round(amount).toLocaleString()}`;
return `${symbol}${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
const totalValue = positions.reduce((s, p) => {
const valueRaw = p.market_value || p.quantity * (p.current_price || p.avg_price);
const src = (p.stock_currency || p.currency || "USD").toUpperCase();
return s + convertAmount(valueRaw, src, displayCurrency);
}, 0);
const totalCost = positions.reduce((s, p) => {
const src = (p.currency || p.avg_price_currency || p.stock_currency || "USD").toUpperCase();
return s + convertAmount(p.quantity * p.avg_price, src, displayCurrency);
}, 0);
const totalGL = totalValue - totalCost;
return (
<div>
<h1 className="text-2xl font-bold mb-6">Portfolio</h1>
<div className="portfolio-header">
<h1 className="text-2xl font-bold">Portfolio</h1>
<div className="currency-toggle">
{["USD", "GBP", "KRW", "EUR", "JPY"].map((cur) => (
<button
key={cur}
className={`currency-btn ${displayCurrency === cur ? "active" : ""}`}
onClick={() => setDisplayCurrency(cur)}
>
{getCurrencySymbol(cur)} {cur}
</button>
))}
</div>
</div>
{/* Summary */}
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">Total Value</div>
<div className="text-text-primary font-mono font-bold text-xl">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
<div className="text-text-primary font-mono font-bold text-xl">{formatCurrencyValue(totalValue, displayCurrency)}</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">Total Cost</div>
<div className="text-text-primary font-mono font-bold text-xl">${totalCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
<div className="text-text-primary font-mono font-bold text-xl">{formatCurrencyValue(totalCost, displayCurrency)}</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">Total P&L</div>
<div className={`font-mono font-bold text-xl ${totalGL >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{totalGL >= 0 ? "+" : ""}${totalGL.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{totalGL >= 0 ? "+" : ""}{formatCurrencyValue(Math.abs(totalGL), displayCurrency)}
</div>
</div>
</div>
@@ -120,36 +346,248 @@ export default function PortfolioPage() {
</div>
</div>
{/* OCR Screenshot Import */}
<div className="bg-bg-card border border-border rounded-lg p-4 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Import from Screenshot (OCR)</h3>
<div
className={`border-2 border-dashed ${isDragging ? "border-accent-blue bg-accent-blue/5" : "border-border"} rounded-lg p-10 text-center cursor-pointer mb-4`}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const f = e.dataTransfer.files?.[0];
if (f) uploadForOcr(f);
}}
onClick={() => document.getElementById("ocr-file-input")?.click()}
>
{isProcessing ? (
<p className="text-accent-green animate-pulse font-mono">AI가 ...</p>
) : (
<>
<span className="text-3xl mb-3 block">📸</span>
<p className="text-text-secondary text-sm">Trading 212 / IBKR </p>
</>
)}
</div>
<input
id="ocr-file-input"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadForOcr(f);
}}
/>
{ocrError && <p className="text-accent-red text-sm mb-3">{ocrError}</p>}
{ocrPositions.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-text-primary text-sm">
Import Results: {ocrPositions.length} positions
</div>
<div className="text-text-muted text-xs">
Account Currency: <span className="font-mono text-text-primary">{ocrAccountCurrency}</span>
</div>
</div>
{ocrWarnings.length > 0 && (
<details className="mb-3 bg-accent-yellow/10 border border-accent-yellow/30 rounded-md p-3">
<summary className="text-accent-yellow text-sm cursor-pointer">Warnings ({ocrWarnings.length})</summary>
<ul className="mt-2 space-y-1 text-xs text-text-secondary">
{ocrWarnings.map((w, i) => <li key={i}>- {w}</li>)}
</ul>
</details>
)}
<div className="overflow-auto border border-border rounded-md mb-3">
<table className="w-full text-xs ocr-result-table">
<thead>
<tr className="border-b border-border">
<th className="text-left px-3 py-2 text-text-muted">Ticker</th>
<th className="text-left px-3 py-2 text-text-muted">Exchange</th>
<th className="text-left px-3 py-2 text-text-muted">Qty</th>
<th className="text-left px-3 py-2 text-text-muted">Avg</th>
<th className="text-left px-3 py-2 text-text-muted">Now</th>
<th className="text-left px-3 py-2 text-text-muted">Value ({ocrAccountCurrency})</th>
<th className="text-left px-3 py-2 text-text-muted">P&L %</th>
<th className="text-left px-3 py-2 text-text-muted">Conf</th>
</tr>
</thead>
<tbody>
{ocrPositions.map((p, i) => (
<tr key={`${p.ticker}-${i}`} className="border-b border-border/40">
{(() => {
const rowKey = `${p.ticker}-${i}`;
const opts = exchangeOptions[rowKey] || [];
const hasMultiple = opts.length > 1;
return (
<>
<td className="px-3 py-2 text-accent-green font-mono">{p.ticker}</td>
<td className="px-3 py-2 text-text-primary font-mono">
{hasMultiple ? (
<select
className="bg-bg-primary border border-border rounded px-2 py-1 text-xs"
value={exchangeSelections[rowKey] || ""}
onChange={(e) => handleExchangeChange(i, e.target.value)}
>
{opts.map((o) => (
<option key={o.exchange} value={o.exchange}>
{o.exchange} ({o.currency})
</option>
))}
</select>
) : (
<span className="text-text-muted text-xs">{(opts[0]?.exchange || p.exchange || p.stock_currency || "Default")}</span>
)}
</td>
<td className="px-3 py-2 text-text-primary font-mono">
<input
type="number"
value={p.quantity ?? 0}
onChange={(e) => updateOcrPosition(i, "quantity", e.target.value)}
className="w-28 bg-bg-primary border border-border rounded px-2 py-1"
/>
</td>
<td className="px-3 py-2 text-text-primary font-mono">
<input
type="number"
value={p.avg_price ?? 0}
onChange={(e) => updateOcrPosition(i, "avg_price", e.target.value)}
className="w-24 bg-bg-primary border border-border rounded px-2 py-1"
/>
<span className="ml-1 text-text-muted">{p.avg_price_currency || p.stock_currency || "USD"}</span>
</td>
<td className="px-3 py-2 text-text-primary font-mono">
{p.current_price != null ? `${p.current_price.toFixed(2)} ${p.stock_currency || ""}` : "—"}
</td>
<td className="px-3 py-2 text-text-primary font-mono">
{p.current_value_account != null ? p.current_value_account.toLocaleString() : "—"}
</td>
<td className={`px-3 py-2 font-mono ${(p.pnl_pct || 0) >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{p.pnl_pct != null ? `${p.pnl_pct >= 0 ? "+" : ""}${p.pnl_pct.toFixed(2)}%` : "—"}
</td>
<td className="px-3 py-2">
{p.confidence === "high" ? "✅" : p.confidence === "medium" ? "⚠️" : "❌"}
</td>
</>
);
})()}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => { setOcrPositions([]); setOcrWarnings([]); setOcrError(""); }}
className="px-5 py-2 rounded-md border border-border text-text-secondary"
>
Cancel
</button>
<button onClick={importOcrPositions} className="bg-accent-green text-bg-primary px-5 py-2 rounded-md font-semibold hover:opacity-90 transition-opacity">
Add All to Portfolio
</button>
</div>
</div>
)}
</div>
{/* Positions Table */}
{positions.length > 0 ? (
<div className="bg-bg-card border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
{["Ticker", "Qty", "Avg Price", "Price", "Value", "P&L", "P&L %"].map((h) => (
{["Ticker", "Exchange", "Qty", "Avg Price", "Price", "Value", "P&L", "P&L %", "Actions"].map((h) => (
<th key={h} className="text-left px-4 py-3 text-text-muted font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{positions.map((p, i) => {
const pid = p.id || `${p.ticker}-${i}`;
const price = p.current_price || p.avg_price;
const value = p.market_value || p.quantity * price;
const gl = p.pnl ?? (value - p.quantity * p.avg_price);
const valueRaw = p.market_value || p.quantity * price;
const srcValueCurrency = (p.stock_currency || p.currency || "USD").toUpperCase();
const value = convertAmount(valueRaw, srcValueCurrency, displayCurrency);
const costRaw = p.quantity * p.avg_price;
const srcCostCurrency = (p.currency || p.avg_price_currency || p.stock_currency || "USD").toUpperCase();
const cost = convertAmount(costRaw, srcCostCurrency, displayCurrency);
const gl = p.pnl != null ? convertAmount(p.pnl, srcValueCurrency, displayCurrency) : (value - cost);
const glPct = p.pnl_pct ?? ((price / p.avg_price - 1) * 100);
return (
<tr key={p.id || `${p.ticker}-${i}`} className="border-b border-border/50 hover:bg-bg-hover/30">
<tr key={pid} className="border-b border-border/50 hover:bg-bg-hover/30">
<td className="px-4 py-2.5 font-mono font-semibold text-accent-green">{p.ticker}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">{p.quantity}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">${p.avg_price.toFixed(2)}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">${price.toFixed(2)}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">${value.toLocaleString(undefined, { minimumFractionDigits: 2 })}</td>
<td className="px-4 py-2.5 font-mono text-text-muted">{p.exchange || "—"}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">
{editingId === pid ? (
<input
type="number"
step="any"
value={editValues.qty}
onChange={(e) => setEditValues((prev) => ({ ...prev, qty: e.target.value }))}
className="w-28 bg-bg-primary border border-accent-blue rounded px-2 py-1"
autoFocus
/>
) : (
p.quantity
)}
</td>
<td className="px-4 py-2.5 font-mono text-text-primary">
{editingId === pid ? (
<input
type="number"
step="0.01"
value={editValues.avgPrice}
onChange={(e) => setEditValues((prev) => ({ ...prev, avgPrice: e.target.value }))}
className="w-24 bg-bg-primary border border-accent-blue rounded px-2 py-1"
/>
) : (
<>${p.avg_price.toFixed(2)}</>
)}
</td>
<td className="px-4 py-2.5 font-mono text-text-primary">{formatCurrencyValue(convertAmount(price, srcValueCurrency, displayCurrency), displayCurrency)}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">{formatCurrencyValue(value, displayCurrency)}</td>
<td className={`px-4 py-2.5 font-mono ${gl >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{gl >= 0 ? "+" : ""}${gl.toFixed(2)}
{gl >= 0 ? "+" : ""}{formatCurrencyValue(Math.abs(gl), displayCurrency)}
</td>
<td className={`px-4 py-2.5 font-mono ${glPct >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{glPct >= 0 ? "+" : ""}{glPct.toFixed(1)}%
</td>
<td className="px-4 py-2.5 text-right">
{editingId === pid ? (
<div className="flex gap-1 justify-end">
<button onClick={() => handleSaveEdit(pid)} className="w-8 h-8 rounded bg-accent-green/20 hover:bg-accent-green/30"></button>
<button onClick={() => setEditingId(null)} className="w-8 h-8 rounded bg-bg-primary hover:bg-bg-hover"></button>
</div>
) : (
<div className="flex gap-1 justify-end">
<button
onClick={() => {
setEditingId(pid);
setEditValues({ qty: String(p.quantity), avgPrice: String(p.avg_price) });
}}
className="w-8 h-8 rounded hover:bg-bg-hover"
title="Edit position"
>
</button>
<button
onClick={() => setDeleteConfirmId(pid)}
className="w-8 h-8 rounded hover:bg-accent-red/20"
title="Delete position"
>
🗑
</button>
</div>
)}
</td>
</tr>
);
})}
@@ -161,6 +599,19 @@ export default function PortfolioPage() {
No positions yet. Add your first position above.
</div>
) : null}
{deleteConfirmId && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[1000]" onClick={() => setDeleteConfirmId(null)}>
<div className="bg-bg-card border border-border rounded-xl p-6 max-w-sm w-full" onClick={(e) => e.stopPropagation()}>
<p className="text-text-primary mb-1"> ?</p>
<p className="text-text-muted text-sm mb-4"> .</p>
<div className="flex justify-end gap-2">
<button onClick={() => setDeleteConfirmId(null)} className="px-4 py-2 border border-border rounded-md text-text-secondary">Cancel</button>
<button onClick={() => handleDelete(deleteConfirmId)} className="px-4 py-2 bg-accent-red text-white rounded-md">Delete</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -17,6 +17,7 @@ interface RadarData {
export default function ResearchPage() {
const { ticker } = useTicker();
const [assetType, setAssetType] = useState<string>("equity");
const [piotroski, setPiotroski] = useState<PiotroskiData | null>(null);
const [radar, setRadar] = useState<RadarData | null>(null);
const [aiAnalysis, setAiAnalysis] = useState<string>("");
@@ -28,9 +29,11 @@ export default function ResearchPage() {
Promise.all([
fetch(`/api/market/piotroski/${ticker}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/market/radar/${ticker}`).then((r) => r.ok ? r.json() : null),
]).then(([p, r]) => {
fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null),
]).then(([p, r, o]) => {
setPiotroski(p);
setRadar(r);
setAssetType(o?.asset_type || "equity");
setLoading(false);
}).catch(() => setLoading(false));
}, [ticker]);
@@ -79,6 +82,7 @@ export default function ResearchPage() {
<span className="text-accent-green">{ticker}</span> Research
</h1>
{assetType === "equity" ? (
<div className="grid grid-cols-2 gap-4 mb-6">
{/* Piotroski F-Score */}
<div className="bg-bg-card border border-border rounded-lg p-5">
@@ -127,6 +131,23 @@ export default function ResearchPage() {
)}
</div>
</div>
) : assetType === "etf" ? (
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-3">ETF Research</h3>
<div className="text-text-secondary text-sm">
Holdings Analysis, Sector Breakdown, Overlap Analysis를 .
Piotroski/F-Score ETF에 .
</div>
</div>
) : (
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Commodity Research</h3>
<div className="text-text-secondary text-sm">
Seasonal Analysis와 Supply/Demand .
(F-Score, DuPont) .
</div>
</div>
)}
{/* AI Analysis */}
<div className="bg-bg-card border border-border rounded-lg p-5">
@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
interface ScreenerRow {
ticker: string;
name?: string;
sector?: string;
market_cap?: number;
pe?: number;
div_yield?: number;
price?: number;
change_pct?: number;
}
interface BacktestResult {
error?: string;
total_return_pct?: number;
benchmark_return_pct?: number;
alpha?: number;
sharpe_ratio?: number;
}
export default function ScreenerPage() {
const [tab, setTab] = useState<"screener" | "backtest">("screener");
const [rows, setRows] = useState<ScreenerRow[]>([]);
const [loading, setLoading] = useState(false);
const [peMax, setPeMax] = useState("25");
const [sector, setSector] = useState("");
const [divMin, setDivMin] = useState("");
const [btTicker, setBtTicker] = useState("AAPL");
const [strategy, setStrategy] = useState("sma_crossover");
const [startDate, setStartDate] = useState("2024-01-01");
const [endDate, setEndDate] = useState("2026-03-01");
const [btResult, setBtResult] = useState<BacktestResult | null>(null);
const [btLoading, setBtLoading] = useState(false);
async function runScreener() {
setLoading(true);
try {
const res = await fetch("/api/screener/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
pe_max: peMax ? parseFloat(peMax) : undefined,
sector: sector || undefined,
div_yield_min: divMin ? parseFloat(divMin) : undefined,
}),
});
const data = await res.json();
setRows(Array.isArray(data) ? data : []);
} catch {
setRows([]);
} finally {
setLoading(false);
}
}
async function runBacktest() {
setBtLoading(true);
try {
const res = await fetch("/api/screener/backtest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ticker: btTicker,
strategy,
start_date: startDate,
end_date: endDate,
}),
});
setBtResult(await res.json());
} catch {
setBtResult(null);
} finally {
setBtLoading(false);
}
}
return (
<div>
<h1 className="text-2xl font-bold mb-4">Stock Screener</h1>
<div className="flex gap-1 mb-4 bg-bg-card rounded-lg p-1 w-fit">
<button
onClick={() => setTab("screener")}
className={`px-4 py-2 rounded-md text-sm font-medium ${tab === "screener" ? "bg-accent-green text-bg-primary" : "text-text-secondary"}`}
>
Screener
</button>
<button
onClick={() => setTab("backtest")}
className={`px-4 py-2 rounded-md text-sm font-medium ${tab === "backtest" ? "bg-accent-green text-bg-primary" : "text-text-secondary"}`}
>
Backtest
</button>
</div>
{tab === "screener" ? (
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="flex flex-wrap gap-2 mb-3">
<input value={peMax} onChange={(e) => setPeMax(e.target.value)} placeholder="P/E < 25" className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
<input value={sector} onChange={(e) => setSector(e.target.value)} placeholder="Sector (optional)" className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
<input value={divMin} onChange={(e) => setDivMin(e.target.value)} placeholder="Div Yield > %" className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
<button onClick={runScreener} className="bg-accent-green text-bg-primary px-4 py-2 rounded font-semibold">
{loading ? "Running..." : "Run Screener"}
</button>
</div>
<div className="overflow-auto border border-border rounded-md">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
{["Ticker", "Name", "Sector", "Price", "P/E", "MCap", "Change"].map((h) => (
<th key={h} className="text-left px-3 py-2 text-text-muted">{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={`${r.ticker}-${i}`} className="border-b border-border/40">
<td className="px-3 py-2 text-accent-green font-mono">{r.ticker}</td>
<td className="px-3 py-2 text-text-primary">{r.name || "—"}</td>
<td className="px-3 py-2 text-text-secondary">{r.sector || "—"}</td>
<td className="px-3 py-2 text-text-primary font-mono">{r.price != null ? `$${r.price.toFixed(2)}` : "—"}</td>
<td className="px-3 py-2 text-text-primary font-mono">{r.pe != null ? r.pe.toFixed(1) : "—"}</td>
<td className="px-3 py-2 text-text-primary font-mono">{r.market_cap ? `${(r.market_cap / 1e9).toFixed(1)}B` : "—"}</td>
<td className={`px-3 py-2 font-mono ${((r.change_pct || 0) >= 0) ? "text-accent-green" : "text-accent-red"}`}>
{r.change_pct != null ? `${r.change_pct >= 0 ? "+" : ""}${r.change_pct.toFixed(2)}%` : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="flex flex-wrap gap-2 mb-3">
<input value={btTicker} onChange={(e) => setBtTicker(e.target.value.toUpperCase())} placeholder="Ticker" className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
<select value={strategy} onChange={(e) => setStrategy(e.target.value)} className="bg-bg-primary border border-border rounded px-3 py-2 text-sm">
<option value="sma_crossover">SMA Crossover</option>
<option value="rsi_oversold">RSI Oversold</option>
<option value="buy_and_hold">Buy & Hold</option>
</select>
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
<button onClick={runBacktest} className="bg-accent-green text-bg-primary px-4 py-2 rounded font-semibold">
{btLoading ? "Running..." : "Run Backtest"}
</button>
</div>
{btResult && !btResult.error && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Metric label="Return" value={`${btResult.total_return_pct}%`} />
<Metric label="Benchmark" value={`${btResult.benchmark_return_pct}%`} />
<Metric label="Alpha" value={`${btResult.alpha}%`} />
<Metric label="Sharpe" value={`${btResult.sharpe_ratio}`} />
</div>
)}
{btResult?.error && <p className="text-accent-red text-sm">{btResult.error}</p>}
</div>
)}
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="bg-bg-primary border border-border rounded-md p-3">
<div className="text-text-muted text-xs">{label}</div>
<div className="text-text-primary font-mono font-semibold">{value}</div>
</div>
);
}
@@ -51,6 +51,7 @@ type ValuationTab = "dcf" | "sensitivity" | "montecarlo" | "tornado" | "reverse"
export default function ValuationPage() {
const { ticker } = useTicker();
const [assetType, setAssetType] = useState<string>("equity");
const [inputs, setInputs] = useState<DCFInputs | null>(null);
const [consensus, setConsensus] = useState<Consensus | null>(null);
const [dcfResult, setDcfResult] = useState<DCFResult | null>(null);
@@ -79,9 +80,11 @@ export default function ValuationPage() {
fetch(`/api/valuation/dcf-inputs/${ticker}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/valuation/consensus/${ticker}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/valuation/smart-defaults/${ticker}`).then((r) => r.ok ? r.json() : null),
]).then(([i, c, d]) => {
fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null),
]).then(([i, c, d, o]) => {
setInputs(i);
setConsensus(c);
setAssetType(o?.asset_type || "equity");
if (d?.wacc) setWacc(d.wacc);
if (d?.terminal_growth) setTerminalGrowth(d.terminal_growth);
if (d?.fcf_growth) setFcfGrowth(d.fcf_growth);
@@ -169,6 +172,26 @@ export default function ValuationPage() {
if (loading) return <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
if (assetType !== "equity") {
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<span className="text-accent-green">{ticker}</span> Valuation
</h1>
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">
{assetType === "etf" ? "ETF Valuation Mode" : "Commodity Valuation Mode"}
</h3>
<div className="text-text-secondary text-sm">
{assetType === "etf"
? "NAV Premium/Discount, Expense 비교, Tracking Error 중심으로 평가합니다. DCF는 주식(EQUITY) 전용입니다."
: "Futures Curve(Contango/Backwardation), Cost of Carry 중심으로 평가합니다. DCF는 주식(EQUITY) 전용입니다."}
</div>
</div>
</div>
);
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">