mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-09 08:47:44 +00:00
feat: add 4-tier auto-valuation system for negative FCF companies + platform-wide improvements
Report page now auto-detects valuation tier based on company financials: - Tier 1 (FCF > 0): Traditional DCF analysis - Tier 2 (EBITDA > 0): EV/EBITDA relative valuation with Bear/Base/Bull scenarios - Tier 3 (Rev Growth > 10%): P/S revenue-based valuation - Tier 4 (all weak): P/B / NAV approach Includes RelativeValuationSection, PathToProfitability components, margin trajectory chart, and cash runway analysis. Also includes fixes across earnings, macro, screener, technical, filings pages and backend routers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,10 @@ import { TickerBar } from "./ticker-bar";
|
||||
import { ChatPanel } from "./chat-panel";
|
||||
|
||||
/**
|
||||
* Sidebar는 usePathname()을 씁니다. Next App Router에서 SSR 출력과 클라이언트 첫 페인트가
|
||||
* 미묘하게 어긋나면 hydration 실패 → 전체 트리가 비거나(흰 화면) 콘솔에 recoverable 에러가 납니다.
|
||||
* 서버에서는 사이드바를 그리지 않고(ssr: false) 클라이언트에서만 마운트해 그 클래스의 버그를 제거합니다.
|
||||
* Sidebar uses usePathname(). In Next App Router, if SSR output and client first paint
|
||||
* mismatch even slightly, hydration fails — the whole tree empties (white screen) or
|
||||
* console shows recoverable errors. We skip sidebar on server (ssr: false) and mount
|
||||
* only on client to eliminate this class of bugs.
|
||||
*/
|
||||
const SidebarClient = dynamic(
|
||||
() => import("./sidebar").then((m) => ({ default: m.Sidebar })),
|
||||
|
||||
@@ -44,11 +44,11 @@ export function ChatPanel() {
|
||||
} else {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: "Settings에서 Gemini API Key를 설정해주세요." },
|
||||
{ role: "assistant", content: "Please set your Gemini API Key in Settings." },
|
||||
]);
|
||||
}
|
||||
} catch {
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: "연결 오류. 다시 시도해주세요." }]);
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: "Connection error. Please try again." }]);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
|
||||
{ label: "Sector", value: String(sector?.sector ?? "—") },
|
||||
{ label: "Industry", value: String(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: "P/E (TTM)", value: sector?.pe_ratio != null ? Number(sector.pe_ratio).toFixed(1) : "—" },
|
||||
{ label: "P/E (NTM)", value: sector?.forward_pe != null ? Number(sector.forward_pe).toFixed(1) : "—" },
|
||||
{ label: "PEG Ratio", value: sector?.peg_ratio != null ? Number(sector.peg_ratio).toFixed(2) : "—" },
|
||||
{ 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)}` : "—" },
|
||||
@@ -56,6 +58,7 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ConsensusGauge sector={sector} />
|
||||
<KpiSection data={kpiData} />
|
||||
<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) : "—"} />
|
||||
@@ -88,6 +91,64 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
|
||||
);
|
||||
}
|
||||
|
||||
function ConsensusGauge({ sector }: { sector: Record<string, unknown> | null }) {
|
||||
const current = sector?.current_price != null ? Number(sector.current_price) : null;
|
||||
const target = sector?.target_mean_price != null ? Number(sector.target_mean_price) : null;
|
||||
const low = sector?.target_low_price != null ? Number(sector.target_low_price) : null;
|
||||
const high = sector?.target_high_price != null ? Number(sector.target_high_price) : null;
|
||||
const rec = sector?.recommendation as string | undefined;
|
||||
const count = sector?.analyst_count != null ? Number(sector.analyst_count) : null;
|
||||
|
||||
if (!current || !target) return null;
|
||||
|
||||
const upside = ((target - current) / current) * 100;
|
||||
const upsideColor = upside >= 0 ? "text-accent-green" : "text-accent-red";
|
||||
const recLabel = rec ? rec.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) : "—";
|
||||
|
||||
// gauge position: map current price within [low, high] range
|
||||
const gaugeLow = low ?? target * 0.7;
|
||||
const gaugeHigh = high ?? target * 1.3;
|
||||
const range = gaugeHigh - gaugeLow;
|
||||
const currentPct = range > 0 ? Math.max(0, Math.min(100, ((current - gaugeLow) / range) * 100)) : 50;
|
||||
const targetPct = range > 0 ? Math.max(0, Math.min(100, ((target - gaugeLow) / range) * 100)) : 50;
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-text-secondary text-sm font-semibold">Analyst Consensus</h3>
|
||||
{count != null && <span className="text-text-muted text-xs">{count} analysts</span>}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-4 mb-4">
|
||||
<div>
|
||||
<span className="text-text-muted text-xs block">Target</span>
|
||||
<span className="text-2xl font-mono font-bold text-text-primary">${target.toFixed(2)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted text-xs block">Upside</span>
|
||||
<span className={`text-xl font-mono font-bold ${upsideColor}`}>
|
||||
{upside >= 0 ? "+" : ""}{upside.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted text-xs block">Rating</span>
|
||||
<span className="text-lg font-semibold text-text-primary">{recLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Visual gauge bar */}
|
||||
<div className="relative h-2 bg-bg-hover rounded-full mb-2">
|
||||
{/* target marker */}
|
||||
<div className="absolute top-0 h-2 w-0.5 bg-accent-yellow" style={{ left: `${targetPct}%` }} />
|
||||
{/* current price marker */}
|
||||
<div className="absolute -top-1 h-4 w-1 bg-accent-green rounded-sm" style={{ left: `${currentPct}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-text-muted text-xs font-mono">
|
||||
<span>${gaugeLow.toFixed(0)}</span>
|
||||
<span>${gaugeHigh.toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, value }: { title: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
|
||||
@@ -26,13 +26,13 @@ export function AnomalyChips({
|
||||
async function onChipClick(a: FinancialAnomalyItem) {
|
||||
const apiKey = typeof window !== "undefined" ? localStorage.getItem("atlas_gemini_key") || "" : "";
|
||||
if (!apiKey) {
|
||||
setError("Settings에서 Gemini API 키를 저장한 뒤 다시 시도하세요.");
|
||||
setError("Please save your Gemini API key in Settings, then try again.");
|
||||
setExplain(null);
|
||||
return;
|
||||
}
|
||||
const email = secEmail.trim() || (typeof window !== "undefined" ? localStorage.getItem("atlas_sec_email") || "" : "");
|
||||
if (!email.trim()) {
|
||||
setError("SEC 공정 이용 이메일을 입력하거나 localStorage `atlas_sec_email`을 설정하세요.");
|
||||
setError("Please enter your SEC fair-use email or set `atlas_sec_email` in localStorage.");
|
||||
setExplain(null);
|
||||
return;
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function AnomalyChips({
|
||||
confidence: data.confidence || "medium",
|
||||
});
|
||||
} catch {
|
||||
setError("네트워크 오류");
|
||||
setError("Network error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export function AnomalyChips({
|
||||
if (!anomalies.length) {
|
||||
return (
|
||||
<div className="text-text-muted text-sm">
|
||||
YoY 변동이 임계값(30%)을 넘는 계정이 없습니다.
|
||||
No accounts with YoY changes exceeding the 30% threshold.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function ResearchGridLayout({ dashboard }: { dashboard: ResearchDashboard
|
||||
<div className="lg:col-span-12 min-h-[200px]">
|
||||
<Panel title={`YoY anomalies — ${ticker}`}>
|
||||
<p className="text-text-muted text-xs mb-3">
|
||||
칩을 누르면 10-K 발췌와 Gemini로 설명합니다. 숫자는 서버에서만 계산됩니다.
|
||||
Click a chip to see the 10-K excerpt explained by Gemini. All numbers are computed server-side.
|
||||
</p>
|
||||
<AnomalyChips ticker={ticker} anomalies={anomalies ?? []} />
|
||||
</Panel>
|
||||
|
||||
@@ -64,10 +64,10 @@ export function SankeyWidget({
|
||||
theme={nivoTheme}
|
||||
valueFormat={(v) => {
|
||||
const abs = Math.abs(v);
|
||||
if (abs >= 1e9) return `$${(v / 1e9).toFixed(2)}B`;
|
||||
if (abs >= 1e6) return `$${(v / 1e6).toFixed(1)}M`;
|
||||
if (abs >= 1e3) return `$${(v / 1e3).toFixed(0)}K`;
|
||||
return `$${v.toFixed(0)}`;
|
||||
if (abs >= 1e9) return `$${(v / 1e9).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}B`;
|
||||
if (abs >= 1e6) return `$${(v / 1e6).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}M`;
|
||||
if (abs >= 1e3) return `$${(v / 1e3).toLocaleString(undefined, { maximumFractionDigits: 0 })}K`;
|
||||
return `$${v.toLocaleString()}`;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,14 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
|
||||
legend: "USD (reported units)",
|
||||
legendPosition: "middle",
|
||||
legendOffset: 32,
|
||||
format: (v) => {
|
||||
const n = Number(v);
|
||||
const abs = Math.abs(n);
|
||||
if (abs >= 1e9) return `${(n / 1e9).toLocaleString(undefined, { maximumFractionDigits: 1 })}B`;
|
||||
if (abs >= 1e6) return `${(n / 1e6).toLocaleString(undefined, { maximumFractionDigits: 0 })}M`;
|
||||
if (abs >= 1e3) return `${(n / 1e3).toLocaleString(undefined, { maximumFractionDigits: 0 })}K`;
|
||||
return n.toLocaleString();
|
||||
},
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 0,
|
||||
@@ -70,6 +78,13 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
|
||||
}}
|
||||
enableGridX
|
||||
enableGridY={false}
|
||||
valueFormat={(v) => {
|
||||
const abs = Math.abs(v);
|
||||
if (abs >= 1e9) return `$${(v / 1e9).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}B`;
|
||||
if (abs >= 1e6) return `$${(v / 1e6).toLocaleString(undefined, { maximumFractionDigits: 0 })}M`;
|
||||
if (abs >= 1e3) return `$${(v / 1e3).toLocaleString(undefined, { maximumFractionDigits: 0 })}K`;
|
||||
return `$${v.toLocaleString()}`;
|
||||
}}
|
||||
labelSkipWidth={12}
|
||||
labelSkipHeight={12}
|
||||
labelTextColor="#E5E7EB"
|
||||
|
||||
@@ -21,29 +21,43 @@ interface QuarterlyData {
|
||||
earnings: number | null;
|
||||
}
|
||||
|
||||
interface DeltaData {
|
||||
available: boolean;
|
||||
latest_quarter?: string;
|
||||
prev_quarter?: string;
|
||||
revenue_delta_pct?: number | null;
|
||||
earnings_delta_pct?: number | null;
|
||||
eps_trend?: { date: string; surprise_pct: number }[];
|
||||
ai_summary?: string | null;
|
||||
}
|
||||
|
||||
export default function EarningsPage() {
|
||||
const { ticker } = useTicker();
|
||||
const { ticker, initialized } = useTicker();
|
||||
const [assetType, setAssetType] = useState<string>("equity");
|
||||
const [history, setHistory] = useState<EarningsRecord[]>([]);
|
||||
const [calendar, setCalendar] = useState<CalendarData | null>(null);
|
||||
const [quarterly, setQuarterly] = useState<QuarterlyData[]>([]);
|
||||
const [delta, setDelta] = useState<DeltaData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
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),
|
||||
fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null),
|
||||
]).then(([h, c, q, o]) => {
|
||||
fetch(`/api/earnings/${ticker}/delta`).then((r) => r.ok ? r.json() : null),
|
||||
]).then(([h, c, q, o, d]) => {
|
||||
setHistory(h?.history || []);
|
||||
setCalendar(c);
|
||||
setQuarterly(q?.quarterly || []);
|
||||
setAssetType(o?.asset_type || "equity");
|
||||
setDelta(d?.available ? d : null);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [ticker]);
|
||||
}, [ticker, initialized]);
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
|
||||
|
||||
@@ -54,7 +68,7 @@ export default function EarningsPage() {
|
||||
<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 데이터가 없습니다.
|
||||
Earnings data is not available for this asset type ({assetType}).
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -88,6 +102,46 @@ export default function EarningsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Earnings Delta — What Changed */}
|
||||
{delta && (
|
||||
<div className="bg-bg-card border border-accent-blue/40 rounded-lg p-5 mb-6">
|
||||
<h3 className="text-accent-blue text-sm font-semibold mb-3">
|
||||
Earnings Delta — {delta.latest_quarter} vs {delta.prev_quarter}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<div className="text-text-muted text-xs mb-1">Revenue Change</div>
|
||||
<div className={`text-2xl font-mono font-bold ${delta.revenue_delta_pct != null && delta.revenue_delta_pct >= 0 ? "text-accent-green" : "text-accent-red"}`}>
|
||||
{delta.revenue_delta_pct != null ? `${delta.revenue_delta_pct >= 0 ? "+" : ""}${delta.revenue_delta_pct}%` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-text-muted text-xs mb-1">Earnings Change</div>
|
||||
<div className={`text-2xl font-mono font-bold ${delta.earnings_delta_pct != null && delta.earnings_delta_pct >= 0 ? "text-accent-green" : "text-accent-red"}`}>
|
||||
{delta.earnings_delta_pct != null ? `${delta.earnings_delta_pct >= 0 ? "+" : ""}${delta.earnings_delta_pct}%` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{delta.eps_trend && delta.eps_trend.length > 0 && (
|
||||
<div className="flex gap-2 mb-3">
|
||||
{delta.eps_trend.map((e, i) => (
|
||||
<div key={i} className="text-xs font-mono px-2 py-1 bg-bg-primary rounded border border-border">
|
||||
<span className="text-text-muted">{e.date.slice(5)}</span>{" "}
|
||||
<span className={e.surprise_pct >= 0 ? "text-accent-green" : "text-accent-red"}>
|
||||
{e.surprise_pct >= 0 ? "+" : ""}{e.surprise_pct}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{delta.ai_summary && (
|
||||
<p className="text-text-secondary text-sm leading-relaxed border-t border-border pt-3">
|
||||
{delta.ai_summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EPS History — Beat/Miss Chart */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-4">EPS History — Beat/Miss</h3>
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function Error({
|
||||
|
||||
return (
|
||||
<div className="min-h-[50vh] flex flex-col items-center justify-center p-8 bg-bg-primary text-text-primary">
|
||||
<p className="text-accent-red font-mono text-sm mb-2">화면을 그리는 중 오류가 났습니다.</p>
|
||||
<p className="text-accent-red font-mono text-sm mb-2">An error occurred while rendering this page.</p>
|
||||
<p className="text-text-muted text-xs font-mono text-center max-w-md mb-6 break-words">
|
||||
{error.message || "Unknown error"}
|
||||
</p>
|
||||
@@ -24,11 +24,11 @@ export default function Error({
|
||||
onClick={() => reset()}
|
||||
className="px-4 py-2 rounded-lg bg-accent-green text-bg-primary font-mono text-sm hover:opacity-90"
|
||||
>
|
||||
다시 시도
|
||||
Retry
|
||||
</button>
|
||||
<p className="text-text-muted text-xs mt-8 text-center max-w-lg">
|
||||
흰 화면만 보일 때는 브라우저 개발자 도구(F12) → Console 탭의 빨간 에러 메시지를 확인하거나, 터미널에서{" "}
|
||||
<code className="text-accent-blue">rm -rf .next && npm run dev</code> 로 캐시를 지우고 다시 실행해 보세요.
|
||||
If you see a blank screen, check the browser developer tools (F12) → Console tab for red error messages, or run{" "}
|
||||
<code className="text-accent-blue">rm -rf .next && npm run dev</code> in your terminal to clear the cache and restart.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,11 +18,11 @@ const SECTIONS_SEC: FilingSectionTab[] = [
|
||||
];
|
||||
|
||||
const SECTIONS_DART: FilingSectionTab[] = [
|
||||
{ key: "item1a", label: "투자위험 (II)", short: "투자위험", anchorId: "dart-item-1a" },
|
||||
{ key: "item3", label: "소송 등", short: "소송", anchorId: "dart-item-3" },
|
||||
{ key: "item7", label: "사업의 내용 / MD&A", short: "사업·MD&A", anchorId: "dart-item-7" },
|
||||
{ key: "item8", label: "재무에 관한 사항", short: "재무", anchorId: "dart-item-8" },
|
||||
{ key: "item9a", label: "내부통제", short: "내부통제", anchorId: "dart-item-9a" },
|
||||
{ key: "item1a", label: "Investment Risk (II)", short: "Risk", anchorId: "dart-item-1a" },
|
||||
{ key: "item3", label: "Litigation", short: "Legal", anchorId: "dart-item-3" },
|
||||
{ key: "item7", label: "Business / MD&A", short: "MD&A", anchorId: "dart-item-7" },
|
||||
{ key: "item8", label: "Financial Statements", short: "Financials", anchorId: "dart-item-8" },
|
||||
{ key: "item9a", label: "Internal Controls", short: "Controls", anchorId: "dart-item-9a" },
|
||||
];
|
||||
|
||||
const SECTIONS_EDINET: FilingSectionTab[] = [
|
||||
@@ -46,7 +46,7 @@ function mapApiSource(s: string | undefined): FilingJurisdiction {
|
||||
}
|
||||
|
||||
export default function FilingsPage() {
|
||||
const { ticker } = useTicker();
|
||||
const { ticker, initialized } = useTicker();
|
||||
const viewerRef = useRef<FilingsViewerHandle | null>(null);
|
||||
const [activeSection, setActiveSection] = useState("item7");
|
||||
const [sections, setSections] = useState<Record<string, string>>({});
|
||||
@@ -61,6 +61,9 @@ export default function FilingsPage() {
|
||||
const [filingSource, setFilingSource] = useState<FilingJurisdiction | null>(null);
|
||||
const [linkMap, setLinkMap] = useState<Record<string, string> | null>(null);
|
||||
const [infoMessage, setInfoMessage] = useState<string>("");
|
||||
const [translatedText, setTranslatedText] = useState<string>("");
|
||||
const [translating, setTranslating] = useState(false);
|
||||
const [showTranslation, setShowTranslation] = useState(false);
|
||||
|
||||
const previewJ = inferFilingJurisdiction(ticker);
|
||||
const activeJurisdiction = filingSource ?? previewJ;
|
||||
@@ -94,6 +97,9 @@ export default function FilingsPage() {
|
||||
item9a: data.item9a || "",
|
||||
});
|
||||
setHtmlDoc(typeof data.html === "string" ? data.html : "");
|
||||
if (data.links && typeof data.links === "object") {
|
||||
setLinkMap(data.links as Record<string, string>);
|
||||
}
|
||||
setActiveSection("item7");
|
||||
setHtmlVersion((v) => v + 1);
|
||||
setLoaded(true);
|
||||
@@ -113,7 +119,7 @@ export default function FilingsPage() {
|
||||
const data = await res.json();
|
||||
setFilingSource(mapApiSource(data.source));
|
||||
if (data.configured === false) {
|
||||
setInfoMessage(data.message || "DART_API_KEY가 설정되지 않았습니다.");
|
||||
setInfoMessage(data.message || "DART_API_KEY is not configured.");
|
||||
setLoaded(false);
|
||||
} else {
|
||||
setSections({
|
||||
@@ -124,13 +130,16 @@ export default function FilingsPage() {
|
||||
item9a: data.item9a || "",
|
||||
});
|
||||
setHtmlDoc(typeof data.html === "string" ? data.html : "");
|
||||
if (data.links && typeof data.links === "object") {
|
||||
setLinkMap(data.links as Record<string, string>);
|
||||
}
|
||||
setActiveSection("item7");
|
||||
setHtmlVersion((v) => v + 1);
|
||||
setLoaded(true);
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
setError(err.detail || "DART 공시를 불러오지 못했습니다.");
|
||||
setError(err.detail || "Failed to load DART filing.");
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -160,7 +169,7 @@ export default function FilingsPage() {
|
||||
setLoaded(true);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
setError(err.detail || "EDINET 데이터를 불러오지 못했습니다.");
|
||||
setError(err.detail || "Failed to load EDINET data.");
|
||||
}
|
||||
} catch {
|
||||
setError("Connection error. Make sure the backend server is running.");
|
||||
@@ -168,6 +177,41 @@ export default function FilingsPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function runTranslation() {
|
||||
const content = sections[activeSection];
|
||||
if (!content) return;
|
||||
const apiKey = localStorage.getItem("atlas_gemini_key") || "";
|
||||
if (!apiKey) {
|
||||
setTranslatedText("Settings에서 Gemini API 키를 먼저 설정해주세요.");
|
||||
setShowTranslation(true);
|
||||
return;
|
||||
}
|
||||
setTranslating(true);
|
||||
try {
|
||||
const res = await fetch("/api/analysis/translate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
text: content.slice(0, 12000),
|
||||
target_lang: "ko",
|
||||
api_key: apiKey,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTranslatedText(data.translated_text || "");
|
||||
setShowTranslation(true);
|
||||
} else {
|
||||
setTranslatedText("번역에 실패했습니다.");
|
||||
setShowTranslation(true);
|
||||
}
|
||||
} catch {
|
||||
setTranslatedText("번역 중 오류가 발생했습니다.");
|
||||
setShowTranslation(true);
|
||||
}
|
||||
setTranslating(false);
|
||||
}
|
||||
|
||||
async function runAiSummary() {
|
||||
const content = sections[activeSection];
|
||||
if (!content) return;
|
||||
@@ -213,8 +257,8 @@ export default function FilingsPage() {
|
||||
const intro = useMemo(() => {
|
||||
if (previewJ === "DART") {
|
||||
return {
|
||||
title: "DART 사업보고서",
|
||||
body: "한국 상장사 최신 사업보고서(연간)를 Open DART에서 받아 옵니다. 티커는 005930.KS 형식이어야 합니다. DART_API_KEY가 .env에 필요합니다.",
|
||||
title: "DART Annual Report",
|
||||
body: "Fetches the latest annual business report for Korean listed companies from Open DART. Ticker must be in 005930.KS format. Requires DART_API_KEY in .env.",
|
||||
};
|
||||
}
|
||||
if (previewJ === "EDINET") {
|
||||
@@ -231,13 +275,22 @@ export default function FilingsPage() {
|
||||
|
||||
const pageTitle =
|
||||
previewJ === "DART"
|
||||
? "DART 공시"
|
||||
? "DART Filings"
|
||||
: previewJ === "EDINET"
|
||||
? "EDINET Filings"
|
||||
: "SEC Filings";
|
||||
|
||||
const needsEmail = previewJ === "SEC";
|
||||
|
||||
// Auto-load filing for non-SEC jurisdictions (no email required)
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
if (!loaded && !loading && previewJ !== "SEC") {
|
||||
loadFiling();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ticker, initialized]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">
|
||||
@@ -267,7 +320,7 @@ export default function FilingsPage() {
|
||||
: previewJ === "SEC"
|
||||
? "Load 10-K Filing"
|
||||
: previewJ === "DART"
|
||||
? "사업보고서 불러오기"
|
||||
? "Load DART Report"
|
||||
: "Load EDINET filing"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -285,7 +338,7 @@ export default function FilingsPage() {
|
||||
<div className="mt-3 text-text-muted text-sm animate-pulse">
|
||||
{previewJ === "SEC"
|
||||
? "Downloading from SEC EDGAR... This may take 10-30 seconds for first download."
|
||||
: "공시 원본을 가져오는 중입니다..."}
|
||||
: "Fetching filing data..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -294,22 +347,26 @@ export default function FilingsPage() {
|
||||
{loaded && (
|
||||
<>
|
||||
{linkMap && Object.keys(linkMap).length > 0 && (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4 mb-4 text-sm text-text-secondary">
|
||||
{infoMessage && <p className="mb-2 text-text-muted">{infoMessage}</p>}
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{Object.entries(linkMap).map(([k, v]) => (
|
||||
<li key={k}>
|
||||
<a
|
||||
href={v}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent-blue hover:underline"
|
||||
>
|
||||
{k}: {v}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
{infoMessage && <p className="w-full text-text-muted text-sm mb-1">{infoMessage}</p>}
|
||||
{Object.entries(linkMap).map(([k, v], i) => (
|
||||
<a
|
||||
key={k}
|
||||
href={v}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-1.5 px-4 py-2 rounded-md text-sm font-semibold transition-opacity hover:opacity-90 ${
|
||||
i === 0
|
||||
? "bg-accent-blue text-white"
|
||||
: "bg-bg-card border border-border text-text-secondary hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{k}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -323,6 +380,8 @@ export default function FilingsPage() {
|
||||
onClick={() => {
|
||||
setActiveSection(s.key);
|
||||
setAiSummary("");
|
||||
setShowTranslation(false);
|
||||
setTranslatedText("");
|
||||
if (hasHtml) {
|
||||
viewerRef.current?.scrollToAnchor(s.anchorId);
|
||||
}
|
||||
@@ -352,14 +411,36 @@ export default function FilingsPage() {
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{currentContent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={runAiSummary}
|
||||
disabled={aiLoading}
|
||||
className="bg-accent-blue text-white px-4 py-1.5 rounded-md text-xs font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
{aiLoading ? "Analyzing..." : "AI Summary"}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runAiSummary}
|
||||
disabled={aiLoading}
|
||||
className="bg-accent-blue text-white px-4 py-1.5 rounded-md text-xs font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
{aiLoading ? "Analyzing..." : "AI Summary"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (showTranslation) {
|
||||
setShowTranslation(false);
|
||||
} else if (translatedText) {
|
||||
setShowTranslation(true);
|
||||
} else {
|
||||
runTranslation();
|
||||
}
|
||||
}}
|
||||
disabled={translating}
|
||||
className={`px-4 py-1.5 rounded-md text-xs font-semibold transition-opacity ${
|
||||
showTranslation
|
||||
? "bg-accent-green text-bg-primary hover:opacity-90"
|
||||
: "bg-bg-card border border-accent-green/50 text-accent-green hover:opacity-90"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{translating ? "번역 중..." : showTranslation ? "English" : "한국어"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -374,15 +455,15 @@ export default function FilingsPage() {
|
||||
|
||||
{!hasHtml && previewJ === "SEC" && (
|
||||
<p className="text-text-muted text-sm mb-3">
|
||||
<strong className="text-text-secondary">서식 HTML 스냅샷</strong>이 아직 없습니다(예: 예전에 텍스트만 캐시된 경우).
|
||||
아래에 SEC에서 추출한 <strong className="text-text-secondary">평문</strong>이 그대로 표시됩니다.
|
||||
표·강조가 있는 원문 형태를 보려면 <strong className="text-text-secondary">Reload</strong>로 다시 받아
|
||||
HTML을 만드세요. (야후 iframe 차단과는 무관합니다.)
|
||||
<strong className="text-text-secondary">HTML snapshot</strong> is not available (e.g. only plain text was cached previously).
|
||||
The <strong className="text-text-secondary">plain text</strong> extracted from SEC is shown below.
|
||||
To view the formatted version with tables and emphasis, click <strong className="text-text-secondary">Reload</strong> to re-fetch
|
||||
and generate the HTML. (This is unrelated to Yahoo iframe blocking.)
|
||||
</p>
|
||||
)}
|
||||
{!hasHtml && previewJ !== "SEC" && currentContent && (
|
||||
<p className="text-text-muted text-sm mb-3">
|
||||
HTML 조각이 없을 때는 아래 <strong className="text-text-secondary">평문</strong>으로 동일 내용을 표시합니다.
|
||||
When HTML fragments are unavailable, the same content is displayed as <strong className="text-text-secondary">plain text</strong> below.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -396,7 +477,22 @@ export default function FilingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasHtml && (
|
||||
{showTranslation && translatedText && (
|
||||
<div className="border border-accent-green/30 rounded-lg bg-bg-card overflow-hidden flex flex-col max-h-[min(72vh,calc(100vh-200px))] mb-4">
|
||||
<div className="px-4 py-2 border-b border-accent-green/30 bg-accent-green/5 text-xs text-accent-green shrink-0 flex items-center gap-2">
|
||||
<span className="font-semibold">한국어 번역</span>
|
||||
<span className="text-text-muted">— Gemini AI 번역</span>
|
||||
</div>
|
||||
<div
|
||||
className="overflow-y-auto flex-1 min-h-0 p-4 md:p-6 pb-10 text-sm text-text-primary leading-relaxed whitespace-pre-wrap break-words"
|
||||
style={{ WebkitOverflowScrolling: "touch" }}
|
||||
>
|
||||
{translatedText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showTranslation && hasHtml && (
|
||||
<FilingsViewer
|
||||
ref={viewerRef}
|
||||
html={htmlDoc}
|
||||
@@ -406,7 +502,7 @@ export default function FilingsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hasHtml && currentContent && (
|
||||
{!showTranslation && !hasHtml && currentContent && (
|
||||
<div className="border border-border rounded-lg bg-bg-card overflow-hidden flex flex-col max-h-[min(72vh,calc(100vh-200px))]">
|
||||
<div className="px-4 py-2 border-b border-border bg-bg-primary/50 text-xs text-text-muted shrink-0">
|
||||
Plain text (cached) — same source as AI Summary; formatted HTML viewer is optional.
|
||||
|
||||
@@ -9,6 +9,7 @@ const EVENT_NAME = "atlas-ticker-change";
|
||||
export function useTicker() {
|
||||
// Must match server render: never read localStorage in useState initializer — hydration mismatch → white screen.
|
||||
const [ticker, setTickerState] = useState(DEFAULT_TICKER);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -16,6 +17,7 @@ export function useTicker() {
|
||||
const n = normalizeTickerInput(saved);
|
||||
if (n) setTickerState(n);
|
||||
}
|
||||
setInitialized(true);
|
||||
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
@@ -33,5 +35,5 @@ export function useTicker() {
|
||||
window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: upper }));
|
||||
}, []);
|
||||
|
||||
return { ticker, setTicker };
|
||||
return { ticker, setTicker, initialized };
|
||||
}
|
||||
|
||||
@@ -22,7 +22,23 @@ interface FredPoint {
|
||||
value: number;
|
||||
}
|
||||
|
||||
type MacroTab = "fred" | "cycle" | "oecd" | "korea" | "calendar";
|
||||
type MacroTab = "fred" | "cycle" | "oecd" | "korea" | "calendar" | "subfactors";
|
||||
|
||||
interface SubfactorIndicator {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number | null;
|
||||
unit?: string;
|
||||
change_3m: number | null;
|
||||
zscore: number | null;
|
||||
signal: "improving" | "neutral" | "deteriorating";
|
||||
}
|
||||
|
||||
interface SubfactorData {
|
||||
composite_score: number;
|
||||
cycle_stage: string;
|
||||
categories: Record<string, { score: number; indicators: SubfactorIndicator[] }>;
|
||||
}
|
||||
|
||||
export default function MacroPage() {
|
||||
const [series, setSeries] = useState("UNRATE");
|
||||
@@ -36,6 +52,7 @@ export default function MacroPage() {
|
||||
const [oecd, setOecd] = useState<OECDData | null>(null);
|
||||
|
||||
const [classicOpen, setClassicOpen] = useState(false);
|
||||
const [subfactors, setSubfactors] = useState<SubfactorData | null>(null);
|
||||
|
||||
const [quadPoints, setQuadPoints] = useState<QuadrantPoint[]>([]);
|
||||
const [quadErr, setQuadErr] = useState<string | null>(null);
|
||||
@@ -88,6 +105,15 @@ export default function MacroPage() {
|
||||
.catch(() => setSnap(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/macro/subfactors")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j) => {
|
||||
if (j && typeof j === "object" && j.categories) setSubfactors(j);
|
||||
})
|
||||
.catch(() => setSubfactors(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/macro/oecd/cli")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
@@ -150,6 +176,7 @@ export default function MacroPage() {
|
||||
|
||||
const tabs: { key: MacroTab; label: string }[] = [
|
||||
{ key: "fred", label: "FRED" },
|
||||
{ key: "subfactors", label: "Sub-Factors" },
|
||||
{ key: "cycle", label: "Cycle & valuation" },
|
||||
{ key: "oecd", label: "OECD CLI" },
|
||||
{ key: "korea", label: "Korea" },
|
||||
@@ -291,6 +318,63 @@ export default function MacroPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "subfactors" && (
|
||||
<div className="space-y-4">
|
||||
{subfactors ? (
|
||||
<>
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="bg-bg-secondary border border-border rounded-lg px-4 py-2">
|
||||
<div className="text-text-muted text-xs">Cycle Stage</div>
|
||||
<div className="text-accent-green font-mono font-bold text-lg">{subfactors.cycle_stage}</div>
|
||||
</div>
|
||||
<div className="bg-bg-secondary border border-border rounded-lg px-4 py-2">
|
||||
<div className="text-text-muted text-xs">Composite Score</div>
|
||||
<div className={`font-mono font-bold text-lg ${subfactors.composite_score >= 0 ? "text-accent-green" : "text-accent-red"}`}>
|
||||
{subfactors.composite_score >= 0 ? "+" : ""}{subfactors.composite_score.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{Object.entries(subfactors.categories).map(([catName, cat]) => (
|
||||
<div key={catName} className="bg-bg-secondary border border-border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-text-primary text-sm font-semibold">{catName}</h3>
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded ${cat.score >= 0.5 ? "bg-accent-green/15 text-accent-green" : cat.score <= -0.5 ? "bg-accent-red/15 text-accent-red" : "bg-bg-hover text-text-muted"}`}>
|
||||
{cat.score >= 0 ? "+" : ""}{cat.score.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{cat.indicators.map((ind) => (
|
||||
<div key={ind.key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-secondary">{ind.label}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-text-primary font-mono">{ind.value != null ? ind.value : "—"}{ind.unit && ind.value != null ? ind.unit : ""}</span>
|
||||
{ind.change_3m != null && (
|
||||
<span className={`text-xs font-mono ${ind.change_3m >= 0 ? "text-accent-green" : "text-accent-red"}`}>
|
||||
{ind.change_3m >= 0 ? "+" : ""}{ind.change_3m}%
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
ind.signal === "improving" ? "bg-accent-green/15 text-accent-green" :
|
||||
ind.signal === "deteriorating" ? "bg-accent-red/15 text-accent-red" :
|
||||
"bg-bg-hover text-text-muted"
|
||||
}`}>
|
||||
{ind.signal}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-accent-green animate-pulse font-mono">Loading sub-factor data...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "cycle" && (
|
||||
<div className="space-y-4">
|
||||
{snapErr && (
|
||||
|
||||
@@ -128,7 +128,7 @@ const ROW_MAP: Record<StatementType, RowDef[]> = {
|
||||
};
|
||||
|
||||
export default function MarketsPage() {
|
||||
const { ticker } = useTicker();
|
||||
const { ticker, initialized } = useTicker();
|
||||
const [data, setData] = useState<FinancialStatements | null>(null);
|
||||
const [tab, setTab] = useState<StatementType>("income_statement");
|
||||
const [viewTab, setViewTab] = useState<ViewTab>("overview");
|
||||
@@ -138,6 +138,7 @@ export default function MarketsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
setLoading(true);
|
||||
fetch(`/api/financials/${ticker}/statements`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
@@ -146,7 +147,7 @@ export default function MarketsPage() {
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [ticker]);
|
||||
}, [ticker, initialized]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/market/overview")
|
||||
|
||||
@@ -81,7 +81,7 @@ interface QuoteRow {
|
||||
}
|
||||
|
||||
export default function NewsPage() {
|
||||
const { ticker } = useTicker();
|
||||
const { ticker, initialized } = useTicker();
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<NewsItem | null>(null);
|
||||
@@ -90,6 +90,7 @@ export default function NewsPage() {
|
||||
const [mentionQuotes, setMentionQuotes] = useState<Record<string, QuoteRow>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
setLoading(true);
|
||||
setSelected(null);
|
||||
fetch(`/api/news/${ticker}`)
|
||||
@@ -99,7 +100,7 @@ export default function NewsPage() {
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [ticker]);
|
||||
}, [ticker, initialized]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase();
|
||||
@@ -305,8 +306,7 @@ export default function NewsPage() {
|
||||
{isIframeEmbeddingBlocked(selected.url) ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[280px] p-8 text-center">
|
||||
<p className="text-text-muted text-sm mb-2 max-w-md">
|
||||
이 출처(Yahoo·Bloomberg 등)는 보안 정책으로 <span className="text-text-primary font-semibold">미리보기 iframe</span>을
|
||||
허용하지 않습니다. 원문은 새 탭에서 열어 주세요.
|
||||
This source (Yahoo, Bloomberg, etc.) does not allow <span className="text-text-primary font-semibold">iframe preview</span> due to security policies. Please open the original article in a new tab.
|
||||
</p>
|
||||
{selected.summary ? (
|
||||
<div className="w-full max-w-2xl mt-4 mb-6 text-left rounded-lg border border-border bg-bg-card p-4">
|
||||
@@ -320,7 +320,7 @@ export default function NewsPage() {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-accent-green text-bg-primary rounded-lg font-semibold text-sm hover:opacity-90"
|
||||
>
|
||||
원문 열기 ↗
|
||||
Open Article ↗
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ETFOverview } from "./components/overview/ETFOverview";
|
||||
import { CommodityOverview } from "./components/overview/CommodityOverview";
|
||||
|
||||
export default function OverviewPage() {
|
||||
const { ticker } = useTicker();
|
||||
const { ticker, initialized } = useTicker();
|
||||
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);
|
||||
@@ -14,19 +14,26 @@ export default function OverviewPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
const ac = new AbortController();
|
||||
setLoading(true);
|
||||
setSector(null);
|
||||
setHealth(null);
|
||||
setOverview(null);
|
||||
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),
|
||||
fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null),
|
||||
fetch(`/api/market/sector/${ticker}`, { signal: ac.signal }).then((r) => r.ok ? r.json() : null),
|
||||
fetch(`/api/market/health/${ticker}`, { signal: ac.signal }).then((r) => r.ok ? r.json() : null),
|
||||
fetch(`/api/market/overview/${ticker}`, { signal: ac.signal }).then((r) => r.ok ? r.json() : null),
|
||||
]).then(([s, h, d]) => {
|
||||
if (ac.signal.aborted) return;
|
||||
setSector(s);
|
||||
setHealth(h);
|
||||
setAssetType(d?.asset_type || "equity");
|
||||
setOverview(d?.data || null);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [ticker]);
|
||||
}).catch(() => { if (!ac.signal.aborted) setLoading(false); });
|
||||
return () => ac.abort();
|
||||
}, [ticker, initialized]);
|
||||
|
||||
if (loading) return <LoadingState />;
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function PortfolioPage() {
|
||||
const geminiKey = localStorage.getItem("atlas_gemini_key") || "";
|
||||
if (!geminiKey.trim()) {
|
||||
setOcrPositions([]);
|
||||
setOcrError("Gemini API 키가 없습니다. Settings에서 Gemini API Key를 먼저 저장하세요.");
|
||||
setOcrError("Gemini API key is missing. Please save your Gemini API Key in Settings first.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function PortfolioPage() {
|
||||
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은 완료됐지만 포지션을 찾지 못했습니다. 표가 선명하게 보이는 스크린샷으로 다시 시도하세요.");
|
||||
setOcrError("OCR completed but no positions were found. Try again with a clearer screenshot showing the table.");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -179,7 +179,7 @@ export default function PortfolioPage() {
|
||||
}
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
setOcrError(`일부 저장 실패: ${failed.join(", ")}. Import Results를 유지합니다.`);
|
||||
setOcrError(`Some imports failed: ${failed.join(", ")}. Keeping Import Results.`);
|
||||
return;
|
||||
}
|
||||
setOcrPositions([]);
|
||||
@@ -369,11 +369,11 @@ export default function PortfolioPage() {
|
||||
onClick={() => document.getElementById("ocr-file-input")?.click()}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<p className="text-accent-green animate-pulse font-mono">AI가 포지션을 분석중...</p>
|
||||
<p className="text-accent-green animate-pulse font-mono">AI is analyzing positions...</p>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl mb-3 block">📸</span>
|
||||
<p className="text-text-secondary text-sm">Trading 212 / IBKR 스크린샷을 드래그하세요</p>
|
||||
<p className="text-text-secondary text-sm">Drag & drop a Trading 212 / IBKR screenshot</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -604,8 +604,8 @@ export default function PortfolioPage() {
|
||||
{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>
|
||||
<p className="text-text-primary mb-1">Are you sure you want to delete this?</p>
|
||||
<p className="text-text-muted text-sm mb-4">This action cannot be undone.</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>
|
||||
|
||||
@@ -43,6 +43,28 @@ interface QuarterlyEarnings { period: string; revenue: number | null; earnings:
|
||||
interface HealthData { dupont: Record<string, number>; altman_z: number | null; current_ratio: number | null; interest_coverage: number | null; debt_to_equity: number | null; red_flags: string[] }
|
||||
interface TechnicalData { [key: string]: any }
|
||||
|
||||
type ValuationTier = "dcf" | "ev_ebitda" | "ps_revenue" | "pb_nav";
|
||||
interface RelativeValData {
|
||||
tier: ValuationTier;
|
||||
tierLabel: string;
|
||||
tierReason: string;
|
||||
method: string;
|
||||
multipleName: string;
|
||||
peerAvgMultiple: number;
|
||||
companyMetric: number;
|
||||
metricLabel: string;
|
||||
bear: { multiple: number; value: number };
|
||||
base: { multiple: number; value: number };
|
||||
bull: { multiple: number; value: number };
|
||||
netDebt: number;
|
||||
shares: number;
|
||||
cashRunwayQuarters: number | null;
|
||||
revenueGrowth: number | null;
|
||||
rule40: number | null;
|
||||
ebitda: number | null;
|
||||
fcf: number | null;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
Utility
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
@@ -76,7 +98,7 @@ function renderMarkdown(text: string) {
|
||||
SECTION COMPONENTS
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
function CoverPage({ ticker, info, consensus, dcf }: { ticker: string; info: Record<string, any>; consensus: ConsensusData | null; dcf: DCFResult | null }) {
|
||||
function CoverPage({ ticker, info, consensus, dcf, relativeVal }: { ticker: string; info: Record<string, any>; consensus: ConsensusData | null; dcf: DCFResult | null; relativeVal: RelativeValData | null }) {
|
||||
const now = new Date();
|
||||
const dateStr = now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
|
||||
const price = info.currentPrice || info.regularMarketPrice || 0;
|
||||
@@ -115,7 +137,7 @@ function CoverPage({ ticker, info, consensus, dcf }: { ticker: string; info: Rec
|
||||
{[
|
||||
{ label: "Price", value: fmtPrice(price) },
|
||||
{ label: "Market Cap", value: fmtB(info.marketCap) },
|
||||
{ label: "DCF Fair Value", value: dcf?.base != null ? fmtPrice(dcf.base) : "N/A" },
|
||||
{ label: relativeVal ? `${relativeVal.method} Fair Value` : "DCF Fair Value", value: relativeVal ? fmtPrice(relativeVal.base.value) : dcf?.base != null ? fmtPrice(dcf.base) : "N/A" },
|
||||
{ label: "Analysts", value: consensus ? `${consensus.num_analysts}` : "N/A" },
|
||||
].map((k) => (
|
||||
<div key={k.label}>
|
||||
@@ -810,6 +832,194 @@ function TechnicalSnapshot({ technical, info }: { technical: TechnicalData | nul
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Valuation Tier Detection ── */
|
||||
function detectValuationTier(fcf: number | null, ebitda: number | null, revenueGrowth: number | null): ValuationTier {
|
||||
if (fcf != null && fcf > 0) return "dcf";
|
||||
if (ebitda != null && ebitda > 0) return "ev_ebitda";
|
||||
if (revenueGrowth != null && revenueGrowth > 0.10) return "ps_revenue";
|
||||
return "pb_nav";
|
||||
}
|
||||
|
||||
function buildRelativeVal(
|
||||
tier: ValuationTier, peers: PeerData | null, info: Record<string, any>,
|
||||
di: { fcf: number; total_debt: number; cash: number; shares: number } | null, hi: any,
|
||||
): RelativeValData | null {
|
||||
if (!di || !di.shares || di.shares <= 0) return null;
|
||||
const netDebt = (di.total_debt || 0) - (di.cash || 0);
|
||||
const revenue = hi?.revenue || info.totalRevenue || 0;
|
||||
const ebitda = hi?.ebitda || 0;
|
||||
const fcf = hi?.free_cash_flow ?? info.freeCashflow ?? di.fcf ?? 0;
|
||||
const revGrowth = hi?.revenue_growth ?? info.revenueGrowth ?? null;
|
||||
const profitMargin = hi?.profit_margin ?? info.profitMargins ?? 0;
|
||||
const rule40 = revGrowth != null ? (revGrowth * 100) + (profitMargin * 100) : null;
|
||||
const cash = di.cash || 0;
|
||||
const qBurn = fcf < 0 ? Math.abs(fcf) / 4 : 0;
|
||||
const cashRunway = qBurn > 0 ? cash / qBurn : null;
|
||||
|
||||
if (tier === "ev_ebitda") {
|
||||
const peerAvg = peers?.averages?.ev_ebitda ?? 12;
|
||||
const impliedEV = peerAvg * ebitda;
|
||||
const baseVal = (impliedEV - netDebt) / di.shares;
|
||||
return {
|
||||
tier, tierLabel: "EV/EBITDA Relative Valuation",
|
||||
tierReason: "Free cash flow is negative due to heavy capital investment, but EBITDA is positive — the company generates operating profit before reinvestment.",
|
||||
method: "EV/EBITDA", multipleName: "EV/EBITDA", peerAvgMultiple: peerAvg,
|
||||
companyMetric: ebitda, metricLabel: "EBITDA",
|
||||
bear: { multiple: peerAvg * 0.7, value: (peerAvg * 0.7 * ebitda - netDebt) / di.shares },
|
||||
base: { multiple: peerAvg, value: baseVal },
|
||||
bull: { multiple: peerAvg * 1.3, value: (peerAvg * 1.3 * ebitda - netDebt) / di.shares },
|
||||
netDebt, shares: di.shares, cashRunwayQuarters: cashRunway, revenueGrowth: revGrowth, rule40, ebitda, fcf,
|
||||
};
|
||||
}
|
||||
if (tier === "ps_revenue") {
|
||||
const peerAvg = peers?.averages?.ps ?? 4;
|
||||
const impliedMC = peerAvg * revenue;
|
||||
const baseVal = impliedMC / di.shares;
|
||||
return {
|
||||
tier, tierLabel: "Price/Sales Relative Valuation",
|
||||
tierReason: "Both FCF and EBITDA are negative, but revenue is growing rapidly. P/S (Price-to-Sales) multiple is the appropriate valuation framework for high-growth, pre-profit companies.",
|
||||
method: "P/S", multipleName: "P/S", peerAvgMultiple: peerAvg,
|
||||
companyMetric: revenue, metricLabel: "Revenue",
|
||||
bear: { multiple: peerAvg * 0.6, value: (peerAvg * 0.6 * revenue) / di.shares },
|
||||
base: { multiple: peerAvg, value: baseVal },
|
||||
bull: { multiple: peerAvg * 1.5, value: (peerAvg * 1.5 * revenue) / di.shares },
|
||||
netDebt, shares: di.shares, cashRunwayQuarters: cashRunway, revenueGrowth: revGrowth, rule40, ebitda, fcf,
|
||||
};
|
||||
}
|
||||
// pb_nav
|
||||
const bookVal = hi?.book_value || 0;
|
||||
const peerAvg = peers?.averages?.pb ?? 2;
|
||||
const baseVal = bookVal > 0 ? bookVal * peerAvg : 0;
|
||||
return {
|
||||
tier, tierLabel: "Price/Book (NAV) Valuation",
|
||||
tierReason: "FCF, EBITDA, and revenue growth are all weak or negative. Asset-based valuation (P/B) provides the most relevant framework.",
|
||||
method: "P/B", multipleName: "P/B", peerAvgMultiple: peerAvg,
|
||||
companyMetric: bookVal * di.shares, metricLabel: "Book Value",
|
||||
bear: { multiple: peerAvg * 0.6, value: bookVal * peerAvg * 0.6 },
|
||||
base: { multiple: peerAvg, value: baseVal },
|
||||
bull: { multiple: peerAvg * 1.5, value: bookVal * peerAvg * 1.5 },
|
||||
netDebt, shares: di.shares, cashRunwayQuarters: cashRunway, revenueGrowth: revGrowth, rule40, ebitda, fcf,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Relative Valuation Section ── */
|
||||
function RelativeValuationSection({ rv, info }: { rv: RelativeValData; info: Record<string, any> }) {
|
||||
const price = info.currentPrice || 0;
|
||||
const scenarios = [
|
||||
{ label: "Bear", ...rv.bear, color: C.red },
|
||||
{ label: "Base", ...rv.base, color: C.blue },
|
||||
{ label: "Bull", ...rv.bull, color: C.green },
|
||||
];
|
||||
const chartData = scenarios.map((s) => ({ name: `${s.label} (${s.multiple.toFixed(1)}x)`, value: Math.max(s.value, 0), fill: s.color }));
|
||||
|
||||
return (
|
||||
<div className="report-section">
|
||||
{/* Tier Banner */}
|
||||
<div className="p-3 rounded mb-4" style={{ background: "#FFF8E1", borderLeft: `4px solid ${C.gold}` }}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm">⚠</span>
|
||||
<span className="text-xs font-bold" style={{ color: C.navy }}>DCF Not Applicable — {rv.tierLabel}</span>
|
||||
</div>
|
||||
<p className="text-[10px] leading-relaxed" style={{ color: C.text }}>{rv.tierReason}</p>
|
||||
</div>
|
||||
|
||||
<h2 className="section-title">{rv.method} Scenario Analysis</h2>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="chart-title">Peer Avg {rv.multipleName}: {rv.peerAvgMultiple.toFixed(1)}x • {rv.metricLabel}: {fmtB(rv.companyMetric)}</h3>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={chartData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
|
||||
<XAxis type="number" tick={{ fontSize: 10 }} tickFormatter={(v) => `$${v.toFixed(0)}`} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 9 }} width={100} />
|
||||
<Tooltip formatter={(v: number) => fmtPrice(v)} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>{chartData.map((d, i) => <Cell key={i} fill={d.fill} />)}</Bar>
|
||||
{price > 0 && <ReferenceLine x={price} stroke={C.gold} strokeWidth={2} strokeDasharray="5 5" label={{ value: `Current $${price.toFixed(0)}`, fill: C.gold, fontSize: 10 }} />}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{scenarios.map((s) => {
|
||||
const upside = price > 0 ? ((s.value - price) / price) * 100 : 0;
|
||||
return (
|
||||
<div key={s.label} className="flex items-center justify-between p-3 rounded" style={{ background: "#F4F6F9" }}>
|
||||
<div>
|
||||
<div className="text-xs font-bold" style={{ color: s.color }}>{s.label} ({s.multiple.toFixed(1)}x)</div>
|
||||
<div className="text-xl font-mono font-bold" style={{ color: C.navy }}>{fmtPrice(Math.max(s.value, 0))}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px]" style={{ color: C.muted }}>vs Current</div>
|
||||
<div className="font-mono font-bold" style={{ color: upside >= 0 ? C.green : C.red }}>{fmtPct(upside)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Path to Profitability ── */
|
||||
function PathToProfitability({ rv, stmts }: { rv: RelativeValData; stmts: { income_statement?: FinancialPeriod[] } }) {
|
||||
const is = stmts.income_statement;
|
||||
// Compute margin trends from statements
|
||||
const marginData = (is || []).slice(0, 5).reverse().map((p) => {
|
||||
const rev = getValue(p, "TotalRevenue|Total Revenue|Revenue");
|
||||
const gp = getValue(p, "GrossProfit|Gross Profit");
|
||||
const op = getValue(p, "OperatingIncome|Operating Income");
|
||||
const ni = getValue(p, "NetIncome|Net Income|Net Income Common Stockholders");
|
||||
const yr = p.asOfDate || p.fiscalYear || "";
|
||||
return {
|
||||
year: typeof yr === "string" ? yr.slice(0, 4) : String(yr),
|
||||
grossMargin: rev && gp ? (gp / rev) * 100 : null,
|
||||
opMargin: rev && op ? (op / rev) * 100 : null,
|
||||
netMargin: rev && ni ? (ni / rev) * 100 : null,
|
||||
};
|
||||
});
|
||||
|
||||
const kpis = [
|
||||
{ l: "FCF (TTM)", v: fmtB(rv.fcf), color: (rv.fcf ?? 0) >= 0 ? C.green : C.red },
|
||||
{ l: "EBITDA (TTM)", v: fmtB(rv.ebitda), color: (rv.ebitda ?? 0) > 0 ? C.green : C.red },
|
||||
{ l: "Revenue Growth", v: rv.revenueGrowth != null ? fmtPct(rv.revenueGrowth * 100) : "N/A", color: (rv.revenueGrowth ?? 0) > 0 ? C.green : C.red },
|
||||
{ l: "Rule of 40", v: rv.rule40 != null ? rv.rule40.toFixed(1) : "N/A", color: (rv.rule40 ?? 0) >= 40 ? C.green : (rv.rule40 ?? 0) >= 20 ? C.gold : C.red },
|
||||
{ l: "Cash Runway", v: rv.cashRunwayQuarters != null ? `${rv.cashRunwayQuarters.toFixed(1)} Q` : "N/A", color: (rv.cashRunwayQuarters ?? 0) > 8 ? C.green : (rv.cashRunwayQuarters ?? 0) > 4 ? C.gold : C.red },
|
||||
{ l: "Net Debt", v: fmtB(rv.netDebt), color: rv.netDebt > 0 ? C.red : C.green },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="report-section">
|
||||
<h2 className="section-title">Path to Profitability</h2>
|
||||
<div className="grid grid-cols-6 gap-2 mb-4">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.l} className="text-center p-2 rounded" style={{ background: "#F4F6F9" }}>
|
||||
<div className="text-[9px] uppercase tracking-wider" style={{ color: C.muted }}>{k.l}</div>
|
||||
<div className="text-sm font-mono font-bold" style={{ color: k.color }}>{k.v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{marginData.length > 1 && (
|
||||
<div>
|
||||
<h3 className="chart-title">Margin Trajectory — Is Profitability Approaching?</h3>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<LineChart data={marginData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
|
||||
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${v}%`} />
|
||||
<Tooltip formatter={(v: number) => `${v?.toFixed(1)}%`} />
|
||||
<ReferenceLine y={0} stroke={C.navy} strokeDasharray="3 3" />
|
||||
<Line type="monotone" dataKey="grossMargin" stroke={C.green} name="Gross" strokeWidth={2} dot={{ r: 3 }} connectNulls />
|
||||
<Line type="monotone" dataKey="opMargin" stroke={C.blue} name="Operating" strokeWidth={2} dot={{ r: 3 }} connectNulls />
|
||||
<Line type="monotone" dataKey="netMargin" stroke={C.gold} name="Net" strokeWidth={2} dot={{ r: 3 }} connectNulls />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Wall Street 10 ── */
|
||||
function WallStreet10Section({ sections }: { sections: Record<string, string> }) {
|
||||
const order: [string, string, string][] = [
|
||||
@@ -887,6 +1097,8 @@ export default function ReportPage() {
|
||||
const [tornado, setTornado] = useState<TornadoItem[] | null>(null);
|
||||
const [reverseDcf, setReverseDcf] = useState<ReverseDCFResult | null>(null);
|
||||
const [institutional, setInstitutional] = useState<InstitutionalData | null>(null);
|
||||
const [valTier, setValTier] = useState<ValuationTier>("dcf");
|
||||
const [relativeVal, setRelativeVal] = useState<RelativeValData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -959,8 +1171,8 @@ export default function ReportPage() {
|
||||
const te = rTe.status === "fulfilled" ? rTe.value : null;
|
||||
if (te) setTechnical(te);
|
||||
|
||||
/* ── Phase 2: DCF Inputs ── */
|
||||
setProgress("Phase 2/4 — Loading DCF inputs...");
|
||||
/* ── Phase 2: DCF Inputs + Tier Detection ── */
|
||||
setProgress("Phase 2/4 — Loading valuation inputs & detecting tier...");
|
||||
const [dcfInputs, smartDefaults] = await Promise.allSettled([
|
||||
fetchJson(`/api/valuation/dcf-inputs/${ticker}`),
|
||||
fetchJson(`/api/valuation/smart-defaults/${ticker}`),
|
||||
@@ -968,8 +1180,15 @@ export default function ReportPage() {
|
||||
const di = dcfInputs.status === "fulfilled" ? dcfInputs.value : null;
|
||||
const sd = smartDefaults.status === "fulfilled" ? smartDefaults.value : null;
|
||||
|
||||
if (di && sd && di.fcf && di.shares) {
|
||||
// smart-defaults returns percentage (e.g. 10 = 10%), but POST endpoints expect decimal (0.10)
|
||||
// Detect valuation tier
|
||||
const fcfVal = hi?.free_cash_flow ?? di?.fcf ?? null;
|
||||
const ebitdaVal = hi?.ebitda ?? null;
|
||||
const revGrowthVal = hi?.revenue_growth ?? null;
|
||||
const tier = detectValuationTier(fcfVal, ebitdaVal, revGrowthVal);
|
||||
setValTier(tier);
|
||||
|
||||
if (tier === "dcf" && di && sd && di.fcf && di.shares) {
|
||||
// Tier 1: Full DCF analysis
|
||||
const waccDec = (sd.wacc || 9) / 100;
|
||||
const tgDec = (sd.terminal_growth || 2.5) / 100;
|
||||
const growthDec = (sd.fcf_growth || 10) / 100;
|
||||
@@ -984,8 +1203,7 @@ export default function ReportPage() {
|
||||
term_growth: tgDec, n_simulations: 5000,
|
||||
};
|
||||
|
||||
/* ── Phase 3: DCF Calculations (5 parallel) ── */
|
||||
setProgress("Phase 3/4 — Running valuation models (DCF, Sensitivity, Monte Carlo, Tornado, Reverse DCF)...");
|
||||
setProgress("Phase 3/4 — Running DCF models (5 parallel)...");
|
||||
const [rDcf, rSens, rMc, rTor, rRev] = await Promise.allSettled([
|
||||
fetchJson("/api/valuation/dcf", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params) }),
|
||||
fetchJson("/api/valuation/sensitivity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params) }),
|
||||
@@ -999,6 +1217,11 @@ export default function ReportPage() {
|
||||
if (rMc.status === "fulfilled" && rMc.value) setMonteCarlo(rMc.value);
|
||||
if (rTor.status === "fulfilled" && rTor.value?.data) setTornado(rTor.value.data);
|
||||
if (rRev.status === "fulfilled" && rRev.value) setReverseDcf(rRev.value);
|
||||
} else if (tier !== "dcf") {
|
||||
// Tier 2/3/4: Relative valuation
|
||||
setProgress("Phase 3/4 — Computing relative valuation (peer multiples)...");
|
||||
const rv = buildRelativeVal(tier, pr, mergedInfo, di, hi);
|
||||
if (rv) setRelativeVal(rv);
|
||||
}
|
||||
|
||||
/* ── Phase 4: Gemini AI ── */
|
||||
@@ -1022,7 +1245,7 @@ export default function ReportPage() {
|
||||
const handlePrint = useCallback(() => { window.print(); }, []);
|
||||
|
||||
const hasReport = institutional && institutional.sections;
|
||||
const hasData = Object.keys(info).length > 0 || Object.keys(stmts).length > 0 || research != null || dcf != null;
|
||||
const hasData = Object.keys(info).length > 0 || Object.keys(stmts).length > 0 || research != null || dcf != null || relativeVal != null;
|
||||
const currentPrice = info.currentPrice || info.regularMarketPrice || dcf?.current_price || 0;
|
||||
|
||||
return (
|
||||
@@ -1084,7 +1307,7 @@ export default function ReportPage() {
|
||||
{(hasReport || hasData) && (
|
||||
<div className="report-container" id="atlas-report">
|
||||
{/* Page 1: Cover */}
|
||||
<CoverPage ticker={ticker} info={info} consensus={consensus} dcf={dcf} />
|
||||
<CoverPage ticker={ticker} info={info} consensus={consensus} dcf={dcf} relativeVal={relativeVal} />
|
||||
|
||||
{/* Page 2: TOC */}
|
||||
<TableOfContents hasInstitutional={!!hasReport} />
|
||||
@@ -1124,29 +1347,40 @@ export default function ReportPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Page 7: DCF Valuation */}
|
||||
{dcf && (
|
||||
<div className="report-page">
|
||||
<div className="page-header">Valuation — DCF Analysis</div>
|
||||
<DCFValuationSection dcf={dcf} reverseDcf={reverseDcf} info={info} />
|
||||
</div>
|
||||
{/* ── Valuation Pages (Tier-Dependent) ── */}
|
||||
{valTier === "dcf" && dcf && (
|
||||
<>
|
||||
<div className="report-page">
|
||||
<div className="page-header">Valuation — DCF Analysis</div>
|
||||
<DCFValuationSection dcf={dcf} reverseDcf={reverseDcf} info={info} />
|
||||
</div>
|
||||
{(sensitivity || monteCarlo) && (
|
||||
<div className="report-page">
|
||||
<div className="page-header">Valuation — Sensitivity & Monte Carlo</div>
|
||||
<SensitivityHeatmap sensitivity={sensitivity} currentPrice={currentPrice} />
|
||||
<MonteCarloSection mc={monteCarlo} />
|
||||
</div>
|
||||
)}
|
||||
{tornado && tornado.length > 0 && (
|
||||
<div className="report-page">
|
||||
<div className="page-header">Valuation — Tornado Sensitivity</div>
|
||||
<TornadoSection tornado={tornado} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Page 8: Sensitivity + Monte Carlo */}
|
||||
{(sensitivity || monteCarlo) && (
|
||||
<div className="report-page">
|
||||
<div className="page-header">Valuation — Sensitivity & Monte Carlo</div>
|
||||
<SensitivityHeatmap sensitivity={sensitivity} currentPrice={currentPrice} />
|
||||
<MonteCarloSection mc={monteCarlo} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Page 9: Tornado */}
|
||||
{tornado && tornado.length > 0 && (
|
||||
<div className="report-page">
|
||||
<div className="page-header">Valuation — Tornado Sensitivity</div>
|
||||
<TornadoSection tornado={tornado} />
|
||||
</div>
|
||||
{valTier !== "dcf" && relativeVal && (
|
||||
<>
|
||||
<div className="report-page">
|
||||
<div className="page-header">Valuation — {relativeVal.tierLabel}</div>
|
||||
<RelativeValuationSection rv={relativeVal} info={info} />
|
||||
</div>
|
||||
<div className="report-page">
|
||||
<div className="page-header">Path to Profitability</div>
|
||||
<PathToProfitability rv={relativeVal} stmts={stmts} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Page 10: Peer Comparison */}
|
||||
|
||||
@@ -6,13 +6,14 @@ import type { ResearchDashboardPayload } from "../components/research/types";
|
||||
import { useTicker } from "../lib/use-ticker";
|
||||
|
||||
export default function ResearchPage() {
|
||||
const { ticker } = useTicker();
|
||||
const { ticker, initialized } = useTicker();
|
||||
const [assetType, setAssetType] = useState<string>("equity");
|
||||
const [dashboard, setDashboard] = useState<ResearchDashboardPayload | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
Promise.all([
|
||||
@@ -30,11 +31,11 @@ export default function ResearchPage() {
|
||||
setDashboard(dash as ResearchDashboardPayload);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoadError("대시보드 데이터를 불러오지 못했습니다.");
|
||||
setLoadError("Failed to load dashboard data.");
|
||||
setDashboard(null);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [ticker]);
|
||||
}, [ticker, initialized]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -57,29 +58,28 @@ export default function ResearchPage() {
|
||||
<>
|
||||
{dashboard.error && (
|
||||
<div className="mb-4 rounded-lg border border-accent-yellow/40 bg-bg-card px-4 py-3 text-sm text-accent-yellow">
|
||||
{dashboard.error} — 일부 위젯이 비어 있을 수 있습니다.
|
||||
{dashboard.error} — Some widgets may be empty.
|
||||
</div>
|
||||
)}
|
||||
<ResearchGridLayout dashboard={dashboard} />
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 text-text-muted text-sm">
|
||||
대시보드 데이터가 없습니다. API 응답을 확인하거나 티커를 바꿔 보세요.
|
||||
No dashboard data available. Check the API response or try a different ticker.
|
||||
</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에 적용되지 않습니다.
|
||||
Provides Holdings Analysis, Sector Breakdown, and Overlap Analysis. Piotroski F-Score and corporate financial dashboards are not applicable to ETFs.
|
||||
</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 요인을 중심으로 분석합니다. 주식 전용 지표는 표시하지 않습니다.
|
||||
Focuses on Seasonal Analysis and Supply/Demand factors. Equity-specific indicators are not displayed.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -26,8 +26,23 @@ interface BacktestResult {
|
||||
benchmark_ticker?: string;
|
||||
}
|
||||
|
||||
interface PortfolioResult {
|
||||
error?: string;
|
||||
total_return_pct?: number;
|
||||
benchmark_return_pct?: number;
|
||||
alpha?: number;
|
||||
sharpe_ratio?: number;
|
||||
sortino_ratio?: number;
|
||||
max_drawdown_pct?: number;
|
||||
contributions?: Record<string, number>;
|
||||
equity_curve?: number[];
|
||||
benchmark_curve?: number[];
|
||||
dates?: string[];
|
||||
benchmark_ticker?: string;
|
||||
}
|
||||
|
||||
export default function ScreenerPage() {
|
||||
const [tab, setTab] = useState<"screener" | "backtest">("screener");
|
||||
const [tab, setTab] = useState<"screener" | "backtest" | "portfolio">("screener");
|
||||
const [rows, setRows] = useState<ScreenerRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [peMax, setPeMax] = useState("25");
|
||||
@@ -44,6 +59,42 @@ export default function ScreenerPage() {
|
||||
const [btLoading, setBtLoading] = useState(false);
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Portfolio backtest state
|
||||
const [ptTickers, setPtTickers] = useState("AAPL, MSFT, GOOG");
|
||||
const [ptWeights, setPtWeights] = useState("40, 30, 30");
|
||||
const [ptBenchmark, setPtBenchmark] = useState("SPY");
|
||||
const [ptRebal, setPtRebal] = useState("3");
|
||||
const [ptStart, setPtStart] = useState("2021-01-01");
|
||||
const [ptEnd, setPtEnd] = useState("2026-01-01");
|
||||
const [ptResult, setPtResult] = useState<PortfolioResult | null>(null);
|
||||
const [ptLoading, setPtLoading] = useState(false);
|
||||
const ptChartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
async function runPortfolioBacktest() {
|
||||
setPtLoading(true);
|
||||
try {
|
||||
const tickers = ptTickers.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean);
|
||||
const weights = ptWeights.split(",").map((s) => parseFloat(s.trim())).filter((n) => !isNaN(n));
|
||||
const res = await fetch("/api/screener/portfolio-backtest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tickers,
|
||||
weights: weights.length === tickers.length ? weights : undefined,
|
||||
start_date: ptStart,
|
||||
end_date: ptEnd,
|
||||
rebalance_months: parseInt(ptRebal, 10) || 3,
|
||||
benchmark_ticker: ptBenchmark || "SPY",
|
||||
}),
|
||||
});
|
||||
setPtResult(await res.json());
|
||||
} catch {
|
||||
setPtResult(null);
|
||||
} finally {
|
||||
setPtLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runScreener() {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -138,6 +189,53 @@ export default function ScreenerPage() {
|
||||
};
|
||||
}, [btResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ptResult?.equity_curve || !ptResult.benchmark_curve || !ptResult.dates || !ptChartRef.current) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let chart: any = null;
|
||||
const el = ptChartRef.current;
|
||||
let onResize: (() => void) | null = null;
|
||||
|
||||
import("lightweight-charts")
|
||||
.then(({ createChart }) => {
|
||||
if (!el) return;
|
||||
el.innerHTML = "";
|
||||
chart = createChart(el, {
|
||||
width: el.clientWidth,
|
||||
height: 320,
|
||||
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" },
|
||||
grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } },
|
||||
crosshair: { mode: 0 },
|
||||
timeScale: { borderColor: "#2A2A3A" },
|
||||
});
|
||||
const port = chart.addLineSeries({ color: "#00D4AA", lineWidth: 2 });
|
||||
port.setData(
|
||||
ptResult.dates!.map((d, i) => ({
|
||||
time: d as string & { __brand?: "Time" },
|
||||
value: ptResult.equity_curve![i],
|
||||
}))
|
||||
);
|
||||
const bench = chart.addLineSeries({ color: "#4DA6FF", lineWidth: 2 });
|
||||
bench.setData(
|
||||
ptResult.dates!.map((d, i) => ({
|
||||
time: d as string & { __brand?: "Time" },
|
||||
value: ptResult.benchmark_curve![i],
|
||||
}))
|
||||
);
|
||||
chart.timeScale().fitContent();
|
||||
onResize = () => {
|
||||
if (el && chart) chart.applyOptions({ width: el.clientWidth });
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
if (onResize) window.removeEventListener("resize", onResize);
|
||||
if (chart) chart.remove();
|
||||
};
|
||||
}, [ptResult]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Stock Screener</h1>
|
||||
@@ -157,11 +255,19 @@ export default function ScreenerPage() {
|
||||
>
|
||||
Backtest
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("portfolio")}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium ${tab === "portfolio" ? "bg-accent-green text-bg-primary" : "text-text-secondary"}`}
|
||||
>
|
||||
Portfolio
|
||||
</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)}
|
||||
@@ -213,7 +319,7 @@ export default function ScreenerPage() {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : tab === "backtest" ? (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4 space-y-4">
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<input
|
||||
@@ -262,7 +368,73 @@ export default function ScreenerPage() {
|
||||
)}
|
||||
{btResult?.error && <p className="text-accent-red text-sm">{btResult.error}</p>}
|
||||
</div>
|
||||
)}
|
||||
) : tab === "portfolio" ? (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4 space-y-4">
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<input
|
||||
value={ptTickers}
|
||||
onChange={(e) => setPtTickers(e.target.value.toUpperCase())}
|
||||
placeholder="Tickers (comma sep)"
|
||||
className="bg-bg-primary border border-border rounded px-3 py-2 text-sm flex-1 min-w-[200px]"
|
||||
/>
|
||||
<input
|
||||
value={ptWeights}
|
||||
onChange={(e) => setPtWeights(e.target.value)}
|
||||
placeholder="Weights (e.g. 40, 30, 30)"
|
||||
className="bg-bg-primary border border-border rounded px-3 py-2 text-sm w-48"
|
||||
/>
|
||||
<input
|
||||
value={ptBenchmark}
|
||||
onChange={(e) => setPtBenchmark(e.target.value.toUpperCase())}
|
||||
placeholder="Benchmark"
|
||||
className="bg-bg-primary border border-border rounded px-3 py-2 text-sm w-28"
|
||||
/>
|
||||
<input
|
||||
value={ptRebal}
|
||||
onChange={(e) => setPtRebal(e.target.value)}
|
||||
placeholder="Rebal months"
|
||||
className="bg-bg-primary border border-border rounded px-3 py-2 text-sm w-28"
|
||||
/>
|
||||
<input type="date" value={ptStart} onChange={(e) => setPtStart(e.target.value)} className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
|
||||
<input type="date" value={ptEnd} onChange={(e) => setPtEnd(e.target.value)} className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
|
||||
<button type="button" onClick={runPortfolioBacktest} className="bg-accent-green text-bg-primary px-4 py-2 rounded font-semibold">
|
||||
{ptLoading ? "Running..." : "Run Portfolio"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-text-muted text-xs">
|
||||
Multi-asset portfolio (green) vs benchmark (blue). Weights are rebalanced every N months.
|
||||
</p>
|
||||
{ptResult && !ptResult.error && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-6 gap-3">
|
||||
<Metric label="Return" value={`${ptResult.total_return_pct}%`} />
|
||||
<Metric label={`Bench (${ptResult.benchmark_ticker || ptBenchmark})`} value={`${ptResult.benchmark_return_pct}%`} />
|
||||
<Metric label="Alpha" value={`${ptResult.alpha}%`} />
|
||||
<Metric label="Sharpe" value={`${ptResult.sharpe_ratio}`} />
|
||||
<Metric label="Sortino" value={`${ptResult.sortino_ratio}`} />
|
||||
<Metric label="Max DD" value={`${ptResult.max_drawdown_pct}%`} />
|
||||
</div>
|
||||
{ptResult.contributions && (
|
||||
<div className="bg-bg-primary border border-border rounded-md p-3">
|
||||
<div className="text-text-muted text-xs mb-2">Contribution by Ticker</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(ptResult.contributions).map(([t, c]) => (
|
||||
<div key={t} className="text-xs font-mono px-2 py-1 rounded border border-border">
|
||||
<span className="text-accent-green">{t}</span>{" "}
|
||||
<span className={c >= 0 ? "text-accent-green" : "text-accent-red"}>
|
||||
{c >= 0 ? "+" : ""}{c}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={ptChartRef} className="w-full min-h-[320px] rounded-lg border border-border overflow-hidden" />
|
||||
</>
|
||||
)}
|
||||
{ptResult?.error && <p className="text-accent-red text-sm">{ptResult.error}</p>}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ interface FibLevels {
|
||||
}
|
||||
|
||||
export default function TechnicalPage() {
|
||||
const { ticker } = useTicker();
|
||||
const { ticker, initialized } = useTicker();
|
||||
const [indicators, setIndicators] = useState<Indicators | null>(null);
|
||||
const [bars, setBars] = useState<ChartBar[]>([]);
|
||||
const [fib, setFib] = useState<FibLevels | null>(null);
|
||||
@@ -40,6 +40,7 @@ export default function TechnicalPage() {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
fetch(`/api/technical/${ticker}/indicators`).then((r) => r.ok ? r.json() : null),
|
||||
@@ -51,19 +52,19 @@ export default function TechnicalPage() {
|
||||
setFib(fibData);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [ticker, period]);
|
||||
}, [ticker, period, initialized]);
|
||||
|
||||
// Render chart using lightweight-charts
|
||||
// Render chart using lightweight-charts v5
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || bars.length === 0) return;
|
||||
// v5 typings omit series helpers; runtime still exposes addCandlestickSeries etc.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let chart: any = null;
|
||||
let resizeHandler: (() => void) | null = null;
|
||||
(async () => {
|
||||
try {
|
||||
const { createChart } = await import("lightweight-charts");
|
||||
const lc = await import("lightweight-charts");
|
||||
chartRef.current!.innerHTML = "";
|
||||
chart = createChart(chartRef.current!, {
|
||||
chart = lc.createChart(chartRef.current!, {
|
||||
width: chartRef.current!.clientWidth,
|
||||
height: 400,
|
||||
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" },
|
||||
@@ -71,43 +72,79 @@ export default function TechnicalPage() {
|
||||
crosshair: { mode: 0 },
|
||||
timeScale: { borderColor: "#2A2A3A" },
|
||||
});
|
||||
const candlestickSeries = chart.addCandlestickSeries({
|
||||
upColor: "#00D4AA",
|
||||
downColor: "#FF4757",
|
||||
borderUpColor: "#00D4AA",
|
||||
borderDownColor: "#FF4757",
|
||||
wickUpColor: "#00D4AA",
|
||||
wickDownColor: "#FF4757",
|
||||
});
|
||||
candlestickSeries.setData(bars);
|
||||
|
||||
const volumeSeries = chart.addHistogramSeries({
|
||||
priceFormat: { type: "volume" },
|
||||
priceScaleId: "",
|
||||
});
|
||||
volumeSeries.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.8, bottom: 0 },
|
||||
});
|
||||
volumeSeries.setData(
|
||||
bars.map((b) => ({
|
||||
time: b.time,
|
||||
value: b.volume,
|
||||
color: b.close >= b.open ? "rgba(0,212,170,0.3)" : "rgba(255,71,87,0.3)",
|
||||
}))
|
||||
);
|
||||
// v5 API: use addSeries with series type constructor
|
||||
const CandlestickSeries = (lc as Record<string, unknown>).CandlestickSeries;
|
||||
const HistogramSeries = (lc as Record<string, unknown>).HistogramSeries;
|
||||
|
||||
if (CandlestickSeries && typeof chart.addSeries === "function") {
|
||||
// v5 path
|
||||
const candlestickSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: "#00D4AA",
|
||||
downColor: "#FF4757",
|
||||
borderUpColor: "#00D4AA",
|
||||
borderDownColor: "#FF4757",
|
||||
wickUpColor: "#00D4AA",
|
||||
wickDownColor: "#FF4757",
|
||||
});
|
||||
candlestickSeries.setData(bars);
|
||||
|
||||
const volumeSeries = chart.addSeries(HistogramSeries, {
|
||||
priceFormat: { type: "volume" },
|
||||
priceScaleId: "volume",
|
||||
});
|
||||
volumeSeries.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.8, bottom: 0 },
|
||||
});
|
||||
volumeSeries.setData(
|
||||
bars.map((b: ChartBar) => ({
|
||||
time: b.time,
|
||||
value: b.volume,
|
||||
color: b.close >= b.open ? "rgba(0,212,170,0.3)" : "rgba(255,71,87,0.3)",
|
||||
}))
|
||||
);
|
||||
} else if (typeof chart.addCandlestickSeries === "function") {
|
||||
// v4 fallback
|
||||
const candlestickSeries = chart.addCandlestickSeries({
|
||||
upColor: "#00D4AA",
|
||||
downColor: "#FF4757",
|
||||
borderUpColor: "#00D4AA",
|
||||
borderDownColor: "#FF4757",
|
||||
wickUpColor: "#00D4AA",
|
||||
wickDownColor: "#FF4757",
|
||||
});
|
||||
candlestickSeries.setData(bars);
|
||||
|
||||
const volumeSeries = chart.addHistogramSeries({
|
||||
priceFormat: { type: "volume" },
|
||||
priceScaleId: "",
|
||||
});
|
||||
volumeSeries.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.8, bottom: 0 },
|
||||
});
|
||||
volumeSeries.setData(
|
||||
bars.map((b: ChartBar) => ({
|
||||
time: b.time,
|
||||
value: b.volume,
|
||||
color: b.close >= b.open ? "rgba(0,212,170,0.3)" : "rgba(255,71,87,0.3)",
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
chart.timeScale().fitContent();
|
||||
|
||||
const handleResize = () => {
|
||||
resizeHandler = () => {
|
||||
if (chartRef.current) chart.applyOptions({ width: chartRef.current.clientWidth });
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
} catch {
|
||||
// lightweight-charts not available
|
||||
window.addEventListener("resize", resizeHandler);
|
||||
} catch (e) {
|
||||
console.error("lightweight-charts render error:", e);
|
||||
}
|
||||
})();
|
||||
return () => { if (chart) chart.remove(); };
|
||||
return () => {
|
||||
if (resizeHandler) window.removeEventListener("resize", resizeHandler);
|
||||
if (chart) chart.remove();
|
||||
};
|
||||
}, [bars]);
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
|
||||
|
||||
@@ -50,7 +50,7 @@ interface MonteCarloData {
|
||||
type ValuationTab = "dcf" | "sensitivity" | "montecarlo" | "tornado" | "reverse";
|
||||
|
||||
export default function ValuationPage() {
|
||||
const { ticker } = useTicker();
|
||||
const { ticker, initialized } = useTicker();
|
||||
const [assetType, setAssetType] = useState<string>("equity");
|
||||
const [inputs, setInputs] = useState<DCFInputs | null>(null);
|
||||
const [consensus, setConsensus] = useState<Consensus | null>(null);
|
||||
@@ -70,6 +70,7 @@ export default function ValuationPage() {
|
||||
const [advLoading, setAdvLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
setLoading(true);
|
||||
setDcfResult(null);
|
||||
setSensitivity(null);
|
||||
@@ -90,7 +91,7 @@ export default function ValuationPage() {
|
||||
if (d?.fcf_growth) setFcfGrowth(d.fcf_growth);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [ticker]);
|
||||
}, [ticker, initialized]);
|
||||
|
||||
async function runDCF() {
|
||||
if (!inputs) return;
|
||||
@@ -184,8 +185,8 @@ export default function ValuationPage() {
|
||||
</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) 전용입니다."}
|
||||
? "Evaluates NAV Premium/Discount, Expense comparison, and Tracking Error. DCF is available for equities only."
|
||||
: "Evaluates Futures Curve (Contango/Backwardation) and Cost of Carry. DCF is available for equities only."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,47 @@
|
||||
ATLAS Terminal — FastAPI Backend
|
||||
Unified entry point with PostgreSQL + SQLite support.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class _NanSafeEncoder(json.JSONEncoder):
|
||||
"""Replace NaN/Inf with None so JSON serialization never crashes."""
|
||||
|
||||
def default(self, o: Any) -> Any:
|
||||
return super().default(o)
|
||||
|
||||
def encode(self, o: Any) -> str:
|
||||
return super().encode(_sanitize(o))
|
||||
|
||||
|
||||
def _sanitize(obj: Any) -> Any:
|
||||
if isinstance(obj, float):
|
||||
if math.isnan(obj) or math.isinf(obj):
|
||||
return None
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
return {k: _sanitize(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [_sanitize(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
class NanSafeJSONResponse(JSONResponse):
|
||||
def render(self, content: Any) -> bytes:
|
||||
return json.dumps(
|
||||
_sanitize(content),
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,6 +64,7 @@ app = FastAPI(
|
||||
description="Personal Bloomberg Terminal — Hybrid AI + Quantitative Analysis",
|
||||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
default_response_class=NanSafeJSONResponse,
|
||||
)
|
||||
|
||||
# CORS — allow local frontend
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""DART Korea — company search (optional ``DART_API_KEY``) + 사업보고서 sections."""
|
||||
"""DART Korea — company search (optional ``DART_API_KEY``) + annual report sections."""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
@@ -32,7 +32,7 @@ async def dart_search(
|
||||
@router.get(
|
||||
"/sections/{ticker}",
|
||||
response_model=EdgarSectionsResponse,
|
||||
summary="Korean 사업보고서 sections (DART Open API)",
|
||||
summary="Korean annual report sections (DART Open API)",
|
||||
)
|
||||
async def dart_sections(
|
||||
ticker: str,
|
||||
@@ -41,7 +41,7 @@ async def dart_sections(
|
||||
description="Include HTML fragment for in-app viewer",
|
||||
),
|
||||
):
|
||||
"""Download latest annual report (사업보고서) and map to SEC-like section keys."""
|
||||
"""Download latest annual report and map to SEC-like section keys."""
|
||||
if not dart_filing_is_configured():
|
||||
return EdgarSectionsResponse(
|
||||
source="dart",
|
||||
@@ -50,7 +50,7 @@ async def dart_sections(
|
||||
status="unconfigured",
|
||||
)
|
||||
try:
|
||||
sections, status, html_frag, _rcept = get_dart_sections(ticker)
|
||||
sections, status, html_frag, rcept_no = get_dart_sections(ticker)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except FileNotFoundError as exc:
|
||||
@@ -59,6 +59,7 @@ async def dart_sections(
|
||||
raise HTTPException(status_code=500, detail=f"DART download failed: {exc}") from exc
|
||||
|
||||
html_payload = html_frag if include_html else ""
|
||||
links = {"DART 원문 공시": f"https://dart.fss.or.kr/dsaf001/main.do?rcpNo={rcept_no}"} if rcept_no else None
|
||||
return EdgarSectionsResponse(
|
||||
source="dart",
|
||||
configured=True,
|
||||
@@ -69,4 +70,5 @@ async def dart_sections(
|
||||
item8=sections.get("item8", ""),
|
||||
item9a=sections.get("item9a", ""),
|
||||
html=html_payload,
|
||||
links=links,
|
||||
)
|
||||
|
||||
@@ -107,6 +107,79 @@ async def earnings_transcript(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{ticker}/delta", summary="What changed vs last quarter")
|
||||
async def earnings_delta(ticker: str) -> Dict[str, Any]:
|
||||
"""Compare the two most recent quarters: revenue/earnings delta + AI summary."""
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
t = yf.Ticker(ticker.upper())
|
||||
quarterly = t.quarterly_earnings
|
||||
if quarterly is None or (hasattr(quarterly, "empty") and quarterly.empty) or len(quarterly) < 2:
|
||||
return {"ticker": ticker.upper(), "available": False, "message": "Not enough quarterly data"}
|
||||
|
||||
rows = []
|
||||
for idx, row in quarterly.iterrows():
|
||||
rows.append({
|
||||
"period": str(idx),
|
||||
"revenue": _safe_float(row.get("Revenue")),
|
||||
"earnings": _safe_float(row.get("Earnings")),
|
||||
})
|
||||
if len(rows) < 2:
|
||||
return {"ticker": ticker.upper(), "available": False, "message": "Not enough quarterly data"}
|
||||
|
||||
latest, prev = rows[0], rows[1]
|
||||
rev_delta = None
|
||||
earn_delta = None
|
||||
if latest["revenue"] and prev["revenue"] and prev["revenue"] != 0:
|
||||
rev_delta = round((latest["revenue"] - prev["revenue"]) / abs(prev["revenue"]) * 100, 2)
|
||||
if latest["earnings"] and prev["earnings"] and prev["earnings"] != 0:
|
||||
earn_delta = round((latest["earnings"] - prev["earnings"]) / abs(prev["earnings"]) * 100, 2)
|
||||
|
||||
# EPS surprise trend from earnings_history
|
||||
eh = t.earnings_history
|
||||
eps_trend: List[Dict[str, Any]] = []
|
||||
if eh is not None and hasattr(eh, "iterrows"):
|
||||
for idx2, row2 in eh.iterrows():
|
||||
eps_trend.append({
|
||||
"date": str(idx2)[:10],
|
||||
"surprise_pct": round(_safe_float(row2.get("surprisePercent", 0), 0) * 100, 2),
|
||||
})
|
||||
eps_trend = eps_trend[-4:]
|
||||
|
||||
# AI summary via Gemini (best-effort)
|
||||
ai_summary: Optional[str] = None
|
||||
try:
|
||||
from server.services.gemini_service import generate_text
|
||||
prompt = (
|
||||
f"Compare {ticker.upper()} most recent two quarters.\n"
|
||||
f"Latest quarter ({latest['period']}): Revenue ${latest['revenue']}, Earnings ${latest['earnings']}.\n"
|
||||
f"Previous quarter ({prev['period']}): Revenue ${prev['revenue']}, Earnings ${prev['earnings']}.\n"
|
||||
f"Revenue changed {rev_delta}%, Earnings changed {earn_delta}%.\n"
|
||||
"In 2-3 sentences, explain what changed and why. Be concise and specific."
|
||||
)
|
||||
ai_summary = await generate_text(prompt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"available": True,
|
||||
"latest_quarter": latest["period"],
|
||||
"prev_quarter": prev["period"],
|
||||
"latest_revenue": latest["revenue"],
|
||||
"prev_revenue": prev["revenue"],
|
||||
"latest_earnings": latest["earnings"],
|
||||
"prev_earnings": prev["earnings"],
|
||||
"revenue_delta_pct": rev_delta,
|
||||
"earnings_delta_pct": earn_delta,
|
||||
"eps_trend": eps_trend,
|
||||
"ai_summary": ai_summary,
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Earnings delta failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/{ticker}/quarterly", summary="Quarterly earnings data")
|
||||
async def quarterly_earnings(ticker: str) -> Dict[str, Any]:
|
||||
try:
|
||||
|
||||
@@ -53,6 +53,22 @@ async def macro_smart_money() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/subfactors", summary="4-category macro subfactor breakdown + cycle stage")
|
||||
async def macro_subfactors() -> Dict[str, Any]:
|
||||
from server.services.macro_cycle import get_subfactor_breakdown
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(get_subfactor_breakdown)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"updated_at": None,
|
||||
"composite_score": 0.0,
|
||||
"cycle_stage": "Unknown",
|
||||
"categories": {},
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/fred/{series_id}", summary="FRED time series (public CSV)")
|
||||
async def macro_fred(
|
||||
series_id: str,
|
||||
@@ -126,7 +142,7 @@ async def macro_economic_calendar(
|
||||
|
||||
@router.get("/ecos", summary="Korea Bank ECOS (requires ECOS_API_KEY)")
|
||||
async def macro_ecos(
|
||||
stat_code: str = Query(..., description="ECOS 통계표 코드"),
|
||||
stat_code: str = Query(..., description="ECOS statistics table code"),
|
||||
cycle: str = Query("M", description="D/W/M/Q/S/Y"),
|
||||
start_ym: str = Query("201501"),
|
||||
end_ym: Optional[str] = Query(None),
|
||||
|
||||
@@ -143,11 +143,20 @@ async def sector_industry(ticker: str):
|
||||
"industry": info.get("industry", "N/A"),
|
||||
"market_cap": _safe_float(info.get("marketCap")),
|
||||
"pe_ratio": _safe_float(info.get("trailingPE")) or _safe_float(info.get("forwardPE")),
|
||||
"forward_pe": _safe_float(info.get("forwardPE")),
|
||||
"dividend_yield": _safe_float(info.get("dividendYield")),
|
||||
"beta": _safe_float(info.get("beta")),
|
||||
"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")),
|
||||
"target_mean_price": _safe_float(info.get("targetMeanPrice")),
|
||||
"target_high_price": _safe_float(info.get("targetHighPrice")),
|
||||
"target_low_price": _safe_float(info.get("targetLowPrice")),
|
||||
"recommendation": info.get("recommendationKey"),
|
||||
"analyst_count": info.get("numberOfAnalystOpinions"),
|
||||
"forward_eps": _safe_float(info.get("forwardEps")),
|
||||
"trailing_eps": _safe_float(info.get("trailingEps")),
|
||||
"peg_ratio": _safe_float(info.get("pegRatio")),
|
||||
"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"),
|
||||
|
||||
@@ -35,3 +35,28 @@ async def backtest(body: dict):
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@router.post("/portfolio-backtest")
|
||||
async def portfolio_backtest(body: dict):
|
||||
"""Run multi-asset portfolio backtest with rebalancing."""
|
||||
try:
|
||||
from server.services.backtester import run_portfolio_backtest
|
||||
|
||||
tickers = body.get("tickers", [])
|
||||
weights = body.get("weights", [])
|
||||
if not tickers:
|
||||
return {"error": "At least one ticker is required"}
|
||||
if not weights:
|
||||
weights = [1.0 / len(tickers)] * len(tickers)
|
||||
|
||||
return await run_portfolio_backtest(
|
||||
tickers=tickers,
|
||||
weights=[float(w) for w in weights],
|
||||
start_date=body.get("start_date", "2021-01-01"),
|
||||
end_date=body.get("end_date", "2026-01-01"),
|
||||
rebalance_months=int(body.get("rebalance_months", 3)),
|
||||
benchmark_ticker=str(body.get("benchmark_ticker") or "SPY"),
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -110,6 +110,157 @@ def _run_backtest_impl(
|
||||
}
|
||||
|
||||
|
||||
def _run_portfolio_backtest_impl(
|
||||
tickers: list[str],
|
||||
weights: list[float],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
rebalance_months: int = 3,
|
||||
benchmark_ticker: str = "SPY",
|
||||
) -> Dict[str, Any]:
|
||||
"""Multi-asset portfolio backtest with periodic rebalancing."""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
if len(tickers) != len(weights) or not tickers:
|
||||
return {"error": "Tickers and weights must be non-empty and same length"}
|
||||
|
||||
# Normalize weights
|
||||
total_w = sum(weights)
|
||||
if total_w <= 0:
|
||||
return {"error": "Weights must sum to a positive number"}
|
||||
norm_weights = [w / total_w for w in weights]
|
||||
|
||||
# Fetch price data
|
||||
price_frames = {}
|
||||
for t in tickers:
|
||||
hist = yf.Ticker(t.upper()).history(start=start_date, end=end_date, auto_adjust=True)
|
||||
if hist is not None and not hist.empty and "Close" in hist:
|
||||
price_frames[t.upper()] = hist["Close"]
|
||||
if not price_frames:
|
||||
return {"error": "No price data for any ticker"}
|
||||
|
||||
prices = pd.DataFrame(price_frames).dropna()
|
||||
if len(prices) < 5:
|
||||
return {"error": "Insufficient overlapping price data"}
|
||||
|
||||
# Benchmark
|
||||
bm_sym = (benchmark_ticker or "SPY").upper()
|
||||
bm_hist = yf.Ticker(bm_sym).history(start=start_date, end=end_date, auto_adjust=True)
|
||||
if bm_hist is None or bm_hist.empty:
|
||||
return {"error": f"No benchmark data for {bm_sym}"}
|
||||
|
||||
common = prices.index.intersection(bm_hist.index)
|
||||
if len(common) < 5:
|
||||
return {"error": "Insufficient overlap with benchmark"}
|
||||
prices = prices.loc[common]
|
||||
bm_close = bm_hist.loc[common, "Close"]
|
||||
|
||||
returns = prices.pct_change().fillna(0)
|
||||
bm_returns = bm_close.pct_change().fillna(0)
|
||||
|
||||
# Map tickers to weights (use only tickers that have data)
|
||||
avail_tickers = list(prices.columns)
|
||||
ticker_weight = {}
|
||||
for t, w in zip(tickers, norm_weights):
|
||||
tu = t.upper()
|
||||
if tu in avail_tickers:
|
||||
ticker_weight[tu] = w
|
||||
# Re-normalize
|
||||
tw_sum = sum(ticker_weight.values())
|
||||
if tw_sum <= 0:
|
||||
return {"error": "No valid tickers with data"}
|
||||
for k in ticker_weight:
|
||||
ticker_weight[k] /= tw_sum
|
||||
|
||||
# Rebalancing: compute portfolio returns
|
||||
current_weights = {t: ticker_weight[t] for t in ticker_weight}
|
||||
portfolio_returns = []
|
||||
last_rebal = None
|
||||
|
||||
for i, dt in enumerate(prices.index):
|
||||
if i == 0:
|
||||
portfolio_returns.append(0.0)
|
||||
last_rebal = dt
|
||||
continue
|
||||
|
||||
# Daily portfolio return = sum of weight * return
|
||||
daily_ret = sum(current_weights.get(t, 0) * returns.loc[dt, t] for t in avail_tickers if t in current_weights)
|
||||
portfolio_returns.append(daily_ret)
|
||||
|
||||
# Drift weights
|
||||
for t in current_weights:
|
||||
current_weights[t] *= (1 + returns.loc[dt, t])
|
||||
w_sum = sum(current_weights.values())
|
||||
if w_sum > 0:
|
||||
for t in current_weights:
|
||||
current_weights[t] /= w_sum
|
||||
|
||||
# Rebalance check
|
||||
if last_rebal is not None and _months_between(last_rebal, dt) >= rebalance_months:
|
||||
current_weights = {t: ticker_weight[t] for t in ticker_weight}
|
||||
last_rebal = dt
|
||||
|
||||
port_ret = pd.Series(portfolio_returns, index=prices.index)
|
||||
cumulative = (1 + port_ret).cumprod()
|
||||
benchmark_cum = (1 + bm_returns).cumprod()
|
||||
|
||||
# Metrics
|
||||
total_ret = round((float(cumulative.iloc[-1]) - 1) * 100, 2)
|
||||
bm_ret = round((float(benchmark_cum.iloc[-1]) - 1) * 100, 2)
|
||||
mdd = round(float(((cumulative / cumulative.cummax()) - 1).min()) * 100, 2)
|
||||
sharpe = round(float(port_ret.mean() / (port_ret.std() + 1e-10) * (252**0.5)), 2)
|
||||
|
||||
# Sortino
|
||||
downside = port_ret[port_ret < 0]
|
||||
sortino = round(float(port_ret.mean() / (downside.std() + 1e-10) * (252**0.5)), 2) if len(downside) > 0 else 0.0
|
||||
|
||||
# Contribution per ticker
|
||||
contributions = {}
|
||||
for t in ticker_weight:
|
||||
t_ret = returns[t]
|
||||
contrib = float((t_ret * ticker_weight[t]).sum()) * 100
|
||||
contributions[t] = round(contrib, 2)
|
||||
|
||||
return {
|
||||
"tickers": list(ticker_weight.keys()),
|
||||
"weights": {t: round(w, 4) for t, w in ticker_weight.items()},
|
||||
"benchmark_ticker": bm_sym,
|
||||
"total_return_pct": total_ret,
|
||||
"benchmark_return_pct": bm_ret,
|
||||
"alpha": round(total_ret - bm_ret, 2),
|
||||
"max_drawdown_pct": mdd,
|
||||
"sharpe_ratio": sharpe,
|
||||
"sortino_ratio": sortino,
|
||||
"rebalance_months": rebalance_months,
|
||||
"contributions": contributions,
|
||||
"equity_curve": [round(float(x), 4) for x in cumulative.tolist()],
|
||||
"benchmark_curve": [round(float(x), 4) for x in benchmark_cum.tolist()],
|
||||
"dates": prices.index.strftime("%Y-%m-%d").tolist(),
|
||||
}
|
||||
|
||||
|
||||
async def run_portfolio_backtest(
|
||||
tickers: list[str],
|
||||
weights: list[float],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
rebalance_months: int = 3,
|
||||
benchmark_ticker: str = "SPY",
|
||||
) -> dict:
|
||||
"""Run a multi-asset portfolio backtest with periodic rebalancing."""
|
||||
return await asyncio.to_thread(
|
||||
_run_portfolio_backtest_impl,
|
||||
tickers,
|
||||
weights,
|
||||
start_date,
|
||||
end_date,
|
||||
rebalance_months,
|
||||
benchmark_ticker,
|
||||
)
|
||||
|
||||
|
||||
async def run_backtest(
|
||||
ticker: str,
|
||||
strategy: str,
|
||||
|
||||
@@ -393,6 +393,142 @@ def get_macro_cycle_snapshot() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
_SUBFACTOR_CATEGORIES: Dict[str, List[MacroSeries]] = {
|
||||
"Growth": [
|
||||
MacroSeries("gdp_growth", "Real GDP Growth", "A191RL1Q225SBEA", "quarterly", "%", True),
|
||||
MacroSeries("ism_pmi", "ISM Manufacturing PMI", "MANEMP", "monthly", "idx", True, is_index=False),
|
||||
MacroSeries("industrial_prod", "Industrial Production", "INDPRO", "monthly", "%", True, is_index=True),
|
||||
MacroSeries("retail_sales", "Retail Sales", "RSXFS", "monthly", "%", True, is_index=True),
|
||||
],
|
||||
"Prices": [
|
||||
MacroSeries("cpi_yoy", "CPI YoY", "CPIAUCSL", "monthly", "%", False, is_index=True),
|
||||
MacroSeries("core_cpi", "Core CPI", "CPILFESL", "monthly", "%", False, is_index=True),
|
||||
MacroSeries("ppi", "PPI", "PPIACO", "monthly", "%", False, is_index=True),
|
||||
MacroSeries("pce", "PCE Price Index", "PCEPI", "monthly", "%", False, is_index=True),
|
||||
],
|
||||
"Labor": [
|
||||
MacroSeries("unemployment", "Unemployment Rate", "UNRATE", "monthly", "%", False),
|
||||
MacroSeries("nonfarm", "Nonfarm Payrolls", "PAYEMS", "monthly", "K", True, is_index=False),
|
||||
MacroSeries("initial_claims", "Initial Claims", "ICSA", "weekly", "K", False),
|
||||
MacroSeries("participation", "Participation Rate", "CIVPART", "monthly", "%", True),
|
||||
],
|
||||
"Financial": [
|
||||
MacroSeries("yield_spread", "10Y-2Y Spread", "T10Y2Y", "daily", "bp", True),
|
||||
MacroSeries("vix", "VIX", "VIXCLS", "daily", "idx", False),
|
||||
MacroSeries("credit_spread", "BAA-AAA Spread", "BAAFFM", "monthly", "bp", False),
|
||||
MacroSeries("fed_funds", "Fed Funds Rate", "FEDFUNDS", "monthly", "%", False),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _subfactor_3m_change(series) -> Optional[float]:
|
||||
"""Compute 3-month change from a pandas Series."""
|
||||
if series is None or len(series) < 4:
|
||||
return None
|
||||
try:
|
||||
recent = float(series.iloc[-1])
|
||||
past = float(series.iloc[-4]) if len(series) >= 4 else float(series.iloc[0])
|
||||
if past == 0:
|
||||
return None
|
||||
return round((recent - past) / abs(past) * 100, 2)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _subfactor_signal(zscore: Optional[float], change_3m: Optional[float], higher_is_better: bool) -> str:
|
||||
"""Return improving / neutral / deteriorating."""
|
||||
if change_3m is None and zscore is None:
|
||||
return "neutral"
|
||||
if change_3m is not None:
|
||||
effective = change_3m if higher_is_better else -change_3m
|
||||
if effective > 1.5:
|
||||
return "improving"
|
||||
if effective < -1.5:
|
||||
return "deteriorating"
|
||||
if zscore is not None:
|
||||
effective_z = zscore if higher_is_better else -zscore
|
||||
if effective_z > 0.5:
|
||||
return "improving"
|
||||
if effective_z < -0.5:
|
||||
return "deteriorating"
|
||||
return "neutral"
|
||||
|
||||
|
||||
_cached_subfactors = cached("macro_subfactors", ttl_seconds=3600)
|
||||
|
||||
|
||||
@_cached_subfactors
|
||||
def get_subfactor_breakdown() -> Dict[str, Any]:
|
||||
"""Return 4-category × 4-indicator subfactor breakdown with cycle stage."""
|
||||
categories: Dict[str, Any] = {}
|
||||
all_scores: List[float] = []
|
||||
|
||||
for cat_name, indicators in _SUBFACTOR_CATEGORIES.items():
|
||||
items: List[Dict[str, Any]] = []
|
||||
cat_scores: List[float] = []
|
||||
|
||||
for s in indicators:
|
||||
try:
|
||||
raw = _fetch_fred_series(s.fred_code)
|
||||
if raw is None or raw.empty:
|
||||
items.append({"key": s.key, "label": s.label, "value": None, "change_3m": None, "zscore": None, "signal": "neutral"})
|
||||
continue
|
||||
values = raw.iloc[:, 0]
|
||||
if s.is_index:
|
||||
values = _series_to_pct_change(values)
|
||||
values = values.dropna() if values is not None else values
|
||||
if values is None or values.empty:
|
||||
items.append({"key": s.key, "label": s.label, "value": None, "change_3m": None, "zscore": None, "signal": "neutral"})
|
||||
continue
|
||||
|
||||
latest = _safe_float(values.iloc[-1])
|
||||
z = _zscore([float(v) for v in values.tail(60).tolist() if _safe_float(v) is not None])
|
||||
change = _subfactor_3m_change(values)
|
||||
signal = _subfactor_signal(z, change, s.higher_is_better)
|
||||
|
||||
if z is not None:
|
||||
effective = z if s.higher_is_better else -z
|
||||
cat_scores.append(effective)
|
||||
all_scores.append(effective)
|
||||
|
||||
items.append({
|
||||
"key": s.key,
|
||||
"label": s.label,
|
||||
"value": round(latest, 2) if latest is not None else None,
|
||||
"unit": s.unit,
|
||||
"change_3m": change,
|
||||
"zscore": round(z, 2) if z is not None else None,
|
||||
"signal": signal,
|
||||
})
|
||||
except Exception:
|
||||
items.append({"key": s.key, "label": s.label, "value": None, "change_3m": None, "zscore": None, "signal": "neutral"})
|
||||
|
||||
cat_score = round(float(np.mean(cat_scores)), 2) if cat_scores else 0.0
|
||||
categories[cat_name] = {"score": cat_score, "indicators": items}
|
||||
|
||||
# Determine cycle stage from composite score
|
||||
composite = round(float(np.mean(all_scores)), 2) if all_scores else 0.0
|
||||
growth_score = categories.get("Growth", {}).get("score", 0)
|
||||
price_score = categories.get("Prices", {}).get("score", 0)
|
||||
|
||||
# 4-stage cycle: growth momentum + price momentum
|
||||
if growth_score > 0 and price_score <= 0:
|
||||
stage = "Early Expansion"
|
||||
elif growth_score > 0 and price_score > 0:
|
||||
stage = "Late Expansion"
|
||||
elif growth_score <= 0 and price_score > 0:
|
||||
stage = "Early Contraction"
|
||||
else:
|
||||
stage = "Late Contraction"
|
||||
|
||||
return {
|
||||
"updated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"composite_score": composite,
|
||||
"cycle_stage": stage,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
|
||||
def get_country_series(country: str, indicator: str, period: str = "5y") -> Dict[str, Any]:
|
||||
"""Return a time series for a single country/indicator pair."""
|
||||
mappings = COUNTRY_SERIES.get(country)
|
||||
|
||||
Reference in New Issue
Block a user