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:
shawnkim1997
2026-03-29 22:22:03 +01:00
parent 8fe3aaf771
commit ec2c5b37a2
29 changed files with 1390 additions and 176 deletions
@@ -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>
+4 -4
View File
@@ -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>
);
+141 -45
View File
@@ -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 };
}
+85 -1
View File
@@ -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>
) : (
+13 -6
View File
@@ -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>
+265 -31
View File
@@ -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">&#9888;</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 &bull; {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 &mdash; 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 &mdash; DCF Analysis</div>
<DCFValuationSection dcf={dcf} reverseDcf={reverseDcf} info={info} />
</div>
{(sensitivity || monteCarlo) && (
<div className="report-page">
<div className="page-header">Valuation &mdash; Sensitivity &amp; 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 &mdash; Tornado Sensitivity</div>
<TornadoSection tornado={tornado} />
</div>
)}
</>
)}
{/* Page 8: Sensitivity + Monte Carlo */}
{(sensitivity || monteCarlo) && (
<div className="report-page">
<div className="page-header">Valuation &mdash; Sensitivity &amp; 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 &mdash; Tornado Sensitivity</div>
<TornadoSection tornado={tornado} />
</div>
{valTier !== "dcf" && relativeVal && (
<>
<div className="report-page">
<div className="page-header">Valuation &mdash; {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>
+37
View File
@@ -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
+6 -4
View File
@@ -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,
)
+73
View File
@@ -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:
+17 -1
View File
@@ -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"),
+25
View File
@@ -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)