mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-22 23:28: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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user