mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-08 08:17:45 +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:
@@ -25,3 +25,10 @@ npm run dev
|
||||
- **Frontend:** Next.js 14, TypeScript, Tailwind CSS, TradingView Lightweight Charts
|
||||
- **Backend:** FastAPI, Python 3.12+, yfinance, yahooquery, Google Gemini
|
||||
- **Database:** SQLite (local) / PostgreSQL (production)
|
||||
|
||||
## Recent Updates
|
||||
|
||||
- Added multi-asset analysis branching across Overview/Research/Valuation/Earnings for equity, ETF, and commodity futures.
|
||||
- Implemented commodity and ETF market widgets, plus index-level stock heatmap with interactive index switching.
|
||||
- Upgraded portfolio OCR with reverse-engineering logic, exchange selection (including SMSN -> `SMSN.L`), and inline edit/delete flows.
|
||||
- Added portfolio exchange-aware recalculation and FX conversion matrix support for multi-currency display.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
# claude.md — ATLAS Terminal Build Guide
|
||||
|
||||
> 이 문서는 Claude Code 에이전트가 프로젝트를 빌드할 때 참조하는 마스터 가이드입니다.
|
||||
> 모든 에이전트는 작업 시작 전 이 문서를 반드시 읽어야 합니다.
|
||||
> **마지막 수정: 2026-03-21**
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Identity
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **이름** | ATLAS Terminal (Advanced Terminal for Liquid Asset Surveillance) |
|
||||
| **목적** | 개인 투자자에게 기관급 투자 리서치를 단일 인터페이스로 제공 |
|
||||
| **핵심 철학** | 금융 정보 비대칭 해소 — 기술로 리테일 투자자의 무기를 평등하게 |
|
||||
| **디자인** | Bloomberg Terminal의 정보 밀도 + Notion의 깔끔함 + 다크 모드 |
|
||||
| **스택** | Next.js 14 (App Router) + FastAPI + SQLite/PostgreSQL + Gemini API |
|
||||
| **프로젝트 루트** | `/Users/seonpil/Library/Mobile Documents/com~apple~CloudDocs/Documents/FQDC Project/atlas-terminal/` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture Principles (절대 규칙)
|
||||
|
||||
### 2.1 하이브리드 분리 원칙
|
||||
```
|
||||
정성 데이터 (Qualitative) → LLM (Gemini) → 텍스트 분석만
|
||||
정량 데이터 (Quantitative) → Pandas + yfinance/yahooquery → 숫자/계산만
|
||||
```
|
||||
- **절대 LLM에 숫자 계산을 맡기지 않는다** — LLM은 텍스트 분석, 번역, 요약만 담당
|
||||
- 모든 재무 지표(DCF, DuPont, Altman Z, F-Score)는 Python 코드로 계산
|
||||
- 이 원칙을 위반하면 비용 폭발 + 정확도 하락
|
||||
|
||||
### 2.2 토큰 최적화 (비용 통제)
|
||||
- 10-K 원문을 LLM에 보내기 전 반드시 `sec_parser.py`로 Item 1A~9A만 추출
|
||||
- `text_chunker.py`의 `smart_chunk()`로 10,000자 이내로 압축 (head+tail 보존)
|
||||
- Gemini 호출은 탭당 최대 1~2회로 제한
|
||||
- 429 에러 시 `_generate_with_retry()`로 60초 대기 후 재시도
|
||||
|
||||
### 2.3 다단계 폴백 체인
|
||||
모든 외부 API 호출은 아래 순서를 따른다:
|
||||
```
|
||||
yfinance (1순위) → yahooquery (2순위) → fast_info (3순위) → info (4순위)
|
||||
→ balance_sheet/cashflow (5순위) → TTM 분기 합산 (6순위) → 수동 입력 (최후)
|
||||
```
|
||||
- 모든 숫자 파싱에 `_safe_float()` 사용 (`server/utils/safe_float.py`)
|
||||
- 실패 시 빈 DataFrame 또는 None 반환, 절대 에러를 UI에 노출하지 않음
|
||||
|
||||
### 2.4 모듈 분리 원칙
|
||||
- **한 파일 = 한 책임** — 단일 파일 3000줄 금지
|
||||
- 파일당 최대 300줄 목표
|
||||
- 비즈니스 로직 → `server/services/`, API 엔드포인트 → `server/routers/`
|
||||
- 프론트엔드 컴포넌트는 기능별로 분리
|
||||
|
||||
### 2.5 Multi-Key Column Lookup (중요!)
|
||||
yfinance와 yahooquery는 같은 데이터의 컬럼명이 다르다:
|
||||
- yfinance: `"Total Revenue"` (띄어쓰기)
|
||||
- yahooquery: `"TotalRevenue"` (CamelCase)
|
||||
|
||||
프론트엔드에서 **파이프 구분자 패턴**으로 해결:
|
||||
```tsx
|
||||
function getValue(periodData: Record<string, any>, key: string) {
|
||||
const keys = key.split("|");
|
||||
for (const k of keys) {
|
||||
const v = periodData[k.trim()];
|
||||
if (v != null && typeof v === "number") return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 사용 예: getValue(data, "TotalRevenue|Total Revenue|Revenue")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 현재 File Structure (실제 아키텍처)
|
||||
|
||||
```
|
||||
atlas-terminal/
|
||||
├── apps/web/ # Next.js 14 프론트엔드
|
||||
│ ├── src/app/
|
||||
│ │ ├── layout.tsx # 루트 레이아웃 (3-panel: Sidebar + Main + ChatPanel)
|
||||
│ │ ├── page.tsx # Overview (Sector, DuPont, Altman Z)
|
||||
│ │ ├── globals.css # Tailwind + Terminal Noir 기본 스타일
|
||||
│ │ ├── research/page.tsx # 10-K AI 분석 + Risk Factors
|
||||
│ │ ├── valuation/page.tsx # 5-Tab: DCF, Sensitivity, Monte Carlo, Tornado, Reverse DCF
|
||||
│ │ ├── technical/page.tsx # TradingView 캔들차트 + RSI/MACD/Bollinger/Fibonacci/MA
|
||||
│ │ ├── markets/page.tsx # 재무제표 테이블 (YoY Growth + Margin %)
|
||||
│ │ ├── earnings/page.tsx # EPS Beat/Miss + Revenue + Next Earnings
|
||||
│ │ ├── news/page.tsx # Split-view: 기사 리스트 + iframe 원문
|
||||
│ │ ├── portfolio/page.tsx # 포지션 CRUD + Risk Metrics
|
||||
│ │ ├── filings/page.tsx # SEC 10-K 원문 (5개 섹션 탭 + AI 요약)
|
||||
│ │ ├── settings/page.tsx # API Key 관리
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── sidebar.tsx # 좌측 네비게이션 (10개 메뉴)
|
||||
│ │ │ ├── ticker-bar.tsx # 상단 실시간 지수 바 (S&P, NASDAQ, KOSPI, BTC)
|
||||
│ │ │ └── chat-panel.tsx # 우측 AI Copilot 채팅
|
||||
│ │ └── lib/
|
||||
│ │ ├── use-ticker.ts # 티커 상태 훅 (localStorage + CustomEvent)
|
||||
│ │ └── api.ts # API 클라이언트 유틸
|
||||
│ ├── next.config.mjs # /api/* → localhost:8000 프록시
|
||||
│ ├── tailwind.config.ts # Terminal Noir 컬러 토큰
|
||||
│ └── package.json
|
||||
│
|
||||
├── server/ # FastAPI 백엔드
|
||||
│ ├── main.py # FastAPI app + 14개 라우터 등록
|
||||
│ ├── routers/ # API 엔드포인트 (14개 라우터)
|
||||
│ │ ├── analysis.py # POST /api/analysis — Gemini LLM 분석
|
||||
│ │ ├── chat.py # /api/chat — AI Copilot
|
||||
│ │ ├── crypto.py # /api/crypto — 암호화폐 가격
|
||||
│ │ ├── earnings.py # /api/earnings/{ticker}/history|calendar|quarterly
|
||||
│ │ ├── edgar.py # /api/edgar — SEC 10-K 다운로드 + 파싱
|
||||
│ │ ├── estimates.py # /api/estimates — 애널리스트 추정치
|
||||
│ │ ├── financials.py # /api/financials/{ticker} — IS/BS/CF
|
||||
│ │ ├── fx.py # /api/fx — 환율
|
||||
│ │ ├── insider.py # /api/insider/{ticker} — 내부자 거래
|
||||
│ │ ├── market_data.py # /api/market — 주가/섹터/헬스체크
|
||||
│ │ ├── news.py # /api/news/{ticker} — Finviz + Google RSS
|
||||
│ │ ├── portfolio.py # /api/portfolio — CRUD + Risk
|
||||
│ │ ├── technical.py # /api/technical/{ticker} — 기술적 지표
|
||||
│ │ └── valuation.py # /api/valuation — DCF, Sensitivity, Monte Carlo, Tornado, Reverse DCF
|
||||
│ ├── services/ # 비즈니스 로직 (17개 서비스)
|
||||
│ │ ├── crypto_fetcher.py # Bithumb + Binance API
|
||||
│ │ ├── dcf_engine.py # excel_style_dcf, dcf_10y_2stage, reverse_dcf (scipy brentq)
|
||||
│ │ ├── financial_metrics.py # DuPont, Altman Z, Piotroski F-Score
|
||||
│ │ ├── financial_metrics_ext.py # 확장 지표
|
||||
│ │ ├── fx_fetcher.py # 환율 데이터
|
||||
│ │ ├── gemini_analysis.py # Gemini 분석 로직
|
||||
│ │ ├── gemini_service.py # Gemini API 래퍼 (retry, streaming)
|
||||
│ │ ├── market_data.py # 시장 데이터 서비스
|
||||
│ │ ├── market_fetcher.py # yfinance/yahooquery 폴백 체인
|
||||
│ │ ├── monte_carlo.py # run_monte_carlo_dcf (numpy, 5000 sims)
|
||||
│ │ ├── news_aggregator.py # RSS + Finviz + Google News
|
||||
│ │ ├── risk_metrics.py # VaR, Sharpe, Sortino, MDD, Beta, Correlation
|
||||
│ │ ├── screenshot_ocr.py # Gemini Vision OCR (포트폴리오 스크린샷)
|
||||
│ │ ├── sec_parser.py # 10-K 다운로드 + HTML 파싱 + 섹션 추출 + 캐싱
|
||||
│ │ ├── sensitivity.py # build_sensitivity_matrix, build_tornado_data
|
||||
│ │ ├── technical_analysis.py # RSI, MACD, Bollinger, Ichimoku, ADX, Fibonacci
|
||||
│ │ └── text_chunker.py # smart_chunk, clean_text_for_llm
|
||||
│ ├── db/ # 데이터베이스 레이어
|
||||
│ │ ├── unified_repo.py # SQLite/PostgreSQL 통합 인터페이스
|
||||
│ │ ├── cache.py # 캐시 저장소
|
||||
│ │ ├── database.py # SQLite 커넥션
|
||||
│ │ ├── pg_database.py # PostgreSQL 커넥션
|
||||
│ │ ├── portfolio_repo.py # 포트폴리오 SQLite CRUD
|
||||
│ │ ├── pg_portfolio_repo.py # 포트폴리오 PostgreSQL CRUD
|
||||
│ │ ├── pg_cache_repo.py # PostgreSQL 캐시
|
||||
│ │ ├── dashboard_repo.py # 대시보드 레이아웃 저장
|
||||
│ │ └── settings_repo.py # 설정 저장소
|
||||
│ ├── models/
|
||||
│ │ ├── schemas.py # Pydantic 모델 (요청/응답)
|
||||
│ │ └── db.py # DB 모델
|
||||
│ ├── ai/
|
||||
│ │ ├── llm_router.py # LLM 프로바이더 라우팅
|
||||
│ │ └── context_builder.py # 컨텍스트 빌더
|
||||
│ └── utils/
|
||||
│ ├── safe_float.py # 안전한 숫자 파싱
|
||||
│ └── ticker_utils.py # 티커 유틸리티
|
||||
│
|
||||
├── claude.md # 이 파일 (AI 에이전트 매뉴얼)
|
||||
├── requirements.txt # Python 의존성
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Design System — "Terminal Noir"
|
||||
|
||||
### 4.1 Tailwind 컬러 토큰 (`tailwind.config.ts`)
|
||||
```typescript
|
||||
colors: {
|
||||
bg: {
|
||||
primary: "#0A0A0F", // 메인 배경
|
||||
secondary: "#12121A", // 서브 배경
|
||||
card: "#1A1A26", // 카드 서피스
|
||||
hover: "#252536", // 호버 상태
|
||||
},
|
||||
accent: {
|
||||
green: "#00D4AA", // 상승, CTA, 활성 (민트 그린)
|
||||
red: "#FF4757", // 하락, 경고
|
||||
yellow: "#FFD93D", // 주의, 하이라이트
|
||||
blue: "#4DA6FF", // 정보, 링크
|
||||
},
|
||||
text: {
|
||||
primary: "#F3F4F6", // 주 텍스트
|
||||
secondary: "#9CA3AF", // 보조 텍스트
|
||||
muted: "#6B7280", // 약한 라벨
|
||||
},
|
||||
border: { DEFAULT: "#2A2A3A" },
|
||||
}
|
||||
fontFamily: {
|
||||
sans: ["Inter", "system-ui", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "monospace"],
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 UI 규칙
|
||||
- **숫자**: 양수 = `text-accent-green` + `+` 접두사, 음수 = `text-accent-red`, 폰트 = `font-mono`
|
||||
- **카드**: `bg-bg-card border border-border rounded-lg p-5`
|
||||
- **로딩**: `text-accent-green animate-pulse font-mono "Loading data..."`
|
||||
- **AI 관련**: `text-accent-blue` 또는 인디고 계열
|
||||
- **에러**: `border-accent-red/30` 배경 + `text-accent-red` 텍스트
|
||||
|
||||
### 4.3 3-Panel 레이아웃
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ [ATLAS TERMINAL] S&P 500 -1.51% NASDAQ +2.81% ... │ ← TickerBar (h-52px, fixed top)
|
||||
├───────────┬──────────────────────────┬───────────────────┤
|
||||
│ │ │ │
|
||||
│ Sidebar │ Main Content │ AI Copilot │
|
||||
│ w-260px │ flex-1 │ w-380px │
|
||||
│ │ p-7 │ │
|
||||
│ Overview │ │ Ask me anything │
|
||||
│ Research │ (각 페이지 콘텐츠) │ about {ticker} │
|
||||
│ Valuation │ │ │
|
||||
│ Technical │ │ [Send] │
|
||||
│ Markets │ │ │
|
||||
│ Earnings │ │ │
|
||||
│ News │ │ │
|
||||
│ Portfolio │ │ │
|
||||
│ Filings │ │ │
|
||||
│ Settings │ │ │
|
||||
│ │ │ │
|
||||
└───────────┴──────────────────────────┴───────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API Endpoints (전체 목록)
|
||||
|
||||
### 5.1 Market Data (`/api/market`)
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/market/sector/{ticker}` | 섹터, 산업, 시총, PE, 베타, 52주 |
|
||||
| GET | `/api/market/health/{ticker}` | DuPont, Altman Z, Red Flags |
|
||||
| GET | `/api/market/price/{ticker}` | 현재가, 변동률 |
|
||||
| GET | `/api/market/indices` | S&P 500, NASDAQ, KOSPI, BTC |
|
||||
|
||||
### 5.2 Financials (`/api/financials`)
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/financials/{ticker}` | 손익계산서 (yfinance → yahooquery 폴백) |
|
||||
| GET | `/api/financials/balance/{ticker}` | 대차대조표 |
|
||||
| GET | `/api/financials/cashflow/{ticker}` | 현금흐름표 |
|
||||
|
||||
### 5.3 Valuation (`/api/valuation`)
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/valuation/dcf` | 10Y 2-Stage DCF |
|
||||
| POST | `/api/valuation/sensitivity` | WACC × Terminal Growth 매트릭스 |
|
||||
| POST | `/api/valuation/monte-carlo` | 5000회 시뮬레이션 + 히스토그램 |
|
||||
| POST | `/api/valuation/tornado` | 변수별 민감도 순위 |
|
||||
| POST | `/api/valuation/reverse-dcf` | 시장 내재 성장률 (scipy brentq) |
|
||||
|
||||
### 5.4 Technical (`/api/technical`)
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/technical/{ticker}` | RSI, MACD, Bollinger, MA, ADX, Ichimoku |
|
||||
| GET | `/api/technical/{ticker}/chart` | OHLCV 캔들 데이터 |
|
||||
|
||||
### 5.5 Earnings (`/api/earnings`)
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/earnings/{ticker}/history` | EPS Beat/Miss 이력 |
|
||||
| GET | `/api/earnings/{ticker}/calendar` | 다음 실적 발표일 |
|
||||
| GET | `/api/earnings/{ticker}/quarterly` | 분기별 매출/순이익 |
|
||||
|
||||
### 5.6 Insider (`/api/insider`)
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/insider/{ticker}` | 최근 내부자 거래 |
|
||||
| GET | `/api/insider/{ticker}/holders` | 기관투자자 보유 현황 |
|
||||
|
||||
### 5.7 News (`/api/news`)
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/news/{ticker}` | Finviz + Google News RSS |
|
||||
|
||||
### 5.8 SEC EDGAR (`/api/edgar`)
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/edgar/download` | 10-K 다운로드 (sec-edgar-downloader) |
|
||||
| GET | `/api/edgar/sections/{ticker}` | 10-K 섹션별 텍스트 (1A, 3, 7, 8, 9A) |
|
||||
|
||||
### 5.9 기타
|
||||
| Prefix | 설명 |
|
||||
|--------|------|
|
||||
| `/api/analysis` | Gemini AI 분석 |
|
||||
| `/api/chat` | AI Copilot 대화 |
|
||||
| `/api/crypto` | 암호화폐 (Bithumb + Binance) |
|
||||
| `/api/fx` | 환율 |
|
||||
| `/api/estimates` | 애널리스트 추정치 |
|
||||
| `/api/portfolio` | 포트폴리오 CRUD + Risk Metrics |
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend Pages (10개)
|
||||
|
||||
| 경로 | 파일 | 핵심 기능 |
|
||||
|------|------|----------|
|
||||
| `/` | `page.tsx` | Overview — 섹터/산업, 시총/PE/베타, Altman Z-Score, DuPont 분해 |
|
||||
| `/research` | `research/page.tsx` | 10-K AI 분석 — MD&A + Risk Factors (Gemini) |
|
||||
| `/valuation` | `valuation/page.tsx` | **5-Tab**: DCF, Sensitivity Matrix (WACC×TG), Monte Carlo (히스토그램), Tornado, Reverse DCF |
|
||||
| `/technical` | `technical/page.tsx` | TradingView 캔들차트 (lightweight-charts), RSI/MACD/ATR 카드, 이동평균 테이블, Bollinger, Fibonacci |
|
||||
| `/markets` | `markets/page.tsx` | 재무제표 테이블 — Revenue→EBITDA, YoY Growth 뱃지(초록/빨강), Margin % 행 |
|
||||
| `/earnings` | `earnings/page.tsx` | 다음 실적일, EPS Beat/Miss 바차트, 분기 매출/순이익 |
|
||||
| `/news` | `news/page.tsx` | Split-view — 좌측 기사 리스트(340px) + 우측 iframe 원문 보기 |
|
||||
| `/portfolio` | `portfolio/page.tsx` | 포지션 관리 + Risk Metrics (VaR, Sharpe, MDD) |
|
||||
| `/filings` | `filings/page.tsx` | SEC 10-K 원문 — 5개 섹션 탭 (Risk, MD&A, Financials, Legal, Controls) + AI Summary |
|
||||
| `/settings` | `settings/page.tsx` | Gemini API Key, SEC Email 설정 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Ticker State Management
|
||||
|
||||
**전역 티커 상태**는 React Context 없이 `localStorage` + `CustomEvent` 패턴으로 관리:
|
||||
|
||||
```tsx
|
||||
// apps/web/src/app/lib/use-ticker.ts
|
||||
export function useTicker() {
|
||||
const [ticker, setTicker] = useState(() =>
|
||||
localStorage.getItem("atlas-ticker") || "MSFT"
|
||||
);
|
||||
|
||||
// 다른 컴포넌트의 변경도 감지
|
||||
useEffect(() => {
|
||||
const handler = () => setTicker(localStorage.getItem("atlas-ticker") || "MSFT");
|
||||
window.addEventListener("ticker-changed", handler);
|
||||
return () => window.removeEventListener("ticker-changed", handler);
|
||||
}, []);
|
||||
|
||||
const updateTicker = (t: string) => {
|
||||
localStorage.setItem("atlas-ticker", t.toUpperCase());
|
||||
window.dispatchEvent(new CustomEvent("ticker-changed"));
|
||||
};
|
||||
|
||||
return { ticker, setTicker: updateTicker };
|
||||
}
|
||||
```
|
||||
|
||||
**사용법**: 모든 페이지에서 `const { ticker } = useTicker();`로 현재 티커 접근.
|
||||
TickerBar의 검색창에서 `setTicker()`로 전역 변경.
|
||||
|
||||
---
|
||||
|
||||
## 8. Key Algorithms
|
||||
|
||||
### DCF 10Y 2-Stage (`server/services/dcf_engine.py`)
|
||||
- Stage 1 (Y1-5): `FCF × (1 + growth)^t`
|
||||
- Stage 2 (Y6-10): growth linearly fades to terminal growth rate
|
||||
- Terminal Value at Y10: `FCF₁₀ × (1 + TG) / (WACC - TG)`
|
||||
- Enterprise Value = sum of discounted FCFs + discounted TV
|
||||
|
||||
### Reverse DCF (`server/services/dcf_engine.py`)
|
||||
- scipy `brentq` root-finding: 현재 시가총액을 설명하는 성장률 역산
|
||||
- `f(g) = DCF(g) - market_cap = 0` 풀기
|
||||
|
||||
### Monte Carlo (`server/services/monte_carlo.py`)
|
||||
- numpy로 5000회 시뮬레이션
|
||||
- growth, wacc, margin을 정규분포로 샘플링
|
||||
- 히스토그램 빈 + P(> current price) 계산
|
||||
|
||||
### Sensitivity Matrix (`server/services/sensitivity.py`)
|
||||
- WACC (행) × Terminal Growth (열) 조합별 DCF 결과 매트릭스
|
||||
- Tornado: 각 변수를 ±20% 변동시켜 가격 영향 범위 계산, 영향력 순 정렬
|
||||
|
||||
### DuPont 3-Factor
|
||||
`ROE = NPM × Asset Turnover × Equity Multiplier`
|
||||
|
||||
### Altman Z-Score
|
||||
`Z = 1.2(WC/TA) + 1.4(RE/TA) + 3.3(EBIT/TA) + 0.6(MC/TL) + 1.0(Sales/TA)`
|
||||
|
||||
### Technical Indicators (`server/services/technical_analysis.py`)
|
||||
- `ta` 라이브러리 사용: RSI, MACD, Bollinger Bands, Ichimoku Cloud, ADX
|
||||
- `detect_signals()`: MA 크로스, RSI 과매수/과매도, MACD 시그널
|
||||
- `compute_fibonacci_levels()`: 52주 고/저 기반 되돌림 레벨
|
||||
|
||||
---
|
||||
|
||||
## 9. Development Rules (가드레일)
|
||||
|
||||
### 코드
|
||||
- TypeScript strict mode (프론트), Python type hints (백엔드)
|
||||
- 모든 API 호출 try/except; UI에 기술적 에러 노출 금지
|
||||
- 캐싱: 재무=TTL 300초, 10-K=영구, 환율=TTL 60초
|
||||
- `"use client"` — 모든 페이지 최상단에 필수 (App Router + hooks)
|
||||
|
||||
### 프론트엔드 API 호출 패턴
|
||||
```tsx
|
||||
// Next.js rewrites가 /api/* → localhost:8000/api/* 프록시
|
||||
// 따라서 상대경로로 호출:
|
||||
fetch(`/api/market/sector/${ticker}`)
|
||||
fetch(`/api/valuation/dcf`, { method: "POST", body: JSON.stringify(params) })
|
||||
```
|
||||
|
||||
### Gemini API
|
||||
- 요청당 최대 25,000자, temperature 0.2~0.4
|
||||
- 429 → 60초 대기 × 3회 재시도
|
||||
- 스트리밍: MD&A/Risk 분석은 `stream=True`
|
||||
|
||||
### yfinance 주의사항
|
||||
- `earnings_history` 컬럼명: `epsActual`, `epsEstimate`, `surprisePercent` (camelCase)
|
||||
- `surprisePercent`는 소수 (0.0759 = 7.59%) → 프론트에서 `× 100` 필요
|
||||
- 날짜는 DataFrame index에 있음 (컬럼 아님) → `str(idx)[:10]`
|
||||
- 연간 데이터에 TTM 행 혼재 가능 → `_filter_annual()` 적용
|
||||
|
||||
### 서버 실행
|
||||
```bash
|
||||
# 백엔드 (포트 8000)
|
||||
cd atlas-terminal
|
||||
PYTHONPATH="." python3 -m uvicorn server.main:app --port 8000 --host 0.0.0.0
|
||||
|
||||
# 프론트엔드 (포트 3000)
|
||||
cd atlas-terminal/apps/web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Git
|
||||
- 커밋: `feat:`, `fix:`, `docs:`, `refactor:` 접두사
|
||||
- 브랜치: `main`, `dev`, `feat/기능명`
|
||||
|
||||
---
|
||||
|
||||
## 10. Dependencies
|
||||
|
||||
### Python (`requirements.txt`)
|
||||
```
|
||||
fastapi>=0.110.0 uvicorn[standard]>=0.29.0
|
||||
pydantic>=2.7.0 google-generativeai>=0.8.0
|
||||
anthropic>=0.39.0 openai>=1.50.0
|
||||
beautifulsoup4>=4.12.0 requests>=2.31.0
|
||||
pandas>=2.0.0 lxml>=4.9.0
|
||||
python-dotenv>=1.0.0 yfinance>=0.2.40
|
||||
yahooquery>=2.2.0 sec-edgar-downloader>=5.0.0
|
||||
feedparser>=6.0.0 ta>=0.11.0
|
||||
numpy scipy
|
||||
aiosqlite>=0.20.0 asyncpg>=0.30.0
|
||||
pillow>=10.0.0
|
||||
```
|
||||
|
||||
### Node.js (`apps/web/package.json`)
|
||||
```
|
||||
next: 14.2.35 react: ^18
|
||||
lightweight-charts: ^5.1.0
|
||||
tailwindcss: ^3.4.1 typescript: ^5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Environment Variables
|
||||
```env
|
||||
GOOGLE_API_KEY= # Gemini API
|
||||
SEC_EDGAR_EMAIL= # SEC 정책 필수 (10-K 다운로드용)
|
||||
DATABASE_URL= # PostgreSQL (없으면 SQLite 자동)
|
||||
NEWS_API_KEY= # 선택
|
||||
DART_API_KEY= # 한국 공시 (선택)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 알려진 이슈 및 주의사항
|
||||
|
||||
1. **`apps/web/app/` vs `apps/web/src/app/`**: 구 TanStack Start의 `app/` 디렉토리가 `_legacy_tanstack_app`으로 이름변경됨. Next.js는 `src/app/`을 사용. 절대 루트의 `app/` 디렉토리를 만들지 말 것.
|
||||
|
||||
2. **Financial 데이터 혼합**: yahooquery는 12M + TTM 데이터를 섞어 반환할 수 있음. `server/routers/financials.py`의 `_filter_annual()` 함수가 TTM 필터링.
|
||||
|
||||
3. **CORS**: `server/main.py`에서 `localhost:3000`, `localhost:3001`, `127.0.0.1:3000` 허용 설정됨.
|
||||
|
||||
4. **Google Fonts**: `layout.tsx`의 `<head>`에서 Inter + JetBrains Mono 로드. `<link>` 태그 직접 삽입 방식.
|
||||
|
||||
---
|
||||
|
||||
## 13. 미구현 기능 (TODO)
|
||||
|
||||
- [ ] Widget-based 대시보드 (react-grid-layout) — 패키지 설치됨, 미구현
|
||||
- [ ] Enhanced AI Copilot — 인용/추론 단계 표시
|
||||
- [ ] Multi-LLM 시스템 (Gemini + Claude + OpenAI provider abstraction) — `ai/llm_router.py` 스캐폴딩만
|
||||
- [ ] Extended DuPont 5-Factor 분석
|
||||
- [ ] DART (한국 공시) / EDINET (일본 공시) 통합
|
||||
- [ ] 포트폴리오 스크린샷 OCR (Gemini Vision) — 서비스 존재, UI 미연결
|
||||
- [ ] ⌘K 글로벌 커맨드 팔레트
|
||||
- [ ] Sankey (자금흐름), Radar (재무건전성) 차트
|
||||
- [ ] GitHub README 자동 업데이트 워크플로우
|
||||
|
||||
---
|
||||
|
||||
*마지막 수정: 2026-03-21*
|
||||
@@ -44,7 +44,7 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
# --- Mount routers ---
|
||||
from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider # noqa: E402
|
||||
from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider, screener, markets # noqa: E402
|
||||
|
||||
app.include_router(edgar.router, prefix="/api/edgar", tags=["SEC EDGAR"])
|
||||
app.include_router(analysis.router, prefix="/api/analysis", tags=["AI Analysis"])
|
||||
@@ -55,10 +55,12 @@ app.include_router(estimates.router, prefix="/api/estimates", tags=["Estimates"]
|
||||
app.include_router(news.router, prefix="/api/news", tags=["News"])
|
||||
app.include_router(crypto.router, prefix="/api/crypto", tags=["Crypto"])
|
||||
app.include_router(fx.router, prefix="/api/fx", tags=["FX"])
|
||||
app.include_router(markets.router, prefix="/api/markets", tags=["Markets"])
|
||||
app.include_router(portfolio.router, prefix="/api/portfolio", tags=["Portfolio"])
|
||||
app.include_router(technical.router, prefix="/api/technical", tags=["Technical"])
|
||||
app.include_router(earnings.router, prefix="/api/earnings", tags=["Earnings"])
|
||||
app.include_router(insider.router, prefix="/api/insider", tags=["Insider Trading"])
|
||||
app.include_router(screener.router, prefix="/api/screener", tags=["Screener"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -75,6 +75,7 @@ class PortfolioPositionCreate(BaseModel):
|
||||
quantity: float
|
||||
avg_price: float
|
||||
currency: str = "USD"
|
||||
exchange: str = ""
|
||||
source: str = "manual"
|
||||
|
||||
|
||||
@@ -182,6 +183,7 @@ class PortfolioPosition(BaseModel):
|
||||
quantity: float
|
||||
avg_price: float
|
||||
currency: str = "USD"
|
||||
exchange: str = ""
|
||||
source: str = "manual"
|
||||
current_price: Optional[float] = None
|
||||
market_value: Optional[float] = None
|
||||
|
||||
@@ -44,19 +44,44 @@ def _fetch_fx_rate(pair: str) -> float | None:
|
||||
@router.get(
|
||||
"/rates",
|
||||
response_model=FXRateResponse,
|
||||
summary="Major FX rates",
|
||||
summary="FX conversion matrix for major currencies",
|
||||
)
|
||||
async def fx_rates():
|
||||
"""Return current exchange rates for major currency pairs
|
||||
(USD/KRW, USD/JPY, EUR/USD, GBP/USD, etc.).
|
||||
"""
|
||||
"""Return conversion matrix (USD/GBP/EUR/JPY/KRW)."""
|
||||
try:
|
||||
rates: Dict[str, float] = {}
|
||||
for pair in MAJOR_PAIRS:
|
||||
rate = _fetch_fx_rate(pair)
|
||||
if rate is not None:
|
||||
rates[pair] = round(rate, 4)
|
||||
return FXRateResponse(pair="MAJOR", rates=rates)
|
||||
gbp_usd = _fetch_fx_rate("GBPUSD") or 1.27
|
||||
eur_usd = _fetch_fx_rate("EURUSD") or 1.08
|
||||
usd_jpy = _fetch_fx_rate("USDJPY") or 149.5
|
||||
usd_krw = _fetch_fx_rate("USDKRW") or 1370.0
|
||||
|
||||
rates = {
|
||||
"USD_USD": 1.0,
|
||||
"USD_GBP": 1 / gbp_usd,
|
||||
"USD_EUR": 1 / eur_usd,
|
||||
"USD_JPY": usd_jpy,
|
||||
"USD_KRW": usd_krw,
|
||||
"GBP_USD": gbp_usd,
|
||||
"GBP_GBP": 1.0,
|
||||
"GBP_EUR": gbp_usd / eur_usd,
|
||||
"GBP_JPY": gbp_usd * usd_jpy,
|
||||
"GBP_KRW": gbp_usd * usd_krw,
|
||||
"EUR_USD": eur_usd,
|
||||
"EUR_GBP": eur_usd / gbp_usd,
|
||||
"EUR_EUR": 1.0,
|
||||
"EUR_JPY": eur_usd * usd_jpy,
|
||||
"EUR_KRW": eur_usd * usd_krw,
|
||||
"JPY_USD": 1 / usd_jpy,
|
||||
"JPY_GBP": 1 / (gbp_usd * usd_jpy),
|
||||
"JPY_EUR": 1 / (eur_usd * usd_jpy),
|
||||
"JPY_JPY": 1.0,
|
||||
"JPY_KRW": usd_krw / usd_jpy,
|
||||
"KRW_USD": 1 / usd_krw,
|
||||
"KRW_GBP": 1 / (gbp_usd * usd_krw),
|
||||
"KRW_EUR": 1 / (eur_usd * usd_krw),
|
||||
"KRW_JPY": usd_jpy / usd_krw,
|
||||
"KRW_KRW": 1.0,
|
||||
}
|
||||
return FXRateResponse(pair="MATRIX", rates=rates)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"FX rates failed: {exc}") from exc
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from server.utils.ticker_utils import AssetType, detect_asset_type
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -51,12 +52,92 @@ async def market_indices():
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/overview", summary="Global market overview")
|
||||
async def market_overview():
|
||||
try:
|
||||
from server.services.market_overview import get_market_overview
|
||||
|
||||
return await get_market_overview()
|
||||
except Exception as e:
|
||||
return {"error": str(e), "data": None}
|
||||
|
||||
|
||||
@router.get("/sectors", summary="Sector heatmap")
|
||||
async def sector_heatmap():
|
||||
try:
|
||||
from server.services.sector_heatmap import get_sector_heatmap
|
||||
|
||||
return await get_sector_heatmap()
|
||||
except Exception as e:
|
||||
return {"error": str(e), "data": None}
|
||||
|
||||
|
||||
@router.get("/overview/{ticker}", summary="Asset-type aware overview")
|
||||
async def market_overview_by_ticker(ticker: str):
|
||||
"""Detect asset type and return overview payload for that type."""
|
||||
try:
|
||||
asset_type = detect_asset_type(ticker)
|
||||
if asset_type == AssetType.ETF:
|
||||
from server.services.etf_analysis import get_etf_overview
|
||||
|
||||
return {"asset_type": AssetType.ETF.value, "data": await get_etf_overview(ticker)}
|
||||
if asset_type == AssetType.COMMODITY_FUTURE:
|
||||
from server.services.commodity_analysis import get_commodity_overview
|
||||
|
||||
return {"asset_type": AssetType.COMMODITY_FUTURE.value, "data": await get_commodity_overview(ticker)}
|
||||
if asset_type == AssetType.CRYPTO:
|
||||
return {"asset_type": AssetType.CRYPTO.value, "data": {"name": ticker.upper()}}
|
||||
if asset_type == AssetType.INDEX:
|
||||
return {"asset_type": AssetType.INDEX.value, "data": {"name": ticker.upper()}}
|
||||
from server.services.etf_analysis import get_equity_overview
|
||||
|
||||
return {"asset_type": AssetType.EQUITY.value, "data": await get_equity_overview(ticker)}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "asset_type": AssetType.EQUITY.value, "data": None}
|
||||
|
||||
|
||||
@router.get("/etf/{ticker}/holdings", summary="ETF top holdings")
|
||||
async def etf_holdings(ticker: str):
|
||||
try:
|
||||
from server.services.etf_analysis import get_etf_holdings
|
||||
|
||||
return {"ticker": ticker.upper(), "holdings": await get_etf_holdings(ticker)}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "ticker": ticker.upper(), "holdings": []}
|
||||
|
||||
|
||||
@router.get("/commodity/{ticker}/seasonal", summary="Commodity monthly seasonal pattern")
|
||||
async def commodity_seasonal(ticker: str):
|
||||
try:
|
||||
from server.services.commodity_analysis import get_commodity_overview
|
||||
|
||||
data = await get_commodity_overview(ticker)
|
||||
return {"ticker": ticker.upper(), "seasonal_pattern": data.get("seasonal_pattern", {})}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "ticker": ticker.upper(), "seasonal_pattern": {}}
|
||||
|
||||
|
||||
@router.get("/commodity/{ticker}/correlations", summary="Commodity correlations")
|
||||
async def commodity_correlations(ticker: str):
|
||||
try:
|
||||
from server.services.commodity_analysis import compute_commodity_correlations
|
||||
|
||||
return {"ticker": ticker.upper(), "correlations": await compute_commodity_correlations(ticker)}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "ticker": ticker.upper(), "correlations": {}}
|
||||
|
||||
|
||||
@router.get("/sector/{ticker}", summary="Sector and industry classification")
|
||||
async def sector_industry(ticker: str):
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
city = (info.get("city") or "").strip()
|
||||
state = (info.get("state") or "").strip()
|
||||
country = (info.get("country") or "").strip()
|
||||
hq_parts = [p for p in [city, state, country] if p]
|
||||
hq = ", ".join(hq_parts) if hq_parts else "N/A"
|
||||
return {
|
||||
"sector": info.get("sector", "N/A"),
|
||||
"industry": info.get("industry", "N/A"),
|
||||
@@ -67,6 +148,13 @@ async def sector_industry(ticker: str):
|
||||
"fifty_two_week_high": _safe_float(info.get("fiftyTwoWeekHigh")),
|
||||
"fifty_two_week_low": _safe_float(info.get("fiftyTwoWeekLow")),
|
||||
"current_price": _safe_float(info.get("currentPrice") or info.get("regularMarketPrice")),
|
||||
"ceo": info.get("companyOfficers", [{}])[0].get("name") if isinstance(info.get("companyOfficers"), list) and info.get("companyOfficers") else None,
|
||||
"employees": info.get("fullTimeEmployees"),
|
||||
"founded": info.get("founded"),
|
||||
"hq": hq,
|
||||
"website": info.get("website"),
|
||||
"ipo_date": info.get("ipoExpectedDate") or info.get("firstTradeDateEpochUtc"),
|
||||
"description": info.get("longBusinessSummary"),
|
||||
}
|
||||
except Exception:
|
||||
return {"sector": "N/A", "industry": "N/A"}
|
||||
@@ -134,7 +222,15 @@ async def industry_comps(tickers: str = Query(..., description="Comma-separated
|
||||
|
||||
@router.get("/health/{ticker}", summary="DuPont, Altman Z-Score, Red Flags")
|
||||
async def financial_health(ticker: str):
|
||||
fallback = {"ticker": ticker.upper(), "dupont": {}, "altman_z": None, "red_flags": []}
|
||||
fallback = {
|
||||
"ticker": ticker.upper(),
|
||||
"dupont": {},
|
||||
"altman_z": None,
|
||||
"current_ratio": None,
|
||||
"interest_coverage": None,
|
||||
"debt_to_equity": None,
|
||||
"red_flags": [],
|
||||
}
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
@@ -199,20 +295,61 @@ async def financial_health(ticker: str):
|
||||
rev_ta = rev / ta
|
||||
altman_z = round(1.2 * wc_ta + 1.4 * re_ta + 3.3 * ebit_ta + 0.6 * mc_tl + 1.0 * rev_ta, 2)
|
||||
|
||||
# Additional health metrics for overview cards
|
||||
current_ratio = None
|
||||
if bs is not None and not bs.empty:
|
||||
col_bs = bs.columns[0]
|
||||
ca = _safe_float(bs.loc["Current Assets"][col_bs]) if "Current Assets" in bs.index else 0
|
||||
cl = _safe_float(bs.loc["Current Liabilities"][col_bs]) if "Current Liabilities" in bs.index else 0
|
||||
current_ratio = (ca / cl) if cl else None
|
||||
if current_ratio is None:
|
||||
info_cr = _safe_float(info.get("currentRatio"), None)
|
||||
current_ratio = info_cr if info_cr and info_cr > 0 else None
|
||||
|
||||
interest_coverage = None
|
||||
if fin is not None and not fin.empty:
|
||||
col_fin = fin.columns[0]
|
||||
ebit = _safe_float(fin.loc["EBIT"][col_fin]) if "EBIT" in fin.index else _safe_float(fin.loc["Operating Income"][col_fin]) if "Operating Income" in fin.index else None
|
||||
int_exp = _safe_float(fin.loc["Interest Expense"][col_fin]) if "Interest Expense" in fin.index else None
|
||||
if ebit is not None and int_exp is not None and int_exp != 0:
|
||||
interest_coverage = abs(ebit / int_exp)
|
||||
|
||||
debt_to_equity = None
|
||||
if bs is not None and not bs.empty:
|
||||
col_bs = bs.columns[0]
|
||||
total_debt = _safe_float(bs.loc["Total Debt"][col_bs], None) if "Total Debt" in bs.index else None
|
||||
if total_debt is None:
|
||||
ltd = _safe_float(bs.loc["Long Term Debt"][col_bs], 0) if "Long Term Debt" in bs.index else 0
|
||||
std = _safe_float(bs.loc["Current Debt"][col_bs], 0) if "Current Debt" in bs.index else 0
|
||||
total_debt = ltd + std if (ltd or std) else None
|
||||
equity = total_equity if total_equity else None
|
||||
if total_debt is not None and equity:
|
||||
debt_to_equity = total_debt / equity
|
||||
if debt_to_equity is None:
|
||||
de_info = _safe_float(info.get("debtToEquity"), None)
|
||||
if de_info is not None:
|
||||
debt_to_equity = de_info / 100 if de_info > 10 else de_info
|
||||
|
||||
# Red Flags
|
||||
red_flags = []
|
||||
cr = _safe_float(info.get("currentRatio"))
|
||||
de = _safe_float(info.get("debtToEquity"))
|
||||
if cr and cr < 1.0:
|
||||
red_flags.append(f"Low current ratio: {cr:.2f}")
|
||||
if de and de > 200:
|
||||
red_flags.append(f"High debt-to-equity: {de:.1f}%")
|
||||
if current_ratio is not None and current_ratio < 1.0:
|
||||
red_flags.append(f"Low current ratio: {current_ratio:.2f}")
|
||||
if debt_to_equity is not None and debt_to_equity > 2.0:
|
||||
red_flags.append(f"High debt-to-equity: {debt_to_equity:.2f}")
|
||||
if npm and npm < 0:
|
||||
red_flags.append("Negative profit margin")
|
||||
if roe and roe < 0:
|
||||
red_flags.append("Negative ROE")
|
||||
|
||||
return {"ticker": ticker.upper(), "dupont": dupont, "altman_z": altman_z, "red_flags": red_flags}
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"dupont": dupont,
|
||||
"altman_z": altman_z,
|
||||
"current_ratio": round(current_ratio, 2) if current_ratio is not None else None,
|
||||
"interest_coverage": round(interest_coverage, 2) if interest_coverage is not None else None,
|
||||
"debt_to_equity": round(debt_to_equity, 2) if debt_to_equity is not None else None,
|
||||
"red_flags": red_flags,
|
||||
}
|
||||
except Exception as e:
|
||||
return fallback
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Markets router for index constituent heatmap."""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/heatmap/{index_name}", summary="Index constituent heatmap data")
|
||||
async def heatmap(index_name: str, top_n: int = Query(default=50, ge=1, le=200)):
|
||||
from server.services.heatmap import get_heatmap_data
|
||||
|
||||
stocks = await get_heatmap_data(index_name, top_n)
|
||||
return {"index": index_name, "count": len(stocks), "stocks": stocks}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Portfolio router -- position management, OCR screenshot upload, summary."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from server.models.schemas import (
|
||||
PortfolioPosition,
|
||||
@@ -19,6 +21,18 @@ router = APIRouter()
|
||||
_PORTFOLIO_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "portfolio.json"
|
||||
|
||||
|
||||
class PositionUpdateRequest(BaseModel):
|
||||
quantity: float = Field(..., gt=0)
|
||||
avg_price: float = Field(..., ge=0)
|
||||
exchange: str = ""
|
||||
|
||||
|
||||
class OcrRecalculateRequest(BaseModel):
|
||||
account_currency: str = "USD"
|
||||
position: dict
|
||||
selected_exchange: str = ""
|
||||
|
||||
|
||||
def _load_positions() -> List[dict]:
|
||||
"""Load positions from the JSON store."""
|
||||
if not _PORTFOLIO_FILE.exists():
|
||||
@@ -77,24 +91,36 @@ async def list_positions():
|
||||
@router.post(
|
||||
"/positions",
|
||||
response_model=PortfolioPosition,
|
||||
summary="Add a portfolio position",
|
||||
summary="Add or update a portfolio position",
|
||||
)
|
||||
async def add_position(pos: PortfolioPositionCreate):
|
||||
"""Add a new position to the portfolio."""
|
||||
"""Add a new position. If ticker exists, update quantity/avg/currency."""
|
||||
try:
|
||||
positions = _load_positions()
|
||||
new_pos = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"ticker": pos.ticker.upper(),
|
||||
"company_name": pos.company_name,
|
||||
"quantity": pos.quantity,
|
||||
"avg_price": pos.avg_price,
|
||||
"currency": pos.currency,
|
||||
"source": pos.source,
|
||||
}
|
||||
positions.append(new_pos)
|
||||
ticker = pos.ticker.upper()
|
||||
existing = next((p for p in positions if str(p.get("ticker", "")).upper() == ticker), None)
|
||||
if existing is not None:
|
||||
existing["company_name"] = pos.company_name or existing.get("company_name", "")
|
||||
existing["quantity"] = float(pos.quantity)
|
||||
existing["avg_price"] = float(pos.avg_price)
|
||||
existing["currency"] = pos.currency or existing.get("currency", "USD")
|
||||
existing["exchange"] = pos.exchange or existing.get("exchange", "")
|
||||
existing["source"] = pos.source or existing.get("source", "manual")
|
||||
saved = existing
|
||||
else:
|
||||
saved = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"ticker": ticker,
|
||||
"company_name": pos.company_name,
|
||||
"quantity": pos.quantity,
|
||||
"avg_price": pos.avg_price,
|
||||
"currency": pos.currency,
|
||||
"exchange": pos.exchange,
|
||||
"source": pos.source,
|
||||
}
|
||||
positions.append(saved)
|
||||
_save_positions(positions)
|
||||
return PortfolioPosition(**new_pos)
|
||||
return PortfolioPosition(**saved)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to add position: {exc}") from exc
|
||||
|
||||
@@ -121,6 +147,79 @@ async def remove_position(position_id: str):
|
||||
raise HTTPException(status_code=500, detail=f"Failed to remove position: {exc}") from exc
|
||||
|
||||
|
||||
@router.put(
|
||||
"/positions/{position_id}",
|
||||
response_model=PortfolioPosition,
|
||||
summary="Update quantity/avg price for a position",
|
||||
)
|
||||
async def update_position(position_id: str, body: PositionUpdateRequest):
|
||||
"""Update an existing position by unique ID."""
|
||||
try:
|
||||
positions = _load_positions()
|
||||
updated = None
|
||||
for p in positions:
|
||||
if p.get("id") == position_id:
|
||||
p["quantity"] = float(body.quantity)
|
||||
p["avg_price"] = float(body.avg_price)
|
||||
if body.exchange:
|
||||
p["exchange"] = body.exchange
|
||||
updated = p
|
||||
break
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail=f"Position {position_id} not found.")
|
||||
_save_positions(positions)
|
||||
return PortfolioPosition(**updated)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update position: {exc}") from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/ocr",
|
||||
summary="OCR screenshot with smart reverse-engineering",
|
||||
)
|
||||
async def ocr_screenshot(
|
||||
file: UploadFile = File(...),
|
||||
x_gemini_api_key: str | None = Header(default=None),
|
||||
):
|
||||
"""Screenshot -> OCR extraction -> market-validated reverse-engineered positions."""
|
||||
try:
|
||||
from server.services.screenshot_ocr import process_portfolio_screenshot
|
||||
|
||||
image_bytes = await file.read()
|
||||
api_key = (x_gemini_api_key or "").strip() or os.getenv("GOOGLE_API_KEY", "").strip()
|
||||
result = await process_portfolio_screenshot(api_key, image_bytes)
|
||||
if result.get("error"):
|
||||
return {"error": result.get("error"), "positions": [], "count": 0, "warnings": []}
|
||||
result["count"] = len(result.get("positions") or [])
|
||||
return result
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"OCR processing failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/exchange-options/{ticker}", summary="Available exchange options for a ticker")
|
||||
async def exchange_options(ticker: str):
|
||||
from server.services.exchange_resolver import get_exchange_options
|
||||
|
||||
return {"ticker": ticker.upper(), "options": get_exchange_options(ticker)}
|
||||
|
||||
|
||||
@router.post("/ocr/recalculate", summary="Recalculate one OCR row with selected exchange")
|
||||
async def ocr_recalculate(body: OcrRecalculateRequest):
|
||||
from server.services.screenshot_ocr import reverse_engineer_positions
|
||||
|
||||
ticker = str((body.position or {}).get("ticker", "")).upper()
|
||||
if not ticker:
|
||||
raise HTTPException(status_code=400, detail="position.ticker is required")
|
||||
payload = {"account_currency": body.account_currency, "positions": [body.position]}
|
||||
overrides = {ticker: body.selected_exchange} if body.selected_exchange else {}
|
||||
recalculated = reverse_engineer_positions(payload, overrides)
|
||||
if not recalculated:
|
||||
raise HTTPException(status_code=400, detail="Failed to recalculate position")
|
||||
return {"position": recalculated[0]}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/screenshot",
|
||||
summary="Upload screenshot for OCR analysis",
|
||||
@@ -209,6 +308,7 @@ async def portfolio_summary():
|
||||
quantity=quantity,
|
||||
avg_price=avg_price,
|
||||
currency=p.get("currency", "USD"),
|
||||
exchange=p.get("exchange", ""),
|
||||
source=p.get("source", "manual"),
|
||||
current_price=current_price,
|
||||
market_value=market_value,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Screener and backtesting router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
async def search_stocks(filters: dict):
|
||||
"""Run stock screener with simple filters."""
|
||||
try:
|
||||
from server.services.screener import run_screener
|
||||
|
||||
return await run_screener(filters)
|
||||
except Exception as e:
|
||||
return {"error": str(e), "data": []}
|
||||
|
||||
|
||||
@router.post("/backtest")
|
||||
async def backtest(body: dict):
|
||||
"""Run strategy backtest for one ticker."""
|
||||
try:
|
||||
from server.services.backtester import run_backtest
|
||||
|
||||
return await run_backtest(
|
||||
ticker=body.get("ticker", ""),
|
||||
strategy=body.get("strategy", "buy_and_hold"),
|
||||
start_date=body.get("start_date", "2024-01-01"),
|
||||
end_date=body.get("end_date", "2026-01-01"),
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Backtesting service for simple strategies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
async def run_backtest(
|
||||
ticker: str,
|
||||
strategy: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
initial_capital: float = 10000.0,
|
||||
) -> dict:
|
||||
"""Run a basic backtest for selected strategy."""
|
||||
import yfinance as yf
|
||||
import ta
|
||||
|
||||
df = yf.Ticker(ticker.upper()).history(start=start_date, end=end_date)
|
||||
if df is None or df.empty:
|
||||
return {"error": "No price data"}
|
||||
|
||||
if strategy == "sma_crossover":
|
||||
df["sma50"] = ta.trend.sma_indicator(df["Close"], 50)
|
||||
df["sma200"] = ta.trend.sma_indicator(df["Close"], 200)
|
||||
df["signal"] = (df["sma50"] > df["sma200"]).astype(int)
|
||||
elif strategy == "rsi_oversold":
|
||||
df["rsi"] = ta.momentum.rsi(df["Close"], 14)
|
||||
df["signal"] = 0
|
||||
df.loc[df["rsi"] < 30, "signal"] = 1
|
||||
df.loc[df["rsi"] > 70, "signal"] = 0
|
||||
else:
|
||||
df["signal"] = 1
|
||||
|
||||
df["returns"] = df["Close"].pct_change().fillna(0)
|
||||
df["strategy_returns"] = (df["returns"] * df["signal"].shift(1)).fillna(0)
|
||||
cumulative = (1 + df["strategy_returns"]).cumprod()
|
||||
benchmark = (1 + df["returns"]).cumprod()
|
||||
|
||||
return {
|
||||
"total_return_pct": round((float(cumulative.iloc[-1]) - 1) * 100, 2),
|
||||
"benchmark_return_pct": round((float(benchmark.iloc[-1]) - 1) * 100, 2),
|
||||
"alpha": round((float(cumulative.iloc[-1]) - float(benchmark.iloc[-1])) * 100, 2),
|
||||
"max_drawdown_pct": round(float(((cumulative / cumulative.cummax()) - 1).min()) * 100, 2),
|
||||
"sharpe_ratio": round(float(df["strategy_returns"].mean() / (df["strategy_returns"].std() + 1e-10) * (252 ** 0.5)), 2),
|
||||
"equity_curve": [float(x) for x in cumulative.tolist()],
|
||||
"benchmark_curve": [float(x) for x in benchmark.tolist()],
|
||||
"dates": df.index.strftime("%Y-%m-%d").tolist(),
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Commodity future analysis helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from server.utils.ticker_utils import COMMODITY_FUTURES
|
||||
|
||||
COMMODITY_RELATED: dict[str, list[str]] = {
|
||||
"GC=F": ["GLD", "SI=F", "DX-Y.NYB", "^TNX"],
|
||||
"CL=F": ["USO", "BZ=F", "XLE", "^GSPC"],
|
||||
"SI=F": ["SLV", "GC=F", "HG=F", "^GSPC"],
|
||||
"NG=F": ["UNG", "CL=F", "XLE"],
|
||||
}
|
||||
|
||||
|
||||
def _get_related_assets(ticker: str) -> list[str]:
|
||||
return COMMODITY_RELATED.get(ticker.upper(), [])
|
||||
|
||||
|
||||
async def compute_commodity_correlations(ticker: str, period: str = "1y") -> dict:
|
||||
import yfinance as yf
|
||||
|
||||
t = ticker.upper()
|
||||
related = _get_related_assets(t)
|
||||
if not related:
|
||||
return {}
|
||||
all_tickers = [t] + related
|
||||
data = yf.download(all_tickers, period=period, auto_adjust=True, progress=False)
|
||||
if data is None or data.empty:
|
||||
return {}
|
||||
close = data["Close"] if "Close" in data else data
|
||||
returns = close.pct_change().dropna()
|
||||
if returns is None or returns.empty or t not in returns.columns:
|
||||
return {}
|
||||
corr = returns.corr()
|
||||
result = {}
|
||||
for r in related:
|
||||
if r in corr.columns:
|
||||
result[r] = round(float(corr.loc[t, r]), 2)
|
||||
return result
|
||||
|
||||
|
||||
async def get_commodity_overview(ticker: str) -> dict:
|
||||
import yfinance as yf
|
||||
|
||||
t = ticker.upper()
|
||||
y = yf.Ticker(t)
|
||||
info = y.info or {}
|
||||
hist_1y = y.history(period="1y", auto_adjust=True)
|
||||
hist_10y = y.history(period="10y", auto_adjust=True)
|
||||
|
||||
seasonal = {}
|
||||
if hist_10y is not None and not hist_10y.empty:
|
||||
monthly = hist_10y["Close"].resample("ME").last().pct_change().dropna()
|
||||
for month in range(1, 13):
|
||||
m = monthly[monthly.index.month == month]
|
||||
seasonal[month] = round(float(m.mean()) * 100, 2) if len(m) > 0 else 0
|
||||
|
||||
related = _get_related_assets(t)
|
||||
related_cards = []
|
||||
if related:
|
||||
data = yf.download(related, period="5d", auto_adjust=True, progress=False)
|
||||
close = data["Close"] if hasattr(data, "columns") and "Close" in data.columns else data
|
||||
if close is not None:
|
||||
try:
|
||||
if hasattr(close, "columns"):
|
||||
for sym in related:
|
||||
if sym not in close.columns:
|
||||
continue
|
||||
s = close[sym].dropna()
|
||||
if len(s) < 1:
|
||||
continue
|
||||
cur = float(s.iloc[-1])
|
||||
prev = float(s.iloc[-2]) if len(s) > 1 else cur
|
||||
pct = ((cur - prev) / prev * 100) if prev else 0
|
||||
related_cards.append({"symbol": sym, "price": round(cur, 2), "change_pct": round(pct, 2)})
|
||||
else:
|
||||
s = close.dropna()
|
||||
if len(s) >= 1:
|
||||
cur = float(s.iloc[-1])
|
||||
prev = float(s.iloc[-2]) if len(s) > 1 else cur
|
||||
pct = ((cur - prev) / prev * 100) if prev else 0
|
||||
related_cards.append({"symbol": related[0], "price": round(cur, 2), "change_pct": round(pct, 2)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"name": COMMODITY_FUTURES.get(t, info.get("shortName", t)),
|
||||
"price": info.get("regularMarketPrice") or info.get("currentPrice"),
|
||||
"open_interest": info.get("openInterest"),
|
||||
"volume": info.get("volume"),
|
||||
"high_52w": info.get("fiftyTwoWeekHigh"),
|
||||
"low_52w": info.get("fiftyTwoWeekLow"),
|
||||
"seasonal_pattern": seasonal,
|
||||
"related_assets": related_cards,
|
||||
"correlation_matrix": await compute_commodity_correlations(t),
|
||||
"asset_class": "commodity_future",
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Build compact copilot context with asset-type aware fields."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def build_copilot_context(asset_type: str, data: dict) -> str:
|
||||
parts: list[str] = []
|
||||
if asset_type == "etf":
|
||||
parts.append(f"[Asset Type] ETF — {data.get('category')}")
|
||||
parts.append(f"[ETF] AUM: {data.get('aum')}, Expense: {data.get('expense_ratio')}")
|
||||
r = data.get("returns") or {}
|
||||
parts.append(f"[Performance] YTD: {r.get('ytd')}%, 1Y: {r.get('1y')}%")
|
||||
elif asset_type == "commodity_future":
|
||||
parts.append(f"[Asset Type] Commodity Future — {data.get('name')}")
|
||||
parts.append(f"[Commodity] Open Interest: {data.get('open_interest')}")
|
||||
seasonal = data.get("seasonal_pattern") or {}
|
||||
if seasonal:
|
||||
best_month = max(seasonal, key=lambda k: seasonal[k])
|
||||
worst_month = min(seasonal, key=lambda k: seasonal[k])
|
||||
parts.append(f"[Seasonal] Best month: {best_month}, Worst: {worst_month}")
|
||||
else:
|
||||
parts.append("[Asset Type] Equity")
|
||||
parts.append(f"[Sector] {data.get('sector')}")
|
||||
return "\n".join(parts)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""ETF and equity-like overview helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _safe_num(v: Any) -> float | None:
|
||||
try:
|
||||
f = float(v)
|
||||
if math.isnan(f) or math.isinf(f):
|
||||
return None
|
||||
return f
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _compute_sharpe(returns) -> float | None:
|
||||
if returns is None or len(returns) < 2:
|
||||
return None
|
||||
std = returns.std()
|
||||
if not std:
|
||||
return None
|
||||
return round(float((returns.mean() / std) * (252**0.5)), 2)
|
||||
|
||||
|
||||
def _compute_sortino(returns) -> float | None:
|
||||
if returns is None or len(returns) < 2:
|
||||
return None
|
||||
downside = returns[returns < 0]
|
||||
if downside is None or len(downside) < 2:
|
||||
return None
|
||||
std = downside.std()
|
||||
if not std:
|
||||
return None
|
||||
return round(float((returns.mean() / std) * (252**0.5)), 2)
|
||||
|
||||
|
||||
def _max_drawdown(returns) -> float | None:
|
||||
if returns is None or len(returns) < 2:
|
||||
return None
|
||||
curve = (1 + returns).cumprod()
|
||||
dd = (curve / curve.cummax()) - 1
|
||||
return round(float(dd.min()) * 100, 2)
|
||||
|
||||
|
||||
async def get_benchmark_comparison(ticker: str, benchmark: str = "SPY", period: str = "1y") -> dict:
|
||||
import yfinance as yf
|
||||
|
||||
data = yf.download([ticker.upper(), benchmark.upper()], period=period, auto_adjust=True, progress=False)
|
||||
if data is None or data.empty:
|
||||
return {}
|
||||
close = data["Close"] if "Close" in data else data
|
||||
if close is None or close.empty:
|
||||
return {}
|
||||
t_col = ticker.upper()
|
||||
b_col = benchmark.upper()
|
||||
if t_col not in close.columns or b_col not in close.columns:
|
||||
return {}
|
||||
close = close[[t_col, b_col]].dropna()
|
||||
if close.empty:
|
||||
return {}
|
||||
normalized = close / close.iloc[0] * 100
|
||||
return {
|
||||
"dates": normalized.index.strftime("%Y-%m-%d").tolist(),
|
||||
"ticker_values": [float(x) for x in normalized[t_col].tolist()],
|
||||
"benchmark_values": [float(x) for x in normalized[b_col].tolist()],
|
||||
"benchmark": benchmark.upper(),
|
||||
}
|
||||
|
||||
|
||||
async def get_etf_holdings(ticker: str, top_n: int = 10) -> list[dict]:
|
||||
import yfinance as yf
|
||||
|
||||
t = yf.Ticker(ticker.upper())
|
||||
out = []
|
||||
try:
|
||||
holdings = getattr(t, "fund_top_holdings", None)
|
||||
if holdings is not None and not holdings.empty:
|
||||
for _, row in holdings.head(top_n).iterrows():
|
||||
out.append(
|
||||
{
|
||||
"symbol": row.get("symbol") or row.get("holdingName") or "",
|
||||
"name": row.get("holdingName") or row.get("symbol") or "",
|
||||
"weight_pct": _safe_num(row.get("holdingPercent")),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
async def get_etf_overview(ticker: str) -> dict:
|
||||
import yfinance as yf
|
||||
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
hist = t.history(period="5y", auto_adjust=True)
|
||||
|
||||
def period_return(days: int) -> float | None:
|
||||
if hist is None or hist.empty or len(hist) <= days:
|
||||
return None
|
||||
cur = _safe_num(hist["Close"].iloc[-1])
|
||||
prev = _safe_num(hist["Close"].iloc[-days])
|
||||
if cur is None or prev is None or prev == 0:
|
||||
return None
|
||||
return round((cur / prev - 1) * 100, 2)
|
||||
|
||||
ytd_days = 0
|
||||
if hist is not None and not hist.empty:
|
||||
ytd_days = int((hist.index.year == hist.index[-1].year).sum())
|
||||
returns = hist["Close"].pct_change().dropna() if hist is not None and not hist.empty else None
|
||||
|
||||
return {
|
||||
"name": info.get("longName") or info.get("shortName", ticker.upper()),
|
||||
"category": info.get("category") or info.get("fundFamily") or "N/A",
|
||||
"aum": _safe_num(info.get("totalAssets")),
|
||||
"expense_ratio": _safe_num(info.get("annualReportExpenseRatio")),
|
||||
"nav": _safe_num(info.get("navPrice")),
|
||||
"inception": info.get("fundInceptionDate"),
|
||||
"price": _safe_num(info.get("currentPrice") or info.get("regularMarketPrice")),
|
||||
"high_52w": _safe_num(info.get("fiftyTwoWeekHigh")),
|
||||
"low_52w": _safe_num(info.get("fiftyTwoWeekLow")),
|
||||
"returns": {
|
||||
"1m": period_return(21),
|
||||
"3m": period_return(63),
|
||||
"6m": period_return(126),
|
||||
"ytd": period_return(ytd_days) if ytd_days else None,
|
||||
"1y": period_return(252),
|
||||
"3y": period_return(756),
|
||||
"5y": period_return(1260),
|
||||
},
|
||||
"holdings": await get_etf_holdings(ticker, top_n=10),
|
||||
"risk": {
|
||||
"sharpe": _compute_sharpe(returns),
|
||||
"sortino": _compute_sortino(returns),
|
||||
"max_drawdown": _max_drawdown(returns),
|
||||
"volatility": round(float(returns.std()) * (252**0.5) * 100, 2) if returns is not None and len(returns) > 1 else None,
|
||||
},
|
||||
"benchmark_comparison": await get_benchmark_comparison(ticker, "SPY", "1y"),
|
||||
}
|
||||
|
||||
|
||||
async def get_equity_overview(ticker: str) -> dict:
|
||||
import yfinance as yf
|
||||
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
return {
|
||||
"name": info.get("longName") or info.get("shortName", ticker.upper()),
|
||||
"sector": info.get("sector"),
|
||||
"industry": info.get("industry"),
|
||||
"market_cap": _safe_num(info.get("marketCap")),
|
||||
"pe_ratio": _safe_num(info.get("trailingPE")) or _safe_num(info.get("forwardPE")),
|
||||
"dividend_yield": _safe_num(info.get("dividendYield")),
|
||||
"beta": _safe_num(info.get("beta")),
|
||||
"high_52w": _safe_num(info.get("fiftyTwoWeekHigh")),
|
||||
"low_52w": _safe_num(info.get("fiftyTwoWeekLow")),
|
||||
"price": _safe_num(info.get("currentPrice") or info.get("regularMarketPrice")),
|
||||
"description": info.get("longBusinessSummary"),
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Resolve multi-exchange tickers for OCR/import workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
MULTI_EXCHANGE_TICKERS = {
|
||||
"SMSN": [
|
||||
{"exchange": "LSE (GDR)", "yf_ticker": "SMSN.L", "currency": "USD", "default": True},
|
||||
{"exchange": "KRX (Korea)", "yf_ticker": "005930.KS", "currency": "KRW"},
|
||||
{"exchange": "OTC (US)", "yf_ticker": "SSNLF", "currency": "USD"},
|
||||
],
|
||||
"NOV": [
|
||||
{"exchange": "NYSE", "yf_ticker": "NVO", "currency": "USD", "default": True},
|
||||
{"exchange": "Copenhagen", "yf_ticker": "NOVO-B.CO", "currency": "DKK"},
|
||||
],
|
||||
"NVO": [
|
||||
{"exchange": "NYSE", "yf_ticker": "NVO", "currency": "USD", "default": True},
|
||||
{"exchange": "Copenhagen", "yf_ticker": "NOVO-B.CO", "currency": "DKK"},
|
||||
],
|
||||
}
|
||||
|
||||
T212_TICKER_MAP = {
|
||||
"SMSN": "SMSN.L",
|
||||
"SMSN.L": "SMSN.L",
|
||||
"NOV": "NVO",
|
||||
"NVDA": "NVDA",
|
||||
"TSLA": "TSLA",
|
||||
"NVO": "NVO",
|
||||
"PLTR": "PLTR",
|
||||
"IONQ": "IONQ",
|
||||
"IREN": "IREN",
|
||||
}
|
||||
|
||||
|
||||
def get_exchange_options(ticker: str) -> list[dict]:
|
||||
return MULTI_EXCHANGE_TICKERS.get((ticker or "").upper(), [])
|
||||
|
||||
|
||||
def resolve_ticker_with_exchange(ticker: str, selected_exchange: str | None = None) -> str:
|
||||
t = (ticker or "").upper().strip()
|
||||
options = get_exchange_options(t)
|
||||
if not options:
|
||||
return T212_TICKER_MAP.get(t, t)
|
||||
if selected_exchange:
|
||||
for opt in options:
|
||||
if opt.get("exchange") == selected_exchange:
|
||||
return opt.get("yf_ticker", t)
|
||||
for opt in options:
|
||||
if opt.get("default"):
|
||||
return opt.get("yf_ticker", t)
|
||||
return options[0].get("yf_ticker", t)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Heatmap data service for index constituents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
|
||||
async def get_index_constituents(index_name: str) -> list[str]:
|
||||
name = (index_name or "").lower().strip()
|
||||
if name == "sp500":
|
||||
try:
|
||||
table = pd.read_html("https://en.wikipedia.org/wiki/List_of_S%26P_500_companies")[0]
|
||||
return table["Symbol"].astype(str).str.replace(".", "-", regex=False).tolist()
|
||||
except Exception:
|
||||
return ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "BRK-B", "TSLA", "UNH", "XOM"]
|
||||
if name == "nasdaq100":
|
||||
try:
|
||||
table = pd.read_html("https://en.wikipedia.org/wiki/Nasdaq-100")[4]
|
||||
return table["Ticker"].astype(str).tolist()
|
||||
except Exception:
|
||||
return ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "AVGO", "COST", "NFLX"]
|
||||
if name == "kospi":
|
||||
return [
|
||||
"005930.KS", "000660.KS", "035420.KS", "051910.KS", "006400.KS",
|
||||
"035720.KS", "068270.KS", "028260.KS", "105560.KS", "012330.KS",
|
||||
"055550.KS", "034730.KS", "003550.KS", "015760.KS", "066570.KS",
|
||||
"032830.KS", "096770.KS", "009150.KS", "003670.KS", "018260.KS",
|
||||
]
|
||||
if name == "ftse100":
|
||||
return ["SHEL.L", "AZN.L", "HSBA.L", "ULVR.L", "BP.L", "GSK.L", "RIO.L", "LSEG.L"]
|
||||
return []
|
||||
|
||||
|
||||
def _calc_change_pct(ticker: str) -> float:
|
||||
try:
|
||||
hist = yf.Ticker(ticker).history(period="2d")
|
||||
if hist is not None and len(hist) >= 2:
|
||||
prev = float(hist["Close"].iloc[-2])
|
||||
cur = float(hist["Close"].iloc[-1])
|
||||
if prev != 0:
|
||||
return round((cur - prev) / prev * 100, 2)
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
async def get_heatmap_data(index_name: str, top_n: int = 50) -> list[dict[str, Any]]:
|
||||
tickers = (await get_index_constituents(index_name))[: max(top_n, 1)]
|
||||
out: list[dict[str, Any]] = []
|
||||
for ticker in tickers:
|
||||
try:
|
||||
info = yf.Ticker(ticker).info or {}
|
||||
mcap = info.get("marketCap")
|
||||
if not mcap or float(mcap) <= 0:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"ticker": ticker.replace(".KS", "").replace(".L", ""),
|
||||
"name": info.get("shortName") or info.get("longName") or ticker,
|
||||
"sector": info.get("sector") or "Other",
|
||||
"market_cap": float(mcap),
|
||||
"change_pct": _calc_change_pct(ticker),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
return sorted(out, key=lambda x: x["market_cap"], reverse=True)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Market overview service: indices, commodities, bonds, crypto, FX."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
INDICES = {
|
||||
"S&P 500": "^GSPC",
|
||||
"NASDAQ": "^IXIC",
|
||||
"Dow Jones": "^DJI",
|
||||
"KOSPI": "^KS11",
|
||||
"Nikkei 225": "^N225",
|
||||
"FTSE 100": "^FTSE",
|
||||
"DAX": "^GDAXI",
|
||||
"Hang Seng": "^HSI",
|
||||
}
|
||||
COMMODITIES = {"Gold": "GC=F", "Oil (WTI)": "CL=F", "Silver": "SI=F", "Nat Gas": "NG=F"}
|
||||
BONDS = {"US 10Y": "^TNX", "US 2Y": "^IRX"}
|
||||
CRYPTO = {"Bitcoin": "BTC-USD", "Ethereum": "ETH-USD"}
|
||||
FX = {"EUR/USD": "EURUSD=X", "GBP/USD": "GBPUSD=X", "USD/JPY": "USDJPY=X", "USD/KRW": "USDKRW=X"}
|
||||
POPULAR_ETFS = {"SPY": "SPY", "QQQ": "QQQ", "GLD": "GLD", "TLT": "TLT", "EEM": "EEM"}
|
||||
|
||||
|
||||
async def get_market_overview() -> dict:
|
||||
"""Fetch concise multi-asset market overview from yfinance."""
|
||||
import yfinance as yf
|
||||
|
||||
results = {}
|
||||
for category, tickers in [
|
||||
("indices", INDICES),
|
||||
("commodities", COMMODITIES),
|
||||
("bonds", BONDS),
|
||||
("crypto", CRYPTO),
|
||||
("fx", FX),
|
||||
("popular_etfs", POPULAR_ETFS),
|
||||
]:
|
||||
cat_data = []
|
||||
for name, symbol in tickers.items():
|
||||
try:
|
||||
t = yf.Ticker(symbol)
|
||||
hist = t.history(period="5d")
|
||||
if hist is None or hist.empty:
|
||||
continue
|
||||
current = float(hist["Close"].iloc[-1])
|
||||
prev = float(hist["Close"].iloc[-2]) if len(hist) > 1 else current
|
||||
change_pct = ((current - prev) / prev * 100) if prev else 0.0
|
||||
cat_data.append(
|
||||
{
|
||||
"name": name,
|
||||
"symbol": symbol,
|
||||
"price": round(current, 2),
|
||||
"change_pct": round(change_pct, 2),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
results[category] = cat_data
|
||||
return results
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Stock screener service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
async def run_screener(filters: dict, universe: str = "sp500") -> list[dict]:
|
||||
"""Run simple screening against S&P 500 universe."""
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
try:
|
||||
table = pd.read_html("https://en.wikipedia.org/wiki/List_of_S%26P_500_companies")[0]
|
||||
tickers = table["Symbol"].astype(str).tolist()
|
||||
except Exception:
|
||||
tickers = []
|
||||
|
||||
results = []
|
||||
for ticker in tickers:
|
||||
try:
|
||||
info = yf.Ticker(ticker).info or {}
|
||||
pe = info.get("forwardPE")
|
||||
mcap = info.get("marketCap")
|
||||
sector = info.get("sector")
|
||||
div = info.get("dividendYield")
|
||||
if filters.get("pe_max") and ((pe or 9999) > filters["pe_max"]):
|
||||
continue
|
||||
if filters.get("sector") and sector != filters["sector"]:
|
||||
continue
|
||||
if filters.get("market_cap_min") and ((mcap or 0) < filters["market_cap_min"]):
|
||||
continue
|
||||
if filters.get("div_yield_min") and (((div or 0) * 100) < filters["div_yield_min"]):
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"ticker": ticker,
|
||||
"name": info.get("shortName", ""),
|
||||
"sector": sector or "",
|
||||
"market_cap": mcap,
|
||||
"pe": pe,
|
||||
"div_yield": (div * 100) if div is not None else None,
|
||||
"price": info.get("currentPrice") or info.get("regularMarketPrice"),
|
||||
"change_pct": info.get("regularMarketChangePercent"),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
return results
|
||||
@@ -1,145 +1,291 @@
|
||||
"""Portfolio screenshot OCR using Gemini Vision.
|
||||
"""Portfolio OCR with smart reverse-engineering against live market prices."""
|
||||
|
||||
Analyses screenshots from Trading 212 or Interactive Brokers (IBKR) portfolio
|
||||
views and extracts structured position data (ticker, quantity, market value,
|
||||
gain/loss) via the Gemini multimodal API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import yfinance as yf
|
||||
from server.services.exchange_resolver import resolve_ticker_with_exchange
|
||||
|
||||
def _get_vision_model(api_key: str) -> Any:
|
||||
"""Configure Gemini and return a multimodal model."""
|
||||
import google.generativeai as genai
|
||||
genai.configure(api_key=api_key)
|
||||
return genai.GenerativeModel("gemini-2.0-flash")
|
||||
SCREENSHOT_OCR_PROMPT = """
|
||||
Analyze this screenshot of a stock trading app portfolio (Trading 212, IBKR, Webull, etc).
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
- Extract ALL positions visible in the image. There are likely 5-15 positions.
|
||||
- Do NOT stop after the first position. Keep going until every position is captured.
|
||||
- You MUST extract ALL positions visible in the screenshot.
|
||||
- If you see 8 positions in the image, you MUST return exactly 8 objects in the positions array.
|
||||
- The account currency shown at the top (£, $, €) may differ from individual stock currencies.
|
||||
|
||||
def _build_prompt() -> str:
|
||||
"""Return the extraction prompt for portfolio screenshots."""
|
||||
return """You are a financial data extraction assistant.
|
||||
For EACH position, extract:
|
||||
1. ticker: Stock ticker symbol exactly as shown (e.g., "IREN", "NVDA", "SMSN")
|
||||
2. name: Company name
|
||||
3. displayed_value: The monetary value shown (number only, no currency symbol)
|
||||
4. displayed_currency: Currency symbol next to the value (£, $, €, ₩, ¥)
|
||||
5. weight_pct: Portfolio weight % if shown (e.g., 28.66)
|
||||
6. gain_loss_pct: P&L percentage if shown (e.g., -16.27 or +8.80)
|
||||
7. gain_loss_amount: P&L monetary amount (number only)
|
||||
8. shares: Number of shares if visible (preserve ALL decimals)
|
||||
9. avg_price: Average purchase price if visible (number only)
|
||||
10. avg_price_currency: Currency of avg price
|
||||
|
||||
Analyse this portfolio screenshot from a brokerage app (Trading 212,
|
||||
Interactive Brokers, or similar).
|
||||
ALSO extract portfolio summary from the top of the screen:
|
||||
- total_value: Total portfolio value (number only)
|
||||
- total_currency: Currency symbol (£, $, €)
|
||||
- cost_basis: Cost basis if shown (number only)
|
||||
- unrealised_pnl: Unrealised P&L (number only)
|
||||
- unrealised_pnl_pct: P&L percentage
|
||||
|
||||
Extract every visible position and return ONLY a valid JSON object with
|
||||
this structure:
|
||||
|
||||
{
|
||||
"broker": "Trading 212" | "IBKR" | "Unknown",
|
||||
"currency": "USD" | "GBP" | "EUR" | ...,
|
||||
"positions": [
|
||||
{
|
||||
"ticker": "AAPL",
|
||||
"name": "Apple Inc.",
|
||||
"quantity": 10.5,
|
||||
"avg_price": 150.00,
|
||||
"current_price": 175.00,
|
||||
"market_value": 1837.50,
|
||||
"gain_loss": 262.50,
|
||||
"gain_loss_pct": 16.67
|
||||
}
|
||||
],
|
||||
"total_value": 50000.00,
|
||||
"total_gain_loss": 5000.00
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Use null for any field you cannot read.
|
||||
- quantity may be fractional (e.g. 0.125 shares).
|
||||
- Monetary values should be plain numbers, no currency symbols.
|
||||
- If the screenshot is not a portfolio view, return {"error": "Not a portfolio screenshot"}.
|
||||
- Output ONLY the JSON object, nothing else.
|
||||
Return ONLY valid JSON, no other text.
|
||||
"""
|
||||
|
||||
def _norm_currency(sym: str | None, default: str = "USD") -> str:
|
||||
s = (sym or "").strip().upper()
|
||||
mapping = {"£": "GBP", "$": "USD", "€": "EUR", "₩": "KRW", "¥": "JPY"}
|
||||
return mapping.get(s, s or default)
|
||||
|
||||
def analyze_portfolio_screenshot(
|
||||
api_key: str,
|
||||
image_bytes: bytes,
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract portfolio positions from a brokerage screenshot.
|
||||
|
||||
Uses Gemini Vision (multimodal) to read the image and return
|
||||
structured position data.
|
||||
def _resolve_ticker(t212_ticker: str) -> str:
|
||||
return resolve_ticker_with_exchange(t212_ticker, None)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
api_key:
|
||||
Google Gemini API key.
|
||||
image_bytes:
|
||||
Raw bytes of the screenshot image (PNG, JPEG, etc.).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Parsed portfolio data with ``broker``, ``currency``,
|
||||
``positions`` (list), ``total_value``, and ``total_gain_loss``.
|
||||
On error, returns ``{"error": "<description>"}``.
|
||||
"""
|
||||
def _get_realtime_price(ticker: str) -> Optional[dict]:
|
||||
try:
|
||||
yf_ticker = _resolve_ticker(ticker)
|
||||
t = yf.Ticker(yf_ticker)
|
||||
info = t.info or {}
|
||||
price = info.get("currentPrice") or info.get("regularMarketPrice") or info.get("previousClose")
|
||||
currency = (info.get("currency") or "USD").upper()
|
||||
if price is None:
|
||||
fast = getattr(t, "fast_info", None)
|
||||
if fast:
|
||||
price = getattr(fast, "last_price", None)
|
||||
if price is None:
|
||||
hist = t.history(period="1d")
|
||||
if hist is not None and not hist.empty:
|
||||
price = float(hist["Close"].iloc[-1])
|
||||
if price is None:
|
||||
return None
|
||||
return {"price": float(price), "currency": currency, "yf_ticker": yf_ticker}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_fx_rate(from_currency: str, to_currency: str) -> float:
|
||||
f = _norm_currency(from_currency)
|
||||
t = _norm_currency(to_currency)
|
||||
if f == t:
|
||||
return 1.0
|
||||
try:
|
||||
pair = f"{f}{t}=X"
|
||||
hist = yf.Ticker(pair).history(period="1d")
|
||||
if hist is not None and not hist.empty:
|
||||
return float(hist["Close"].iloc[-1])
|
||||
rev = f"{t}{f}=X"
|
||||
hist2 = yf.Ticker(rev).history(period="1d")
|
||||
if hist2 is not None and not hist2.empty:
|
||||
return 1.0 / float(hist2["Close"].iloc[-1])
|
||||
except Exception:
|
||||
pass
|
||||
fallback = {
|
||||
("GBP", "USD"): 1.27, ("USD", "GBP"): 0.79,
|
||||
("EUR", "USD"): 1.08, ("USD", "EUR"): 0.93,
|
||||
("USD", "KRW"): 1370.0, ("KRW", "USD"): 0.00073,
|
||||
("USD", "JPY"): 149.5, ("JPY", "USD"): 0.0067,
|
||||
}
|
||||
return fallback.get((f, t), 1.0)
|
||||
|
||||
|
||||
def reverse_engineer_positions(ocr_result: dict, exchange_overrides: dict[str, str] | None = None) -> list[dict]:
|
||||
account_currency = _norm_currency(ocr_result.get("account_currency"), "USD")
|
||||
out: list[dict] = []
|
||||
for pos in ocr_result.get("positions", []) or []:
|
||||
ticker = (pos.get("ticker") or "").upper().strip()
|
||||
if not ticker:
|
||||
continue
|
||||
selected_exchange = (exchange_overrides or {}).get(ticker)
|
||||
yf_ticker = resolve_ticker_with_exchange(ticker, selected_exchange)
|
||||
mkt = _get_realtime_price(yf_ticker)
|
||||
if not mkt:
|
||||
out.append({
|
||||
"ticker": ticker,
|
||||
"name": pos.get("name") or ticker,
|
||||
"quantity": pos.get("shares"),
|
||||
"avg_price": pos.get("avg_price"),
|
||||
"avg_price_currency": _norm_currency(pos.get("avg_price_currency"), "USD"),
|
||||
"current_price": None,
|
||||
"stock_currency": "USD",
|
||||
"account_currency": account_currency,
|
||||
"current_value_account": pos.get("displayed_value"),
|
||||
"pnl_pct": pos.get("gain_loss_pct"),
|
||||
"confidence": "low",
|
||||
"method": "ocr_only",
|
||||
"yf_ticker": yf_ticker,
|
||||
})
|
||||
continue
|
||||
|
||||
stock_price = float(mkt["price"])
|
||||
stock_currency = _norm_currency(mkt["currency"], "USD")
|
||||
shares = pos.get("shares")
|
||||
confidence = "high"
|
||||
method = "ocr_shares"
|
||||
|
||||
if not shares:
|
||||
displayed_value = pos.get("displayed_value")
|
||||
displayed_currency = _norm_currency(pos.get("displayed_currency"), account_currency)
|
||||
if displayed_value and float(displayed_value) > 0:
|
||||
v_stock = float(displayed_value) * _get_fx_rate(displayed_currency, stock_currency)
|
||||
shares = v_stock / stock_price if stock_price > 0 else None
|
||||
confidence = "medium"
|
||||
method = "reverse_from_value"
|
||||
else:
|
||||
shares = None
|
||||
confidence = "low"
|
||||
method = "unknown"
|
||||
|
||||
avg_price = pos.get("avg_price")
|
||||
avg_currency = _norm_currency(pos.get("avg_price_currency"), stock_currency)
|
||||
avg_price_stock = None
|
||||
avg_method = "ocr_avg"
|
||||
if avg_price:
|
||||
avg_price_stock = float(avg_price) * _get_fx_rate(avg_currency, stock_currency)
|
||||
else:
|
||||
gain_loss_pct = pos.get("gain_loss_pct")
|
||||
gain_loss_amount = pos.get("gain_loss_amount")
|
||||
displayed_value = pos.get("displayed_value")
|
||||
displayed_currency = _norm_currency(pos.get("displayed_currency"), account_currency)
|
||||
|
||||
# Method 1: reverse from PnL %
|
||||
try:
|
||||
if gain_loss_pct is not None and stock_price is not None:
|
||||
gl_pct = float(gain_loss_pct)
|
||||
denom = 1 + (gl_pct / 100.0)
|
||||
if abs(denom) > 1e-9:
|
||||
avg_price_stock = stock_price / denom
|
||||
avg_method = "reverse_from_pnl_pct"
|
||||
except Exception:
|
||||
avg_price_stock = None
|
||||
|
||||
# Method 2: reverse from displayed value and pnl amount
|
||||
if avg_price_stock is None:
|
||||
try:
|
||||
if gain_loss_amount is not None and displayed_value is not None and shares and float(shares) > 0:
|
||||
cost_basis_display = float(displayed_value) - float(gain_loss_amount)
|
||||
fx = _get_fx_rate(displayed_currency, stock_currency)
|
||||
cost_basis_stock = cost_basis_display * fx
|
||||
avg_price_stock = cost_basis_stock / float(shares)
|
||||
avg_method = "reverse_from_pnl_amount"
|
||||
except Exception:
|
||||
avg_price_stock = None
|
||||
|
||||
# Method 3: fallback to current price
|
||||
if avg_price_stock is None:
|
||||
avg_price_stock = stock_price
|
||||
avg_method = "fallback_current_price"
|
||||
|
||||
if shares and pos.get("displayed_value"):
|
||||
displayed = float(pos["displayed_value"])
|
||||
displayed_currency = _norm_currency(pos.get("displayed_currency"), account_currency)
|
||||
calc_value = float(shares) * stock_price * _get_fx_rate(stock_currency, displayed_currency)
|
||||
err = abs(calc_value - displayed) / displayed * 100 if displayed > 0 else 999
|
||||
if err > 10 and stock_price > 0:
|
||||
shares = displayed * _get_fx_rate(displayed_currency, stock_currency) / stock_price
|
||||
confidence = "medium"
|
||||
method = "reverse_recalculated"
|
||||
|
||||
total_pnl = None
|
||||
pnl_pct = pos.get("gain_loss_pct")
|
||||
if shares and avg_price_stock and stock_price:
|
||||
pnl_per_share = stock_price - avg_price_stock
|
||||
total_pnl = pnl_per_share * float(shares)
|
||||
pnl_pct = (pnl_per_share / avg_price_stock) * 100 if avg_price_stock > 0 else None
|
||||
|
||||
cur_val = None
|
||||
if shares:
|
||||
cur_val = float(shares) * stock_price * _get_fx_rate(stock_currency, account_currency)
|
||||
|
||||
# If avg is reconstructed and shares are available, promote confidence.
|
||||
if confidence == "medium" and avg_method in {"reverse_from_pnl_pct", "reverse_from_pnl_amount"} and shares:
|
||||
confidence = "high"
|
||||
|
||||
out.append({
|
||||
"ticker": ticker,
|
||||
"name": pos.get("name") or ticker,
|
||||
"quantity": round(float(shares), 6) if shares else None,
|
||||
"avg_price": round(float(avg_price_stock), 4) if avg_price_stock is not None else avg_price,
|
||||
"avg_price_currency": stock_currency,
|
||||
"current_price": round(stock_price, 2),
|
||||
"stock_currency": stock_currency,
|
||||
"account_currency": account_currency,
|
||||
"current_value_account": round(cur_val, 2) if cur_val is not None else None,
|
||||
"total_pnl": round(float(total_pnl), 2) if total_pnl is not None else None,
|
||||
"pnl_pct": round(float(pnl_pct), 2) if pnl_pct is not None else None,
|
||||
"weight_pct": pos.get("weight_pct"),
|
||||
"confidence": confidence,
|
||||
"method": method,
|
||||
"avg_method": avg_method,
|
||||
"yf_ticker": yf_ticker,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _detect_mime(image_bytes: bytes) -> str:
|
||||
if image_bytes[:3] == b"\xff\xd8\xff":
|
||||
return "image/jpeg"
|
||||
if image_bytes[:4] == b"RIFF":
|
||||
return "image/webp"
|
||||
return "image/png"
|
||||
|
||||
|
||||
def _parse_llm_json(text: str) -> dict:
|
||||
raw = (text or "").strip()
|
||||
raw = re.sub(r"^```json\s*", "", raw, flags=re.I)
|
||||
raw = re.sub(r"^```\s*", "", raw)
|
||||
raw = re.sub(r"\s*```$", "", raw)
|
||||
return json.loads(raw.strip())
|
||||
|
||||
|
||||
async def process_portfolio_screenshot(api_key: str, image_bytes: bytes) -> dict:
|
||||
if not api_key or not api_key.strip():
|
||||
return {"error": "API key is required."}
|
||||
if not image_bytes:
|
||||
return {"error": "No image data provided."}
|
||||
|
||||
try:
|
||||
model = _get_vision_model(api_key)
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to initialise Gemini Vision: {e}"}
|
||||
|
||||
prompt = _build_prompt()
|
||||
|
||||
# Build multimodal content: image + text prompt
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
|
||||
# Detect MIME type from magic bytes
|
||||
mime_type = "image/png"
|
||||
if image_bytes[:3] == b"\xff\xd8\xff":
|
||||
mime_type = "image/jpeg"
|
||||
elif image_bytes[:4] == b"\x89PNG":
|
||||
mime_type = "image/png"
|
||||
elif image_bytes[:4] == b"RIFF":
|
||||
mime_type = "image/webp"
|
||||
|
||||
image_part = {"mime_type": mime_type, "data": image_bytes}
|
||||
response = model.generate_content(
|
||||
[image_part, prompt],
|
||||
generation_config={"temperature": 0.0, "max_output_tokens": 4096},
|
||||
genai.configure(api_key=api_key)
|
||||
model = genai.GenerativeModel("gemini-2.0-flash")
|
||||
image_part = {"mime_type": _detect_mime(image_bytes), "data": image_bytes}
|
||||
response = await asyncio.to_thread(
|
||||
model.generate_content,
|
||||
[image_part, SCREENSHOT_OCR_PROMPT],
|
||||
generation_config={"temperature": 0.0, "max_output_tokens": 8192},
|
||||
)
|
||||
|
||||
raw = (response.text or "").strip()
|
||||
if not raw:
|
||||
return {"error": "Gemini returned an empty response."}
|
||||
|
||||
# Strip markdown code fences if present
|
||||
raw = re.sub(r"^```\s*json\s*", "", raw)
|
||||
raw = re.sub(r"^```\s*", "", raw)
|
||||
raw = re.sub(r"\s*```\s*$", "", raw)
|
||||
raw = raw.strip()
|
||||
|
||||
result: Dict[str, Any] = json.loads(raw)
|
||||
|
||||
# Validate structure
|
||||
if "error" in result:
|
||||
return result
|
||||
if "positions" not in result:
|
||||
return {"error": "Response missing 'positions' key.", "raw": raw}
|
||||
|
||||
# Coerce numeric fields
|
||||
for pos in result.get("positions", []):
|
||||
for key in ("quantity", "avg_price", "current_price", "market_value", "gain_loss", "gain_loss_pct"):
|
||||
val = pos.get(key)
|
||||
if val is not None:
|
||||
try:
|
||||
pos[key] = float(val)
|
||||
except (TypeError, ValueError):
|
||||
pos[key] = None
|
||||
|
||||
return result
|
||||
|
||||
parsed = _parse_llm_json(response.text or "")
|
||||
except json.JSONDecodeError:
|
||||
return {"error": "Failed to parse JSON from Gemini response.", "raw": raw}
|
||||
return {"error": "Failed to parse OCR result."}
|
||||
except Exception as e:
|
||||
return {"error": f"Screenshot analysis failed: {e}"}
|
||||
return {"error": f"OCR model call failed: {e}"}
|
||||
|
||||
enriched = reverse_engineer_positions(parsed)
|
||||
warnings = []
|
||||
for p in enriched:
|
||||
if p.get("confidence") == "low":
|
||||
warnings.append(f"{p.get('ticker')}: Low confidence (market verify failed)")
|
||||
if p.get("method") == "reverse_recalculated":
|
||||
warnings.append(f"{p.get('ticker')}: Quantity recalculated due to >10% mismatch")
|
||||
|
||||
return {
|
||||
"account_currency": _norm_currency(parsed.get("account_currency"), "USD"),
|
||||
"total_value": {
|
||||
"amount": parsed.get("total_value"),
|
||||
"currency": _norm_currency(parsed.get("account_currency"), "USD"),
|
||||
},
|
||||
"positions": enriched,
|
||||
"warnings": warnings,
|
||||
"raw_ocr": parsed,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Sector performance heatmap service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
SECTOR_ETFS = {
|
||||
"Technology": "XLK",
|
||||
"Healthcare": "XLV",
|
||||
"Financials": "XLF",
|
||||
"Consumer Disc.": "XLY",
|
||||
"Industrials": "XLI",
|
||||
"Energy": "XLE",
|
||||
"Utilities": "XLU",
|
||||
"Materials": "XLB",
|
||||
"Real Estate": "XLRE",
|
||||
"Comm. Services": "XLC",
|
||||
"Consumer Staples": "XLP",
|
||||
}
|
||||
|
||||
|
||||
async def get_sector_heatmap() -> list[dict]:
|
||||
"""Return daily percent change for major US sector ETFs."""
|
||||
import yfinance as yf
|
||||
|
||||
results = []
|
||||
for sector, etf in SECTOR_ETFS.items():
|
||||
try:
|
||||
hist = yf.Ticker(etf).history(period="2d")
|
||||
if hist is None or len(hist) < 2:
|
||||
continue
|
||||
prev = float(hist["Close"].iloc[-2])
|
||||
cur = float(hist["Close"].iloc[-1])
|
||||
change = ((cur - prev) / prev * 100) if prev else 0.0
|
||||
results.append({"sector": sector, "etf": etf, "change_pct": round(change, 2)})
|
||||
except Exception:
|
||||
continue
|
||||
return results
|
||||
@@ -5,6 +5,7 @@ Yahoo Finance-compatible identifiers with the correct market suffix,
|
||||
and provides the static lookup tables for companies and sectors.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import List, Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -41,6 +42,41 @@ MARKET_OPTIONS: List[str] = [
|
||||
"UK (LSE)",
|
||||
]
|
||||
|
||||
|
||||
class AssetType(str, Enum):
|
||||
EQUITY = "equity"
|
||||
ETF = "etf"
|
||||
COMMODITY_FUTURE = "commodity_future"
|
||||
CRYPTO = "crypto"
|
||||
INDEX = "index"
|
||||
|
||||
|
||||
COMMODITY_FUTURES: dict[str, str] = {
|
||||
"GC=F": "Gold", "SI=F": "Silver", "PL=F": "Platinum", "PA=F": "Palladium",
|
||||
"CL=F": "Crude Oil (WTI)", "BZ=F": "Brent Crude", "NG=F": "Natural Gas",
|
||||
"HO=F": "Heating Oil", "RB=F": "Gasoline",
|
||||
"ZC=F": "Corn", "ZS=F": "Soybeans", "ZW=F": "Wheat",
|
||||
"KC=F": "Coffee", "CT=F": "Cotton", "SB=F": "Sugar",
|
||||
"CC=F": "Cocoa", "OJ=F": "Orange Juice",
|
||||
"LE=F": "Live Cattle", "HE=F": "Lean Hogs",
|
||||
"HG=F": "Copper", "ALI=F": "Aluminum",
|
||||
}
|
||||
|
||||
POPULAR_COMMODITY_ETFS: dict[str, str] = {
|
||||
"GLD": "SPDR Gold Trust", "IAU": "iShares Gold Trust", "SLV": "iShares Silver Trust",
|
||||
"PPLT": "abrdn Platinum ETF", "USO": "United States Oil Fund", "UNG": "United States Natural Gas Fund",
|
||||
"XLE": "Energy Select Sector SPDR", "VDE": "Vanguard Energy ETF", "DBC": "Invesco DB Commodity Tracking",
|
||||
"GSG": "iShares S&P GSCI Commodity", "PDBC": "Invesco Optimum Yield Diversified Commodity",
|
||||
"COM": "Direxion Auspice Broad Commodity", "DBA": "Invesco DB Agriculture Fund",
|
||||
"WEAT": "Teucrium Wheat Fund", "CORN": "Teucrium Corn Fund", "SOYB": "Teucrium Soybean Fund",
|
||||
"SPY": "S&P 500 ETF", "QQQ": "Nasdaq 100 ETF", "IWM": "Russell 2000 ETF",
|
||||
"EEM": "Emerging Markets ETF", "VWO": "Vanguard FTSE Emerging Markets",
|
||||
"TLT": "20+ Year Treasury Bond ETF", "HYG": "High Yield Corporate Bond ETF",
|
||||
"LQD": "Investment Grade Corporate Bond ETF", "ARKK": "ARK Innovation ETF",
|
||||
"XLK": "Technology Select Sector SPDR", "XLF": "Financial Select Sector SPDR",
|
||||
"XLV": "Health Care Select Sector SPDR",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sector / industry peer groups (top-down analysis)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -120,3 +156,28 @@ def infer_market_from_ticker(ticker: str) -> str:
|
||||
if t.endswith(".L"):
|
||||
return "UK (LSE)"
|
||||
return "US (S&P/Dow/Nasdaq)"
|
||||
|
||||
|
||||
def detect_asset_type(ticker: str) -> AssetType:
|
||||
"""Detect asset type by ticker pattern and quoteType fallback."""
|
||||
t = (ticker or "").strip().upper()
|
||||
if not t:
|
||||
return AssetType.EQUITY
|
||||
if t.endswith("=F") or t in COMMODITY_FUTURES:
|
||||
return AssetType.COMMODITY_FUTURE
|
||||
if t.endswith("-USD") or t.endswith("-KRW"):
|
||||
return AssetType.CRYPTO
|
||||
if t.startswith("^"):
|
||||
return AssetType.INDEX
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
info = yf.Ticker(t).info or {}
|
||||
quote_type = str(info.get("quoteType", "")).upper()
|
||||
if quote_type in {"ETF", "MUTUALFUND"}:
|
||||
return AssetType.ETF
|
||||
except Exception:
|
||||
pass
|
||||
if t in POPULAR_COMMODITY_ETFS:
|
||||
return AssetType.ETF
|
||||
return AssetType.EQUITY
|
||||
|
||||
Reference in New Issue
Block a user