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

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

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