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
co-authored by Claude Opus 4.6
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>