diff --git a/app/api/routers/system.py b/app/api/routers/system.py index 513a1aa5..c20a18c2 100644 --- a/app/api/routers/system.py +++ b/app/api/routers/system.py @@ -61,12 +61,16 @@ def _ui_model_options() -> list[dict[str, Any]]: + primary_model = getattr(_settings, "primary_model", "qwen2.5:7b") options = [ { "id": "qwen", - "label": f"{getattr(_settings, 'primary_model', 'qwen2.5:7b')} (Ollama · 기본)", + "model": primary_model, + "label": f"{primary_model} (Ollama · 기본)", "role": "primary", "enabled": True, + "availability": "runtime_checked", + "availability_note": "Configured route; Ollama model availability is verified when a request runs.", } ] gemma4_model = getattr(_settings, "gemma4_model", None) or getattr(_settings, "experimental_fallback_model", "gemma4:e4b") @@ -74,18 +78,24 @@ def _ui_model_options() -> list[dict[str, Any]]: options.append( { "id": "gemma4", + "model": gemma4_model, "label": f"{gemma4_model} (Gemma4 E4B experimental)", "role": "experimental", "enabled": True, + "availability": "runtime_checked", + "availability_note": "Displayed only as a configured route; local model availability is not claimed until runtime.", } ) if bool(getattr(_settings, "enable_experimental_fallback", False)): options.append( { "id": "gemma-experimental", + "model": getattr(_settings, "experimental_fallback_model", "gemma4:e4b"), "label": f"{getattr(_settings, 'experimental_fallback_model', 'gemma4:e4b')} (fallback)", "role": "fallback", "enabled": True, + "availability": "runtime_checked", + "availability_note": "Displayed only when experimental fallback is enabled; checked at runtime.", } ) return options diff --git a/app/web/app.js b/app/web/app.js index 3c24c604..9a5b0a72 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -23,9 +23,23 @@ const API = { qdrantInfo: "/api/v1/qdrant/collection", qdrantPurge: "/api/v1/qdrant/purge", evalDashboard: "/api/v1/eval/dashboard", - dashboardNews: "/api/v1/dashboard/news?limit=20", + dashboardNews: (options = {}) => { + const params = new URLSearchParams({ limit: String(options.limit || 20) }); + if (options.query) params.set("query", options.query); + if (options.ticker) params.set("ticker", options.ticker); + if (options.topic) params.set("topic", options.topic); + return `/api/v1/dashboard/news?${params.toString()}`; + }, dashboardMarket: "/api/v1/dashboard/market", dashboardMarketOverview: "/api/v1/dashboard/market/overview", + dashboardDecisionCards: "/api/v1/dashboard/decision-cards", + dashboardCrossAssetAnalyze: (options = {}) => { + const params = new URLSearchParams(); + if (options.symbols) params.set("symbols", options.symbols); + if (options.topic) params.set("topic", options.topic); + if (options.horizon) params.set("horizon", options.horizon); + return `/api/v1/dashboard/cross-asset/analyze?${params.toString()}`; + }, dashboardIntraday: (ticker, interval = "5m", limit = 500) => { const params = new URLSearchParams({ interval: String(interval), limit: String(limit) }); return `/api/v1/dashboard/market/intraday/${encodeURIComponent(ticker)}?${params.toString()}`; @@ -60,6 +74,11 @@ const API = { if (options.refresh) params.set("refresh", "true"); if (options.startDate) params.set("start_date", options.startDate); if (options.endDate) params.set("end_date", options.endDate); + if (options.freshnessProfile) params.set("freshness_profile", options.freshnessProfile); + if (options.requireFreshPrices) params.set("require_fresh_prices", "true"); + if (options.maxMarketCalendarLagDays !== undefined && options.maxMarketCalendarLagDays !== null) { + params.set("max_market_calendar_lag_days", String(options.maxMarketCalendarLagDays)); + } return `/api/v1/data/prices/${encodeURIComponent(ticker)}?${params.toString()}`; }, dataFundamentals: (ticker) => `/api/v1/data/fundamentals/${encodeURIComponent(ticker)}`, @@ -131,6 +150,69 @@ const API = { aiPortfolioReportsGenerate: "/api/v1/ai-portfolio/reports", aiPortfolioReports: (policyId) => `/api/v1/ai-portfolio/reports?policy_id=${encodeURIComponent(policyId)}`, aiPortfolioHistory: (policyId) => `/api/v1/ai-portfolio/history?policy_id=${encodeURIComponent(policyId)}`, + quantamentalAnalysis: (ticker, options = {}) => { + const params = new URLSearchParams({ + market: String(options.market || "US"), + period: String(options.period || "annual"), + years: String(options.years || 5), + lookback: String(options.lookback || 252), + style: String(options.style || "balanced"), + include_ai: String(options.includeAi ?? true), + use_llm: String(options.useLlm ?? false), + force_refresh: String(options.forceRefresh ?? false), + output_language: String(options.outputLanguage || selectedOutputLanguage()), + }); + return `/api/v1/quantamental/analysis/${encodeURIComponent(ticker)}?${params.toString()}`; + }, + quantamentalAiReport: "/api/v1/quantamental/ai/report", + quantamentalAiQa: "/api/v1/quantamental/ai/qa", + quantamentalCompare: "/api/v1/quantamental/compare", + quantamentalTopSignals: (options = {}) => { + const params = new URLSearchParams({ + universe: String(options.universe || "default_us_large_cap"), + market: String(options.market || "US"), + period: String(options.period || "annual"), + years: String(options.years || 5), + lookback: String(options.lookback || 252), + style: String(options.style || "balanced"), + limit: String(options.limit || 5), + refresh_stale: String(options.refreshStale ?? true), + force_refresh: String(options.forceRefresh ?? false), + output_language: String(options.outputLanguage || selectedOutputLanguage()), + }); + if (options.tickers) params.set("tickers", String(options.tickers)); + return `/api/v1/quantamental/screen/top-signals?${params.toString()}`; + }, + quantamentalScoreScreen: (options = {}) => { + const params = new URLSearchParams({ + universe: String(options.universe || "default_us_large_cap"), + market: String(options.market || "US"), + period: String(options.period || "annual"), + years: String(options.years || 5), + lookback: String(options.lookback || 252), + style: String(options.style || "balanced"), + score_key: String(options.scoreKey || "composite"), + min_score: String(options.minScore ?? 70), + limit: String(options.limit || 20), + refresh_stale: String(options.refreshStale ?? true), + force_refresh: String(options.forceRefresh ?? false), + output_language: String(options.outputLanguage || selectedOutputLanguage()), + }); + if (options.tickers) params.set("tickers", String(options.tickers)); + return `/api/v1/quantamental/screen/by-score?${params.toString()}`; + }, + quantamentalCompareWatchlists: "/api/v1/quantamental/compare/watchlists", + quantamentalCompareWatchlist: (id) => `/api/v1/quantamental/compare/watchlists/${encodeURIComponent(id || "")}`, + quantamentalSnapshotExport: (snapshotId, format = "json") => `/api/v1/quantamental/snapshots/${encodeURIComponent(snapshotId || "")}/export?format=${encodeURIComponent(format)}`, + quantamentalSnapshotDiff: (baseSnapshotId, targetSnapshotId) => `/api/v1/quantamental/snapshots/diff?base_snapshot_id=${encodeURIComponent(baseSnapshotId || "")}&target_snapshot_id=${encodeURIComponent(targetSnapshotId || "")}`, + quantamentalSnapshotRetention: (options = {}) => { + const params = new URLSearchParams({ + keep_last: String(options.keepLast || 20), + dry_run: String(options.dryRun ?? true), + }); + if (options.ticker) params.set("ticker", String(options.ticker)); + return `/api/v1/quantamental/snapshots/retention?${params.toString()}`; + }, }; const STORAGE = { @@ -138,14 +220,40 @@ const STORAGE = { form: "fingpt.form.v1", controlPanel: "fingpt.controlPanel.v1", dashboardLayout: "fingpt.dashboardLayout.v1", + dashboardLayoutVersion: "fingpt.dashboardLayout.version", + dashboardRange: "fingpt.dashboardRange.v1", tvChart: "fingpt.tvChart.v1", theme: "fingpt.theme.v1", + outputLanguage: "fingpt.outputLanguage.v1", + quantamentalCompareWatchlists: "fingpt.quantamental.compareWatchlists.v1", }; const STAGES = ["collect", "ingest", "retrieve", "infer", "analyze", "report", "output"]; +const DASHBOARD_PANEL_LAYOUT_VERSION = "20260519-all-default"; +const DEFAULT_DASHBOARD_PANEL_VIEWS = { + market: "all", + macro: "all", + quant: "all", + quantamental: "all", + forecast: "all", + "ai-portfolio": "all", +}; +const DEFAULT_GLOBAL_RANGE = { range: "1Y", startDate: "", endDate: "" }; +const DASHBOARD_RANGE_OPTIONS = new Set(["1D", "1W", "1M", "3M", "6M", "YTD", "1Y", "3Y", "5Y", "MAX", "custom"]); +const DASHBOARD_RANGE_LOOKBACK_DAYS = { + "1D": 1, + "1W": 5, + "1M": 21, + "3M": 63, + "6M": 126, + "1Y": 252, + "3Y": 756, + "5Y": 1260, + MAX: 5000, +}; const TV_ADVANCED_CHART_SCRIPT = "https://s3.tradingview.com/external-embedding/embed-widget-advanced-chart.js"; -const TV_CHART_DEFAULTS = { source: "tradingview", symbolKey: "SPY", interval: "D", compareKey: "" }; +const TV_CHART_DEFAULTS = { source: "internal", symbolKey: "SPY", interval: "D", compareKey: "" }; const TV_CHART_SYMBOLS = { SPY: { label: "SPY · S&P 500", symbol: "AMEX:SPY", dataTicker: "SPY" }, QQQ: { label: "QQQ · Nasdaq 100", symbol: "NASDAQ:QQQ", dataTicker: "QQQ" }, @@ -198,6 +306,35 @@ function safeWriteStoredValue(key, value) { } catch (_) {} } +function initDashboardPanelViews() { + const savedVersion = safeReadStoredValue(STORAGE.dashboardLayoutVersion, ""); + if (savedVersion !== DASHBOARD_PANEL_LAYOUT_VERSION) { + safeWriteStoredJson(STORAGE.dashboardLayout, DEFAULT_DASHBOARD_PANEL_VIEWS); + safeWriteStoredValue(STORAGE.dashboardLayoutVersion, DASHBOARD_PANEL_LAYOUT_VERSION); + return { ...DEFAULT_DASHBOARD_PANEL_VIEWS }; + } + return safeReadStoredJson(STORAGE.dashboardLayout, DEFAULT_DASHBOARD_PANEL_VIEWS); +} + +function normalizeGlobalRange(value) { + const raw = String(value || "").trim(); + const normalized = raw.toLowerCase() === "custom" ? "custom" : raw.toUpperCase(); + return DASHBOARD_RANGE_OPTIONS.has(normalized) ? normalized : "1Y"; +} + +function initGlobalRangeFromLocation() { + const saved = safeReadStoredJson(STORAGE.dashboardRange, DEFAULT_GLOBAL_RANGE); + const params = new URLSearchParams(window.location.search || ""); + const urlRange = params.get("range"); + const range = normalizeGlobalRange(urlRange || saved.range || DEFAULT_GLOBAL_RANGE.range); + const ordered = normalizeCustomGlobalDateOrder(params.get("start") || saved.startDate, params.get("end") || saved.endDate); + return { + range, + startDate: ordered.startDate, + endDate: ordered.endDate, + }; +} + const els = { homeBtn: document.getElementById("homeBtn"), controlPanel: document.querySelector(".control-panel"), @@ -249,10 +386,13 @@ const els = { qdrantPurgeResult: document.getElementById("qdrantPurgeResult"), qualityDashBtn: document.getElementById("qualityDashBtn"), + globalQualitySummary: document.getElementById("globalQualitySummary"), themeToggleBtn: document.getElementById("themeToggleBtn"), + languageToggle: document.getElementById("languageToggle"), qualityPanel: document.getElementById("qualityPanel"), qualitySubtitle: document.getElementById("qualitySubtitle"), qualitySummary: document.getElementById("qualitySummary"), + qualityContextSummary: document.getElementById("qualityContextSummary"), qualityDataHealth: document.getElementById("qualityDataHealth"), qualityMacroData: document.getElementById("qualityMacroData"), qualityCategories: document.getElementById("qualityCategories"), @@ -324,13 +464,25 @@ const els = { homeNewsList: document.getElementById("homeNewsList"), homeNewsCategories: document.getElementById("homeNewsCategories"), homeNewsRefresh: document.getElementById("homeNewsRefresh"), + homeNewsTicker: document.getElementById("homeNewsTicker"), + homeNewsTickerOpen: document.getElementById("homeNewsTickerOpen"), + homeNewsTopic: document.getElementById("homeNewsTopic"), + homeNewsSearchRun: document.getElementById("homeNewsSearchRun"), + homeNewsSearchStatus: document.getElementById("homeNewsSearchStatus"), + homeNewsFocusedList: document.getElementById("homeNewsFocusedList"), homeDashboardTabs: document.getElementById("homeDashboardTabs"), dashboardContextStrip: document.getElementById("dashboardContextStrip"), + dashboardRangeControls: document.getElementById("dashboardRangeControls"), + dashboardRangeSelect: document.getElementById("dashboardRangeSelect"), + dashboardRangeStart: document.getElementById("dashboardRangeStart"), + dashboardRangeEnd: document.getElementById("dashboardRangeEnd"), + dashboardRangeSupport: document.getElementById("dashboardRangeSupport"), dashboardViewControls: document.getElementById("dashboardViewControls"), homeSurfaceGrid: document.getElementById("homeSurfaceGrid"), marketDashboardTab: document.getElementById("marketDashboardTab"), macroDashboardTab: document.getElementById("macroDashboardTab"), quantLabTab: document.getElementById("quantLabTab"), + quantamentalTab: document.getElementById("quantamentalTab"), mlForecastTab: document.getElementById("mlForecastTab"), aiPortfolioTab: document.getElementById("aiPortfolioTab"), homeHeatmap: document.getElementById("homeHeatmap"), @@ -339,6 +491,13 @@ const els = { marketOverviewMeta: document.getElementById("marketOverviewMeta"), marketTapeSurface: document.getElementById("marketTapeSurface"), marketSignalSurface: document.getElementById("marketSignalSurface"), + crossAssetSymbols: document.getElementById("crossAssetSymbols"), + crossAssetSymbolOpen: document.getElementById("crossAssetSymbolOpen"), + crossAssetHorizon: document.getElementById("crossAssetHorizon"), + crossAssetTopic: document.getElementById("crossAssetTopic"), + crossAssetRun: document.getElementById("crossAssetRun"), + crossAssetStatus: document.getElementById("crossAssetStatus"), + crossAssetAnalysisSurface: document.getElementById("crossAssetAnalysisSurface"), homeMarketList: document.getElementById("homeMarketList"), dataHealthRefresh: document.getElementById("dataHealthRefresh"), homeDataHealth: document.getElementById("homeDataHealth"), @@ -554,6 +713,43 @@ const els = { aiPortfolioReportRebalance: document.getElementById("aiPortfolioReportRebalance"), aiPortfolioReportsSurface: document.getElementById("aiPortfolioReportsSurface"), aiPortfolioHistorySurface: document.getElementById("aiPortfolioHistorySurface"), + quantamentalTicker: document.getElementById("quantamentalTicker"), + quantamentalTickerOpen: document.getElementById("quantamentalTickerOpen"), + quantamentalMarket: document.getElementById("quantamentalMarket"), + quantamentalPeriod: document.getElementById("quantamentalPeriod"), + quantamentalYears: document.getElementById("quantamentalYears"), + quantamentalLookback: document.getElementById("quantamentalLookback"), + quantamentalStyle: document.getElementById("quantamentalStyle"), + quantamentalAiModel: document.getElementById("quantamentalAiModel"), + quantamentalAiModelStatus: document.getElementById("quantamentalAiModelStatus"), + quantamentalAnalyze: document.getElementById("quantamentalAnalyze"), + quantamentalStatus: document.getElementById("quantamentalStatus"), + quantamentalCompanySurface: document.getElementById("quantamentalCompanySurface"), + quantamentalSignalSurface: document.getElementById("quantamentalSignalSurface"), + quantamentalScoreSurface: document.getElementById("quantamentalScoreSurface"), + quantamentalFactorSurface: document.getElementById("quantamentalFactorSurface"), + quantamentalMainSurface: document.getElementById("quantamentalMainSurface"), + quantamentalAiRefresh: document.getElementById("quantamentalAiRefresh"), + quantamentalDataQualitySurface: document.getElementById("quantamentalDataQualitySurface"), + quantamentalCompareTickers: document.getElementById("quantamentalCompareTickers"), + quantamentalCompareRun: document.getElementById("quantamentalCompareRun"), + quantamentalCompareSurface: document.getElementById("quantamentalCompareSurface"), + quantamentalExpandPeers: document.getElementById("quantamentalExpandPeers"), + quantamentalPeerLimit: document.getElementById("quantamentalPeerLimit"), + quantamentalWatchlistName: document.getElementById("quantamentalWatchlistName"), + quantamentalWatchlistSelect: document.getElementById("quantamentalWatchlistSelect"), + quantamentalWatchlistSave: document.getElementById("quantamentalWatchlistSave"), + quantamentalWatchlistLoad: document.getElementById("quantamentalWatchlistLoad"), + quantamentalCompareCsv: document.getElementById("quantamentalCompareCsv"), + quantamentalScreenRun: document.getElementById("quantamentalScreenRun"), + quantamentalScreenSurface: document.getElementById("quantamentalScreenSurface"), + quantamentalScreenStatus: document.getElementById("quantamentalScreenStatus"), + quantamentalScoreThreshold: document.getElementById("quantamentalScoreThreshold"), + quantamentalScoreMetric: document.getElementById("quantamentalScoreMetric"), + quantamentalScoreScreenLimit: document.getElementById("quantamentalScoreScreenLimit"), + quantamentalScoreScreenRun: document.getElementById("quantamentalScoreScreenRun"), + quantamentalScoreScreenSurface: document.getElementById("quantamentalScoreScreenSurface"), + quantamentalScoreScreenStatus: document.getElementById("quantamentalScoreScreenStatus"), tvOverviewMeta: document.getElementById("tvOverviewMeta"), tvOverviewWidget: document.getElementById("tvOverviewWidget"), tvOverviewFallback: document.getElementById("tvOverviewFallback"), @@ -609,18 +805,21 @@ const state = { dashboardHeatmapLoaded: false, tradingViewInitialized: false, dashboardNewsItems: [], + focusedNewsItems: [], dashboardNewsCategory: "all", + crossAssetAnalysis: null, marketOverview: null, dashboardMarketItems: [], + dashboardDecisionCardsByTab: {}, + dashboardDecisionCardsLoaded: false, + dashboardDecisionCardsRequest: null, activeDashboardTab: "market", + outputLanguage: safeReadStoredValue(STORAGE.outputLanguage, "ko"), tvChartSettings: safeReadStoredJson(STORAGE.tvChart, TV_CHART_DEFAULTS), - dashboardPanelViewByTab: safeReadStoredJson(STORAGE.dashboardLayout, { - market: "all", - macro: "overview", - quant: "overview", - forecast: "overview", - "ai-portfolio": "overview", - }), + dashboardPanelViewByTab: initDashboardPanelViews(), + globalRange: initGlobalRangeFromLocation(), + globalRangeNotice: "", + globalQuality: null, macroLoaded: false, macroLoading: false, macroOverview: null, @@ -673,6 +872,19 @@ const state = { aiPortfolioPolicy: null, aiPortfolioRecommendation: null, aiPortfolioSignal: null, + quantamentalLoaded: false, + quantamentalLoading: false, + quantamentalAnalysis: null, + quantamentalComparison: null, + quantamentalCompareWatchlists: [], + quantamentalAiModels: [], + quantamentalLastSnapshotId: "", + quantamentalActiveTab: "overview", + quantamentalScreen: null, + quantamentalScreenLoaded: false, + quantamentalScreenLoading: false, + quantamentalScoreScreen: null, + quantamentalScoreScreenLoading: false, }; function symbolList(text) { @@ -1364,6 +1576,13 @@ const KOREAN_ETF_NAMES = Object.fromEntries(symbolNameList(` const CRYPTO_SYMBOLS = ["BTC-USD", "ETH-USD"]; +const GLOBAL_EQUITY_SYMBOLS = [ + "ASML.AS", "SHEL.L", "AZN.L", "BP.L", "RIO.L", "BHP.AX", + "ULVR.L", "HSBA.L", "7203.T", "6758.T", "7267.T", "7974.T", "9984.T", + "0700.HK", "9988.HK", "TSM", "NVO", "SAP", "SIE.DE", "BAS.DE", "SHOP.TO", + "UBSG.SW", "NESN.SW", "NOVN.SW", "MC.PA", "OR.PA", "AIR.PA", +]; + const SYMBOL_NAME_OVERRIDES = { AAPL: "Apple Inc.", MSFT: "Microsoft Corporation", @@ -1384,6 +1603,33 @@ const SYMBOL_NAME_OVERRIDES = { "035720.KS": "카카오 (035720 · KOSPI 200)", "357780.KQ": "솔브레인 (357780 · KOSDAQ 100)", "247540.KQ": "에코프로비엠 (247540 · KOSDAQ 100)", + "ASML.AS": "ASML Holding N.V. (Euronext Amsterdam)", + "SHEL.L": "Shell plc (London)", + "ULVR.L": "Unilever PLC (London)", + "AZN.L": "AstraZeneca PLC (London)", + "HSBA.L": "HSBC Holdings plc (London)", + "BP.L": "BP p.l.c. (London)", + "RIO.L": "Rio Tinto Group (London)", + "BHP.AX": "BHP Group (Australia)", + "7203.T": "Toyota Motor Corporation (Tokyo)", + "6758.T": "Sony Group Corporation (Tokyo)", + "7267.T": "Honda Motor Co., Ltd. (Tokyo)", + "7974.T": "Nintendo Co., Ltd. (Tokyo)", + "9984.T": "SoftBank Group Corp. (Tokyo)", + "0700.HK": "Tencent Holdings (Hong Kong)", + "9988.HK": "Alibaba Group (Hong Kong)", + TSM: "Taiwan Semiconductor Manufacturing Company ADR", + NVO: "Novo Nordisk A/S ADR", + SAP: "SAP SE ADR", + "SIE.DE": "Siemens AG (Xetra)", + "BAS.DE": "BASF SE (Xetra)", + "SHOP.TO": "Shopify Inc. (Toronto)", + "UBSG.SW": "UBS Group AG (SIX)", + "NESN.SW": "Nestle S.A. (SIX)", + "NOVN.SW": "Novartis AG (SIX)", + "MC.PA": "LVMH (Euronext Paris)", + "OR.PA": "L'Oreal S.A. (Euronext Paris)", + "AIR.PA": "Airbus SE (Euronext Paris)", "BTC-USD": "Bitcoin USD", "ETH-USD": "Ethereum USD", }; @@ -1495,6 +1741,16 @@ function buildSymbolCatalog() { universe: "crypto_major", rank: idx + 1, })); + GLOBAL_EQUITY_SYMBOLS.forEach((symbol, idx) => pushCatalogItem(rows, seen, { + symbol, + name: SYMBOL_NAME_OVERRIDES[symbol] || `${symbol} · Global equity`, + type: "stock", + country: "GLOBAL", + sector: "global_equity", + exchange: "Global", + universe: "global_equity", + rank: idx + 1, + })); return rows; } @@ -1525,6 +1781,173 @@ const sourceStatusClass = (s) => { return "muted"; }; +function qualityStatusClass(status) { + const key = String(status || "").toLowerCase(); + if (["ok", "success", "good", "normal", "fresh", "healthy"].includes(key)) return "ok"; + if (["failed", "fail", "error", "poor", "danger", "critical"].includes(key)) return "fail"; + if (["partial", "warn", "warning", "stale", "limited", "empty", "delayed"].includes(key)) return "warn"; + return "unknown"; +} + +function qualityStatusLabel(status) { + const cls = qualityStatusClass(status); + if (cls === "ok") return "정상"; + if (cls === "warn") return "주의"; + if (cls === "fail") return "위험"; + return "확인 불가"; +} + +function displayQualityValue(value, fallback = "-") { + if (value === null || value === undefined || value === "") return fallback; + if (Array.isArray(value)) return value.length ? value.join(", ") : "없음"; + return String(value); +} + +function displayQualityCount(value, fallback = "확인 불가") { + if (value === null || value === undefined || value === "") return fallback; + if (Array.isArray(value)) return value.length ? _fmtNumber(value.length) : "0"; + const numeric = Number(value); + if (Number.isFinite(numeric)) return _fmtNumber(numeric); + return displayQualityValue(value, fallback); +} + +function displayMissingSummary(value, fallback = "확인 불가") { + if (value === null || value === undefined || value === "") return fallback; + if (Array.isArray(value)) return value.length ? `${_fmtNumber(value.length)}개` : "없음"; + if (typeof value === "boolean") return value ? "있음" : "없음"; + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric > 0 ? `${_fmtNumber(numeric)}개` : "없음"; + const text = displayQualityValue(value, fallback); + return ["none", "no", "false", "0", "없음"].includes(text.toLowerCase()) ? "없음" : text; +} + +function displayCompactQualityTime(value, fallback = "확인 불가") { + const text = displayQualityValue(value, fallback); + if (text === fallback || text === "-") return text; + const match = text.match(/^(\d{4}-\d{2}-\d{2})(?:[T\s](\d{2}:\d{2}))?/); + if (match) return match[2] ? `${match[1]} ${match[2]}` : match[1]; + return text.length > 24 ? `${text.slice(0, 21)}...` : text; +} + +function selectedRangeLabel() { + const range = state.globalRange || DEFAULT_GLOBAL_RANGE; + if (range.range === "custom") { + const bounds = globalRangeDateBounds(range.range, range.startDate, range.endDate); + const start = bounds.startDate || "시작일 미지정"; + const end = bounds.endDate || range.endDate || "종료일 미지정"; + return `${start}~${end}`; + } + return range.range || DEFAULT_GLOBAL_RANGE.range; +} + +function renderGlobalQualitySummary() { + if (!els.globalQualitySummary) return; + const detail = globalQualityContextModel(); + const status = detail.status; + const statusClassName = qualityStatusClass(status); + const label = [ + `품질 ${detail.statusLabel}`, + `기준일 ${detail.asOf}`, + `업데이트 ${detail.updatedAt}`, + `기간 ${detail.range}`, + `관측치 ${detail.observations}`, + `결측 ${detail.missing}`, + `AI 기준 ${detail.aiSnapshot}`, + ].join(", "); + els.globalQualitySummary.className = `global-quality-summary ${statusClassName}`; + els.globalQualitySummary.setAttribute("aria-label", label); + els.globalQualitySummary.title = [ + `데이터 소스: ${detail.source}`, + `분석 기간: ${detail.range}`, + `관측치 수: ${detail.observations}`, + `결측치: ${detail.missing}`, + `캐시: ${detail.cache}`, + `AI 분석 기준: ${detail.aiSnapshot}`, + ].join("\n"); + els.globalQualitySummary.innerHTML = ` + 품질: ${escapeHtml(detail.statusLabel)} + 기준일: ${escapeHtml(detail.asOf)} + 업데이트: ${escapeHtml(detail.updatedAt)} + 기간: ${escapeHtml(detail.range)} + 관측치: ${escapeHtml(detail.observations)} + 결측: ${escapeHtml(detail.missing)} + AI 기준: ${escapeHtml(detail.aiSnapshot)} + `; + renderGlobalQualityContextSummary(); +} + +function updateGlobalQualitySummary(next = {}) { + state.globalQuality = { + ...(state.globalQuality || {}), + ...next, + }; + renderGlobalQualitySummary(); +} + +function markGlobalQualityRangePending() { + const isEnglish = selectedOutputLanguage() === "en"; + state.globalQuality = { + status: "unknown", + asOf: "", + updatedAt: isEnglish ? "refreshing" : "갱신 중", + source: isEnglish ? "range changed; waiting for refreshed data" : "기간 변경 후 데이터 재계산 대기", + observations: "", + missing: isEnglish ? "checking" : "확인 중", + cache: isEnglish ? "refreshing" : "재조회 중", + aiSnapshotAt: isEnglish ? "pending recalculation" : "재계산 대기", + }; + renderGlobalQualitySummary(); +} + +function globalQualityContextModel() { + const quality = state.globalQuality || {}; + const status = quality.status || "unknown"; + const cacheValue = quality.cache ?? quality.cacheStatus ?? quality.cacheLayer + ?? (quality.cache_hit === true ? "사용" : (quality.cache_hit === false ? "미사용" : undefined)); + const rangeSupport = globalRangeSupportSummary(); + return { + status, + statusLabel: qualityStatusLabel(status), + asOf: displayQualityValue(quality.asOf || quality.dataBasisDate), + updatedAt: displayCompactQualityTime(quality.updatedAt || quality.lastUpdated, "-"), + range: selectedRangeLabel(), + observations: displayQualityCount(quality.observations, "확인 불가"), + missing: displayMissingSummary(quality.missing, "확인 불가"), + source: displayQualityValue(quality.source, "확인 불가"), + cache: displayQualityValue(cacheValue, "확인 불가"), + aiSnapshot: displayCompactQualityTime(quality.aiSnapshotAt, "확인 불가"), + rangeSupport: rangeSupport.detail, + }; +} + +function qualityContextItem(label, value, field) { + return `${escapeHtml(label)}${escapeHtml(value)}`; +} + +function renderGlobalQualityContextSummary() { + if (!els.qualityContextSummary) return; + const detail = globalQualityContextModel(); + const statusClassName = qualityStatusClass(detail.status); + els.qualityContextSummary.className = `quality-context-summary ${statusClassName}`; + els.qualityContextSummary.innerHTML = ` +
+ 현재 분석 신뢰도: ${escapeHtml(detail.statusLabel)} + 상단 품질 배지와 같은 기준입니다. 확인 불가 값은 아직 출처나 기준일이 확보되지 않은 항목입니다. +
+
+ ${qualityContextItem("데이터 소스", detail.source, "source")} + ${qualityContextItem("분석 기간", detail.range, "range")} + ${qualityContextItem("기준일", detail.asOf, "basis-date")} + ${qualityContextItem("마지막 업데이트", detail.updatedAt, "updated-at")} + ${qualityContextItem("관측치", detail.observations, "observations")} + ${qualityContextItem("결측치", detail.missing, "missing")} + ${qualityContextItem("캐시", detail.cache, "cache")} + ${qualityContextItem("AI 분석 기준", detail.aiSnapshot, "ai-snapshot")} + ${qualityContextItem("기간 적용 방식", detail.rangeSupport, "range-support")} +
+ `; +} + const KNOWN_TICKERS = SYMBOL_CATALOG .map((item) => normalizeTickerToken(item.symbol)) .filter(Boolean) @@ -1577,6 +2000,22 @@ const SYMBOL_PICKER_TARGETS = { emptyLabel: "리서치에 사용할 심볼을 선택하세요.", applyLabel: "티커 적용", }, + crossAssetSymbols: { + inputKey: "crossAssetSymbols", + mode: "multi", + title: "교차자산 심볼 선택", + description: "대시보드 교차자산 분석에 사용할 지수, ETF, 암호화폐, 글로벌 심볼을 선택합니다.", + emptyLabel: "교차자산 분석에 사용할 심볼을 선택하세요.", + applyLabel: "자산 적용", + }, + homeNewsTicker: { + inputKey: "homeNewsTicker", + mode: "single", + title: "뉴스 티커 선택", + description: "회사 자체 뉴스를 조회할 단일 티커를 선택합니다.", + emptyLabel: "뉴스를 조회할 티커를 선택하세요.", + applyLabel: "티커 적용", + }, assetDetailTicker: { inputKey: "assetDetailTicker", mode: "single", @@ -1643,6 +2082,14 @@ const SYMBOL_PICKER_TARGETS = { emptyLabel: "예측 벤치마크를 선택하세요.", applyLabel: "벤치마크 적용", }, + quantamentalTicker: { + inputKey: "quantamentalTicker", + mode: "single", + title: "Quantamental 티커 선택", + description: "재무제표, 가격, 팩터, 리스크를 분석할 단일 종목을 선택합니다.", + emptyLabel: "Quantamental 분석 대상 심볼을 선택하세요.", + applyLabel: "티커 적용", + }, aiPortfolioCustomUniverse: { inputKey: "aiPortfolioCustomUniverse", chipsKey: "aiPortfolioCustomUniverseChips", @@ -1741,10 +2188,15 @@ function renderUniverseResolutionNotice(data) { if (!data || typeof data !== "object") return ""; const available = Array.isArray(data.available) ? data.available : []; const unavailable = Array.isArray(data.unavailable) ? data.unavailable : []; + const staleAssets = Array.isArray(data.stale_assets) ? data.stale_assets : []; const hydration = data.hydration || {}; const hydratedCount = Number(hydration.hydrated_count || (Array.isArray(hydration.hydrated) ? hydration.hydrated.length : 0)); - const status = unavailable.length ? "warn" : "ok"; + const staleRefreshed = Array.isArray(hydration.stale_refreshed) ? hydration.stale_refreshed : []; + const staleRefreshAttempted = !!hydration.stale_refresh_attempted; + const strictViolation = !!data.strict_freshness_violation; + const status = unavailable.length || strictViolation || staleAssets.length ? "warn" : "ok"; const hidden = Math.max(0, unavailable.length - 12); + const hiddenStale = Math.max(0, staleAssets.length - 12); let summary = unavailable.length ? `실행 가능 ${_fmtNumber(available.length)}개 · 보강 후에도 가격 이력이 부족한 종목 ${_fmtNumber(unavailable.length)}개가 남았습니다.` : `실행 가능 ${_fmtNumber(available.length)}개 · 선택 종목의 저장 가격을 확인했습니다.`; @@ -1753,25 +2205,44 @@ function renderUniverseResolutionNotice(data) { ? `가격 이력 ${_fmtNumber(hydratedCount)}개 자동 보강 · 실행 가능 ${_fmtNumber(available.length)}개 · 추가 확인 필요 ${_fmtNumber(unavailable.length)}개` : `가격 이력 ${_fmtNumber(hydratedCount)}개 자동 보강 완료 · 실행 가능 ${_fmtNumber(available.length)}개`; } + if (staleRefreshAttempted && staleRefreshed.length > 0) { + summary += ` · stale 가격 ${_fmtNumber(staleRefreshed.length)}개 최신 보강`; + } return `
${escapeHtml(summary)} + ${data.expected_latest_date ? `
기대 최신일: ${escapeHtml(data.expected_latest_date)}` : ""} ${unavailable.length ? `
확인 필요: ${escapeHtml(unavailable.slice(0, 12).join(", "))}${hidden ? ` 외 ${escapeHtml(_fmtNumber(hidden))}개` : ""}` : ""} - ${unavailable.length ? `
` : ""} + ${staleAssets.length ? `
stale: ${escapeHtml(staleAssets.slice(0, 12).join(", "))}${hiddenStale ? ` 외 ${escapeHtml(_fmtNumber(hiddenStale))}개` : ""}` : ""} + ${(unavailable.length || strictViolation || staleAssets.length) ? `
` : ""}
`; + updateGlobalQualitySummary({ + status: quality.status || (statusCounts.stale || statusCounts.partial || statusCounts.unavailable ? "warn" : "ok"), + asOf: quality.as_of || quality.latest_date || quality.last_updated || "", + updatedAt: quality.last_updated || macroJob.finished_at || lastResult.finished_at || "", + source: `macro · ${quality.provider || "mixed"}`, + observations: coverage.evaluated_series || rows.length || "", + missing: (quality.missing_series || []).length ? `${(quality.missing_series || []).length} series` : "없음", + }); } async function resolveQuantUniverseForTickers(tickers, options = {}) { const clean = Array.isArray(tickers) ? tickers.map(normalizeTickerToken).filter(Boolean) : []; if (!clean.length) return null; + const extra = Array.isArray(options.extraTickers) ? options.extraTickers.map(normalizeTickerToken).filter(Boolean) : []; + const requested = normalizeSymbolSelection([...clean, ...extra], "multi"); const res = await fetch(API.quantUniverseResolve, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - tickers: clean, + tickers: requested, start_date: options.startDate || null, end_date: options.endDate || null, + freshness_profile: options.freshnessProfile || els.backtestFreshnessProfile?.value || "research_default", + require_fresh_prices: options.requireFreshPrices ? true : undefined, + max_market_calendar_lag_days: options.maxMarketCalendarLagDays || undefined, + refresh_stale: options.refreshStale !== false, min_rows: options.minRows || 2, hydrate_missing: options.hydrateMissing !== false, max_hydrate_assets: options.maxHydrateAssets || 750, @@ -1784,6 +2255,42 @@ async function resolveQuantUniverseForTickers(tickers, options = {}) { return data; } +function scopeUniverseResolution(data, tickers) { + if (!data || !Array.isArray(tickers)) return data; + const selected = new Set(tickers.map(normalizeTickerToken).filter(Boolean)); + const items = (Array.isArray(data.items) ? data.items : []).filter((item) => selected.has(normalizeTickerToken(item.ticker || ""))); + const available = (Array.isArray(data.available) ? data.available : []).filter((ticker) => selected.has(normalizeTickerToken(ticker))); + const unavailable = (Array.isArray(data.unavailable) ? data.unavailable : []).filter((ticker) => selected.has(normalizeTickerToken(ticker))); + const scopedCounts = {}; + const scopedLatest = {}; + Object.entries(data.price_counts || {}).forEach(([ticker, value]) => { + if (selected.has(normalizeTickerToken(ticker))) scopedCounts[ticker] = value; + }); + Object.entries(data.latest_price_dates || {}).forEach(([ticker, value]) => { + if (selected.has(normalizeTickerToken(ticker))) scopedLatest[ticker] = value; + }); + return { + ...data, + requested_count: selected.size, + available_count: available.length, + unavailable_count: unavailable.length, + available, + unavailable, + items, + price_counts: scopedCounts, + latest_price_dates: scopedLatest, + stale_assets: (Array.isArray(data.stale_assets) ? data.stale_assets : []).filter((ticker) => selected.has(normalizeTickerToken(ticker))), + }; +} + +function selectedQuantFreshnessOptions() { + return { + freshnessProfile: els.backtestFreshnessProfile?.value || "research_default", + requireFreshPrices: !!els.backtestRequireFresh?.checked, + refreshStale: true, + }; +} + async function resolveBacktestUniverseAvailability(surface = null, options = {}) { const tickers = selectedBacktestUniverse(); if (!tickers.length) return { ok: false, tickers: [], data: null }; @@ -1797,14 +2304,18 @@ async function resolveBacktestUniverseAvailability(surface = null, options = {}) numberInputValue(els.backtestLongWindow, 50, { min: 2, max: 5000 }) + 2, options.minRows || 2, ); + const benchmark = normalizeTickerToken(els.backtestBenchmark?.value || "SPY") || "SPY"; const data = await resolveQuantUniverseForTickers(tickers, { startDate: textInputValue(els.backtestStartDate), endDate: textInputValue(els.backtestEndDate), + extraTickers: benchmark && !tickers.includes(benchmark) ? [benchmark] : [], minRows: requiredRows, hydrateMissing: options.hydrateMissing !== false, + ...selectedQuantFreshnessOptions(), }); - const available = Array.isArray(data?.available) ? data.available : []; - const unavailable = Array.isArray(data?.unavailable) ? data.unavailable : []; + const scopedData = scopeUniverseResolution(data, tickers); + const available = Array.isArray(scopedData?.available) ? scopedData.available : []; + const unavailable = Array.isArray(scopedData?.unavailable) ? scopedData.unavailable : []; if (available.length && unavailable.length) { setBacktestUniverse(available); if (els.portfolioTickers) { @@ -1817,9 +2328,9 @@ async function resolveBacktestUniverseAvailability(surface = null, options = {}) if (surface) { surface.innerHTML = decisionEmpty("선택한 종목 중 저장 가격이 있는 종목이 없습니다. 데이터 마트 업데이트 후 다시 실행하세요."); } - return { ok: false, tickers: [], data }; + return { ok: false, tickers: [], data: scopedData }; } - return { ok: true, tickers: available, data }; + return { ok: true, tickers: available, data: scopedData }; } catch (err) { if (surface) surface.innerHTML = decisionEmpty(`종목 데이터 확인 실패: ${err.message || err}`); return { ok: false, tickers: [], data: null, error: err }; @@ -1832,23 +2343,27 @@ async function resolvePortfolioUniverseAvailability(surface = null) { if (surface) surface.innerHTML = decisionEmpty(`${tickers.length}개 종목의 포트폴리오 가격 이력을 확인하고 자동 보강하는 중입니다.`); try { const requiredRows = Math.max(2, Math.min(52, numberInputValue(els.portfolioLookbackDays, 756, { min: 2, max: 5000 }))); + const benchmark = normalizeTickerToken(els.portfolioBenchmark?.value || "SPY") || "SPY"; const data = await resolveQuantUniverseForTickers(tickers, { startDate: textInputValue(els.portfolioStartDate), endDate: textInputValue(els.portfolioEndDate), + extraTickers: benchmark && !tickers.includes(benchmark) ? [benchmark] : [], minRows: requiredRows, hydrateMissing: true, + ...selectedQuantFreshnessOptions(), }); - const available = Array.isArray(data?.available) ? data.available : []; - const unavailable = Array.isArray(data?.unavailable) ? data.unavailable : []; + const scopedData = scopeUniverseResolution(data, tickers); + const available = Array.isArray(scopedData?.available) ? scopedData.available : []; + const unavailable = Array.isArray(scopedData?.unavailable) ? scopedData.unavailable : []; if (available.length && unavailable.length && els.portfolioTickers) { els.portfolioTickers.value = available.join(","); renderSymbolTargetChips("portfolio"); } if (!available.length) { if (surface) surface.innerHTML = decisionEmpty("포트폴리오 최적화에 사용할 가격 이력이 있는 종목이 없습니다."); - return { ok: false, tickers: [], data }; + return { ok: false, tickers: [], data: scopedData }; } - return { ok: true, tickers: available, data }; + return { ok: true, tickers: available, data: scopedData }; } catch (err) { if (surface) surface.innerHTML = decisionEmpty(`포트폴리오 종목 확인 실패: ${err.message || err}`); return { ok: false, tickers: [], data: null, error: err }; @@ -1888,6 +2403,7 @@ function renderSymbolPickerSummary(filteredCount, selectedCount) { ["미국 대형주", symbolPickerScopeCount("us_large_cap"), "us_large_cap"], ["ETF", symbolPickerScopeCount("etf_core"), "etf_core"], ["한국", symbolPickerScopeCount("kr_equity"), "kr_equity"], + ["글로벌 주식", symbolPickerScopeCount("global_equity"), "global_equity"], ["암호화폐", symbolPickerScopeCount("crypto_major"), "crypto_major"], ]; els.symbolPickerSummary.innerHTML = ` @@ -1911,6 +2427,7 @@ function applySymbolPickerScope(scope) { us_large_cap: { type: "stock", country: "US", sector: "us_large_cap" }, etf_core: { type: "etf", country: "all", sector: "all" }, kr_equity: { type: "stock", country: "KR", sector: "all" }, + global_equity: { type: "stock", country: "GLOBAL", sector: "global_equity" }, crypto_major: { type: "crypto", country: "GLOBAL", sector: "crypto" }, }; const preset = presets[scope]; @@ -2404,6 +2921,7 @@ function renderQdrantInfo(info) { async function openQualityPanel() { if (!els.qualityPanel) return; els.qualityPanel.classList.remove("hidden"); + renderGlobalQualityContextSummary(); await loadQualityDashboard(); } @@ -2678,13 +3196,19 @@ async function loadConfig() { return; } state.config = await res.json(); + if (!safeReadStoredValue(STORAGE.outputLanguage, "")) { + state.outputLanguage = normalizeOutputLanguage(state.config.output_language || "ko"); + } renderModelOptions(state.config.models || []); + renderQuantamentalAiModelOptions(state.config.models || []); renderPresets(state.config.presets || []); applyLimits(state.config.limits || {}); renderFinGPTStatus(state.config); + applyUiLanguage(state.outputLanguage, { persist: false }); } catch (e) { console.warn("config fetch failed", e); renderModelOptions([]); + renderQuantamentalAiModelOptions([]); renderPresets([]); renderFinGPTStatus(null); } @@ -2698,9 +3222,456 @@ const CLEAN_PRESETS = [ { id: "competitive", label: "경쟁 구도", question: "경쟁 구도가 어떻게 변하고 있고, 가격 결정력과 시장점유율에는 어떤 영향을 주나요?" }, ]; +const CLEAN_PRESETS_EN = [ + { id: "risk", label: "Near-term risk", question: "What near-term risks are visible now, and which downside scenario is the market underpricing?" }, + { id: "catalyst", label: "Growth catalyst", question: "Which catalysts could move the price over the next 6 to 12 months, and what metrics should verify them?" }, + { id: "thesis", label: "12-month thesis", question: "Build a 12-month investment thesis using the latest public evidence and quantitative metrics." }, + { id: "earnings", label: "Earnings signal", question: "Summarize the key revenue, margin, cost, and guidance signals from recent results." }, + { id: "competitive", label: "Competition", question: "How is the competitive landscape changing, and what does that imply for pricing power and share?" }, +]; + +const UI_LANGUAGE_COPY = { + ko: { + languageLabel: "출력 언어", + pipeline: "수집 -> 적재 -> 검색 -> 추론 -> 분석 -> 보고", + preflightTitle: "로컬 의존성 상태 확인", + preflightChecking: "사전 점검: 확인 중", + quality: "품질", + qualityTitle: "평가 및 품질 대시보드", + theme: "테마", + themeTitle: "테마 전환", + healthChecking: "api · 확인 중", + tickerHint: "ticker 없이 질의 가능, ticker는 참고 힌트", + formLabels: { + ticker: "티커", + question: "질문", + sources: "소스", + compare: "비교 모드", + find: "찾기", + lookback: "조회 기간", + topK: "상위 K", + model: "추론 경로", + hotkey: "실행", + }, + tickerPlaceholder: "선택: TLT, GLD, BTC-USD", + questionPlaceholder: "예: 현재 시장이 무시하는 리스크는 무엇인가요?", + questionHint: "자유 질문 또는 프리셋 선택", + modeLabels: { auto: "자동", ticker: "종목", topic: "주제" }, + run: "분석 실행", + latest: "최근 결과", + commandPanel: "리서치 패널", + commandOpen: "컨트롤 열기", + commandHide: "컨트롤 숨기기", + formNotice: "요청 언어: 한국어", + dashboardContext: [ + ["Data", "시장 데이터"], + ["Mode", "근거 우선"], + ["Runtime", "로컬 전용"], + ["Execution", "자문용"], + ], + dashboardHero: { + market: ["시장 대시보드", "티커를 입력하면 종목 분석으로, 비워두고 질문만 입력하면 금리·신용·FX·원자재·테마 topic 분석으로 라우팅합니다."], + macro: ["매크로", "데이터 품질, 레짐, 자산군 영향, 정책 힌트, 리서치 맥락을 AI 해석과 분리해 점검합니다."], + quant: ["퀀트 랩", "저장 가격 기반 리스크, 전략 검증, 포트폴리오 배분을 같은 조건으로 점검합니다."], + quantamental: ["Quantamental", "재무제표, 가격, 팩터, 리스크를 deterministic engine으로 계산하고 AI는 구조화 결과만 해석합니다."], + forecast: ["ML Forecast", "검증 가능한 예측 실험실입니다. 가격 경로가 아니라 OOS forward return, 확률, 신뢰도, 신호, 비용 반영 백테스트를 분리해 점검합니다."], + "ai-portfolio": ["AI Portfolio", "투자형과 정책을 선택하고, 정량 엔진이 계산한 포트폴리오를 AI가 설명하는 사용자 승인 기반 워크플로우입니다."], + }, + quantamental: { + labels: { + ticker: "티커", + market: "시장", + period: "기간", + years: "연수", + lookback: "가격 조회", + style: "전략 스타일", + peerLimit: "피어 한도", + scoreMetric: "점수 기준", + scoreThreshold: "최소 점수", + scoreScreenLimit: "결과 한도", + }, + buttons: { + find: "찾기", + analyze: "분석", + refresh: "새로고침", + aiReport: "AI 보고서", + compare: "비교", + screen: "스크린", + ask: "질문", + expandPeers: "피어 확장", + saveSet: "세트 저장", + loadSet: "세트 불러오기", + }, + cards: { + signal: ["Quantamental 신호", "리서치 후보 분류"], + composite: ["복합 점수 대시보드", "하이브리드 점수와 데이터 품질"], + screen: ["Signal Screener Top 5", "복합 점수 자동 랭킹"], + scoreScreen: ["점수 임계값 스크리너", "선택한 점수 기준 이상의 후보"], + factor: ["팩터 점수 그리드", "가치, 퀄리티, 성장, 모멘텀, 저변동성, 유동성"], + terminal: ["리서치 터미널", "개요, 재무, 퀀트, 리스크, 밸류에이션, AI, Q&A"], + quality: ["데이터 품질", "누락 데이터와 제공자 커버리지"], + compare: ["피어 비교", "배치 비교와 피어 상대 팩터"], + }, + messages: { + status: "Quantamental 분석은 리서치 전용이며 투자 자문이 아닙니다.", + topStatus: "Top 5 스크리너가 신선한 신호 후보를 자동 로드합니다.", + topEmpty: "Top 5 신호 스크리너가 여기에 표시됩니다.", + scoreStatus: "최소 점수를 설정한 뒤 현재 Quantamental 유니버스를 스크리닝합니다.", + scoreEmpty: "점수 임계값 스크리너가 여기에 표시됩니다.", + compareEmpty: "두 개 이상 티커를 비교해 팩터를 피어 대비 정규화합니다.", + screenLoading: "Top 5 신호 스크리너가 최신 데이터를 새로고침 중입니다.", + scoreScreenLoading: "점수 임계값 스크리너가 최신 데이터를 새로고침 중입니다.", + compareLoading: "피어 비교를 실행 중입니다.", + qaLoading: "Q&A 답변을 생성하는 중입니다.", + questionRequired: "질문을 입력해야 합니다.", + runFirst: "Quantamental 분석을 먼저 실행하세요.", + tickerRequired: "티커가 필요합니다.", + noSavedSets: "저장된 세트 없음", + }, + scoreMetricLabels: { + composite: "복합", + value: "가치", + quality: "품질", + growth: "성장", + momentum: "모멘텀", + low_volatility: "저변동성", + liquidity: "유동성", + }, + }, + }, + en: { + languageLabel: "Output language", + pipeline: "Collect -> Store -> Search -> Infer -> Analyze -> Report", + preflightTitle: "Check local dependencies", + preflightChecking: "Preflight: checking", + quality: "Quality", + qualityTitle: "Evaluation and quality dashboard", + theme: "Theme", + themeTitle: "Toggle theme", + healthChecking: "api · checking", + tickerHint: "Ticker is optional; questions can route by topic", + formLabels: { + ticker: "Ticker", + question: "Question", + sources: "Sources", + compare: "Compare mode", + find: "Find", + lookback: "Lookback", + topK: "Top K", + model: "Inference route", + hotkey: "run", + }, + tickerPlaceholder: "Optional: TLT, GLD, BTC-USD", + questionPlaceholder: "Example: What risk is the market ignoring right now?", + questionHint: "Free-form question or preset", + modeLabels: { auto: "Auto", ticker: "Ticker", topic: "Topic" }, + run: "Run analysis", + latest: "Latest result", + commandPanel: "Research panel", + commandOpen: "Open controls", + commandHide: "Hide controls", + formNotice: "Request language: English", + dashboardContext: [ + ["Data", "Market feed"], + ["Mode", "Evidence-first"], + ["Runtime", "Local only"], + ["Execution", "Advisory"], + ], + dashboardHero: { + market: ["Market Dashboard", "Enter a ticker for single-name research, or leave it blank so the question can route to rates, credit, FX, commodities, or themes."], + macro: ["Macro", "Review data quality, regimes, asset impact, policy hints, and research context separately from AI interpretation."], + quant: ["Quant Lab", "Evaluate stored-price risk, strategy validation, and portfolio allocation under consistent assumptions."], + quantamental: ["Quantamental", "Compute fundamentals, price, factor, and risk signals deterministically while AI only interprets the structured output."], + forecast: ["ML Forecast", "A verifiable forecasting lab for OOS forward returns, probabilities, confidence, signals, and cost-aware backtests."], + "ai-portfolio": ["AI Portfolio", "Choose investment type and policy, then review user-approved portfolios calculated by the quantitative engine and explained by AI."], + }, + quantamental: { + labels: { + ticker: "Ticker", + market: "Market", + period: "Period", + years: "Years", + lookback: "Lookback", + style: "Style", + peerLimit: "Peer limit", + scoreMetric: "Score Type", + scoreThreshold: "Min Score", + scoreScreenLimit: "Limit", + }, + buttons: { + find: "Find", + analyze: "Analyze", + refresh: "Refresh", + aiReport: "AI Report", + compare: "Compare", + screen: "Screen", + ask: "Ask", + expandPeers: "Expand peers", + saveSet: "Save set", + loadSet: "Load set", + }, + cards: { + signal: ["Quantamental Signal", "research candidate classification"], + composite: ["Composite Score Dashboard", "hybrid score and data quality"], + screen: ["Signal Screener Top 5", "auto-ranked by composite score"], + scoreScreen: ["Score Threshold Screener", "filter candidates above a selected minimum score"], + factor: ["Factor Score Grid", "value, quality, growth, momentum, low volatility, liquidity"], + terminal: ["Research Terminal", "Overview, Fundamentals, Quant, Risk, Valuation, AI, and Q&A"], + quality: ["Data Quality", "missing data and provider coverage"], + compare: ["Peer Comparison", "batch compare and peer-relative factors"], + }, + messages: { + status: "Quantamental analysis is research-only, not investment advice.", + topStatus: "Top 5 screener loads fresh signal candidates automatically.", + topEmpty: "Top 5 signal screener appears here.", + scoreStatus: "Set a minimum score, then screen the current Quantamental universe.", + scoreEmpty: "Score threshold screener appears here.", + compareEmpty: "Compare two or more tickers to normalize factors against peers.", + screenLoading: "Top 5 signal screener is refreshing fresh data.", + scoreScreenLoading: "Score threshold screener is refreshing fresh data.", + compareLoading: "Peer comparison is running.", + qaLoading: "Q&A answer is being generated.", + questionRequired: "Question is required.", + runFirst: "Run Quantamental analysis first.", + tickerRequired: "Ticker is required.", + noSavedSets: "No saved sets", + }, + scoreMetricLabels: { + composite: "Composite", + value: "Value", + quality: "Quality", + growth: "Growth", + momentum: "Momentum", + low_volatility: "Low Volatility", + liquidity: "Liquidity", + }, + }, + }, +}; + +const FORM_MESSAGES = { + ko: { + tickerRequired: "종목 모드는 ticker가 필요합니다. ticker 없이 질문하려면 자동 또는 주제 모드를 선택하세요.", + questionRequired: "질문을 입력해야 분석을 실행할 수 있습니다.", + sourceRequired: "최소 한 개의 소스를 선택해야 합니다.", + compareRequired: "Compare mode는 2개 이상의 ticker가 필요합니다. 쉼표 또는 공백으로 구분하세요.", + streamNoResult: "스트림이 결과 이벤트 없이 종료되었습니다.", + }, + en: { + tickerRequired: "Ticker mode requires a ticker. Use Auto or Topic mode for tickerless questions.", + questionRequired: "Enter a question before running analysis.", + sourceRequired: "Select at least one source.", + compareRequired: "Compare mode requires at least two tickers separated by commas or spaces.", + streamNoResult: "The stream closed without a result event.", + }, +}; + +function normalizeOutputLanguage(value) { + const clean = String(value || "ko").trim().toLowerCase(); + if (["en", "eng", "english"].includes(clean)) return "en"; + return "ko"; +} + +function selectedOutputLanguage() { + return normalizeOutputLanguage(state.outputLanguage || state.config?.output_language || "ko"); +} + +function formMessage(key) { + const language = selectedOutputLanguage(); + return FORM_MESSAGES[language]?.[key] || FORM_MESSAGES.ko[key] || key; +} + +function setLeadingText(selector, text) { + const el = document.querySelector(selector); + if (!el) return; + const node = Array.from(el.childNodes).find((child) => child.nodeType === Node.TEXT_NODE && child.textContent.trim()); + if (node) node.textContent = `${text} `; + else el.insertBefore(document.createTextNode(`${text} `), el.firstChild); +} + +function dashboardHeroForTab(tab = "market") { + const copy = UI_LANGUAGE_COPY[selectedOutputLanguage()] || UI_LANGUAGE_COPY.ko; + return copy.dashboardHero?.[tab] || copy.dashboardHero?.market || UI_LANGUAGE_COPY.ko.dashboardHero.market; +} + +function updateDashboardHero(tab = "market") { + const [title, body] = dashboardHeroForTab(tab); + const homeTitle = document.querySelector(".home-hero h2"); + const homeCopy = document.querySelector(".home-hero p:not(.eyebrow)"); + if (homeTitle) homeTitle.textContent = title; + if (homeCopy) homeCopy.textContent = body; +} + +function setWrappedLabelText(control, text) { + const label = control?.closest?.("label"); + const span = label?.querySelector?.("span"); + if (span) span.textContent = text; +} + +function setCardCopy(surface, copy) { + const card = surface?.closest?.(".home-card"); + const [title, subtitle] = copy || []; + if (!card || !title) return; + const heading = card.querySelector(".home-card-head h3"); + const caption = card.querySelector(".home-card-head span"); + if (heading) heading.textContent = title; + if (caption && subtitle) caption.textContent = subtitle; +} + +function applyQuantamentalUiLanguage(copy) { + const q = copy.quantamental || UI_LANGUAGE_COPY.en.quantamental; + setWrappedLabelText(els.quantamentalTicker, q.labels.ticker); + setWrappedLabelText(els.quantamentalMarket, q.labels.market); + setWrappedLabelText(els.quantamentalPeriod, q.labels.period); + setWrappedLabelText(els.quantamentalYears, q.labels.years); + setWrappedLabelText(els.quantamentalLookback, q.labels.lookback); + setWrappedLabelText(els.quantamentalStyle, q.labels.style); + setWrappedLabelText(els.quantamentalPeerLimit, q.labels.peerLimit); + setWrappedLabelText(els.quantamentalScoreMetric, q.labels.scoreMetric); + setWrappedLabelText(els.quantamentalScoreThreshold, q.labels.scoreThreshold); + setWrappedLabelText(els.quantamentalScoreScreenLimit, q.labels.scoreScreenLimit); + if (els.quantamentalScoreMetric) { + Array.from(els.quantamentalScoreMetric.options || []).forEach((option) => { + option.textContent = q.scoreMetricLabels?.[option.value] || option.textContent; + }); + } + if (els.quantamentalTickerOpen) els.quantamentalTickerOpen.textContent = q.buttons.find; + if (els.quantamentalAnalyze) els.quantamentalAnalyze.textContent = q.buttons.analyze; + if (els.quantamentalScreenRun) els.quantamentalScreenRun.textContent = q.buttons.refresh; + if (els.quantamentalScoreScreenRun) els.quantamentalScoreScreenRun.textContent = q.buttons.screen; + if (els.quantamentalAiRefresh) els.quantamentalAiRefresh.textContent = q.buttons.aiReport; + if (els.quantamentalCompareRun) els.quantamentalCompareRun.textContent = q.buttons.compare; + const expandLabel = els.quantamentalExpandPeers?.closest?.("label"); + const expandText = Array.from(expandLabel?.childNodes || []).find((node) => node.nodeType === Node.TEXT_NODE); + if (expandText) expandText.textContent = ` ${q.buttons.expandPeers}`; + if (els.quantamentalWatchlistSave) els.quantamentalWatchlistSave.textContent = q.buttons.saveSet; + if (els.quantamentalWatchlistLoad) els.quantamentalWatchlistLoad.textContent = q.buttons.loadSet; + setCardCopy(els.quantamentalSignalSurface, q.cards.signal); + setCardCopy(els.quantamentalScoreSurface, q.cards.composite); + setCardCopy(els.quantamentalScreenSurface, q.cards.screen); + setCardCopy(els.quantamentalScoreScreenSurface, q.cards.scoreScreen); + setCardCopy(els.quantamentalFactorSurface, q.cards.factor); + setCardCopy(els.quantamentalMainSurface, q.cards.terminal); + setCardCopy(els.quantamentalDataQualitySurface, q.cards.quality); + setCardCopy(els.quantamentalCompareSurface, q.cards.compare); + if (!state.quantamentalLoaded && !state.quantamentalLoading) { + const starter = quantamentalUi().starter ? quantamentalUi().starter() : decisionEmpty(q.messages.status); + if (els.quantamentalCompanySurface) els.quantamentalCompanySurface.innerHTML = starter; + if (els.quantamentalSignalSurface) els.quantamentalSignalSurface.innerHTML = starter; + if (els.quantamentalScoreSurface) els.quantamentalScoreSurface.innerHTML = starter; + if (els.quantamentalFactorSurface) els.quantamentalFactorSurface.innerHTML = starter; + if (els.quantamentalMainSurface) els.quantamentalMainSurface.innerHTML = starter; + if (els.quantamentalDataQualitySurface) els.quantamentalDataQualitySurface.innerHTML = starter; + } + if (!state.quantamentalLoaded && els.quantamentalStatus) els.quantamentalStatus.textContent = q.messages.status; + if (!state.quantamentalScreenLoaded && !state.quantamentalScreenLoading && els.quantamentalScreenStatus) els.quantamentalScreenStatus.textContent = q.messages.topStatus; + if (!state.quantamentalScreenLoaded && !state.quantamentalScreenLoading && els.quantamentalScreenSurface) els.quantamentalScreenSurface.innerHTML = decisionEmpty(q.messages.topEmpty); + if (!state.quantamentalScoreScreen && !state.quantamentalScoreScreenLoading && els.quantamentalScoreScreenStatus) els.quantamentalScoreScreenStatus.textContent = q.messages.scoreStatus; + if (!state.quantamentalScoreScreen && !state.quantamentalScoreScreenLoading && els.quantamentalScoreScreenSurface) els.quantamentalScoreScreenSurface.innerHTML = decisionEmpty(q.messages.scoreEmpty); + if (!state.quantamentalComparison && els.quantamentalCompareSurface) els.quantamentalCompareSurface.innerHTML = decisionEmpty(q.messages.compareEmpty); + renderQuantamentalCompareWatchlists(); +} + +function applyUiLanguage(language, options = {}) { + const normalized = normalizeOutputLanguage(language); + state.outputLanguage = normalized; + const copy = UI_LANGUAGE_COPY[normalized] || UI_LANGUAGE_COPY.ko; + document.documentElement.lang = normalized; + document.title = "FinGPT Local Research Assistant"; + if (options.persist !== false) safeWriteStoredValue(STORAGE.outputLanguage, normalized); + if (els.languageToggle) { + els.languageToggle.setAttribute("aria-label", copy.languageLabel); + els.languageToggle.querySelectorAll("[data-language]").forEach((button) => { + const active = button.dataset.language === normalized; + button.setAttribute("aria-pressed", active ? "true" : "false"); + button.classList.toggle("active", active); + }); + } + setText(".pipeline-pill", copy.pipeline); + if (els.preflightPill) { + els.preflightPill.title = copy.preflightTitle; + if (els.preflightLabel && /확인 중|checking/i.test(els.preflightLabel.textContent || "")) { + els.preflightLabel.textContent = copy.preflightChecking; + } + } + if (els.qualityDashBtn) { + els.qualityDashBtn.textContent = copy.quality; + els.qualityDashBtn.title = copy.qualityTitle; + } + if (els.themeToggleBtn) { + els.themeToggleBtn.textContent = copy.theme; + els.themeToggleBtn.title = copy.themeTitle; + els.themeToggleBtn.setAttribute("aria-label", copy.themeTitle); + } + if (els.healthPill && /확인 중|checking/i.test(els.healthPill.textContent || "")) { + els.healthPill.textContent = copy.healthChecking; + } + if (els.tickerHint) els.tickerHint.textContent = copy.tickerHint; + if (els.ticker) els.ticker.placeholder = copy.tickerPlaceholder; + if (els.question) els.question.placeholder = copy.questionPlaceholder; + setLeadingText('label[for="ticker"]', copy.formLabels.ticker); + setLeadingText('label[for="question"]', copy.formLabels.question); + setLeadingText('label[for="lookback"]', copy.formLabels.lookback); + setLeadingText('label[for="topk"]', copy.formLabels.topK); + setLeadingText('label[for="model"]', copy.formLabels.model); + const sourceLabel = document.querySelector("#sourceToggles")?.closest(".field-group")?.querySelector(".field-label"); + if (sourceLabel) sourceLabel.textContent = copy.formLabels.sources; + const compareLabel = document.querySelector(".compare-toggle span"); + if (compareLabel) compareLabel.textContent = copy.formLabels.compare; + if (els.tickerSearchOpen) els.tickerSearchOpen.textContent = copy.formLabels.find; + const qHint = document.querySelector('label[for="question"] .hint'); + if (qHint) qHint.textContent = copy.questionHint; + els.researchModeInputs().forEach((input) => { + const span = input.parentElement?.querySelector("span"); + if (span && copy.modeLabels[input.value]) span.textContent = copy.modeLabels[input.value]; + }); + const runLabel = els.runBtn?.querySelector(".btn-label"); + if (runLabel) runLabel.textContent = copy.run; + if (els.loadLatestBtn) els.loadLatestBtn.textContent = copy.latest; + const commandLabel = els.commandPanelToggle?.querySelector(".command-panel-label"); + if (commandLabel) commandLabel.textContent = copy.commandPanel; + const commandState = els.commandPanelToggle?.querySelector(".command-panel-state"); + if (commandState) { + const collapsed = els.controlPanel?.classList.contains("is-collapsed"); + commandState.textContent = collapsed ? copy.commandOpen : copy.commandHide; + } + const contextItems = els.dashboardContextStrip?.querySelectorAll("span") || []; + copy.dashboardContext.forEach(([label, value], idx) => { + if (contextItems[idx]) contextItems[idx].innerHTML = `${escapeHtml(label)} ${escapeHtml(value)}`; + }); + if (state.config) renderPresets(state.config.presets || []); + const runMeta = document.querySelector(".meta-row"); + if (runMeta) runMeta.innerHTML = `Ctrl + Enter ${escapeHtml(copy.formLabels.hotkey)}`; + updateDashboardHero(state.activeDashboardTab || "market"); + renderGlobalQualitySummary(); + applyQuantamentalUiLanguage(copy); + updateQuantamentalAiModelStatus(); + if (state.quantamentalAnalysis) renderQuantamentalAnalysis(state.quantamentalAnalysis); + if (state.quantamentalScreen) renderQuantamentalScreen(state.quantamentalScreen); + if (state.quantamentalScoreScreen) renderQuantamentalScoreScreen(state.quantamentalScoreScreen); + if ( + state.tradingViewInitialized + && els.tvOverviewWidget + && state.activeDashboardTab === "market" + && normalizeTvChartSettings(state.tvChartSettings).source === "tradingview" + ) { + mountMarketOverviewChart(state.tvChartSettings); + } +} + +function bindLanguageToggle() { + if (!els.languageToggle) return; + els.languageToggle.querySelectorAll("[data-language]").forEach((button) => { + button.addEventListener("click", () => { + applyUiLanguage(button.dataset.language || "ko", { persist: true }); + persistForm(); + }); + }); +} + function isReadablePreset(p) { const text = `${p?.label || ""} ${p?.question || ""}`; - return /[가-힣A-Za-z]/.test(text) && !/[�]/.test(text); + return /[가-힣A-Za-z]/.test(text) && !/\uFFFD/.test(text); } function renderModelOptions(models) { @@ -2709,18 +3680,117 @@ function renderModelOptions(models) { const rawOptions = Array.isArray(models) && models.length ? models.map((m) => (typeof m === "string" ? { id: m, label: m } : m)) : [{ id: "qwen", label: "qwen2.5:7b (Ollama · 기본)" }]; - const options = rawOptions.filter((m) => m && m.id && (m.id === "qwen" || m.role === "fallback" || m.role === "experimental")); + const options = rawOptions.filter((m) => ( + m + && m.id + && m.enabled !== false + && (m.id === "qwen" || m.role === "fallback" || m.role === "experimental") + )); if (!options.length) options.push({ id: "qwen", label: "qwen2.5:7b (Ollama · 기본)" }); els.model.innerHTML = ""; options.forEach((m) => { const opt = document.createElement("option"); opt.value = m.id; + const availability = m.availability || "runtime_checked"; opt.textContent = m.label || m.id; + opt.title = availability === "runtime_checked" + ? "Configured route; actual Ollama model availability is checked when a request runs." + : String(availability); + opt.dataset.availability = availability; els.model.appendChild(opt); - }); + }); els.model.value = Array.from(els.model.options).some((opt) => opt.value === current) ? current : "qwen"; } +function normalizeQuantamentalAiModels(models) { + const runtimeModels = (Array.isArray(models) ? models : []) + .map((m) => (typeof m === "string" ? { id: m, model: m, label: m } : m)) + .filter((m) => ( + m + && m.id + && m.enabled !== false + && (m.id === "qwen" || String(m.id).toLowerCase().includes("gemma") || m.role === "experimental" || m.role === "fallback") + )) + .map((m) => ({ + id: String(m.id), + model: String(m.model || m.model_name || m.id), + label: String(m.label || m.model || m.id), + role: String(m.role || "runtime"), + availability: String(m.availability || "runtime_checked"), + note: String(m.availability_note || "Model availability is checked when the AI request runs."), + })); + return [ + { + id: "deterministic", + model: "", + label: "Deterministic guardrail", + role: "guardrail", + availability: "always_available", + note: "Uses the deterministic Quantamental interpreter and does not call a local LLM.", + }, + ...runtimeModels, + ]; +} + +function quantamentalAiModelStatusText(option) { + const isEnglish = selectedOutputLanguage() === "en"; + if (!option || option.id === "deterministic") { + return isEnglish + ? "Deterministic guardrail active. No local LLM call is made." + : "Deterministic 해석이 활성화되어 로컬 LLM을 호출하지 않습니다."; + } + const role = option.role === "primary" ? "primary" : option.role; + const availability = option.availability === "runtime_checked" + ? (isEnglish ? "runtime checked" : "실행 시 확인") + : option.availability; + return isEnglish + ? `${option.label} · ${role} · ${availability}; fallback remains deterministic if the provider fails.` + : `${option.label} · ${role} · ${availability}; 공급자 실패 시 deterministic fallback을 유지합니다.`; +} + +function selectedQuantamentalAiModelOption() { + const options = state.quantamentalAiModels?.length + ? state.quantamentalAiModels + : normalizeQuantamentalAiModels(state.config?.models || []); + const selected = els.quantamentalAiModel?.value || "deterministic"; + return options.find((option) => option.id === selected) || options[0]; +} + +function quantamentalAiRequestOptions() { + const option = selectedQuantamentalAiModelOption(); + return { + use_llm: option?.id !== "deterministic", + model: option?.id === "deterministic" ? null : option?.model, + }; +} + +function updateQuantamentalAiModelStatus() { + if (!els.quantamentalAiModelStatus) return; + els.quantamentalAiModelStatus.textContent = quantamentalAiModelStatusText(selectedQuantamentalAiModelOption()); +} + +function renderQuantamentalAiModelOptions(models) { + state.quantamentalAiModels = normalizeQuantamentalAiModels(models); + if (!els.quantamentalAiModel) return; + const current = els.quantamentalAiModel.value || "deterministic"; + els.quantamentalAiModel.innerHTML = ""; + state.quantamentalAiModels.forEach((model) => { + const opt = document.createElement("option"); + opt.value = model.id; + opt.textContent = model.id === "deterministic" + ? model.label + : `${model.label} · ${model.availability === "runtime_checked" ? "runtime checked" : model.availability}`; + opt.title = model.note; + opt.dataset.model = model.model || ""; + opt.dataset.availability = model.availability; + els.quantamentalAiModel.appendChild(opt); + }); + els.quantamentalAiModel.value = state.quantamentalAiModels.some((model) => model.id === current) + ? current + : "deterministic"; + updateQuantamentalAiModelStatus(); +} + function renderFinGPTStatus(config) { if (!els.fingptStatus) return; const fingpt = config?.fingpt || {}; @@ -2738,9 +3808,11 @@ function renderFinGPTStatus(config) { function renderPresets(presets) { els.presetChips.innerHTML = ""; - const usable = Array.isArray(presets) && presets.length && presets.every(isReadablePreset) + const language = selectedOutputLanguage(); + const fallbackPresets = language === "en" ? CLEAN_PRESETS_EN : CLEAN_PRESETS; + const usable = language === "ko" && Array.isArray(presets) && presets.length && presets.every(isReadablePreset) ? presets - : CLEAN_PRESETS; + : fallbackPresets; usable.forEach((p) => { const btn = document.createElement("button"); btn.type = "button"; @@ -2793,6 +3865,7 @@ function readForm() { lookback_days: parseInt(els.lookback.value, 10), top_k: parseInt(els.topk.value, 10), model: els.model.value, + output_language: selectedOutputLanguage(), }; } @@ -2813,6 +3886,9 @@ function restoreForm() { if (saved.model && Array.from(els.model.options).some((opt) => opt.value === saved.model)) { els.model.value = saved.model; } + if (saved.output_language) { + applyUiLanguage(saved.output_language, { persist: false }); + } if (Array.isArray(saved.sources)) { els.sourceInputs().forEach((i) => { if (i.disabled) return; @@ -2891,50 +3967,109 @@ function setCardHeader(surfaceId, title, subtitle = "") { if (caption && subtitle) caption.textContent = subtitle; } -function setDashboardContextStrip(tab = "market") { - if (!els.dashboardContextStrip) return; +function fallbackDashboardContextItems(tab = "market") { const itemsByTab = { market: [ - ["데이터", "TradingView / Yahoo"], - ["범위", "시장 스냅샷"], - ["최신성", "가능 시 장중 데이터"], - ["작업", "새로고침과 점검"], + { label: "데이터", value: "TradingView / Yahoo", status: "ok", detail: "시장 대시보드 기본 데이터" }, + { label: "범위", value: "시장 스냅샷", status: "ok", detail: "시장 테이프와 교차자산 신호" }, + { label: "최신성", value: "가능 시 장중 데이터", status: "warn", detail: "freshness 확인 필요" }, + { label: "작업", value: "새로고침과 점검", status: "ok", detail: "로컬 데이터 갱신" }, ], macro: [ - ["데이터", "FRED / Yahoo / data mart"], - ["경계", "관측 데이터 우선"], - ["레짐", "신호와 AI 해석 분리"], - ["출력", "정책 힌트 전용"], + { label: "데이터", value: "FRED / Yahoo / data mart", status: "ok", detail: "매크로 관측 데이터" }, + { label: "경계", value: "관측 데이터 우선", status: "ok", detail: "AI 해석 전 데이터 확인" }, + { label: "레짐", value: "신호와 AI 해석 분리", status: "warn", detail: "레짐 엔진 결과 우선" }, + { label: "출력", value: "정책 힌트 전용", status: "ok", detail: "자문용 맥락" }, ], quant: [ - ["데이터", "저장 가격 이력"], - ["경계", "No-lookahead 검사"], - ["체결", "다음 봉 기준"], - ["출력", "아티팩트와 리플레이"], + { label: "데이터", value: "저장 가격 이력", status: "ok", detail: "data mart 가격" }, + { label: "경계", value: "No-lookahead 검사", status: "warn", detail: "미래 데이터 누수 방지" }, + { label: "체결", value: "다음 봉 기준", status: "ok", detail: "체결 가정 명시" }, + { label: "출력", value: "아티팩트와 리플레이", status: "ok", detail: "재현 가능한 결과" }, + ], + quantamental: [ + { label: "데이터", value: "yfinance + DART", status: "ok", detail: "시장별 provider coverage" }, + { label: "계산", value: "deterministic + peer", status: "ok", detail: "엔진 산출값 우선" }, + { label: "AI", value: "해석만 수행", status: "warn", detail: "AI는 점수 생성 금지" }, + { label: "출력", value: "classification + audit", status: "ok", detail: "스냅샷 감사" }, ], forecast: [ - ["데이터", "data_mart 가격"], - ["검증", "Walk-forward 기본"], - ["가드", "Leakage / embargo"], - ["출력", "자문용 신호만"], + { label: "데이터", value: "data_mart 가격", status: "ok", detail: "예측 피처 입력" }, + { label: "검증", value: "Walk-forward 기본", status: "ok", detail: "시계열 검증" }, + { label: "가드", value: "Leakage / embargo", status: "warn", detail: "누수 검사 우선" }, + { label: "출력", value: "자문용 신호만", status: "ok", detail: "매매 지시 아님" }, ], "ai-portfolio": [ - ["저장소", "Local SQLite"], - ["정책", "제약조건 우선"], - ["승인", "사용자 확인 작업"], - ["감사", "Hash와 이력"], + { label: "저장소", value: "Local SQLite", status: "ok", detail: "로컬 포트폴리오 상태" }, + { label: "정책", value: "제약조건 우선", status: "ok", detail: "정책 기반 엔진" }, + { label: "승인", value: "사용자 확인 작업", status: "warn", detail: "자동 리밸런싱 금지" }, + { label: "감사", value: "Hash와 이력", status: "ok", detail: "요청/정책 해시" }, ], }; - const items = itemsByTab[tab] || itemsByTab.market; - els.dashboardContextStrip.innerHTML = items - .map(([label, value]) => `${escapeHtml(label)}${escapeHtml(value)}`) + return itemsByTab[tab] || itemsByTab.market; +} + +function dashboardDecisionCardForTab(tab = "market") { + return state.dashboardDecisionCardsByTab?.[tab] || null; +} + +function setDashboardContextStrip(tab = "market") { + if (!els.dashboardContextStrip) return; + const card = dashboardDecisionCardForTab(tab); + const chips = Array.isArray(card?.chips) && card.chips.length + ? card.chips + : fallbackDashboardContextItems(tab); + els.dashboardContextStrip.dataset.contract = card?.contract_version || "static-fallback"; + els.dashboardContextStrip.innerHTML = chips + .map((item) => { + const status = decisionStatusClass(item.status || card?.status || "ok"); + const detail = item.detail || card?.primary_output || ""; + return ` + + ${escapeHtml(item.label || "Context")} + ${escapeHtml(item.value || "")} + ${detail ? `${escapeHtml(detail)}` : ""} + + `; + }) .join(""); } +async function loadDashboardDecisionCards(force = false) { + if (!els.dashboardContextStrip) return null; + if (state.dashboardDecisionCardsLoaded && !force) return state.dashboardDecisionCardsByTab; + if (state.dashboardDecisionCardsRequest && !force) return state.dashboardDecisionCardsRequest; + state.dashboardDecisionCardsRequest = (async () => { + try { + const res = await fetch(API.dashboardDecisionCards); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const byTab = {}; + for (const item of Array.isArray(data.items) ? data.items : []) { + if (item?.tab) { + byTab[item.tab] = { ...item, contract_version: data.contract_version }; + } + } + state.dashboardDecisionCardsByTab = byTab; + state.dashboardDecisionCardsLoaded = true; + setDashboardContextStrip(state.activeDashboardTab || "market"); + return byTab; + } catch (err) { + els.dashboardContextStrip.dataset.contract = "static-fallback-error"; + console.warn("dashboard decision cards fetch failed", err); + return state.dashboardDecisionCardsByTab || {}; + } finally { + state.dashboardDecisionCardsRequest = null; + } + })(); + return state.dashboardDecisionCardsRequest; +} + const DASHBOARD_PANEL_VIEWS = new Set(["overview", "details", "operations", "all"]); function panelViewForTab(tab = "market") { - return state.dashboardPanelViewByTab?.[tab] || (tab === "market" ? "all" : "overview"); + if (state.dashboardPanelViewByTab?.[tab]) return state.dashboardPanelViewByTab[tab]; + return "all"; } function updateDashboardViewControls() { @@ -2950,9 +4085,9 @@ function updateDashboardViewControls() { }); } -function setDashboardPanelView(view = "overview", options = {}) { +function setDashboardPanelView(view = "all", options = {}) { const activeTab = options.tab || state.activeDashboardTab || "market"; - const fallback = activeTab === "market" ? "all" : "overview"; + const fallback = "all"; const normalized = DASHBOARD_PANEL_VIEWS.has(view) ? view : fallback; if (state.dashboardPanelViewByTab) { state.dashboardPanelViewByTab[activeTab] = normalized; @@ -2967,9 +4102,11 @@ function setDashboardPanelView(view = "overview", options = {}) { function setCommandPanelCollapsed(collapsed, options = {}) { if (!els.controlPanel || !els.commandPanelToggle) return; els.controlPanel.classList.toggle("is-collapsed", collapsed); + document.body.classList.toggle("command-panel-expanded", !collapsed); els.commandPanelToggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); const stateLabel = els.commandPanelToggle.querySelector(".command-panel-state"); - if (stateLabel) stateLabel.textContent = collapsed ? "컨트롤 열기" : "컨트롤 숨기기"; + const copy = UI_LANGUAGE_COPY[selectedOutputLanguage()] || UI_LANGUAGE_COPY.ko; + if (stateLabel) stateLabel.textContent = collapsed ? copy.commandOpen : copy.commandHide; if (options.persist) { safeWriteStoredValue(STORAGE.controlPanel, collapsed ? "collapsed" : "expanded"); } @@ -3024,28 +4161,32 @@ function dashboardTabFromLocation() { if (fromParam === "ai-portfolio" || fromParam === "ai") return "ai-portfolio"; if (fromParam === "ml-forecast" || fromParam === "forecast") return "forecast"; if (fromParam === "macro") return "macro"; + if (fromParam === "quantamental") return "quantamental"; if (fromParam === "quant") return "quant"; if (fromParam === "market") return "market"; if (window.location.hash === "#ai-portfolio" || window.location.hash === "#ai") return "ai-portfolio"; if (window.location.hash === "#ml-forecast" || window.location.hash === "#forecast-lab" || window.location.hash === "#forecast") return "forecast"; if (window.location.hash === "#macro") return "macro"; + if (window.location.hash === "#quantamental") return "quantamental"; if (window.location.hash === "#quant-lab" || window.location.hash === "#quant") return "quant"; if (window.location.hash === "#market-dashboard" || window.location.hash === "#market") return "market"; return ""; } function setDashboardTab(tab = "market", options = {}) { - const active = tab === "quant" ? "quant" : (tab === "forecast" || tab === "ml-forecast" ? "forecast" : (tab === "macro" ? "macro" : (tab === "ai-portfolio" || tab === "ai" ? "ai-portfolio" : "market"))); + const active = tab === "quantamental" ? "quantamental" : (tab === "quant" ? "quant" : (tab === "forecast" || tab === "ml-forecast" ? "forecast" : (tab === "macro" ? "macro" : (tab === "ai-portfolio" || tab === "ai" ? "ai-portfolio" : "market")))); state.activeDashboardTab = active; if (els.homeSurfaceGrid) { els.homeSurfaceGrid.dataset.dashboardTab = active; } setDashboardContextStrip(active); + loadDashboardDecisionCards(false); setDashboardPanelView(panelViewForTab(active), { tab: active }); const buttons = [ { el: els.marketDashboardTab, tab: "market" }, { el: els.macroDashboardTab, tab: "macro" }, { el: els.quantLabTab, tab: "quant" }, + { el: els.quantamentalTab, tab: "quantamental" }, { el: els.mlForecastTab, tab: "forecast" }, { el: els.aiPortfolioTab, tab: "ai-portfolio" }, ]; @@ -3055,32 +4196,23 @@ function setDashboardTab(tab = "market", options = {}) { el.classList.toggle("active", isActive); el.setAttribute("aria-selected", isActive ? "true" : "false"); }); - const homeTitle = document.querySelector(".home-hero h2"); - const homeCopy = document.querySelector(".home-hero p:not(.eyebrow)"); + updateDashboardHero(active); if (active === "quant") { - if (homeTitle) homeTitle.textContent = "퀀트 랩"; - if (homeCopy) homeCopy.textContent = "저장 가격 기반 리스크, 전략 검증, 포트폴리오 배분을 같은 조건으로 점검합니다."; loadQuantRunHistory(false); loadQuantStrategies(false); + } else if (active === "quantamental") { + loadQuantamental(false); } else if (active === "forecast") { - if (homeTitle) homeTitle.textContent = "ML Forecast"; - if (homeCopy) homeCopy.textContent = "검증 가능한 예측 실험실입니다. 가격 경로가 아니라 OOS forward return, 확률, 신뢰도, 신호, 비용 반영 백테스트를 분리해 점검합니다."; loadForecastLab(false); } else if (active === "macro") { - if (homeTitle) homeTitle.textContent = "매크로"; - if (homeCopy) homeCopy.textContent = "데이터 품질, 레짐, 자산군 영향, 정책 힌트, 리서치 맥락을 AI 해석과 분리해 점검합니다."; loadMacro(false); } else if (active === "ai-portfolio") { - if (homeTitle) homeTitle.textContent = "AI Portfolio"; - if (homeCopy) homeCopy.textContent = "투자형과 정책을 선택하고, 정량 엔진이 계산한 포트폴리오를 AI가 설명하는 사용자 승인 기반 워크플로우입니다."; loadAiPortfolio(false); } else { - if (homeTitle) homeTitle.textContent = "시장 대시보드"; - if (homeCopy) homeCopy.textContent = "티커를 입력하면 종목 분석으로, 비워두고 질문만 입력하면 금리·신용·FX·원자재·테마 topic 분석으로 라우팅합니다."; loadMarketDashboard(false); } if (options.updateUrl && window.history?.replaceState) { - const hash = active === "quant" ? "#quant-lab" : (active === "forecast" ? "#ml-forecast" : (active === "macro" ? "#macro" : (active === "ai-portfolio" ? "#ai-portfolio" : "#market-dashboard"))); + const hash = active === "quantamental" ? "#quantamental" : (active === "quant" ? "#quant-lab" : (active === "forecast" ? "#ml-forecast" : (active === "macro" ? "#macro" : (active === "ai-portfolio" ? "#ai-portfolio" : "#market-dashboard")))); window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}${hash}`); } } @@ -3124,6 +4256,7 @@ function normalizeStaticLabels() { if (els.marketDashboardTab) els.marketDashboardTab.textContent = "Market Dashboard"; if (els.macroDashboardTab) els.macroDashboardTab.textContent = "Macro"; if (els.quantLabTab) els.quantLabTab.textContent = "Quant Lab"; + if (els.quantamentalTab) els.quantamentalTab.textContent = "Quantamental"; if (els.mlForecastTab) els.mlForecastTab.textContent = "ML Forecast"; if (els.aiPortfolioTab) els.aiPortfolioTab.textContent = "AI Portfolio"; setCardHeader("macroOverviewSurface", "매크로 레짐 요약", "데이터, 신호, 해석 분리"); @@ -3169,6 +4302,7 @@ function normalizeStaticLabels() { setText(".home-news-card .home-card-head h3", "주요 뉴스"); const runMeta = document.querySelector(".meta-row"); if (runMeta) runMeta.innerHTML = 'Ctrl + Enter 실행'; + applyUiLanguage(state.outputLanguage || "ko", { persist: false }); applyUrlUiMode(); } @@ -3231,52 +4365,31 @@ function mountTradingViewIframe(container, scriptSrc, config, label) { function mountTradingViewWidget(container, fallback, scriptSrc, config, label) { if (!container) return; - container.innerHTML = '
'; - container.dataset.tvStatus = "loading"; hideTvFallback(fallback); - const script = document.createElement("script"); - script.type = "text/javascript"; - script.async = true; - script.src = scriptSrc; - script.textContent = JSON.stringify(config); - script.onerror = () => { - const iframeMounted = mountTradingViewIframe(container, scriptSrc, config, label); - container.dataset.tvStatus = iframeMounted ? "iframe-fallback" : "failed"; - showTvFallback(fallback, iframeMounted - ? `${label} 스크립트가 차단되어 직접 iframe 경로로 전환했습니다.` - : `${label} 로드에 실패했습니다. 아래 내부 시장 스냅샷을 기준으로 확인하세요.`); - }; - container.appendChild(script); - - let checks = 0; - const verify = () => { - const frame = container.querySelector("iframe"); - const frameSrc = frame?.getAttribute("src") || ""; - if (frame && frameSrc && frameSrc !== "about:blank") { - hideTvFallback(fallback); + const iframeMounted = mountTradingViewIframe(container, scriptSrc, config, label); + if (!iframeMounted) { + container.dataset.tvStatus = "failed"; + showTvFallback(fallback, `${label} 로드에 실패했습니다. 아래 내부 시장 스냅샷을 기준으로 확인하세요.`); + return; + } + container.dataset.tvStatus = "iframe-loading"; + const frame = container.querySelector("iframe"); + if (frame) { + frame.addEventListener("load", () => { container.dataset.tvStatus = "ready"; - return; - } - checks += 1; - if (checks === 4) { - const iframeMounted = mountTradingViewIframe(container, scriptSrc, config, label); - if (iframeMounted) { - container.dataset.tvStatus = "iframe-fallback"; - showTvFallback(fallback, `${label} 로딩이 지연되어 직접 iframe 경로로 재시도했습니다.`); - } - } - if (checks >= 8) { - container.dataset.tvStatus = "degraded"; - showTvFallback(fallback, `${label} 위젯이 아직 응답하지 않습니다. 네트워크 또는 외부 스크립트 차단 시 내부 시장 스냅샷을 사용하세요.`); - return; - } - window.setTimeout(verify, 1000); - }; - window.setTimeout(verify, 1200); + hideTvFallback(fallback); + }, { once: true }); + } + + window.setTimeout(() => { + if (container.dataset.tvStatus === "ready") return; + container.dataset.tvStatus = "iframe-fallback"; + showTvFallback(fallback, `${label}는 직접 iframe 경로로 로드 중입니다. 외부 네트워크가 느리면 내부 시장 스냅샷을 함께 확인하세요.`); + }, 6000); } function normalizeTvChartSettings(raw = {}) { - const source = raw.source === "internal" ? "internal" : TV_CHART_DEFAULTS.source; + const source = raw.source === "tradingview" ? "tradingview" : raw.source === "internal" ? "internal" : TV_CHART_DEFAULTS.source; const symbolKey = TV_CHART_SYMBOLS[raw.symbolKey] ? raw.symbolKey : TV_CHART_DEFAULTS.symbolKey; const interval = TV_CHART_INTERVALS[raw.interval] ? raw.interval : TV_CHART_DEFAULTS.interval; let compareKey = TV_CHART_SYMBOLS[raw.compareKey] ? raw.compareKey : ""; @@ -3335,7 +4448,7 @@ function tradingViewOverviewConfig(settings = state.tvChartSettings) { timezone: "Etc/UTC", theme: visual.theme, style: "1", - locale: "kr", + locale: selectedOutputLanguage() === "en" ? "en" : "kr", backgroundColor: visual.backgroundColor, gridColor: visual.gridColor, hide_top_toolbar: false, @@ -3460,15 +4573,16 @@ async function fetchInternalChartPayload(symbolKey, interval) { function renderInternalOhlcChart(primaryPayload, comparePayload, settings = state.tvChartSettings) { const safe = normalizeTvChartSettings(settings); - const primaryRows = normalizeInternalChartRows(primaryPayload).slice(-160); + const allPrimaryRows = normalizeInternalChartRows(primaryPayload); + const primaryRows = allPrimaryRows; if (primaryRows.length < 2) { return decisionEmpty(`내부 가격 데이터가 부족합니다: ${TV_CHART_SYMBOLS[safe.symbolKey]?.dataTicker || safe.symbolKey}`); } const hasOhlc = primaryRows.some((row) => Number.isFinite(row.open) && Number.isFinite(row.high) && Number.isFinite(row.low)); - const width = 920; + const width = Math.max(980, Math.min(5600, primaryRows.length * 8 + 150)); const height = 390; const padLeft = 72; - const padRight = 22; + const padRight = 56; const padTop = 24; const padBottom = 44; const domainValues = primaryRows.flatMap((row) => [ @@ -3488,7 +4602,7 @@ function renderInternalOhlcChart(primaryPayload, comparePayload, settings = stat closeY: chartY(min, max, row.close, height, padTop, padBottom), }; }); - const candleWidth = Math.max(2.8, Math.min(7, (width - padLeft - padRight) / Math.max(points.length, 1) * 0.42)); + const candleWidth = Math.max(2.8, Math.min(7.5, (width - padLeft - padRight) / Math.max(points.length, 1) * 0.46)); const candles = points.map((point) => { const up = point.close >= point.open; const bodyTop = Math.min(point.openY, point.closeY); @@ -3507,7 +4621,7 @@ function renderInternalOhlcChart(primaryPayload, comparePayload, settings = stat const last = primaryRows[primaryRows.length - 1]; const returnPct = first.close ? (last.close / first.close - 1) * 100 : null; const returnClass = Number(returnPct) >= 0 ? "ok" : "warn"; - const compareRows = normalizeInternalChartRows(comparePayload).slice(-160); + const compareRows = normalizeInternalChartRows(comparePayload).slice(-primaryRows.length); const compareChart = safe.compareKey && compareRows.length >= 2 ? renderNormalizedComparisonChart({ primary: internalChartReturnRows(primaryRows), @@ -3527,22 +4641,25 @@ function renderInternalOhlcChart(primaryPayload, comparePayload, settings = stat ${escapeHtml(returnPct === null ? "-" : fmtPct(returnPct))} - - ${renderChartYAxis({ - width, - height, - padLeft, - padRight, - padTop, - padBottom, - min, - max, - formatter: (value) => fmtDecimal(value, Math.abs(value) >= 100 ? 0 : 2), - })} - ${candles} - - ${renderChartHoverTargets(points.map((point) => ({ ...point, y: point.closeY, value: point.close })), (point) => `${point.date || "-"} · Close ${fmtDecimal(point.value, 2)}`)} - +
+ + ${renderChartYAxis({ + width, + height, + padLeft, + padRight, + padTop, + padBottom, + min, + max, + formatter: (value) => fmtDecimal(value, Math.abs(value) >= 100 ? 0 : 2), + })} + ${candles} + + ${renderChartHoverTargets(points.map((point) => ({ ...point, y: point.closeY, value: point.close })), (point) => `${point.date || "-"} · Close ${fmtDecimal(point.value, 2)}`)} + +
+
좌우 스크롤로 전체 내부 데이터 구간을 확인할 수 있습니다. 최신 구간은 오른쪽 끝입니다.
Open ${escapeHtml(fmtDecimal(last.open, 2))} High ${escapeHtml(fmtDecimal(Math.max(...primaryRows.map((row) => row.high)), 2))} @@ -3554,6 +4671,14 @@ function renderInternalOhlcChart(primaryPayload, comparePayload, settings = stat `; } +function scrollInternalChartToLatest() { + const scroller = els.tvOverviewWidget?.querySelector?.(".internal-chart-scroll"); + if (!scroller) return; + window.requestAnimationFrame(() => { + scroller.scrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth); + }); +} + async function mountInternalMarketChart(settings = state.tvChartSettings) { const safe = normalizeTvChartSettings(settings); const symbolInfo = TV_CHART_SYMBOLS[safe.symbolKey]; @@ -3568,6 +4693,7 @@ async function mountInternalMarketChart(settings = state.tvChartSettings) { const comparePromise = safe.compareKey ? fetchInternalChartPayload(safe.compareKey, safe.interval) : Promise.resolve(null); const [primaryPayload, comparePayload] = await Promise.all([primaryPromise, comparePromise]); els.tvOverviewWidget.innerHTML = renderInternalOhlcChart(primaryPayload, comparePayload, safe); + scrollInternalChartToLatest(); els.tvOverviewWidget.dataset.tvStatus = normalizeInternalChartRows(primaryPayload).length >= 2 ? "internal-ready" : "internal-empty"; } catch (err) { els.tvOverviewWidget.dataset.tvStatus = "internal-failed"; @@ -3640,7 +4766,7 @@ function initializeTradingViewDashboard(force = false) { grouping: "sector", blockSize: "market_cap_basic", blockColor: "change", - locale: "kr", + locale: selectedOutputLanguage() === "en" ? "en" : "kr", symbolUrl: "", colorTheme: "dark", hasTopBar: false, @@ -4175,6 +5301,157 @@ function renderMarketSignals(overview) { els.marketSignalSurface.innerHTML = rendered || decisionEmpty("Market UI module is unavailable."); } +function crossAssetRequestFromControls() { + const symbols = parseTickerInput(els.crossAssetSymbols?.value || "") + .slice(0, 12) + .join(","); + return { + symbols: symbols || "SPY,QQQ,TLT,HYG,LQD,GLD,BTC-USD,DXY,US10Y", + horizon: els.crossAssetHorizon?.value || "1m", + topic: textInputValue(els.crossAssetTopic) || "", + }; +} + +function crossAssetStateLabel(stateKey) { + const labels = { + risk_on: "위험선호", + risk_off: "방어 우위", + mixed: "혼재", + unavailable: "데이터 부족", + }; + return labels[stateKey] || stateKey || "미확인"; +} + +function crossAssetRoleLabel(role) { + const labels = { + equity: "주식", + rates: "금리/채권", + credit: "신용", + commodity: "원자재", + crypto: "크립토", + fx: "FX", + custom: "사용자", + }; + return labels[role] || role || "기타"; +} + +function crossAssetReturnCell(label, value) { + const cls = Number(value) >= 0 ? "ok" : "warn"; + return `${escapeHtml(label)} ${escapeHtml(fmtPct(value))}`; +} + +function renderCrossAssetAnalysis(payload) { + if (!els.crossAssetAnalysisSurface) return; + const items = Array.isArray(payload?.items) ? payload.items : []; + const summary = payload?.summary || {}; + if (!items.length) { + els.crossAssetAnalysisSurface.innerHTML = decisionEmpty("교차자산 분석 결과가 없습니다."); + return; + } + const horizon = payload?.horizon || "1m"; + const maxAbs = Math.max(1, ...items.map((item) => Math.abs(Number(item?.returns?.[horizon] || 0))).filter(Number.isFinite)); + const usable = items.filter((item) => item.is_decision_usable); + const roleReturns = summary.role_returns || {}; + const roleKeys = ["equity", "credit", "rates", "defensive", "crypto"].filter((key) => roleReturns[key] !== null && roleReturns[key] !== undefined); + els.crossAssetAnalysisSurface.innerHTML = ` +
+
+ 현재 상태 + ${escapeHtml(crossAssetStateLabel(summary.state))} +
+
+ Regime score + ${escapeHtml(fmtDecimal(summary.risk_score, 2))} +
+
+ 사용 가능 + ${escapeHtml(_fmtNumber(usable.length))}/${escapeHtml(_fmtNumber(items.length))} +
+
+ 기준 기간 + ${escapeHtml(String(horizon).toUpperCase())} +
+
+
+
+

${escapeHtml(summary.title || "교차자산 해석")}

+

${escapeHtml(summary.current_state || "현재 상태를 계산하지 못했습니다.")}

+
+
+

향후 동향

+

${escapeHtml(summary.forward_bias || "추가 데이터 확인이 필요합니다.")}

+
+
+ ${roleKeys.length ? ` +
+ ${roleKeys.map((key) => crossAssetReturnCell(key === "defensive" ? "방어자산" : crossAssetRoleLabel(key), roleReturns[key])).join("")} +
+ ` : ""} +
+ ${items.map((item) => { + const value = item?.returns?.[horizon]; + const n = Number(value); + const width = Number.isFinite(n) ? Math.max(4, Math.min(100, Math.abs(n) / maxAbs * 100)) : 0; + const cls = !item.is_decision_usable ? "muted" : n >= 0 ? "up" : "down"; + return ` +
+
+ ${escapeHtml(item.symbol || "")} + ${escapeHtml(item.label || "")} · ${escapeHtml(crossAssetRoleLabel(item.role))} + ${escapeHtml(fmtPct(value))} +
+
+
+ 1D ${escapeHtml(fmtPct(item?.returns?.["1d"]))} + 5D ${escapeHtml(fmtPct(item?.returns?.["5d"]))} + 1M ${escapeHtml(fmtPct(item?.returns?.["1m"]))} + 3M ${escapeHtml(fmtPct(item?.returns?.["3m"]))} + ${item.error ? `${escapeHtml(item.error)}` : `${escapeHtml(item.as_of || "")}`} +
+
+ `; + }).join("")} +
+
+ ${(Array.isArray(summary.watch_points) ? summary.watch_points : []).map((item) => `${escapeHtml(item)}`).join("")} +
+
+ 개발자 진단 +
${escapeHtml(JSON.stringify({
+        provider: payload?.provider,
+        engine: payload?.analysis_engine,
+        generated_at: payload?.generated_at,
+        guardrails: payload?.guardrails,
+        contributors: summary.contributors,
+      }, null, 2))}
+
+ `; +} + +async function loadCrossAssetAnalysis(force = false) { + if (!els.crossAssetAnalysisSurface || (state.crossAssetAnalysis && !force)) return; + const request = crossAssetRequestFromControls(); + if (els.crossAssetStatus) els.crossAssetStatus.textContent = "교차자산 데이터를 계산하는 중입니다."; + if (els.crossAssetRun) els.crossAssetRun.disabled = true; + els.crossAssetAnalysisSurface.innerHTML = '
교차자산 분석을 불러오는 중입니다.
'; + try { + const res = await fetch(API.dashboardCrossAssetAnalyze(request)); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + state.crossAssetAnalysis = data; + renderCrossAssetAnalysis(data); + if (els.crossAssetStatus) { + const usable = Number(data.decision_usable_count || 0); + els.crossAssetStatus.textContent = `${_fmtNumber(usable)}개 자산 기준 · ${fmtDate(data.generated_at)}`; + } + } catch (err) { + els.crossAssetAnalysisSurface.innerHTML = decisionEmpty(`교차자산 분석 실패: ${err.message || err}`); + if (els.crossAssetStatus) els.crossAssetStatus.textContent = "교차자산 분석 실패"; + } finally { + if (els.crossAssetRun) els.crossAssetRun.disabled = false; + } +} + async function loadDashboardMarketOverview(force = false) { if ((!els.marketTapeSurface && !els.marketSignalSurface) || (state.marketOverviewLoaded && !force)) return; if (els.marketTapeSurface) els.marketTapeSurface.innerHTML = '
시장 테이프를 불러오는 중입니다.
'; @@ -4186,12 +5463,25 @@ async function loadDashboardMarketOverview(force = false) { state.marketOverview = data; renderMarketTape(data); renderMarketSignals(data); + updateGlobalQualitySummary({ + status: data.status || data.quality_status || data.decision_status || "ok", + asOf: data.as_of || data.generated_at || data.updated_at || "", + updatedAt: data.generated_at || data.updated_at || "", + source: data.provider || data.source || "market overview", + observations: data.observation_count || data.count || "", + missing: data.missing_count ? `${data.missing_count}` : "없음", + }); state.marketOverviewLoaded = true; } catch (err) { const message = `시장 overview 로드 실패: ${escapeHtml(err.message || err)}`; if (els.marketTapeSurface) els.marketTapeSurface.innerHTML = `
${message}
`; if (els.marketSignalSurface) els.marketSignalSurface.innerHTML = `
${message}
`; if (els.marketOverviewMeta) els.marketOverviewMeta.textContent = "overview load failed"; + updateGlobalQualitySummary({ + status: "unknown", + source: "market overview", + missing: "확인 불가", + }); } } @@ -4233,10 +5523,12 @@ function renderActionCompletion(label, startedAt, detail = "", status = "ok") { `; } -function setButtonBusy(button, busy, busyText = "처리 중") { +function setButtonBusy(button, busy, busyText = "처리 중", idleText = null) { if (!button) return; if (busy) { - if (!button.dataset.idleText) button.dataset.idleText = button.textContent || ""; + if (idleText !== null || button.getAttribute("aria-busy") !== "true") { + button.dataset.idleText = idleText !== null ? String(idleText) : (button.textContent || ""); + } button.textContent = busyText; button.disabled = true; button.setAttribute("aria-busy", "true"); @@ -4244,7 +5536,10 @@ function setButtonBusy(button, busy, busyText = "처리 중") { } button.disabled = false; button.removeAttribute("aria-busy"); - if (button.dataset.idleText) button.textContent = button.dataset.idleText; + if (button.dataset.idleText) { + button.textContent = button.dataset.idleText; + delete button.dataset.idleText; + } } function decisionMetric(label, value, status = "") { @@ -4561,6 +5856,235 @@ function localIsoDate(date = new Date()) { return local.toISOString().slice(0, 10); } +function isoDateDayOffset(dateText, days = 0) { + if (!dateText) return ""; + const date = new Date(`${dateText}T00:00:00`); + if (Number.isNaN(date.getTime())) return ""; + date.setDate(date.getDate() - Number(days || 0)); + return date.toISOString().slice(0, 10); +} + +function sanitizeDateInput(value) { + const text = String(value || "").trim(); + return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ""; +} + +function normalizeCustomGlobalDateOrder(startDate, endDate) { + const start = sanitizeDateInput(startDate); + const end = sanitizeDateInput(endDate); + if (start && end && start > end) { + return { startDate: end, endDate: start, reordered: true }; + } + return { startDate: start, endDate: end, reordered: false }; +} + +function globalRangeValidationMessage(rangeState = state.globalRange) { + const normalized = normalizeGlobalRange(rangeState?.range); + if (normalized !== "custom") return state.globalRangeNotice || ""; + if (state.globalRangeNotice) return state.globalRangeNotice; + const start = sanitizeDateInput(rangeState?.startDate); + const end = sanitizeDateInput(rangeState?.endDate); + if (!start && !end) return "Custom 기간은 시작일 또는 종료일이 필요합니다. 현재는 1Y 기본 기간으로 계산합니다."; + if (!start) return "시작일이 없어 1Y lookback으로 계산합니다."; + if (!end) return "종료일이 없어 오늘 기준으로 계산합니다."; + return ""; +} + +function globalRangeLookbackDays(range = state.globalRange?.range, startDate = state.globalRange?.startDate, endDate = state.globalRange?.endDate) { + const normalized = normalizeGlobalRange(range); + if (normalized === "YTD") { + const today = sanitizeDateInput(endDate) || localIsoDate(); + const start = `${today.slice(0, 4)}-01-01`; + const diff = Math.ceil((new Date(`${today}T00:00:00`) - new Date(`${start}T00:00:00`)) / 86400000) + 1; + return Math.max(1, Math.min(5000, diff)); + } + if (normalized === "custom") { + const ordered = normalizeCustomGlobalDateOrder(startDate, endDate); + const start = ordered.startDate; + const end = ordered.endDate || localIsoDate(); + if (!start) return DASHBOARD_RANGE_LOOKBACK_DAYS["1Y"]; + const diff = Math.ceil((new Date(`${end}T00:00:00`) - new Date(`${start}T00:00:00`)) / 86400000) + 1; + return Number.isFinite(diff) ? Math.max(1, Math.min(5000, diff)) : DASHBOARD_RANGE_LOOKBACK_DAYS["1Y"]; + } + return DASHBOARD_RANGE_LOOKBACK_DAYS[normalized] || DASHBOARD_RANGE_LOOKBACK_DAYS["1Y"]; +} + +function globalRangeDateBounds(range = state.globalRange?.range, startDate = state.globalRange?.startDate, endDate = state.globalRange?.endDate) { + const normalized = normalizeGlobalRange(range); + const end = sanitizeDateInput(endDate) || localIsoDate(); + if (normalized === "custom") { + const ordered = normalizeCustomGlobalDateOrder(startDate, endDate || end); + return { startDate: ordered.startDate, endDate: ordered.endDate || end }; + } + if (normalized === "MAX") return { startDate: "", endDate: end }; + if (normalized === "YTD") return { startDate: `${end.slice(0, 4)}-01-01`, endDate: end }; + const months = { "1M": 1, "3M": 3, "6M": 6 }[normalized] || 0; + const years = { "1Y": 1, "3Y": 3, "5Y": 5 }[normalized] || 0; + if (months || years) return { startDate: isoDateOffset(end, { months, years }), endDate: end }; + if (normalized === "1W") return { startDate: isoDateDayOffset(end, 7), endDate: end }; + if (normalized === "1D") return { startDate: end, endDate: end }; + return { startDate: isoDateOffset(end, { years: 1 }), endDate: end }; +} + +function globalRangeToAssetRange(range = state.globalRange?.range) { + const normalized = normalizeGlobalRange(range); + return { + "1D": "1d", + "1W": "1w", + "1M": "1m", + "3M": "3m", + "6M": "6m", + YTD: "ytd", + "1Y": "1y", + "3Y": "3y", + "5Y": "5y", + MAX: "all", + custom: "custom", + }[normalized] || "1y"; +} + +function quantamentalLookbackFromRange(range = state.globalRange?.range, startDate = state.globalRange?.startDate, endDate = state.globalRange?.endDate) { + const days = globalRangeLookbackDays(range, startDate, endDate); + if (days <= 1) return "1"; + if (days <= 7) return "5"; + if (days <= 31) return "21"; + if (days <= 95) return "63"; + if (days <= 190) return "126"; + if (days <= 380) return "252"; + if (days <= 900) return "756"; + if (days <= 1500) return "1260"; + return "5000"; +} + +function globalRangeSupportSummary(rangeState = state.globalRange) { + const range = normalizeGlobalRange(rangeState?.range); + const bounds = globalRangeDateBounds(range, rangeState?.startDate, rangeState?.endDate); + const lookbackDays = globalRangeLookbackDays(range, bounds.startDate, bounds.endDate); + const researchDays = Math.max( + Number(els.lookback?.min || 1), + Math.min(Number(els.lookback?.max || 180), lookbackDays), + ); + const quantamentalBucket = quantamentalLookbackFromRange(range, bounds.startDate, bounds.endDate); + const dateTargets = "자산·백테스트·포트폴리오·Forecast"; + const dateWindow = range === "custom" && !bounds.startDate + ? `Custom 시작일 없음(1Y 기본 기간)~${bounds.endDate || "오늘"}` + : `${bounds.startDate || "시작 제한 없음"}~${bounds.endDate || "오늘"}`; + return { + lookbackDays, + researchDays, + quantamentalBucket, + summary: `${selectedRangeLabel()} · 약 ${_fmtNumber(lookbackDays)}일 기준. 날짜 지원 화면은 직접 반영하고, lookback 기반 화면은 지원 버킷으로 변환합니다.`, + detail: `${dateTargets}: ${dateWindow} · Research: ${_fmtNumber(researchDays)}일 · Quantamental: ${_fmtNumber(Number(quantamentalBucket))}일 버킷`, + }; +} + +function setSelectValueIfPresent(select, value) { + if (!select) return false; + const exists = Array.from(select.options || []).some((option) => option.value === value); + if (!exists) return false; + select.value = value; + return true; +} + +function syncDashboardRangeControls() { + if (!state.globalRange) state.globalRange = { ...DEFAULT_GLOBAL_RANGE }; + if (els.dashboardRangeSelect) els.dashboardRangeSelect.value = normalizeGlobalRange(state.globalRange.range); + if (els.dashboardRangeStart) els.dashboardRangeStart.value = sanitizeDateInput(state.globalRange.startDate); + if (els.dashboardRangeEnd) els.dashboardRangeEnd.value = sanitizeDateInput(state.globalRange.endDate); + const isCustom = normalizeGlobalRange(state.globalRange.range) === "custom"; + const validationMessage = globalRangeValidationMessage(); + if (els.dashboardRangeControls) els.dashboardRangeControls.classList.toggle("custom-active", isCustom); + if (els.dashboardRangeControls) els.dashboardRangeControls.classList.toggle("range-warning", Boolean(validationMessage)); + const missingCustomStart = isCustom && !state.globalRangeNotice && !sanitizeDateInput(state.globalRange.startDate); + const missingCustomEnd = isCustom && !state.globalRangeNotice && !sanitizeDateInput(state.globalRange.endDate); + if (els.dashboardRangeStart) els.dashboardRangeStart.setAttribute("aria-invalid", missingCustomStart ? "true" : "false"); + if (els.dashboardRangeEnd) els.dashboardRangeEnd.setAttribute("aria-invalid", missingCustomEnd ? "true" : "false"); + if (els.dashboardRangeSupport) { + const support = globalRangeSupportSummary(); + els.dashboardRangeSupport.textContent = validationMessage ? `${validationMessage} · ${support.summary}` : support.summary; + els.dashboardRangeSupport.title = support.detail; + if (els.dashboardRangeControls) els.dashboardRangeControls.dataset.rangeSupport = support.detail; + } + renderGlobalQualitySummary(); +} + +function applyGlobalRangeToControls() { + const range = normalizeGlobalRange(state.globalRange?.range); + const bounds = globalRangeDateBounds(range, state.globalRange?.startDate, state.globalRange?.endDate); + const lookbackDays = globalRangeLookbackDays(range, bounds.startDate, bounds.endDate); + const cappedResearchLookback = Math.max( + Number(els.lookback?.min || 1), + Math.min(Number(els.lookback?.max || 180), lookbackDays), + ); + if (els.lookback) { + els.lookback.value = String(cappedResearchLookback); + els.lookback.dataset.globalRange = range; + } + setSelectValueIfPresent(els.assetDetailRange, globalRangeToAssetRange(range)); + if (els.assetDetailStartDate) els.assetDetailStartDate.value = bounds.startDate; + if (els.assetDetailEndDate) els.assetDetailEndDate.value = bounds.endDate; + if (els.backtestStartDate) els.backtestStartDate.value = bounds.startDate; + if (els.backtestEndDate) els.backtestEndDate.value = bounds.endDate; + if (els.portfolioStartDate) els.portfolioStartDate.value = bounds.startDate; + if (els.portfolioEndDate) els.portfolioEndDate.value = bounds.endDate; + if (els.portfolioLookbackDays) els.portfolioLookbackDays.value = String(Math.min(5000, Math.max(1, lookbackDays))); + if (els.forecastStartDate) els.forecastStartDate.value = bounds.startDate; + if (els.forecastEndDate) els.forecastEndDate.value = bounds.endDate; + setSelectValueIfPresent(els.quantamentalLookback, quantamentalLookbackFromRange(range, bounds.startDate, bounds.endDate)); + if (els.aiPortfolioLookbackMonths) { + const months = range === "MAX" ? 120 : Math.max(1, Math.round(lookbackDays / 21)); + els.aiPortfolioLookbackMonths.value = String(Math.min(120, months)); + } + if (els.crossAssetHorizon) { + const horizon = lookbackDays <= 1 ? "1d" : (lookbackDays <= 7 ? "5d" : (lookbackDays <= 63 ? "1m" : "3m")); + setSelectValueIfPresent(els.crossAssetHorizon, horizon); + } + updateRangeLabels(); + syncDashboardRangeControls(); + persistForm(); +} + +function updateGlobalRangeUrl() { + if (!window.history?.replaceState) return; + const params = new URLSearchParams(window.location.search || ""); + const range = normalizeGlobalRange(state.globalRange?.range); + params.set("range", range); + if (range === "custom") { + if (state.globalRange.startDate) params.set("start", state.globalRange.startDate); + else params.delete("start"); + if (state.globalRange.endDate) params.set("end", state.globalRange.endDate); + else params.delete("end"); + } else { + params.delete("start"); + params.delete("end"); + } + const query = params.toString(); + window.history.replaceState(null, "", `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`); +} + +function setGlobalRange(range, options = {}) { + const normalized = normalizeGlobalRange(range); + const start = sanitizeDateInput(options.startDate ?? els.dashboardRangeStart?.value ?? state.globalRange?.startDate); + const end = sanitizeDateInput(options.endDate ?? els.dashboardRangeEnd?.value ?? state.globalRange?.endDate); + state.globalRangeNotice = ""; + if (normalized === "custom") { + const ordered = normalizeCustomGlobalDateOrder(start, end); + if (ordered.reordered) { + state.globalRangeNotice = "시작일과 종료일이 역순이라 자동으로 정렬했습니다."; + } + state.globalRange = { range: normalized, startDate: ordered.startDate, endDate: ordered.endDate }; + } else { + state.globalRange = { range: normalized, ...globalRangeDateBounds(normalized, start, end) }; + } + if (options.persist !== false) safeWriteStoredJson(STORAGE.dashboardRange, state.globalRange); + applyGlobalRangeToControls(); + if (options.updateUrl) updateGlobalRangeUrl(); + if (options.reload) { + markGlobalQualityRangePending(); + loadActiveDashboardResources(true); + } +} + function assetDetailOptionsFromControls() { return { range: els.assetDetailRange?.value || "1y", @@ -4574,9 +6098,15 @@ function assetDetailOptionsFromControls() { function assetDetailRangeStart(latestDate, range) { if (!latestDate || range === "all") return ""; + if (range === "custom") return ""; + if (range === "1d") return latestDate; + if (range === "1w") return isoDateDayOffset(latestDate, 7); + if (range === "1m") return isoDateOffset(latestDate, { months: 1 }); if (range === "3m") return isoDateOffset(latestDate, { months: 3 }); if (range === "6m") return isoDateOffset(latestDate, { months: 6 }); + if (range === "ytd") return `${latestDate.slice(0, 4)}-01-01`; if (range === "3y") return isoDateOffset(latestDate, { years: 3 }); + if (range === "5y") return isoDateOffset(latestDate, { years: 5 }); return isoDateOffset(latestDate, { years: 1 }); } @@ -4587,20 +6117,32 @@ function assetDetailRefreshStart(options) { } function assetDetailPriceQueryOptions(options) { + const freshness = selectedQuantFreshnessOptions(); return { refresh: true, startDate: assetDetailRefreshStart(options), endDate: options.endDate || "", + freshnessProfile: freshness.freshnessProfile, + requireFreshPrices: freshness.requireFreshPrices, }; } function assetDetailRefreshWarning(data, ticker) { + const warnings = []; const refresh = data?.refresh || {}; - if (!refresh.enabled || !refresh.attempted) return ""; - const status = String(refresh.status || "").toLowerCase(); - if (!status || ["success", "ok", "partial"].includes(status)) return ""; - const message = refresh.error ? ` · ${refresh.error}` : ""; - return `${ticker} 최신 종가 보강 실패${message}`; + if (refresh.enabled && refresh.attempted) { + const status = String(refresh.status || "").toLowerCase(); + if (status && !["success", "ok", "partial"].includes(status)) { + const message = refresh.error ? ` · ${refresh.error}` : ""; + warnings.push(`${ticker} 최신 종가 보강 실패${message}`); + } + } + const audit = data?.asset_freshness || {}; + const freshnessStatus = String(audit.freshness_status || "").toLowerCase(); + if (data?.strict_freshness_violation || freshnessStatus === "stale") { + warnings.push(`${ticker} 가격 신선도 ${freshnessStatus || "unknown"} · 최근 ${audit.latest_price_date || "unknown"} · 기대 ${data?.expected_latest_date || audit.expected_latest_date || "unknown"} · 지연 ${data?.market_calendar_lag_days ?? audit.market_calendar_lag_days ?? "-"}일`); + } + return warnings.join(" · "); } function filterPriceRowsByAssetOptions(rows, options) { @@ -4860,6 +6402,9 @@ const MACRO_CATEGORY_LABELS = { market: "시장", }; +const MACRO_DASHBOARD_TIMEOUT_MS = 20000; +const MACRO_PANEL_TIMEOUT_MS = 30000; + const MACRO_SCENARIO_PRESETS = { rates_up: { name: "rates_up", @@ -5313,7 +6858,7 @@ async function loadMacroSeriesDetail(seriesId) { if (!id || !els.macroSeriesDetailSurface) return; els.macroSeriesDetailSurface.innerHTML = decisionEmpty(`${escapeHtml(id)} 상세 데이터를 불러오는 중입니다.`); try { - const data = await macroFetchJsonWithTimeout(API.macroSeriesDetail(id, 240), {}, 10000); + const data = await macroFetchJsonWithTimeout(API.macroSeriesDetail(id, 240), {}, MACRO_PANEL_TIMEOUT_MS); state.macroSeriesDetail = data; renderMacroSeriesDetail(data); } catch (err) { @@ -5623,6 +7168,8 @@ function renderMacroDataQuality(data = {}, refreshStatus = {}) { if (!els.macroDataQualitySurface) return; const quality = data.data_quality || data; const rows = Array.isArray(data.series) ? data.series : []; + const coverage = data.coverage || {}; + const statusCounts = coverage.status_counts || {}; const scheduler = refreshStatus.scheduler || {}; const lastResult = scheduler.last_result || {}; const macroJob = lastResult.jobs?.macro_platform_data || {}; @@ -5631,9 +7178,11 @@ function renderMacroDataQuality(data = {}, refreshStatus = {}) { els.macroDataQualitySurface.innerHTML = `
${escapeHtml(quality.status || "unknown")} - 공급자 ${escapeHtml(quality.provider || "mixed")} · 마지막 갱신 ${escapeHtml(quality.last_updated || "사용 불가")} + ${escapeHtml(data.scope === "all" ? "전체 registry" : "핵심 지표")} · 공급자 ${escapeHtml(quality.provider || "mixed")} · 마지막 갱신 ${escapeHtml(quality.last_updated || "사용 불가")}
+ ${decisionMetric("검증 시계열", _fmtNumber(coverage.evaluated_series || rows.length), "ok")} + ${decisionMetric("정상 시계열", _fmtNumber(statusCounts.ok || 0), (statusCounts.stale || statusCounts.partial || statusCounts.unavailable) ? "warn" : "ok")} ${decisionMetric("누락 시계열", _fmtNumber((quality.missing_series || []).length), (quality.missing_series || []).length ? "warn" : "ok")} ${decisionMetric("지연 시계열", _fmtNumber((quality.stale_series || []).length), (quality.stale_series || []).length ? "warn" : "ok")} ${decisionMetric("오류", _fmtNumber((quality.errors || []).length), (quality.errors || []).length ? "warn" : "ok")} @@ -5646,13 +7195,14 @@ function renderMacroDataQuality(data = {}, refreshStatus = {}) { ${(quality.errors || []).length ? `
${escapeHtml(quality.errors.slice(0, 6).join("; "))}
` : ""}
- + ${rows.map((row) => ` - + + @@ -5750,7 +7300,7 @@ async function runMacroScenario(presetName) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), - }, 10000); + }, MACRO_PANEL_TIMEOUT_MS); state.macroScenario = data; renderMacroScenarioResult(data, startedAt); } catch (err) { @@ -5787,7 +7337,7 @@ async function runMacroResearchPreview() { setButtonBusy(els.macroResearchPreviewRun, true, "조회 중"); els.macroResearchPreviewResult.innerHTML = decisionEmpty(`${escapeHtml(ticker)} 매크로 리서치 컨텍스트를 불러오는 중입니다.`); try { - const data = await macroFetchJsonWithTimeout(API.macroResearchContext(ticker), {}, 10000); + const data = await macroFetchJsonWithTimeout(API.macroResearchContext(ticker), {}, MACRO_PANEL_TIMEOUT_MS); state.macroResearchContext = data; renderMacroResearchContext(data, startedAt); } catch (err) { @@ -5835,7 +7385,7 @@ async function hydrateMacroCategoryPanels() { for (const [url, surface, label] of panels) { if (runId !== macroCategoryHydrationRun) return; try { - const data = await macroFetchJsonWithTimeout(url, {}, 9000); + const data = await macroFetchJsonWithTimeout(url, {}, MACRO_PANEL_TIMEOUT_MS); renderMacroCategory(surface, data); } catch (err) { renderMacroPanelFailure(surface, label, err); @@ -5885,7 +7435,7 @@ async function loadMacroProgressive(force = false) { } try { macroCategoryHydrationRun += 1; - const dashboard = await macroFetchJsonWithTimeout(API.macroDashboard, {}, 9000); + const dashboard = await macroFetchJsonWithTimeout(API.macroDashboard, {}, MACRO_DASHBOARD_TIMEOUT_MS); const overview = dashboard.overview || {}; const dashboardQuality = dashboard.data_quality || overview.data_quality || {}; const dashboardRefresh = dashboard.refresh || {}; @@ -5897,20 +7447,18 @@ async function loadMacroProgressive(force = false) { renderMacroCharts(overview); renderMacroRegime(overview.regime || {}, overview.signals || []); renderMacroAssetImpact(overview.asset_impact_summary || dashboard.asset_impacts || []); - renderMacroDataQuality(dashboard.data_quality || dashboardQuality, dashboardRefresh); + renderMacroDataQuality(dashboard.quality_detail || dashboard.data_quality || dashboardQuality, dashboardRefresh); renderMacroComparePlaceholder([]); renderMacroActionPaneStarters(); setMacroLoadStatus(dashboard, startedAt, "대시보드 집계 렌더링 완료", dashboard.status || dashboardQuality.status || "ok"); const panelTasks = [ ["seriesList", API.macroSeriesList], - ["dataQuality", API.macroDataQuality], - ["refreshStatus", API.macroRefreshStatus], ["providerHealth", API.macroProviderHealth], ["portfolioHints", API.macroPortfolioHints], ]; const settled = await Promise.allSettled( - panelTasks.map(([name, url]) => macroFetchJsonWithTimeout(url, {}, 9000) + panelTasks.map(([name, url]) => macroFetchJsonWithTimeout(url, {}, MACRO_PANEL_TIMEOUT_MS) .then((data) => ({ name, data })) .catch((error) => Promise.reject({ name, error }))) ); @@ -5948,19 +7496,9 @@ async function loadMacroProgressive(force = false) { } else { renderMacroPanelFailure(els.macroPortfolioHintsSurface, "포트폴리오 힌트 패널", failureByName.portfolioHints); } - state.macroDataQuality = results.dataQuality || dashboard.data_quality || dashboardQuality; - state.macroRefreshStatus = results.refreshStatus || dashboardRefresh; - if (failureByName.dataQuality) { - renderMacroPanelFailure(els.macroDataQualitySurface, "데이터 품질 패널", failureByName.dataQuality); - } else { - renderMacroDataQuality(state.macroDataQuality, state.macroRefreshStatus); - if (failureByName.refreshStatus && els.macroDataQualitySurface) { - els.macroDataQualitySurface.insertAdjacentHTML( - "afterbegin", - `
갱신 상태 로드 실패: ${escapeHtml(failureByName.refreshStatus.message || String(failureByName.refreshStatus))}
` - ); - } - } + state.macroDataQuality = dashboard.quality_detail || dashboard.data_quality || dashboardQuality; + state.macroRefreshStatus = dashboardRefresh; + renderMacroDataQuality(state.macroDataQuality, state.macroRefreshStatus); state.macroLoaded = true; const failureDetail = failures.length ? `부분 실패 ${failures.length}개 · ${failures.slice(0, 2).join(" / ")}` @@ -6179,7 +7717,7 @@ function enableStrictFreshnessFromUi() { if (els.backtestSurface) { els.backtestSurface.insertAdjacentHTML( "afterbegin", - '
최신 가격 강제 옵션이 켜졌습니다.같은 조건으로 다시 실행하면 오래된 가격을 실패로 처리합니다.
' + '
최신 가격 강제 옵션이 켜졌습니다.다시 실행하면 공급자 갱신을 먼저 시도하고, 남은 stale 가격은 실패로 처리합니다.
' ); } } @@ -6237,9 +7775,24 @@ async function loadDataHealth(force = false) { `).join("") : '
No quality checks recorded yet.
'} `; + updateGlobalQualitySummary({ + status, + asOf: latest.finished_at || latest.started_at || data.as_of || "", + updatedAt: latest.finished_at || latest.started_at || "", + source: latest.market ? `data mart · ${latest.market}` : "data mart", + observations: counts.prices_daily || counts.macro_observations || "", + missing: failedCount || staleCount ? `provider ${failedCount} · quality ${staleCount}` : "없음", + cache: latest.status || "", + }); state.dataHealthLoaded = true; } catch (err) { els.homeDataHealth.innerHTML = decisionEmpty(`데이터 마트 상태 조회 실패: ${err.message || err}`); + updateGlobalQualitySummary({ + status: "unknown", + source: "data mart", + missing: "확인 불가", + updatedAt: "", + }); } } @@ -6271,6 +7824,8 @@ async function loadAssetDetail() { } const scopedLatest = rows[rows.length - 1] || latest; const metrics = assetDetailMetrics(rows, scopedLatest); + const freshnessStatus = data.asset_freshness?.freshness_status || (data.strict_freshness_violation ? "stale" : "fresh"); + const freshnessClass = freshnessStatus === "fresh" ? "ok" : decisionStatusClass(freshnessStatus); let benchmarkRows = []; let benchmarkWarning = ""; if (options.compareBenchmark && options.benchmark && options.benchmark !== ticker) { @@ -6288,7 +7843,7 @@ async function loadAssetDetail() { } els.assetDetailSurface.innerHTML = `
- 정상 + ${escapeHtml(freshnessStatus === "fresh" ? "정상" : freshnessStatus)} 선택 ${escapeHtml(_fmtNumber(rows.length))}/${escapeHtml(_fmtNumber(allRows.length))}행 · ${escapeHtml(rows[0]?.date || "-")} -> ${escapeHtml(scopedLatest.date || "-")} · ${escapeHtml(scopedLatest.source || latest.source || "소스 미확인")}
@@ -6297,6 +7852,7 @@ async function loadAssetDetail() { 시작 ${escapeHtml(options.startDate || "자동")} 종료 ${escapeHtml(options.endDate || "최신")} 최신 종가 보강 ${escapeHtml(priceQuery.startDate || "최근")} -> ${escapeHtml(priceQuery.endDate || "제공자 최신")} + 신선도 ${escapeHtml(data.freshness_policy?.profile || priceQuery.freshnessProfile || "research_default")} · 기대 ${escapeHtml(data.expected_latest_date || "unknown")} ${options.compareBenchmark ? `벤치마크 ${escapeHtml(options.benchmark)}` : ""}
${refreshWarning ? `
${escapeHtml(refreshWarning)}
` : ""} @@ -6463,6 +8019,7 @@ function renderFreshnessAuditPanel(diagnostics) {
데이터 신선도 정책
${escapeHtml(policy.policy_id || "daily_price_policy")} + profile ${escapeHtml(policy.profile || "research_default")} 기준일 ${escapeHtml(diagnostics.expected_latest_date || policy.expected_latest_date || "알 수 없음")} 허용 지연 ${escapeHtml(String(policy.max_market_calendar_lag_days ?? "-"))}일 강제 최신 ${policy.require_fresh_prices ? "켜짐" : "꺼짐"} @@ -6494,6 +8051,22 @@ function renderFreshnessAuditPanel(diagnostics) { `; } +function renderSnapshotFreshnessBadge(snapshotFreshness = {}) { + const status = snapshotFreshness.status || "unknown"; + const statusClass = status === "fresh" || status === "historical" ? "ok" : decisionStatusClass(status); + const expected = snapshotFreshness.current_expected_latest_date || snapshotFreshness.expected_latest_date || "unknown"; + const stale = Array.isArray(snapshotFreshness.stale_assets) ? snapshotFreshness.stale_assets : []; + const missing = Array.isArray(snapshotFreshness.missing_assets) ? snapshotFreshness.missing_assets : []; + const detail = status === "historical" + ? `historical ${snapshotFreshness.historical_end_date || ""}` + : stale.length + ? `stale ${stale.slice(0, 3).join(",")}` + : missing.length + ? `missing ${missing.slice(0, 3).join(",")}` + : `expected ${expected}`; + return `${escapeHtml(status)}
${escapeHtml(detail)}`; +} + function renderRebalanceSnapshots(weights) { const snapshots = (Array.isArray(weights) ? weights : []) .filter((row) => row && (row.selected || row.target_weights || row.weights)) @@ -8281,7 +9854,7 @@ async function loadQuantRunHistory(force = false) {
시계열상태최근일공급자메모
시계열상태최근일신선도 기준공급자메모
${escapeHtml(row.series_id || "")}${escapeHtml(row.series_id || "")}
${escapeHtml(row.category || "")}
${escapeHtml(row.status || "unknown")} ${escapeHtml(row.latest_date || "사용 불가")}${escapeHtml(row.frequency || "-")} · ${escapeHtml(_fmtNumber(row.stale_after_days || 0))}일 ${escapeHtml(row.provider || "unknown")} ${escapeHtml([...(row.errors || []), ...(row.notes || [])].slice(0, 2).join("; "))}
- + ${items.map((item) => { const metrics = item.metrics || {}; @@ -8297,6 +9870,7 @@ async function loadQuantRunHistory(force = false) { +
CompareRunTemplateUniverseSharpeMDDContextLookaheadActions
CompareRunTemplateUniverseSharpeMDDContextDataLookaheadActions
${escapeHtml(fmtDecimal(metrics.sharpe, 2))} ${escapeHtml(fmtMetricRatio(metrics.max_drawdown))} ${escapeHtml(policy.profile || "-")}${configHash ? ` · ${escapeHtml(configHash)}` : ""}${renderSnapshotFreshnessBadge(item.snapshot_freshness || item.data_snapshot?.snapshot_freshness || {})} ${diagnostics.lookahead_safe ? "safe" : "check"}
@@ -8363,22 +9937,26 @@ async function runPortfolioOptimize() { const benchmark = normalizeTickerToken(els.portfolioBenchmark?.value || "SPY") || "SPY"; const covarianceMethod = els.portfolioCovarianceMethod?.value || "sample"; const shrinkageAlpha = numberInputValue(els.portfolioShrinkageAlpha, 0.1, { min: 0, max: 1 }); + const freshnessOptions = selectedQuantFreshnessOptions(); els.portfolioSurface.innerHTML = decisionEmpty(`${tickers.join(", ")} 포트폴리오 최적화를 실행 중입니다.`); try { + const payload = { + tickers, + method: els.portfolioMethod?.value || "equal_weight", + benchmark, + start_date: startDate, + end_date: endDate, + lookback_days: lookbackDays, + max_weight: maxWeight, + covariance_method: covarianceMethod, + shrinkage_alpha: shrinkageAlpha, + freshness_profile: freshnessOptions.freshnessProfile, + }; + if (freshnessOptions.requireFreshPrices) payload.require_fresh_prices = true; const res = await fetch(API.portfolioOptimize, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - tickers, - method: els.portfolioMethod?.value || "equal_weight", - benchmark, - start_date: startDate, - end_date: endDate, - lookback_days: lookbackDays, - max_weight: maxWeight, - covariance_method: covarianceMethod, - shrinkage_alpha: shrinkageAlpha, - }), + body: JSON.stringify(payload), }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`); @@ -8400,6 +9978,7 @@ async function runPortfolioOptimize() {
${escapeHtml(tickers.join(", "))} · ${escapeHtml(startDate || "조회 기간")} -> ${escapeHtml(endDate || "최근")} · ${escapeHtml(String(lookbackDays))}일 가격 기준
+ ${renderFreshnessAuditPanel(data)} ${renderPortfolioDecisionBrief({ entries, portfolioMetrics, @@ -9265,6 +10844,8 @@ const NEWS_CATEGORY_LABELS = { earnings: "실적", commodity: "원자재", crypto: "크립토", + company_news: "회사 뉴스", + topic_news: "주제 뉴스", market: "기타", }; @@ -9324,11 +10905,87 @@ function renderDashboardNews() { }).join(""); } +function newsSearchRequestFromControls() { + return { + ticker: normalizeTickerToken(els.homeNewsTicker?.value || ""), + topic: textInputValue(els.homeNewsTopic) || "", + }; +} + +function renderFocusedNews(items, meta = {}) { + if (!els.homeNewsFocusedList) return; + const clean = Array.isArray(items) ? items : []; + if (!clean.length) { + els.homeNewsFocusedList.innerHTML = '
입력한 티커/주제에 대한 뉴스가 없습니다. 검색어를 조금 넓혀서 다시 시도하세요.
'; + return; + } + const focused = clean.filter((item) => ["company_news", "topic_news"].includes(item.category || "")); + const seen = new Set(focused.map((item) => item.url || item.title || JSON.stringify(item))); + const display = [ + ...focused, + ...clean.filter((item) => !seen.has(item.url || item.title || JSON.stringify(item))), + ].slice(0, 10); + els.homeNewsFocusedList.innerHTML = ` +
+ ${escapeHtml(meta.ticker || meta.topic || "맞춤 뉴스")} + ${escapeHtml(meta.selection_policy || "focused query")} · ${escapeHtml(fmtDate(meta.generated_at) || "")} +
+ ${display.map((item) => { + const date = item.published_at ? fmtDate(item.published_at) : (item.collected_at ? fmtDate(item.collected_at) : ""); + const href = item.url ? `href="${escapeHtml(item.url)}" target="_blank" rel="noopener"` : ""; + const categoryLabel = NEWS_CATEGORY_LABELS[item.category || "market"] || item.category || "기타"; + const sourceTier = Number(item.source_tier); + const sourceClass = sourceTier === 0 ? "major" : (sourceTier >= 3 ? "low" : ""); + return ` +
+
+ ${escapeHtml(categoryLabel)} + ${escapeHtml(item.symbol || "")} + ${escapeHtml(item.source || "")} + ${escapeHtml(date)} +
+ ${escapeHtml(item.title || "Untitled")} + ${item.summary ? `

${escapeHtml(String(item.summary).slice(0, 180))}

` : ""} +
+ `; + }).join("")} + `; +} + +async function loadFocusedDashboardNews(force = false) { + if (!els.homeNewsFocusedList) return; + const request = newsSearchRequestFromControls(); + if (!request.ticker && !request.topic) { + if (force) { + els.homeNewsFocusedList.innerHTML = '
티커나 주제를 입력하면 관련 회사/주제 뉴스가 현재 주요 뉴스 위에 추가됩니다.
'; + } + return; + } + if (els.homeNewsSearchStatus) els.homeNewsSearchStatus.textContent = "맞춤 뉴스를 불러오는 중입니다."; + if (els.homeNewsSearchRun) els.homeNewsSearchRun.disabled = true; + els.homeNewsFocusedList.innerHTML = '
회사/주제 뉴스를 불러오는 중입니다.
'; + try { + const res = await fetch(API.dashboardNews({ limit: 18, ticker: request.ticker, topic: request.topic, query: request.topic })); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const items = Array.isArray(data.items) ? data.items : []; + state.focusedNewsItems = items; + renderFocusedNews(items, data); + if (els.homeNewsSearchStatus) els.homeNewsSearchStatus.textContent = `${_fmtNumber(items.length)}개 뉴스 · ${fmtDate(data.generated_at)}`; + } catch (err) { + state.focusedNewsItems = []; + els.homeNewsFocusedList.innerHTML = `
맞춤 뉴스 로드 실패: ${escapeHtml(err.message || err)}
`; + if (els.homeNewsSearchStatus) els.homeNewsSearchStatus.textContent = "맞춤 뉴스 로드 실패"; + } finally { + if (els.homeNewsSearchRun) els.homeNewsSearchRun.disabled = false; + } +} + async function loadDashboardNews(force = false) { if (!els.homeNewsList || (state.dashboardLoaded && !force)) return; els.homeNewsList.innerHTML = '
뉴스를 불러오는 중입니다.
'; try { - const res = await fetch(API.dashboardNews); + const res = await fetch(API.dashboardNews({ limit: 20 })); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const items = Array.isArray(data.items) ? data.items : []; @@ -9355,6 +11012,7 @@ function loadMarketDashboard(force = false) { loadDashboardMarket(force), loadDataHealth(force), loadDashboardNews(force), + loadCrossAssetAnalysis(force), ]).then(() => loadDashboardMarketOverview(true)); } @@ -10458,12 +12116,623 @@ function renderForecastRegimePerformance(items) { `; } +function quantamentalUi() { + return window.FinGPTQuantamentalUi || {}; +} + +function quantamentalRequestFromControls() { + const ticker = normalizeTickerToken(els.quantamentalTicker?.value || ""); + return { + ticker, + market: els.quantamentalMarket?.value || "US", + period: els.quantamentalPeriod?.value || "annual", + years: Number(els.quantamentalYears?.value || 5), + lookback: els.quantamentalLookback?.value || "252", + style: els.quantamentalStyle?.value || "balanced", + output_language: selectedOutputLanguage(), + }; +} + +function renderQuantamentalStarter() { + const ui = quantamentalUi(); + const starter = ui.starter ? ui.starter() : decisionEmpty("Quantamental module is loading."); + const q = UI_LANGUAGE_COPY[selectedOutputLanguage()]?.quantamental || UI_LANGUAGE_COPY.en.quantamental; + if (els.quantamentalCompanySurface) els.quantamentalCompanySurface.innerHTML = starter; + if (els.quantamentalSignalSurface) els.quantamentalSignalSurface.innerHTML = starter; + if (els.quantamentalScoreSurface) els.quantamentalScoreSurface.innerHTML = starter; + if (els.quantamentalFactorSurface) els.quantamentalFactorSurface.innerHTML = starter; + if (els.quantamentalMainSurface) els.quantamentalMainSurface.innerHTML = starter; + if (els.quantamentalDataQualitySurface) els.quantamentalDataQualitySurface.innerHTML = starter; + if (els.quantamentalStatus) els.quantamentalStatus.textContent = q.messages.status; + if (els.quantamentalCompareSurface) els.quantamentalCompareSurface.innerHTML = decisionEmpty(q.messages.compareEmpty); + if (els.quantamentalScreenSurface) els.quantamentalScreenSurface.innerHTML = decisionEmpty(q.messages.topEmpty); + if (els.quantamentalScreenStatus) els.quantamentalScreenStatus.textContent = q.messages.topStatus; + if (els.quantamentalScoreScreenSurface) els.quantamentalScoreScreenSurface.innerHTML = decisionEmpty(q.messages.scoreEmpty); + if (els.quantamentalScoreScreenStatus) els.quantamentalScoreScreenStatus.textContent = q.messages.scoreStatus; +} + +function setQuantamentalLoading(isLoading, message = "Quantamental analysis를 계산하는 중입니다.") { + state.quantamentalLoading = isLoading; + const q = UI_LANGUAGE_COPY[selectedOutputLanguage()]?.quantamental || UI_LANGUAGE_COPY.en.quantamental; + setButtonBusy( + els.quantamentalAnalyze, + isLoading, + selectedOutputLanguage() === "en" ? "Analyzing" : "분석 중", + q.buttons.analyze, + ); + const ui = quantamentalUi(); + const content = ui.loading ? ui.loading(message) : decisionEmpty(message); + if (!isLoading) return; + if (els.quantamentalCompanySurface) els.quantamentalCompanySurface.innerHTML = content; + if (els.quantamentalSignalSurface) els.quantamentalSignalSurface.innerHTML = content; + if (els.quantamentalScoreSurface) els.quantamentalScoreSurface.innerHTML = content; + if (els.quantamentalFactorSurface) els.quantamentalFactorSurface.innerHTML = content; + if (els.quantamentalMainSurface) els.quantamentalMainSurface.innerHTML = content; + if (els.quantamentalDataQualitySurface) els.quantamentalDataQualitySurface.innerHTML = content; +} + +function renderQuantamentalAnalysis(data) { + const ui = quantamentalUi(); + if (!ui.companyHeader) { + renderQuantamentalStarter(); + return; + } + if (els.quantamentalCompanySurface) els.quantamentalCompanySurface.innerHTML = ui.companyHeader(data); + if (els.quantamentalSignalSurface) els.quantamentalSignalSurface.innerHTML = ui.signalCard(data); + if (els.quantamentalScoreSurface) els.quantamentalScoreSurface.innerHTML = ui.scoreDashboard(data); + if (els.quantamentalFactorSurface) els.quantamentalFactorSurface.innerHTML = ui.factorGrid(data); + if (els.quantamentalMainSurface) els.quantamentalMainSurface.innerHTML = ui.mainPanel(data, state.quantamentalActiveTab || "overview"); + if (els.quantamentalDataQualitySurface) els.quantamentalDataQualitySurface.innerHTML = ui.dataQuality(data); + const quality = data?.data_quality || {}; + const freshness = quality.freshness || {}; + const usedData = data?.ai_report?.data_snapshot || data?.ai_report?.report?.used_data || {}; + updateGlobalQualitySummary({ + status: quality.quality_level || freshness.status || data?.status || "unknown", + asOf: usedData.data_basis_date || freshness.as_of || data?.generated_at || "", + updatedAt: usedData.ai_snapshot_at || data?.generated_at || "", + source: usedData.data_source || "quantamental engine", + observations: usedData.observation_count || quality.observation_count || data?.quant?.observation_count || "", + missing: (usedData.missing_data || (quality.missing_sections || []).length) + ? (usedData.missing_data || (quality.missing_sections || []).join(", ")) + : "없음", + aiSnapshotAt: usedData.ai_snapshot_at || data?.generated_at || "", + }); + if (els.quantamentalStatus) { + const quality = data?.data_quality?.quality_level || "unknown"; + const signal = data?.signal?.signal_label || "Insufficient Data"; + const isEnglish = selectedOutputLanguage() === "en"; + els.quantamentalStatus.textContent = isEnglish + ? `${data?.ticker || ""} · ${signal} · data quality ${quality}` + : `${data?.ticker || ""} · ${signal} · 데이터 품질 ${quality}`; + els.quantamentalStatus.className = `form-notice ${["ok", "success"].includes(data?.status) ? "success" : "info"}`; + } +} + +function renderQuantamentalScreen(data) { + const ui = quantamentalUi(); + if (els.quantamentalScreenSurface) { + els.quantamentalScreenSurface.innerHTML = ui.topSignals ? ui.topSignals(data) : `
${escapeHtml(JSON.stringify(data, null, 2))}
`; + } + if (els.quantamentalScreenStatus) { + const summary = data?.freshness_summary || {}; + const isEnglish = selectedOutputLanguage() === "en"; + els.quantamentalScreenStatus.textContent = isEnglish + ? `${data?.scored_count ?? 0}/${data?.requested_count ?? 0} scored · freshness ${summary.status || "unknown"} · ${data?.style || "balanced"}` + : `${data?.scored_count ?? 0}/${data?.requested_count ?? 0}개 산출 · 신선도 ${summary.status || "unknown"} · ${data?.style || "balanced"}`; + els.quantamentalScreenStatus.className = `form-notice ${data?.status === "ok" ? "success" : "info"}`; + } +} + +function quantamentalScoreScreenThreshold() { + const raw = Number(els.quantamentalScoreThreshold?.value || 70); + const threshold = Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : 70; + if (els.quantamentalScoreThreshold) els.quantamentalScoreThreshold.value = String(threshold); + return threshold; +} + +function quantamentalScoreScreenLimit() { + const raw = Number(els.quantamentalScoreScreenLimit?.value || 20); + return Number.isFinite(raw) ? Math.max(1, Math.min(50, raw)) : 20; +} + +function quantamentalScoreScreenMetric() { + const raw = String(els.quantamentalScoreMetric?.value || "composite"); + return ["composite", "value", "quality", "growth", "momentum", "low_volatility", "liquidity"].includes(raw) ? raw : "composite"; +} + +function quantamentalScoreMetricLabel(scoreKey) { + const q = UI_LANGUAGE_COPY[selectedOutputLanguage()]?.quantamental || UI_LANGUAGE_COPY.en.quantamental; + return q.scoreMetricLabels?.[scoreKey] || scoreKey || "composite"; +} + +function renderQuantamentalScoreScreen(data) { + const ui = quantamentalUi(); + if (els.quantamentalScoreScreenSurface) { + els.quantamentalScoreScreenSurface.innerHTML = ui.scoreScreen ? ui.scoreScreen(data) : `
${escapeHtml(JSON.stringify(data, null, 2))}
`; + } + if (els.quantamentalScoreScreenStatus) { + const summary = data?.freshness_summary || {}; + const isEnglish = selectedOutputLanguage() === "en"; + const scoreLabel = quantamentalScoreMetricLabel(data?.score_key || quantamentalScoreScreenMetric()); + els.quantamentalScoreScreenStatus.textContent = isEnglish + ? `${data?.returned_count ?? 0}/${data?.matched_count ?? 0} returned · ${scoreLabel} >= ${fmtDecimal(data?.min_score, 1)} · freshness ${summary.status || "unknown"}` + : `${data?.returned_count ?? 0}/${data?.matched_count ?? 0}개 반환 · ${scoreLabel} >= ${fmtDecimal(data?.min_score, 1)} · 신선도 ${summary.status || "unknown"}`; + els.quantamentalScoreScreenStatus.className = `form-notice ${data?.status === "ok" ? "success" : "info"}`; + } +} + +async function loadQuantamentalScreen(force = false) { + if (!els.quantamentalScreenSurface || state.quantamentalScreenLoading) return; + if (state.quantamentalScreenLoaded && !force) { + renderQuantamentalScreen(state.quantamentalScreen); + return; + } + const request = quantamentalRequestFromControls(); + state.quantamentalScreenLoading = true; + const q = UI_LANGUAGE_COPY[selectedOutputLanguage()]?.quantamental || UI_LANGUAGE_COPY.en.quantamental; + setButtonBusy( + els.quantamentalScreenRun, + true, + selectedOutputLanguage() === "en" ? "Screening" : "스크리닝", + q.buttons.refresh, + ); + if (els.quantamentalScreenSurface) { + els.quantamentalScreenSurface.innerHTML = quantamentalUi().loading ? quantamentalUi().loading(q.messages.screenLoading) : decisionEmpty(q.messages.screenLoading); + } + try { + const data = await quantamentalFetchJson(API.quantamentalTopSignals({ + market: request.market, + period: request.period, + years: request.years, + lookback: request.lookback, + style: request.style, + limit: 5, + refreshStale: true, + // Force reloads bypass the UI cache; stale-aware server refresh keeps Top 5 fast. + forceRefresh: false, + outputLanguage: request.output_language, + })); + state.quantamentalScreen = data; + state.quantamentalScreenLoaded = true; + renderQuantamentalScreen(data); + } catch (err) { + const message = err.message || String(err); + if (els.quantamentalScreenSurface) { + els.quantamentalScreenSurface.innerHTML = quantamentalUi().error ? quantamentalUi().error(message) : decisionEmpty(message); + } + if (els.quantamentalScreenStatus) { + els.quantamentalScreenStatus.textContent = message; + els.quantamentalScreenStatus.className = "form-notice error"; + } + } finally { + state.quantamentalScreenLoading = false; + setButtonBusy(els.quantamentalScreenRun, false); + if (els.quantamentalScreenRun) els.quantamentalScreenRun.textContent = q.buttons.refresh; + } +} + +async function runQuantamentalScoreScreen() { + if (!els.quantamentalScoreScreenSurface || state.quantamentalScoreScreenLoading) return; + const request = quantamentalRequestFromControls(); + const scoreKey = quantamentalScoreScreenMetric(); + const threshold = quantamentalScoreScreenThreshold(); + const limit = quantamentalScoreScreenLimit(); + const q = UI_LANGUAGE_COPY[selectedOutputLanguage()]?.quantamental || UI_LANGUAGE_COPY.en.quantamental; + state.quantamentalScoreScreenLoading = true; + setButtonBusy( + els.quantamentalScoreScreenRun, + true, + selectedOutputLanguage() === "en" ? "Screening" : "스크리닝", + q.buttons.screen, + ); + if (els.quantamentalScoreScreenSurface) { + els.quantamentalScoreScreenSurface.innerHTML = quantamentalUi().loading ? quantamentalUi().loading(q.messages.scoreScreenLoading) : decisionEmpty(q.messages.scoreScreenLoading); + } + try { + const data = await quantamentalFetchJson(API.quantamentalScoreScreen({ + market: request.market, + period: request.period, + years: request.years, + lookback: request.lookback, + style: request.style, + scoreKey, + minScore: threshold, + limit, + refreshStale: true, + forceRefresh: true, + outputLanguage: request.output_language, + })); + state.quantamentalScoreScreen = data; + renderQuantamentalScoreScreen(data); + } catch (err) { + const message = err.message || String(err); + if (els.quantamentalScoreScreenSurface) { + els.quantamentalScoreScreenSurface.innerHTML = quantamentalUi().error ? quantamentalUi().error(message) : decisionEmpty(message); + } + if (els.quantamentalScoreScreenStatus) { + els.quantamentalScoreScreenStatus.textContent = message; + els.quantamentalScoreScreenStatus.className = "form-notice error"; + } + } finally { + state.quantamentalScoreScreenLoading = false; + setButtonBusy(els.quantamentalScoreScreenRun, false); + if (els.quantamentalScoreScreenRun) els.quantamentalScoreScreenRun.textContent = q.buttons.screen; + } +} + +async function quantamentalFetchJson(url, options = {}) { + const res = await fetch(url, options); + const text = await res.text(); + let data = {}; + if (text) { + try { + data = JSON.parse(text); + } catch (_) { + data = { detail: text }; + } + } + if (!res.ok) { + const detail = data.detail?.message || data.detail || data.error || `HTTP ${res.status}`; + throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); + } + return data; +} + +async function runQuantamentalAnalysis() { + const request = quantamentalRequestFromControls(); + const q = UI_LANGUAGE_COPY[selectedOutputLanguage()]?.quantamental || UI_LANGUAGE_COPY.en.quantamental; + if (!request.ticker) { + if (els.quantamentalStatus) { + els.quantamentalStatus.textContent = q.messages.tickerRequired; + els.quantamentalStatus.className = "form-notice error"; + } + if (els.quantamentalCompanySurface) els.quantamentalCompanySurface.innerHTML = quantamentalUi().error ? quantamentalUi().error(q.messages.tickerRequired) : decisionEmpty(q.messages.tickerRequired); + return; + } + if (els.quantamentalTicker) els.quantamentalTicker.value = request.ticker; + setQuantamentalLoading(true, selectedOutputLanguage() === "en" ? "Quantamental analysis is calculating." : "Quantamental 분석을 계산하는 중입니다."); + try { + const data = await quantamentalFetchJson(API.quantamentalAnalysis(request.ticker, { + market: request.market, + period: request.period, + years: request.years, + lookback: request.lookback, + style: request.style, + includeAi: true, + useLlm: false, + outputLanguage: request.output_language, + })); + const previousSnapshotId = quantamentalSnapshotId(state.quantamentalAnalysis); + if (previousSnapshotId && previousSnapshotId !== data?.snapshot?.snapshot_id) { + state.quantamentalLastSnapshotId = previousSnapshotId; + } + state.quantamentalAnalysis = data; + state.quantamentalLoaded = true; + renderQuantamentalAnalysis(data); + } catch (err) { + const message = err.message || String(err); + const content = quantamentalUi().error ? quantamentalUi().error(message) : decisionEmpty(message); + if (els.quantamentalCompanySurface) els.quantamentalCompanySurface.innerHTML = content; + if (els.quantamentalSignalSurface) els.quantamentalSignalSurface.innerHTML = content; + if (els.quantamentalStatus) { + els.quantamentalStatus.textContent = message; + els.quantamentalStatus.className = "form-notice error"; + } + } finally { + setQuantamentalLoading(false); + } +} + +async function refreshQuantamentalAiReport() { + if (!state.quantamentalAnalysis) { + await runQuantamentalAnalysis(); + return; + } + const q = UI_LANGUAGE_COPY[selectedOutputLanguage()]?.quantamental || UI_LANGUAGE_COPY.en.quantamental; + setButtonBusy(els.quantamentalAiRefresh, true, "AI", q.buttons.aiReport); + try { + const aiOptions = quantamentalAiRequestOptions(); + const report = await quantamentalFetchJson(API.quantamentalAiReport, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + context: state.quantamentalAnalysis, + use_llm: aiOptions.use_llm, + model: aiOptions.model, + output_language: selectedOutputLanguage(), + }), + }); + state.quantamentalAnalysis = { ...state.quantamentalAnalysis, ai_report: report }; + state.quantamentalActiveTab = "ai"; + renderQuantamentalAnalysis(state.quantamentalAnalysis); + } catch (err) { + if (els.quantamentalMainSurface) els.quantamentalMainSurface.insertAdjacentHTML("afterbegin", `
AI report failed: ${escapeHtml(err.message || err)}
`); + } finally { + setButtonBusy(els.quantamentalAiRefresh, false); + if (els.quantamentalAiRefresh) els.quantamentalAiRefresh.textContent = q.buttons.aiReport; + } +} + +async function askQuantamentalQuestion() { + const questionEl = document.getElementById("quantamentalQuestion"); + const surface = document.getElementById("quantamentalQaSurface"); + const question = String(questionEl?.value || "").trim(); + const q = UI_LANGUAGE_COPY[selectedOutputLanguage()]?.quantamental || UI_LANGUAGE_COPY.en.quantamental; + if (!question) { + if (surface) surface.innerHTML = quantamentalUi().error ? quantamentalUi().error(q.messages.questionRequired) : decisionEmpty(q.messages.questionRequired); + return; + } + if (!state.quantamentalAnalysis) { + if (surface) surface.innerHTML = quantamentalUi().error ? quantamentalUi().error(q.messages.runFirst) : decisionEmpty(q.messages.runFirst); + return; + } + const button = document.getElementById("quantamentalAsk"); + setButtonBusy(button, true, selectedOutputLanguage() === "en" ? "Asking" : "질문 중", q.buttons.ask || "Ask"); + if (surface) surface.innerHTML = quantamentalUi().loading ? quantamentalUi().loading(q.messages.qaLoading) : decisionEmpty(q.messages.qaLoading); + try { + const aiOptions = quantamentalAiRequestOptions(); + const answer = await quantamentalFetchJson(API.quantamentalAiQa, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + question, + context: state.quantamentalAnalysis, + use_llm: aiOptions.use_llm, + model: aiOptions.model, + output_language: selectedOutputLanguage(), + }), + }); + if (surface) surface.innerHTML = quantamentalUi().qaAnswer ? quantamentalUi().qaAnswer(answer) : `
${escapeHtml(JSON.stringify(answer, null, 2))}
`; + } catch (err) { + if (surface) surface.innerHTML = quantamentalUi().error ? quantamentalUi().error(err.message || String(err)) : decisionEmpty(err.message || String(err)); + } finally { + setButtonBusy(button, false); + } +} + +function quantamentalCompareTickersFromControl() { + return String(els.quantamentalCompareTickers?.value || "") + .split(/[\s,]+/) + .map((item) => normalizeTickerToken(item)) + .filter(Boolean) + .filter((item, idx, arr) => arr.indexOf(item) === idx) + .slice(0, 20); +} + +function quantamentalPeerLimitFromControl() { + const value = Number(els.quantamentalPeerLimit?.value || 8); + return Number.isFinite(value) ? Math.max(2, Math.min(20, value)) : 8; +} + +async function loadQuantamentalCompareWatchlists() { + let loadedFromServer = false; + try { + const payload = await quantamentalFetchJson(API.quantamentalCompareWatchlists); + const items = Array.isArray(payload.items) ? payload.items : []; + state.quantamentalCompareWatchlists = items + .filter((item) => item && item.name && Array.isArray(item.tickers)) + .slice(0, 48); + loadedFromServer = true; + } catch (_) { + loadedFromServer = false; + } + if (loadedFromServer) { + renderQuantamentalCompareWatchlists(); + return; + } + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE.quantamentalCompareWatchlists) || "[]"); + state.quantamentalCompareWatchlists = Array.isArray(parsed) ? parsed.filter((item) => item && item.name && Array.isArray(item.tickers)).slice(0, 20) : []; + } catch (_) { + state.quantamentalCompareWatchlists = []; + } + renderQuantamentalCompareWatchlists(); +} + +function persistQuantamentalCompareWatchlists() { + localStorage.setItem(STORAGE.quantamentalCompareWatchlists, JSON.stringify(state.quantamentalCompareWatchlists || [])); + renderQuantamentalCompareWatchlists(); +} + +function renderQuantamentalCompareWatchlists() { + if (!els.quantamentalWatchlistSelect) return; + const items = Array.isArray(state.quantamentalCompareWatchlists) ? state.quantamentalCompareWatchlists : []; + const q = UI_LANGUAGE_COPY[selectedOutputLanguage()]?.quantamental || UI_LANGUAGE_COPY.en.quantamental; + els.quantamentalWatchlistSelect.innerHTML = items.length + ? items.map((item, idx) => ``).join("") + : ``; +} + +async function saveQuantamentalCompareWatchlist() { + const tickers = quantamentalCompareTickersFromControl(); + if (tickers.length < 2) { + if (els.quantamentalCompareSurface) els.quantamentalCompareSurface.innerHTML = quantamentalUi().error ? quantamentalUi().error("At least two tickers are required to save a comparison set.") : decisionEmpty("At least two tickers are required to save a comparison set."); + return; + } + const name = String(els.quantamentalWatchlistName?.value || tickers.slice(0, 4).join(" ")).trim().slice(0, 40) || "Quantamental Set"; + const request = quantamentalRequestFromControls(); + const payload = { + name, + tickers, + market: request.market, + style: request.style, + expand_peer_universe: !!els.quantamentalExpandPeers?.checked, + peer_limit: quantamentalPeerLimitFromControl(), + }; + try { + const saved = await quantamentalFetchJson(API.quantamentalCompareWatchlists, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const item = saved.item || payload; + const next = (state.quantamentalCompareWatchlists || []).filter((entry) => entry.id !== item.id && entry.name !== item.name); + next.unshift(item); + state.quantamentalCompareWatchlists = next.slice(0, 48); + renderQuantamentalCompareWatchlists(); + } catch (_) { + const next = (state.quantamentalCompareWatchlists || []).filter((item) => item.name !== name); + next.unshift({ ...payload, saved_at: new Date().toISOString(), storage: "localStorage_fallback" }); + state.quantamentalCompareWatchlists = next.slice(0, 20); + persistQuantamentalCompareWatchlists(); + } +} + +function loadQuantamentalCompareWatchlist() { + const idx = Number(els.quantamentalWatchlistSelect?.value || 0); + const item = (state.quantamentalCompareWatchlists || [])[idx]; + if (!item || !Array.isArray(item.tickers)) return; + if (els.quantamentalCompareTickers) els.quantamentalCompareTickers.value = item.tickers.join(" "); + if (els.quantamentalWatchlistName) els.quantamentalWatchlistName.value = item.name || ""; + if (els.quantamentalMarket && item.market) els.quantamentalMarket.value = item.market; + if (els.quantamentalStyle && item.style) els.quantamentalStyle.value = item.style; + if (els.quantamentalExpandPeers) els.quantamentalExpandPeers.checked = !!item.expand_peer_universe; + if (els.quantamentalPeerLimit && item.peer_limit) els.quantamentalPeerLimit.value = String(item.peer_limit); +} + +function quantamentalComparisonCsv(data) { + const rows = Array.isArray(data?.rows) ? data.rows : []; + const headers = ["ticker", "signal_label", "final_score", "peer_strength", "peer_rank", "peer_group", "quality_level", "sector", "industry"]; + const lines = [headers.join(",")]; + rows.forEach((row) => { + lines.push([ + row.ticker, + row.signal_label, + row.final_score, + row.peer_relative?.relative_strength_score, + row.peer_relative?.rank, + row.peer_relative?.group_key, + row.quality_level, + row.sector, + row.industry, + ].map(csvCell).join(",")); + }); + return lines.join("\n"); +} + +function csvCell(value) { + const text = String(value ?? ""); + return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function exportQuantamentalCompareCsv() { + if (!state.quantamentalComparison) { + if (els.quantamentalCompareSurface) els.quantamentalCompareSurface.innerHTML = quantamentalUi().error ? quantamentalUi().error("Run comparison before exporting CSV.") : decisionEmpty("Run comparison before exporting CSV."); + return; + } + const ticker = (state.quantamentalComparison.rows || [])[0]?.ticker || "comparison"; + downloadBlob(`quantamental_compare_${ticker}_${Date.now()}.csv`, quantamentalComparisonCsv(state.quantamentalComparison), "text/csv"); +} + +function quantamentalSnapshotId(data = state.quantamentalAnalysis) { + return data?.snapshot?.snapshot_id || ""; +} + +async function exportQuantamentalSnapshot(format = "json") { + const snapshotId = quantamentalSnapshotId(); + if (!snapshotId) { + if (els.quantamentalMainSurface) els.quantamentalMainSurface.insertAdjacentHTML("afterbegin", `
Snapshot is not available yet.
`); + return; + } + const res = await fetch(API.quantamentalSnapshotExport(snapshotId, format)); + const text = await res.text(); + if (!res.ok) throw new Error(text || `HTTP ${res.status}`); + downloadBlob(`quantamental_${snapshotId}.${format}`, text, format === "csv" ? "text/csv" : "application/json"); +} + +async function diffQuantamentalSnapshot() { + const snapshotId = quantamentalSnapshotId(); + const previous = state.quantamentalLastSnapshotId; + if (!snapshotId || !previous || previous === snapshotId) { + if (els.quantamentalMainSurface) els.quantamentalMainSurface.insertAdjacentHTML("afterbegin", `
Run another analysis snapshot before diffing.
`); + return; + } + const diff = await quantamentalFetchJson(API.quantamentalSnapshotDiff(previous, snapshotId)); + if (els.quantamentalMainSurface) { + els.quantamentalMainSurface.insertAdjacentHTML("afterbegin", quantamentalUi().snapshotDiff ? quantamentalUi().snapshotDiff(diff) : `
${escapeHtml(JSON.stringify(diff, null, 2))}
`); + } +} + +async function previewQuantamentalSnapshotRetention() { + const ticker = state.quantamentalAnalysis?.ticker || ""; + const preview = await quantamentalFetchJson(API.quantamentalSnapshotRetention({ ticker, keepLast: 5, dryRun: true }), { method: "POST" }); + if (els.quantamentalMainSurface) { + els.quantamentalMainSurface.insertAdjacentHTML("afterbegin", quantamentalUi().snapshotRetention ? quantamentalUi().snapshotRetention(preview) : `
${escapeHtml(JSON.stringify(preview, null, 2))}
`); + } +} + +async function runQuantamentalCompare() { + const tickers = quantamentalCompareTickersFromControl(); + const request = quantamentalRequestFromControls(); + if (tickers.length < 2) { + if (els.quantamentalCompareSurface) { + els.quantamentalCompareSurface.innerHTML = quantamentalUi().error ? quantamentalUi().error("At least two tickers are required.") : decisionEmpty("At least two tickers are required."); + } + return; + } + const q = UI_LANGUAGE_COPY[selectedOutputLanguage()]?.quantamental || UI_LANGUAGE_COPY.en.quantamental; + setButtonBusy( + els.quantamentalCompareRun, + true, + selectedOutputLanguage() === "en" ? "Comparing" : "비교 중", + q.buttons.compare, + ); + if (els.quantamentalCompareSurface) { + els.quantamentalCompareSurface.innerHTML = quantamentalUi().loading ? quantamentalUi().loading(q.messages.compareLoading) : decisionEmpty(q.messages.compareLoading); + } + try { + const data = await quantamentalFetchJson(API.quantamentalCompare, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tickers, + market: request.market, + period: request.period, + years: request.years, + lookback: request.lookback, + style: request.style, + include_ai: false, + use_llm: false, + expand_peer_universe: !!els.quantamentalExpandPeers?.checked, + peer_limit: quantamentalPeerLimitFromControl(), + output_language: request.output_language, + }), + }); + state.quantamentalComparison = data; + if (els.quantamentalCompareSurface) { + els.quantamentalCompareSurface.innerHTML = quantamentalUi().comparisonTable ? quantamentalUi().comparisonTable(data) : `
${escapeHtml(JSON.stringify(data, null, 2))}
`; + } + } catch (err) { + if (els.quantamentalCompareSurface) { + els.quantamentalCompareSurface.innerHTML = quantamentalUi().error ? quantamentalUi().error(err.message || String(err)) : decisionEmpty(err.message || String(err)); + } + } finally { + setButtonBusy(els.quantamentalCompareRun, false); + if (els.quantamentalCompareRun) els.quantamentalCompareRun.textContent = q.buttons.compare; + } +} + +function loadQuantamental(force = false) { + if (!els.quantamentalMainSurface) return; + if (!state.quantamentalLoaded || force) { + renderQuantamentalStarter(); + } else { + renderQuantamentalAnalysis(state.quantamentalAnalysis); + } + loadQuantamentalScreen(force); +} + function loadActiveDashboardResources(force = false) { + loadDashboardDecisionCards(force); if (state.activeDashboardTab === "quant") { loadQuantRunHistory(force); loadQuantStrategies(force); return; } + if (state.activeDashboardTab === "quantamental") { + loadQuantamental(force); + return; + } if (state.activeDashboardTab === "forecast") { loadForecastLab(force); return; @@ -11010,7 +13279,7 @@ async function runStreamAnalysis(url, payload, renderRequest) { throw new Error(streamError); } if (!finalData) { - throw new Error("스트림이 결과 이벤트 없이 종료되었습니다."); + throw new Error(formMessage("streamNoResult")); } if (finalData.mode === "multi_ticker") { @@ -11034,22 +13303,22 @@ async function runAnalysis(e) { const requiresTicker = payload.compare || payload.mode_hint === "ticker"; if (requiresTicker && !payload.ticker) { els.ticker.focus(); - setFormNotice("종목 모드는 ticker가 필요합니다. ticker 없이 질문하려면 자동 또는 주제 모드를 선택하세요.", "warning"); + setFormNotice(formMessage("tickerRequired"), "warning"); return; } if (!payload.question) { els.question.focus(); - setFormNotice("질문을 입력해야 분석을 실행할 수 있습니다.", "warning"); + setFormNotice(formMessage("questionRequired"), "warning"); return; } if (payload.sources.length === 0) { - setFormNotice("최소 한 개의 소스를 선택해야 합니다.", "warning"); + setFormNotice(formMessage("sourceRequired"), "warning"); return; } if (payload.compare) { if (payload.tickers.length < 2) { - setFormNotice("Compare mode는 2개 이상의 ticker가 필요합니다. 쉼표 또는 공백으로 구분하세요.", "warning"); + setFormNotice(formMessage("compareRequired"), "warning"); els.ticker.focus(); return; } @@ -11106,6 +13375,7 @@ async function runCompare(payload) { lookback_days: payload.lookback_days, top_k: payload.top_k, model: payload.model, + output_language: payload.output_language, concurrency: 2, }; const res = await fetch(API.compare, { @@ -13358,6 +15628,27 @@ function bindInputs() { }); }); if (els.tickerSearchOpen) els.tickerSearchOpen.addEventListener("click", () => openSymbolPicker("research")); + if (els.crossAssetSymbolOpen) els.crossAssetSymbolOpen.addEventListener("click", () => openSymbolPicker("crossAssetSymbols")); + if (els.crossAssetRun) els.crossAssetRun.addEventListener("click", () => loadCrossAssetAnalysis(true)); + [els.crossAssetSymbols, els.crossAssetTopic].forEach((input) => { + if (!input) return; + input.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + loadCrossAssetAnalysis(true); + }); + }); + if (els.crossAssetHorizon) els.crossAssetHorizon.addEventListener("change", () => loadCrossAssetAnalysis(true)); + if (els.homeNewsTickerOpen) els.homeNewsTickerOpen.addEventListener("click", () => openSymbolPicker("homeNewsTicker")); + if (els.homeNewsSearchRun) els.homeNewsSearchRun.addEventListener("click", () => loadFocusedDashboardNews(true)); + [els.homeNewsTicker, els.homeNewsTopic].forEach((input) => { + if (!input) return; + input.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + loadFocusedDashboardNews(true); + }); + }); if (els.compareMode) { els.compareMode.addEventListener("change", () => { updateCompareModeUI(); @@ -13405,6 +15696,9 @@ function bindInputs() { if (els.quantLabTab) { els.quantLabTab.addEventListener("click", () => setDashboardTab("quant", { updateUrl: true })); } + if (els.quantamentalTab) { + els.quantamentalTab.addEventListener("click", () => setDashboardTab("quantamental", { updateUrl: true })); + } if (els.mlForecastTab) { els.mlForecastTab.addEventListener("click", () => setDashboardTab("forecast", { updateUrl: true })); } @@ -13431,12 +15725,30 @@ function bindInputs() { setDashboardPanelView(nextView); }); } + if (els.dashboardRangeSelect) { + els.dashboardRangeSelect.addEventListener("change", () => { + setGlobalRange(els.dashboardRangeSelect.value, { persist: true, updateUrl: true, reload: true }); + }); + } + [els.dashboardRangeStart, els.dashboardRangeEnd].forEach((input) => { + if (!input) return; + input.addEventListener("change", () => { + setGlobalRange("custom", { + startDate: els.dashboardRangeStart?.value || "", + endDate: els.dashboardRangeEnd?.value || "", + persist: true, + updateUrl: true, + reload: true, + }); + }); + }); window.addEventListener("hashchange", () => { const requestedTab = dashboardTabFromLocation(); if (requestedTab) setDashboardTab(requestedTab); }); if (els.homeNewsRefresh) els.homeNewsRefresh.addEventListener("click", () => { loadMarketDashboard(true); + loadFocusedDashboardNews(true); }); document.addEventListener("click", (event) => { const rawTarget = event.target; @@ -13613,6 +15925,79 @@ function bindInputs() { } }); } + if (els.quantamentalAnalyze) els.quantamentalAnalyze.addEventListener("click", runQuantamentalAnalysis); + if (els.quantamentalTicker) { + els.quantamentalTicker.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + runQuantamentalAnalysis(); + } + }); + els.quantamentalTicker.addEventListener("input", () => { + els.quantamentalTicker.value = String(els.quantamentalTicker.value || "").toUpperCase(); + }); + } + if (els.quantamentalTickerOpen) els.quantamentalTickerOpen.addEventListener("click", () => openSymbolPicker("quantamentalTicker")); + if (els.quantamentalAiRefresh) els.quantamentalAiRefresh.addEventListener("click", refreshQuantamentalAiReport); + if (els.quantamentalAiModel) els.quantamentalAiModel.addEventListener("change", updateQuantamentalAiModelStatus); + if (els.quantamentalCompareRun) els.quantamentalCompareRun.addEventListener("click", runQuantamentalCompare); + if (els.quantamentalScreenRun) els.quantamentalScreenRun.addEventListener("click", () => loadQuantamentalScreen(true)); + if (els.quantamentalScoreScreenRun) els.quantamentalScoreScreenRun.addEventListener("click", runQuantamentalScoreScreen); + if (els.quantamentalScoreThreshold) { + els.quantamentalScoreThreshold.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + runQuantamentalScoreScreen(); + } + }); + els.quantamentalScoreThreshold.addEventListener("change", quantamentalScoreScreenThreshold); + } + if (els.quantamentalWatchlistSave) els.quantamentalWatchlistSave.addEventListener("click", () => saveQuantamentalCompareWatchlist().catch((err) => { + if (els.quantamentalCompareSurface) els.quantamentalCompareSurface.innerHTML = quantamentalUi().error ? quantamentalUi().error(err.message || String(err)) : decisionEmpty(err.message || String(err)); + })); + if (els.quantamentalWatchlistLoad) els.quantamentalWatchlistLoad.addEventListener("click", loadQuantamentalCompareWatchlist); + if (els.quantamentalCompareCsv) els.quantamentalCompareCsv.addEventListener("click", exportQuantamentalCompareCsv); + if (els.quantamentalCompareTickers) { + els.quantamentalCompareTickers.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + runQuantamentalCompare(); + } + }); + } + [els.quantamentalMarket, els.quantamentalPeriod, els.quantamentalYears, els.quantamentalLookback, els.quantamentalStyle, els.quantamentalScoreMetric, els.quantamentalScoreScreenLimit].forEach((control) => { + if (!control) return; + control.addEventListener("change", () => { + state.quantamentalScreenLoaded = false; + state.quantamentalScoreScreen = null; + }); + }); + if (els.quantamentalMainSurface) { + els.quantamentalMainSurface.addEventListener("click", (event) => { + const rawTarget = event.target; + const tabTarget = rawTarget?.closest ? rawTarget.closest("[data-quantamental-tab]") : null; + if (tabTarget?.dataset?.quantamentalTab) { + event.preventDefault(); + state.quantamentalActiveTab = tabTarget.dataset.quantamentalTab; + if (state.quantamentalAnalysis) renderQuantamentalAnalysis(state.quantamentalAnalysis); + return; + } + const askTarget = rawTarget?.closest ? rawTarget.closest("#quantamentalAsk") : null; + if (askTarget) { + event.preventDefault(); + askQuantamentalQuestion(); + } + const actionTarget = rawTarget?.closest ? rawTarget.closest("[data-quantamental-action]") : null; + if (actionTarget?.dataset?.quantamentalAction) { + event.preventDefault(); + const action = actionTarget.dataset.quantamentalAction; + if (action === "export-snapshot-json") exportQuantamentalSnapshot("json").catch((err) => alert(`Snapshot export failed: ${err.message || err}`)); + if (action === "export-snapshot-csv") exportQuantamentalSnapshot("csv").catch((err) => alert(`Snapshot export failed: ${err.message || err}`)); + if (action === "diff-snapshot") diffQuantamentalSnapshot().catch((err) => alert(`Snapshot diff failed: ${err.message || err}`)); + if (action === "retention-preview") previewQuantamentalSnapshotRetention().catch((err) => alert(`Retention preview failed: ${err.message || err}`)); + } + }); + } if (els.backtestTicker) els.backtestTicker.addEventListener("input", () => { state.lastUniverseResolution = null; renderBacktestUniverseChips(); @@ -13842,6 +16227,12 @@ function bindInputs() { else closeQualityPanel(); }); } + if (els.globalQualitySummary) { + els.globalQualitySummary.addEventListener("click", () => { + if (els.qualityPanel.classList.contains("hidden")) openQualityPanel(); + else closeQualityPanel(); + }); + } if (els.qualityClose) els.qualityClose.addEventListener("click", closeQualityPanel); if (els.qualityRefresh) els.qualityRefresh.addEventListener("click", loadQualityDashboard); document.addEventListener("keydown", (e) => { @@ -13860,21 +16251,26 @@ function bindInputs() { // ---------- Init ---------- (async function init() { bindThemeToggle(); + bindLanguageToggle(); normalizeStaticLabels(); - await loadConfig(); bindTabs(); bindDownloads(); bindInputs(); + await loadConfig(); bindCommandPanelToggle(); bindTvChartControls(); initChartTooltips(); restoreForm(); + syncDashboardRangeControls(); + applyGlobalRangeToControls(); + renderGlobalQualitySummary(); syncAiPortfolioUniverseMode(); renderBacktestUniverseChips(); renderSymbolTargetChips("portfolio"); renderSymbolTargetChips("aiPortfolioCustomUniverse"); populateBacktestStrategyRegistry(); renderHistory(); + loadQuantamentalCompareWatchlists().catch(() => {}); renderWatchlist({ force: true }); loadActiveDashboardResources(false); if (els.watchlistAddBtn) { diff --git a/app/web/index.html b/app/web/index.html index d2ed8008..9473c94d 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -4,7 +4,7 @@ FinGPT Local Research Assistant - + @@ -24,7 +24,20 @@ + +
+ + +
api · 확인 중 @@ -74,6 +87,21 @@

종목별 현황 <
+
+
+ 현재 분석 신뢰도: 확인 불가 + 상단 품질 배지와 같은 기준으로 표시합니다. +
+
+ 데이터 소스확인 불가 + 분석 기간1Y + 관측치확인 불가 + 결측치확인 불가 + 캐시확인 불가 + AI 분석 기준확인 불가 + 기간 적용 방식날짜 지원 화면은 직접 반영, lookback 기반 화면은 지원 버킷으로 변환 +
+

데이터 마트 품질

@@ -278,6 +306,9 @@

시장 대시보드

+ @@ -291,11 +322,38 @@

시장 대시보드

Runtime Local only Execution Advisory
+
+ + + + 날짜 지원 화면은 직접 반영하고, lookback 기반 화면은 지원 기간 버킷으로 변환합니다. +
@@ -310,8 +368,34 @@

시장 테이프

교차자산 신호

- rules, not predictions + user-controlled regime analysis
+
+ + + + + +
+
+
+
자산과 주제를 입력하면 교차자산 상태와 향후 동향 해석이 여기에 표시됩니다.
+
+
기본 교차자산 스냅샷
시장 신호를 불러오는 중입니다.
@@ -576,7 +660,7 @@

Macro Explorer

Data Quality

missing, stale, provider errors
Loading data quality.
-
+

자산 상세

가격 · 수익률 · 리스크 @@ -592,11 +676,17 @@

자산 상세

- 현재 화면의 실제 전략 코드는 Python 코드가 아니며 JSON 전략 정의를 기준으로 재현성을 보장합니다. Python 전략 코드는 서버 측 샌드박스, 허용 API, 산출물 추적, 보안 검토가 붙은 별도 어댑터로 도입해야 하며, 지금 화면에서는 실행 가능한 Python을 직접 저장하지 않습니다. + 프롬프트 결과가 Python 코드가 아니면 실행 코드로 취급하지 않고 JSON 전략 정의를 기준으로 재현성을 보장합니다. Python 전략 코드는 서버 측 샌드박스, 허용 API, 산출물 추적, 보안 검토가 붙은 별도 어댑터로 도입해야 하며, 지금 화면에서는 실행 가능한 Python을 직접 저장하지 않습니다.
-
+

Feature Lab

feature shift=1 기본
feature group, 결측률, timestamp alignment를 계산합니다.
@@ -943,7 +1033,7 @@

ML Forecast

Forecast Result

수익률, 확률, 신뢰도
Train / Forecast를 실행하면 OOS 검증 기반 예측 결과가 표시됩니다.
-
+

Signal Generator

advisory-only
예측을 threshold, confidence, volatility filter로 advisory signal로 변환합니다.
@@ -1158,11 +1248,130 @@

Create Portfolio

정책 생성, 추천 생성, 리밸런싱 점검, 사용자 조치가 이력으로 기록됩니다.
+ +
+
+

Quantamental

+ 펀더멘털 + 가격 + 리스크 + AI 해석 +
+
+ 데이터 yfinance + DART + 시장 US/KR/Global 지원 + 신선도 stale 데이터 차단 + AI 정량 결과만 해석 +
+
+ + + + + + + +
+
Quantamental 분석은 리서치 전용이며 투자 자문이 아닙니다.
+
분석을 실행하면 회사 개요와 데이터 커버리지가 표시됩니다.
+
+ +
+

Quantamental 시그널

결정론적 점수 요약
+
분석의 deterministic 시그널이 여기에 표시됩니다.
+
+ +
+

종합 점수 브레이크다운

펀더멘털과 가격 요인의 결합
+
분석 결과가 여기에 표시됩니다.
+
+ +
+
+

Signal Screener Top 5

사용 가능한 신선 데이터 기준
+ +
+
Top 5 스크리너는 신선한 데이터만 후보로 올립니다.
+
Top 5 후보를 계산하면 여기에 표시됩니다.
+
+ +
+
+

점수 기준 스크리너

요인별 최소 점수 필터
+ +
+
+ + + +
+
점수 기준을 선택한 뒤 Quantamental 스크리너를 실행하세요.
+
점수 기준 후보가 여기에 표시됩니다.
+
+ +
+

요인별 점수

가치, 퀄리티, 성장, 모멘텀, 저변동성, 유동성
+
요인별 점수가 여기에 표시됩니다.
+
+ +
+
+

분석 리포트

+ +
+
+ + 기본값은 deterministic 해석입니다. Qwen/Gemma는 실행 시 가용성을 확인합니다. +
+
가격, 재무, 리스크, 피어 비교, AI 해석, Q&A 결과가 여기에 표시됩니다.
+
+ +
+

데이터 품질

누락 항목과 신선도 상태
+
데이터 품질 점검 결과가 여기에 표시됩니다.
+
+ +
+

종목 비교

여러 티커의 점수와 피어 상대값
+
+ + +
+
+ + + + + + + +
+
두 개 이상 티커를 입력하면 비교 결과가 여기에 표시됩니다.
+
+

주요 뉴스

+
+ + + + +
+
+
+
티커나 주제를 입력하면 관련 회사/주제 뉴스가 현재 주요 뉴스 위에 추가됩니다.
+
뉴스를 불러오는 중입니다.
@@ -1382,6 +1591,7 @@

심볼 찾기

+
@@ -1402,6 +1612,7 @@

심볼 찾기

- + + diff --git a/app/web/modules/quantamental-ui.js b/app/web/modules/quantamental-ui.js new file mode 100644 index 00000000..d5743542 --- /dev/null +++ b/app/web/modules/quantamental-ui.js @@ -0,0 +1,1320 @@ +(function initQuantamentalUi(global) { + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function fmt(value, digits = 2) { + const num = Number(value); + if (!Number.isFinite(num)) return "-"; + return num.toFixed(digits).replace(/\.?0+$/, ""); + } + + function fmtPct(value) { + const num = Number(value); + return Number.isFinite(num) ? `${fmt(num * 100, 1)}%` : "-"; + } + + function statusClass(value) { + const key = String(value || "").toLowerCase(); + if (["ok", "success", "good", "usable", "fresh", "high", "low risk"].includes(key)) return "ok"; + if (["failed", "fail", "error", "poor", "low", "high risk", "missing", "blocked"].includes(key)) return "fail"; + if (["partial", "limited", "medium", "stale", "unknown", "elevated risk", "medium risk"].includes(key)) return "warn"; + return "neutral"; + } + + const I18N = { + en: { + starter: "Run Analyze to load deterministic Quantamental Engine results.", + loading: "Quantamental analysis is loading.", + companyLimited: "company data limited", + price: "Price", + marketCap: "Market Cap", + quality: "Quality", + freshness: "Freshness", + status: "Status", + notAdvice: "Research classification only. Not investment advice.", + deterministicUnavailable: "Deterministic signal is unavailable.", + composite: "Composite", + fundamental: "Fundamental", + quant: "Quant", + risk: "Risk", + noTop: "No scored signal candidates were returned.", + warnings: "Warnings", + screened: "Screened", + symbols: "symbols", + rank: "Rank", + ticker: "Ticker", + company: "Company", + signal: "Signal", + fund: "Fund", + fresh: "Fresh", + integrity: "Integrity", + usable: "usable", + blocked: "blocked", + staleVisible: "Stale sections remain visible", + qaQuestionDefault: "Why is this signal classified this way?", + qaAsk: "Ask", + qaEmpty: "Ask a question about the deterministic result.", + latestPrice: "Latest Price", + priceAsOf: "Price As Of", + oneYearReturn: "1Y Return", + vol60d: "60D Vol", + maxDrawdown: "Max Drawdown", + latestFiling: "Latest Filing", + revenue: "Revenue", + opMargin: "Op Margin", + stale: "stale", + missing: "missing", + none: "none", + chartAxisNote: "X-axis is observation date. Y-axis units are shown per chart: price, percent return, annualized volatility, drawdown, volume, and statement amounts. Missing values are skipped rather than filled.", + coverage: "Coverage", + fundamentalMissing: "fundamental missing", + quantMissing: "quant missing", + freshnessScore: "freshness score", + missingFactors: "missing factors", + missingMetricsRemain: "Missing metrics remain explicit", + missingMetrics: "Missing metrics", + fundamentalMetrics: "Fundamental Metrics", + quantMetrics: "Quant Metrics", + valuation: "Valuation", + riskUnavailable: "Risk unavailable.", + peerRelative: "Peer Relative", + scope: "Scope", + peers: "Peers", + strength: "Strength", + secEvidence: "SEC Evidence", + filings: "Filings", + facts: "Facts", + latest: "Latest", + riskFlags: "Risk Flags", + qualityFlags: "Quality Flags", + auditSnapshot: "Audit Snapshot", + snapshot: "Snapshot", + storage: "Storage", + created: "Created", + exportJson: "export JSON", + exportCsv: "export CSV", + diffLatest: "diff latest", + retentionPreview: "retention preview", + aiReport: "AI Report", + usedData: "Used Data", + dataBasisDate: "Basis Date", + analysisPeriod: "Analysis Period", + dataSource: "Data Source", + observations: "Observations", + missingData: "Missing Data", + model: "Model", + aiSnapshot: "AI Snapshot", + cacheState: "Cache", + keyChanges: "Key Changes", + interpretation: "Interpretation", + scenarios: "Scenarios", + userActions: "User Actions", + unavailable: "Unavailable", + provider: "Provider", + signalPreserved: "signal preserved", + aiUnavailable: "AI report unavailable. Deterministic engine results remain visible.", + bullCase: "Bull Case", + bearCase: "Bear Case", + qaTitle: "Q&A", + score: "Score", + level: "Level", + warningsCount: "Warnings", + freshScore: "Fresh Score", + signalUsable: "Signal Usable", + yes: "yes", + no: "no", + strictGate: "Strict gate", + strictPolicyDefault: "core data must be fresh and complete", + blocking: "blocking", + optional: "optional", + section: "Section", + asOf: "As Of", + age: "Age", + basis: "Basis", + action: "Action", + refreshable: "refreshable", + reported: "reported", + errors: "Errors", + freshnessWarnings: "Freshness Warnings", + noValues: "No values available.", + metric: "Metric", + value: "Value", + noFilingExcerpts: "No filing excerpts available.", + noConceptProvenance: "No SEC concept provenance available.", + form: "Form", + filed: "Filed", + excerpt: "Excerpt", + source: "Source", + field: "Field", + concept: "Concept", + chartUnavailable: "Chart unavailable.", + xDate: "date", + yAxis: "Y", + xAxis: "X", + noComparisonRows: "No comparison rows returned.", + peerUniverse: "Peer universe", + added: "added", + tickers: "tickers", + peerGroups: "peer groups", + peerStrength: "Peer Strength", + snapshotDiff: "Snapshot diff", + changedFields: "changed field(s)", + path: "Path", + before: "Before", + after: "After", + noDiff: "No tracked snapshot fields changed.", + retentionPreviewLabel: "Retention preview", + keepLast: "keep last", + prune: "prune", + snapshots: "snapshot(s)", + noSnapshotsPruned: "No snapshots would be pruned.", + noEvidenceMetrics: "No evidence metrics returned.", + caveats: "Caveats", + noAnswer: "No answer.", + factorLabels: { + value: "Value", + quality: "Quality", + growth: "Growth", + momentum: "Momentum", + lowVolatility: "Low Volatility", + liquidity: "Liquidity", + }, + chart: { + priceTitle: "Price + SMA", + priceY: "price", + close: "Close", + sma50: "SMA 50", + sma200: "SMA 200", + priceNote: "Trend view: close above both moving averages usually supports trend strength; breaks below them flag weakening momentum.", + cumulativeTitle: "Cumulative Return", + cumulativeY: "total return", + cumulativeLegend: "Cumulative return", + cumulativeNote: "Shows compounded performance over the selected lookback from the first visible bar.", + volatilityTitle: "Rolling Volatility", + volatilityY: "annualized vol", + volatilityLegend: "20D realized volatility", + volatilityNote: "Higher values mean the price path has become more unstable over the recent 20-day window.", + drawdownTitle: "Drawdown", + drawdownY: "drawdown", + drawdownLegend: "Drawdown", + drawdownNote: "Measures the decline from the latest running peak; deeper negative values indicate larger capital impairment.", + volumeTitle: "Volume", + volumeY: "shares", + volumeLegend: "Volume", + volumeNote: "Volume gives liquidity context for whether signals are practical at realistic size.", + revenueTitle: "Revenue / Income", + revenueY: "statement amount", + revenueLegend: "Revenue", + incomeLegend: "Net income", + revenueNote: "Compares top-line scale with bottom-line profitability across reported periods.", + marginTitle: "Margin", + marginY: "margin", + grossMargin: "Gross margin", + operatingMargin: "Operating margin", + netMargin: "Net margin", + marginNote: "Margins show whether growth converts into durable profitability instead of only revenue expansion.", + cashFlowTitle: "Cash Flow", + cashFlowY: "cash flow", + fcfLegend: "Free cash flow", + ocfLegend: "Operating cash flow", + cashFlowNote: "Cash-flow conversion helps separate accounting earnings from internally generated cash.", + balanceTitle: "Balance Sheet", + balanceY: "balance sheet", + assetsLegend: "Assets", + debtLegend: "Debt", + balanceNote: "Assets and debt give scale and leverage context for the risk score.", + returnsTitle: "ROE / ROA", + returnsY: "return ratio", + roeLegend: "ROE", + roaLegend: "ROA", + returnsNote: "Capital efficiency view: ROE can be leverage-sensitive, so ROA provides a cleaner asset-return cross-check.", + }, + tabs: { overview: "Overview", fundamental: "Fundamental", quant: "Quant", risk: "Risk", valuation: "Valuation", peer: "Peer", sec: "SEC", audit: "Audit", ai: "AI", qa: "Q&A" }, + }, + ko: { + starter: "분석을 실행하면 deterministic Quantamental Engine 결과가 표시됩니다.", + loading: "Quantamental 분석을 로드 중입니다.", + companyLimited: "기업 데이터 제한", + price: "가격", + marketCap: "시가총액", + quality: "품질", + freshness: "신선도", + status: "상태", + notAdvice: "리서치 분류용입니다. 투자 조언이 아닙니다.", + deterministicUnavailable: "Deterministic 신호를 사용할 수 없습니다.", + composite: "복합", + fundamental: "펀더멘털", + quant: "퀀트", + risk: "리스크", + noTop: "점수화된 신호 후보가 반환되지 않았습니다.", + warnings: "경고", + screened: "스크리닝", + symbols: "개 종목", + rank: "순위", + ticker: "티커", + company: "기업", + signal: "신호", + fund: "펀더", + fresh: "신선도", + integrity: "무결성", + usable: "사용 가능", + blocked: "차단", + staleVisible: "남은 stale 섹션", + qaQuestionDefault: "이 신호가 이렇게 분류된 이유는 무엇인가요?", + qaAsk: "질문", + qaEmpty: "deterministic 결과에 대해 질문하세요.", + latestPrice: "최근 가격", + priceAsOf: "가격 기준일", + oneYearReturn: "1년 수익률", + vol60d: "60일 변동성", + maxDrawdown: "최대 낙폭", + latestFiling: "최근 공시", + revenue: "매출", + opMargin: "영업이익률", + stale: "stale", + missing: "누락", + none: "없음", + chartAxisNote: "X축은 관측일입니다. Y축 단위는 차트별로 가격, 수익률, 연율화 변동성, 낙폭, 거래량, 재무제표 금액으로 표시됩니다. 누락값은 임의로 채우지 않고 제외합니다.", + coverage: "커버리지", + fundamentalMissing: "펀더멘털 누락", + quantMissing: "퀀트 누락", + freshnessScore: "신선도 점수", + missingFactors: "누락 팩터", + missingMetricsRemain: "누락 지표는 명시적으로 유지됩니다", + missingMetrics: "누락 지표", + fundamentalMetrics: "펀더멘털 지표", + quantMetrics: "퀀트 지표", + valuation: "밸류에이션", + riskUnavailable: "리스크 정보를 사용할 수 없습니다.", + peerRelative: "피어 상대 비교", + scope: "범위", + peers: "피어", + strength: "강도", + secEvidence: "SEC 근거", + filings: "공시", + facts: "팩트", + latest: "최근", + riskFlags: "리스크 플래그", + qualityFlags: "품질 플래그", + auditSnapshot: "감사 스냅샷", + snapshot: "스냅샷", + storage: "저장소", + created: "생성", + exportJson: "JSON 내보내기", + exportCsv: "CSV 내보내기", + diffLatest: "최근 차이", + retentionPreview: "보존 미리보기", + aiReport: "AI 리포트", + usedData: "사용 데이터", + dataBasisDate: "데이터 기준일", + analysisPeriod: "분석 기간", + dataSource: "데이터 소스", + observations: "관측치", + missingData: "결측치", + model: "모델", + aiSnapshot: "AI 기준 시각", + cacheState: "캐시", + keyChanges: "핵심 변화", + interpretation: "해석", + scenarios: "시나리오", + userActions: "사용자 액션", + unavailable: "확인 불가", + provider: "공급자", + signalPreserved: "신호 보존", + aiUnavailable: "AI 리포트를 사용할 수 없습니다. Deterministic Engine 결과는 계속 표시됩니다.", + bullCase: "상승 근거", + bearCase: "하락 근거", + qaTitle: "Q&A", + score: "점수", + level: "등급", + warningsCount: "경고", + freshScore: "신선도 점수", + signalUsable: "신호 사용 가능", + yes: "예", + no: "아니오", + strictGate: "엄격 게이트", + strictPolicyDefault: "핵심 데이터는 신선하고 완전해야 합니다", + blocking: "차단", + optional: "선택", + section: "섹션", + asOf: "기준일", + age: "경과", + basis: "기준", + action: "조치", + refreshable: "갱신 가능", + reported: "보고됨", + errors: "오류", + freshnessWarnings: "신선도 경고", + noValues: "표시할 값이 없습니다.", + metric: "지표", + value: "값", + noFilingExcerpts: "표시할 공시 발췌가 없습니다.", + noConceptProvenance: "SEC concept 출처가 없습니다.", + form: "양식", + filed: "제출일", + excerpt: "발췌", + source: "출처", + field: "필드", + concept: "Concept", + chartUnavailable: "차트를 사용할 수 없습니다.", + xDate: "날짜", + yAxis: "Y", + xAxis: "X", + noComparisonRows: "비교 행이 반환되지 않았습니다.", + peerUniverse: "피어 유니버스", + added: "추가", + tickers: "티커", + peerGroups: "피어 그룹", + peerStrength: "피어 강도", + snapshotDiff: "스냅샷 차이", + changedFields: "변경 필드", + path: "경로", + before: "이전", + after: "이후", + noDiff: "추적 대상 스냅샷 필드 변경이 없습니다.", + retentionPreviewLabel: "보존 미리보기", + keepLast: "최근 보존", + prune: "정리", + snapshots: "스냅샷", + noSnapshotsPruned: "정리될 스냅샷이 없습니다.", + noEvidenceMetrics: "근거 지표가 반환되지 않았습니다.", + caveats: "주의 사항", + noAnswer: "답변이 없습니다.", + factorLabels: { + value: "가치", + quality: "품질", + growth: "성장", + momentum: "모멘텀", + lowVolatility: "저변동성", + liquidity: "유동성", + }, + chart: { + priceTitle: "가격 + SMA", + priceY: "가격", + close: "종가", + sma50: "SMA 50", + sma200: "SMA 200", + priceNote: "종가가 두 이동평균 위에 있으면 추세 강도를 지지하고, 하향 이탈은 모멘텀 약화를 경고합니다.", + cumulativeTitle: "누적 수익률", + cumulativeY: "총수익률", + cumulativeLegend: "누적 수익률", + cumulativeNote: "선택한 lookback의 첫 관측치 이후 복리 성과를 보여줍니다.", + volatilityTitle: "롤링 변동성", + volatilityY: "연율화 변동성", + volatilityLegend: "20일 실현 변동성", + volatilityNote: "값이 높을수록 최근 20일 가격 경로가 더 불안정해졌다는 의미입니다.", + drawdownTitle: "낙폭", + drawdownY: "낙폭", + drawdownLegend: "낙폭", + drawdownNote: "최근 고점 대비 하락폭을 측정합니다. 음수 폭이 깊을수록 손실 위험이 큽니다.", + volumeTitle: "거래량", + volumeY: "주식 수", + volumeLegend: "거래량", + volumeNote: "거래량은 실제 운용 규모에서 신호가 실행 가능한지 판단하는 유동성 맥락을 제공합니다.", + revenueTitle: "매출 / 순이익", + revenueY: "재무제표 금액", + revenueLegend: "매출", + incomeLegend: "순이익", + revenueNote: "매출 규모와 최종 수익성을 함께 비교합니다.", + marginTitle: "마진", + marginY: "마진", + grossMargin: "매출총이익률", + operatingMargin: "영업이익률", + netMargin: "순이익률", + marginNote: "성장이 매출 확대에 그치지 않고 지속 가능한 수익성으로 전환되는지 확인합니다.", + cashFlowTitle: "현금흐름", + cashFlowY: "현금흐름", + fcfLegend: "잉여현금흐름", + ocfLegend: "영업현금흐름", + cashFlowNote: "현금흐름 전환은 회계상 이익과 실제 내부 창출 현금을 구분하는 데 도움을 줍니다.", + balanceTitle: "재무상태표", + balanceY: "재무상태", + assetsLegend: "자산", + debtLegend: "부채", + balanceNote: "자산과 부채는 리스크 점수의 규모와 레버리지 맥락을 제공합니다.", + returnsTitle: "ROE / ROA", + returnsY: "수익성 비율", + roeLegend: "ROE", + roaLegend: "ROA", + returnsNote: "ROE는 레버리지 영향을 받을 수 있으므로 ROA가 자산수익률을 교차 확인합니다.", + }, + tabs: { overview: "개요", fundamental: "재무", quant: "퀀트", risk: "리스크", valuation: "밸류에이션", peer: "피어", sec: "SEC", audit: "감사", ai: "AI", qa: "Q&A" }, + }, + }; + + function activeLanguage() { + const lang = String(global?.document?.documentElement?.lang || "en").toLowerCase(); + return lang.startsWith("ko") ? "ko" : "en"; + } + + function copy() { + return I18N[activeLanguage()] || I18N.en; + } + + function empty(message) { + return `
${escapeHtml(message)}
`; + } + + function starter() { + return empty(copy().starter); + } + + function loading(message = "") { + return empty(message || copy().loading); + } + + function error(message) { + return `
${escapeHtml(message || "Quantamental error")}
`; + } + + function metric(label, value, cls = "neutral") { + return ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
+ `; + } + + function companyHeader(data) { + const company = data?.company || {}; + const quality = data?.data_quality || {}; + const freshness = data?.freshness || quality?.freshness || {}; + return ` +
+
+ ${escapeHtml(company.name || data?.ticker || "-")} + ${escapeHtml([company.sector, company.industry].filter(Boolean).join(" / ") || copy().companyLimited)} +
+
+ ${metric(copy().price, company.current_price == null ? "-" : fmt(company.current_price), "neutral")} + ${metric(copy().marketCap, compact(company.market_cap), "neutral")} + ${metric(copy().quality, quality.quality_level || "unknown", statusClass(quality.quality_level))} + ${metric(copy().freshness, freshness.status || "unknown", statusClass(freshness.status))} + ${metric(copy().status, data?.status || "unknown", statusClass(data?.status))} +
+
+ `; + } + + function signalCard(data) { + const signal = data?.signal || {}; + const warnings = Array.isArray(signal.warnings) ? signal.warnings : []; + return ` +
+
+ ${escapeHtml(data?.ticker || "")} / ${escapeHtml(data?.market || "")} + ${escapeHtml(signal.signal_label || "Insufficient Data")} +

${escapeHtml((signal.rationale || [])[0] || copy().deterministicUnavailable)}

+
+
+ ${escapeHtml(fmt(signal.signal_score))} + ${escapeHtml(signal.signal_confidence || "low")} +
+
+
${escapeHtml(copy().notAdvice)}
+ ${warnings.length ? `
    ${warnings.slice(0, 8).map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
` : ""} + `; + } + + function scoreDashboard(data) { + const c = data?.composite || {}; + const risk = data?.risk || {}; + return ` +
+ ${metric(copy().composite, fmt(c.final_score), scoreClass(c.final_score))} + ${metric(copy().fundamental, fmt(c.fundamental_score), scoreClass(c.fundamental_score))} + ${metric(copy().quant, fmt(c.quant_score), scoreClass(c.quant_score))} + ${metric(copy().risk, fmt(c.risk_score), statusClass(risk.risk_level))} +
+
+ ${escapeHtml(c.style || "balanced")} / ${escapeHtml(c.data_conflict_classification || "mixed_or_insufficient_data")} +
+ `; + } + + function scoreClass(value) { + const num = Number(value); + if (!Number.isFinite(num)) return "neutral"; + if (num >= 70) return "ok"; + if (num >= 45) return "warn"; + return "fail"; + } + + function factorGrid(data) { + const f = data?.factors || {}; + const peer = data?.peer_relative || {}; + const labels = copy().factorLabels; + const items = [ + [labels.value, f.value_score], + [labels.quality, f.quality_score], + [labels.growth, f.growth_score], + [labels.momentum, f.momentum_score], + [labels.lowVolatility, f.low_volatility_score], + [labels.liquidity, f.liquidity_score], + ]; + return ` +
+ ${items.map(([label, value]) => ` +
+ ${escapeHtml(label)} + ${escapeHtml(fmt(value))} + +
+ `).join("")} +
+ ${peer.relative_strength_score == null ? "" : `
${escapeHtml(copy().peerStrength)} ${escapeHtml(fmt(peer.relative_strength_score))} / ${escapeHtml(copy().rank)} ${escapeHtml(peer.rank || "-")} / ${escapeHtml(copy().peers)} ${escapeHtml(peer.peer_count || "-")}
`} + `; + } + + function mainPanel(data, activeTab = "overview") { + const tabs = [ + ["overview", copy().tabs.overview], + ["fundamental", copy().tabs.fundamental], + ["quant", copy().tabs.quant], + ["risk", copy().tabs.risk], + ["valuation", copy().tabs.valuation], + ["peer", copy().tabs.peer], + ["sec", copy().tabs.sec], + ["audit", copy().tabs.audit], + ["ai", copy().tabs.ai], + ["qa", copy().tabs.qa], + ]; + const body = { + overview: overviewPanel(data), + fundamental: objectPanel(copy().fundamentalMetrics, data?.fundamentals?.metrics || {}, data?.fundamentals?.missing_metrics || [], "fundamental"), + quant: objectPanel(copy().quantMetrics, data?.quant?.metrics || {}, data?.quant?.missing_metrics || [], "quant"), + risk: riskPanel(data?.risk || {}), + valuation: objectPanel(copy().valuation, data?.fundamentals?.metrics?.valuation || {}, [], "valuation"), + peer: peerPanel(data?.peer_relative || {}), + sec: secPanel(data?.sec_evidence || {}), + audit: auditPanel(data?.snapshot || {}), + ai: aiPanel(data?.ai_report || {}), + qa: qaPanel(), + }[activeTab] || overviewPanel(data); + return ` +
+ ${tabs.map(([key, label]) => ` + + `).join("")} +
+
${body}
+ `; + } + + function overviewPanel(data) { + const cpy = copy(); + const chart = cpy.chart; + const chartData = data?.quant?.chart_data || {}; + const statements = data?.fundamentals?.statements || []; + const ratioRows = fundamentalRatioRows(statements); + const qMetrics = data?.quant?.metrics || {}; + const fMetrics = data?.fundamentals?.metrics || {}; + const freshness = data?.freshness || data?.data_quality?.freshness || {}; + const latestPrice = lastFinite(chartData.price || [], "close"); + const latestPriceDate = lastDate(chartData.price || []); + const latestStatement = Array.isArray(statements) && statements.length ? statements[0] : {}; + const missingMetrics = [ + ...(data?.fundamentals?.missing_metrics || []), + ...(data?.quant?.missing_metrics || []), + ...(data?.factors?.missing_factor_inputs || []), + ]; + return ` +
+
+
+ ${metric(cpy.latestPrice, latestPrice == null ? "-" : fmt(latestPrice), "neutral")} + ${metric(cpy.priceAsOf, latestPriceDate || "-", statusClass(freshness?.sections?.prices?.status))} + ${metric(cpy.oneYearReturn, fmtPct(qMetrics?.return?.return_252d), scoreClass((qMetrics?.return?.return_252d || 0) * 100 + 50))} + ${metric(cpy.vol60d, fmtPct(qMetrics?.volatility?.realized_volatility_60d), statusClass(qMetrics?.liquidity?.liquidity_risk))} + ${metric(cpy.maxDrawdown, fmtPct(qMetrics?.drawdown?.max_drawdown), "warn")} + ${metric(cpy.latestFiling, latestStatement?.date || "-", statusClass(freshness?.sections?.fundamentals?.status))} + ${metric(cpy.revenue, compact(latestStatement?.revenue), "neutral")} + ${metric(cpy.opMargin, fmtPct(fMetrics?.profitability?.operating_margin), scoreClass((fMetrics?.profitability?.operating_margin || 0) * 220))} +
+
+ ${escapeHtml(cpy.freshness)} ${escapeHtml(freshness.status || "unknown")} / ${escapeHtml(cpy.stale)} ${(freshness.stale_sections || []).map(escapeHtml).join(", ") || escapeHtml(cpy.none)} / ${escapeHtml(cpy.missing)} ${(data?.data_quality?.missing_sections || []).map(escapeHtml).join(", ") || escapeHtml(cpy.none)} +
+
+ ${escapeHtml(cpy.chartAxisNote)} +
+
+
+ ${chartCard(chart.priceTitle, lineChart(chartData.price || [], "close", ["sma_50", "sma_200"], { yLabel: chart.priceY, yFormat: "number", legendLabels: [chart.close, chart.sma50, chart.sma200] }), chart.priceNote)} + ${chartCard(chart.cumulativeTitle, lineChart(chartData.cumulative_return || [], "cumulative_return", [], { yLabel: chart.cumulativeY, yFormat: "percent", legendLabels: [chart.cumulativeLegend] }), chart.cumulativeNote)} + ${chartCard(chart.volatilityTitle, lineChart(chartData.rolling_volatility || [], "rolling_volatility_20d", [], { yLabel: chart.volatilityY, yFormat: "percent", legendLabels: [chart.volatilityLegend] }), chart.volatilityNote)} + ${chartCard(chart.drawdownTitle, lineChart(chartData.drawdown || [], "drawdown", [], { yLabel: chart.drawdownY, yFormat: "percent", legendLabels: [chart.drawdownLegend] }), chart.drawdownNote)} + ${chartCard(chart.volumeTitle, barChart(chartData.volume || [], "volume", "", { yLabel: chart.volumeY, yFormat: "compact", legendLabels: [chart.volumeLegend] }), chart.volumeNote)} + ${chartCard(chart.revenueTitle, barChart(statements.slice().reverse(), "revenue", "net_income", { yLabel: chart.revenueY, yFormat: "compact", legendLabels: [chart.revenueLegend, chart.incomeLegend] }), chart.revenueNote)} + ${chartCard(chart.marginTitle, lineChart(ratioRows, "gross_margin", ["operating_margin", "net_margin"], { yLabel: chart.marginY, yFormat: "percent", legendLabels: [chart.grossMargin, chart.operatingMargin, chart.netMargin] }), chart.marginNote)} + ${chartCard(chart.cashFlowTitle, barChart(statements.slice().reverse(), "free_cash_flow", "operating_cash_flow", { yLabel: chart.cashFlowY, yFormat: "compact", legendLabels: [chart.fcfLegend, chart.ocfLegend] }), chart.cashFlowNote)} + ${chartCard(chart.balanceTitle, barChart(statements.slice().reverse(), "total_assets", "total_debt", { yLabel: chart.balanceY, yFormat: "compact", legendLabels: [chart.assetsLegend, chart.debtLegend] }), chart.balanceNote)} + ${chartCard(chart.returnsTitle, lineChart(ratioRows, "roe", ["roa"], { yLabel: chart.returnsY, yFormat: "percent", legendLabels: [chart.roeLegend, chart.roaLegend] }), chart.returnsNote)} +
+
+ ${escapeHtml(cpy.coverage)} + ${escapeHtml(cpy.fundamentalMissing)} ${escapeHtml(String(data?.data_quality?.fundamental_missing_metric_count ?? 0))} + ${escapeHtml(cpy.quantMissing)} ${escapeHtml(String(data?.data_quality?.quant_missing_metric_count ?? 0))} + ${escapeHtml(cpy.freshnessScore)} ${escapeHtml(fmt(freshness.freshness_score))} + ${escapeHtml(cpy.missingFactors)} ${escapeHtml(String((data?.factors?.missing_factor_inputs || []).length))} +
+ ${missingMetrics.length ? `
${escapeHtml(cpy.missingMetricsRemain)}: ${escapeHtml(missingMetrics.slice(0, 24).join(", "))}${missingMetrics.length > 24 ? " ..." : ""}
` : ""} +
+ `; + } + + function fundamentalRatioRows(statements) { + if (!Array.isArray(statements)) return []; + return statements.slice().reverse().map((row) => ({ + date: row?.date, + gross_margin: safeRatio(row?.gross_profit, row?.revenue), + operating_margin: safeRatio(row?.operating_income, row?.revenue), + net_margin: safeRatio(row?.net_income, row?.revenue), + roe: safeRatio(row?.net_income, row?.total_equity), + roa: safeRatio(row?.net_income, row?.total_assets), + })); + } + + function safeRatio(numerator, denominator) { + const num = Number(numerator); + const den = Number(denominator); + if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return null; + return num / den; + } + + function lastFinite(rows, key) { + if (!Array.isArray(rows)) return null; + for (let idx = rows.length - 1; idx >= 0; idx -= 1) { + const value = Number(rows[idx]?.[key]); + if (Number.isFinite(value)) return value; + } + return null; + } + + function lastDate(rows) { + if (!Array.isArray(rows)) return ""; + for (let idx = rows.length - 1; idx >= 0; idx -= 1) { + const date = rows[idx]?.date; + if (date) return String(date); + } + return ""; + } + + function objectPanel(title, obj, missing, testName) { + return ` +
+

${escapeHtml(title)}

+ ${objectTable(obj)} + ${missing.length ? `
${escapeHtml(copy().missingMetrics)}: ${escapeHtml(missing.slice(0, 20).join(", "))}
` : ""} +
+ `; + } + + function riskPanel(risk) { + return ` +
+

${escapeHtml(copy().risk)}

+
${escapeHtml(risk.risk_summary || copy().riskUnavailable)}
+ ${objectTable(risk)} +
+ `; + } + + function peerPanel(peer) { + return ` +
+

${escapeHtml(copy().peerRelative)}

+
+ ${metric(copy().status, peer.status || "empty", statusClass(peer.status))} + ${metric(copy().scope, peer.scope || "-", "neutral")} + ${metric(copy().peers, peer.peer_count == null ? "-" : peer.peer_count, "neutral")} + ${metric(copy().strength, fmt(peer.relative_strength_score), scoreClass(peer.relative_strength_score))} +
+ ${objectTable(peer.normalized_factor_scores || {})} + ${listBlock(copy().warnings, peer.warnings)} +
+ `; + } + + function secPanel(sec) { + return ` +
+

${escapeHtml(copy().secEvidence)}

+
+ ${metric(copy().status, sec.status || "unknown", statusClass(sec.status))} + ${metric(copy().filings, sec.filing_count == null ? "-" : sec.filing_count, "neutral")} + ${metric(copy().facts, sec.fact_count == null ? "-" : sec.fact_count, "neutral")} + ${metric(copy().latest, sec.latest_filing_at || "-", "neutral")} +
+ ${listBlock(copy().riskFlags, sec.risk_flags)} + ${listBlock(copy().qualityFlags, sec.quality_flags)} + ${filingExcerptTable(sec.filing_excerpts || [])} + ${conceptProvenanceTable(sec.concept_provenance || [])} + ${objectTable(sec.metrics || {})} + ${listBlock(copy().warnings, sec.warnings)} +
+ `; + } + + function auditPanel(snapshot) { + return ` +
+

${escapeHtml(copy().auditSnapshot)}

+
+ ${metric(copy().status, snapshot.status || "unknown", statusClass(snapshot.status))} + ${metric(copy().snapshot, snapshot.snapshot_id || "-", "neutral")} + ${metric(copy().storage, snapshot.storage || "-", "neutral")} + ${metric(copy().created, snapshot.created_at || "-", "neutral")} +
+
+ + + + +
+ ${snapshot.database ? `
${escapeHtml(snapshot.database)}
` : ""} +
+ `; + } + + function aiValue(value) { + if (value === null || value === undefined || value === "") return copy().unavailable; + if (Array.isArray(value)) return value.length ? value.map((item) => aiValue(item)).join(", ") : copy().none; + if (typeof value === "object") { + const pairs = Object.entries(value) + .filter(([, nested]) => nested !== null && nested !== undefined && nested !== "") + .map(([key, nested]) => `${key}: ${aiValue(nested)}`); + return pairs.length ? pairs.join(" / ") : copy().unavailable; + } + return String(value); + } + + function aiReportSection(title, payload, testName) { + if (!payload || (typeof payload === "object" && !Array.isArray(payload) && !Object.keys(payload).length)) return ""; + const rows = Array.isArray(payload) + ? payload.map((value, index) => [String(index + 1), value]) + : Object.entries(payload); + if (!rows.length) return ""; + return ` +
+

${escapeHtml(title)}

+
+ ${rows.map(([key, value]) => ` +
+ ${escapeHtml(key)}
+ ${escapeHtml(aiValue(value))} +
+ `).join("")} +
+
+ `; + } + + function aiMissingStatus(value) { + const text = aiValue(value).trim().toLowerCase(); + if ([copy().none.toLowerCase(), "none", "none identified", "없음", "0"].includes(text)) return "ok"; + if (!text || text === copy().unavailable.toLowerCase() || text === "unavailable" || text === "확인 불가") return "warn"; + return "warn"; + } + + function aiPanel(ai) { + const report = ai.report || {}; + const usedData = report.used_data || ai.data_snapshot || {}; + return ` +
+

${escapeHtml(copy().aiReport)}

+
+ ${escapeHtml(copy().provider)}: ${escapeHtml(ai.provider || "unavailable")} / ${escapeHtml(copy().signalPreserved)}: ${escapeHtml(String(ai.signal_preserved !== false))} +
+
+

${escapeHtml(copy().usedData)}

+
+ ${metric(copy().dataBasisDate, aiValue(usedData.data_basis_date), "neutral")} + ${metric(copy().analysisPeriod, aiValue(usedData.analysis_period), "neutral")} + ${metric(copy().dataSource, aiValue(usedData.data_source), "neutral")} + ${metric(copy().observations, aiValue(usedData.observation_count), "neutral")} + ${metric(copy().missingData, aiValue(usedData.missing_data), aiMissingStatus(usedData.missing_data))} + ${metric(copy().model, aiValue(usedData.model || ai.provider), "neutral")} + ${metric(copy().aiSnapshot, aiValue(usedData.ai_snapshot_at), "neutral")} + ${metric(copy().cacheState, aiValue(usedData.cache_state), "neutral")} +
+
+

${escapeHtml(report.summary || copy().aiUnavailable)}

+ ${aiReportSection(copy().keyChanges, report.key_changes, "key-changes")} + ${aiReportSection(copy().interpretation, report.interpretation, "interpretation")} + ${aiReportSection(copy().scenarios, report.scenarios, "scenarios")} + ${aiReportSection(copy().userActions, report.user_actions, "user-actions")} + ${listBlock(copy().bullCase, report.bull_case)} + ${listBlock(copy().bearCase, report.bear_case)} +
${escapeHtml(report.safety_note || copy().notAdvice)}
+
+ `; + } + + function qaPanel() { + return ` +
+

${escapeHtml(copy().qaTitle)}

+
+ + +
+
${empty(copy().qaEmpty)}
+
+ `; + } + + function dataQuality(data) { + const quality = data?.data_quality || {}; + const evidence = quality.evidence_sources?.sec_edgar || {}; + const freshness = data?.freshness || quality.freshness || {}; + const integrity = data?.data_integrity || quality.data_integrity || {}; + const sections = freshness.sections || {}; + return ` +
+
+ ${metric(copy().score, fmt(quality.data_quality_score), statusClass(quality.quality_level))} + ${metric(copy().level, quality.quality_level || "unknown", statusClass(quality.quality_level))} + ${metric(copy().missing, (quality.missing_sections || []).length, (quality.missing_sections || []).length ? "warn" : "ok")} + ${metric(copy().warningsCount, (quality.warnings || []).length, (quality.warnings || []).length ? "warn" : "ok")} + ${metric(copy().freshness, freshness.status || "unknown", statusClass(freshness.status))} + ${metric(copy().freshScore, fmt(freshness.freshness_score), statusClass(freshness.status))} + ${metric(copy().integrity, integrity.status || "unknown", statusClass(integrity.status || (integrity.usable_for_signal ? "ok" : "warn")))} + ${metric(copy().signalUsable, integrity.usable_for_signal ? copy().yes : copy().no, integrity.usable_for_signal ? "ok" : "fail")} +
+
${escapeHtml(copy().strictGate)}: ${escapeHtml(integrity.strict_policy || copy().strictPolicyDefault)} / ${escapeHtml(copy().blocking)} ${(integrity.blocking_sections || []).map(escapeHtml).join(", ") || escapeHtml(copy().none)} / ${escapeHtml(copy().optional)} ${(integrity.optional_issue_sections || []).map(escapeHtml).join(", ") || escapeHtml(copy().none)}
+
${escapeHtml(copy().secEvidence)}: ${escapeHtml(evidence.status || "unknown")} / ${escapeHtml(copy().filings)} ${escapeHtml(evidence.filing_count ?? "-")} / ${escapeHtml(copy().facts)} ${escapeHtml(evidence.fact_count ?? "-")}
+
+ + + ${Object.keys(sections).map((name) => { + const item = sections[name] || {}; + return ``; + }).join("")} +
${escapeHtml(copy().section)}${escapeHtml(copy().status)}${escapeHtml(copy().asOf)}${escapeHtml(copy().age)}${escapeHtml(copy().basis)}${escapeHtml(copy().action)}
${escapeHtml(name)}${escapeHtml(item.status || "-")}${escapeHtml(item.as_of || "-")}${escapeHtml(item.age_days == null ? "-" : `${item.age_days}d`)}${escapeHtml(item.basis || "-")}${escapeHtml(item.refreshable ? copy().refreshable : copy().reported)}
+
+ ${listBlock(copy().warnings, quality.warnings)} + ${listBlock(copy().freshnessWarnings, freshness.warnings)} + ${listBlock(copy().errors, quality.errors)} +
+ `; + } + + function topSignals(data) { + const limit = Math.max(1, Math.min(20, Number(data?.limit || data?.top_count || 5) || 5)); + const sourceRows = Array.isArray(data?.top_signals) + ? data.top_signals + : (Array.isArray(data?.top) + ? data.top + : (Array.isArray(data?.ranked_rows) + ? data.ranked_rows + : (Array.isArray(data?.rows) ? data.rows : []))); + const rows = sourceRows.slice(0, limit); + const summary = data?.freshness_summary || data?.freshness || {}; + if (!rows.length) { + return ` +
+
${escapeHtml(copy().noTop)}
+ ${listBlock(copy().warnings, data?.warnings)} +
+ `; + } + return ` +
+
+ ${escapeHtml(copy().screened)} ${escapeHtml(String(data?.scored_count ?? rows.length))}/${escapeHtml(String(data?.requested_count ?? rows.length))} ${escapeHtml(copy().symbols)} / ${escapeHtml(copy().freshness)} ${escapeHtml(summary.status || "unknown")} / style ${escapeHtml(data?.style || "balanced")} +
+
+ + + ${rows.map((row, idx) => ` + + + + + + + + + + + + + `).join("")} +
${escapeHtml(copy().rank)}${escapeHtml(copy().ticker)}${escapeHtml(copy().company)}${escapeHtml(copy().signal)}${escapeHtml(copy().composite)}${escapeHtml(copy().fund)}${escapeHtml(copy().quant)}${escapeHtml(copy().risk)}${escapeHtml(copy().fresh)}${escapeHtml(copy().integrity)}
${escapeHtml(String(row.rank || idx + 1))}${escapeHtml(row.ticker || "")}${escapeHtml(row.name || [row.sector, row.industry].filter(Boolean).join(" / ") || "-")}${escapeHtml(row.signal_label || "-")}${escapeHtml(fmt(row.final_score))}${escapeHtml(fmt(row.fundamental_score))}${escapeHtml(fmt(row.quant_score))}${escapeHtml(fmt(row.risk_score))}${escapeHtml(row.freshness_status || "unknown")}${escapeHtml(row.usable_for_signal ? copy().usable : (row.data_integrity_status || copy().blocked))}
+
+ ${rows.some((row) => (row.stale_sections || []).length) ? `
${escapeHtml(copy().staleVisible)}: ${escapeHtml(rows.map((row) => `${row.ticker}:${(row.stale_sections || []).join("|")}`).filter((item) => !item.endsWith(":")).join(", "))}
` : ""} + ${listBlock(copy().warnings, data?.warnings)} +
+ `; + } + + function screenScoreLabel(scoreKey) { + const labels = copy().factorLabels || {}; + return { + composite: copy().composite, + value: labels.value, + quality: labels.quality, + growth: labels.growth, + momentum: labels.momentum, + low_volatility: labels.lowVolatility, + liquidity: labels.liquidity, + }[String(scoreKey || "composite")] || copy().composite; + } + + function scoreScreen(data) { + const limit = Math.max(1, Math.min(50, Number(data?.limit || data?.returned_count || 20) || 20)); + const sourceRows = Array.isArray(data?.matches) + ? data.matches + : (Array.isArray(data?.top) + ? data.top + : (Array.isArray(data?.ranked_rows) ? data.ranked_rows : [])); + const rows = sourceRows.slice(0, limit); + const summary = data?.freshness_summary || data?.freshness || {}; + const minScore = Number(data?.min_score); + const threshold = Number.isFinite(minScore) ? fmt(minScore) : "-"; + const scoreLabel = screenScoreLabel(data?.score_key); + if (!rows.length) { + return ` +
+
${escapeHtml(copy().noTop)} / ${escapeHtml(scoreLabel)} >= ${escapeHtml(threshold)}
+ ${listBlock(copy().warnings, data?.warnings)} +
+ `; + } + return ` +
+
+ ${escapeHtml(String(data?.returned_count ?? rows.length))}/${escapeHtml(String(data?.matched_count ?? rows.length))} ${escapeHtml(copy().screened)} / ${escapeHtml(scoreLabel)} >= ${escapeHtml(threshold)} / ${escapeHtml(copy().freshness)} ${escapeHtml(summary.status || "unknown")} / style ${escapeHtml(data?.style || "balanced")} +
+
+ + + ${rows.map((row, idx) => ` + + + + + + + + + + + + + + + + + `).join("")} +
${escapeHtml(copy().rank)}${escapeHtml(copy().ticker)}${escapeHtml(copy().company)}${escapeHtml(copy().signal)}${escapeHtml(scoreLabel)}${escapeHtml(copy().composite)}${escapeHtml(copy().factorLabels.value)}${escapeHtml(copy().factorLabels.quality)}${escapeHtml(copy().factorLabels.growth)}${escapeHtml(copy().factorLabels.momentum)}${escapeHtml(copy().factorLabels.lowVolatility)}${escapeHtml(copy().factorLabels.liquidity)}${escapeHtml(copy().fresh)}${escapeHtml(copy().integrity)}
${escapeHtml(String(row.threshold_rank || row.rank || idx + 1))}${escapeHtml(row.ticker || "")}${escapeHtml(row.name || [row.sector, row.industry].filter(Boolean).join(" / ") || "-")}${escapeHtml(row.signal_label || "-")}${escapeHtml(fmt(row.screen_score))}${escapeHtml(fmt(row.final_score))}${escapeHtml(fmt(row.value_score))}${escapeHtml(fmt(row.quality_score))}${escapeHtml(fmt(row.growth_score))}${escapeHtml(fmt(row.momentum_score))}${escapeHtml(fmt(row.low_volatility_score))}${escapeHtml(fmt(row.liquidity_score))}${escapeHtml(row.freshness_status || "unknown")}${escapeHtml(row.usable_for_signal ? copy().usable : (row.data_integrity_status || copy().blocked))}
+
+ ${rows.some((row) => (row.stale_sections || []).length) ? `
${escapeHtml(copy().staleVisible)}: ${escapeHtml(rows.map((row) => `${row.ticker}:${(row.stale_sections || []).join("|")}`).filter((item) => !item.endsWith(":")).join(", "))}
` : ""} + ${listBlock(copy().warnings, data?.warnings)} +
+ `; + } + + function comparisonTable(data) { + const rows = Array.isArray(data?.rows) ? data.rows : []; + if (!rows.length) return empty(copy().noComparisonRows); + return ` +
+ ${data.peer_universe ? `
${escapeHtml(copy().peerUniverse)}: ${escapeHtml(data.peer_universe.status || "batch")} / ${escapeHtml(copy().added)} ${(data.peer_universe.added_tickers || []).map(escapeHtml).join(", ") || escapeHtml(copy().none)}
` : ""} +
${escapeHtml(data.count || rows.length)} ${escapeHtml(copy().tickers)} / ${escapeHtml(data.style || "balanced")} / ${escapeHtml(copy().peerGroups)} ${escapeHtml((data.peer_groups || []).length)}
+
+ + + ${rows.map((row) => ` + + + + + + + + + `).join("")} +
${escapeHtml(copy().ticker)}${escapeHtml(copy().signal)}${escapeHtml(copy().score)}${escapeHtml(copy().peerStrength)}${escapeHtml(copy().rank)}${escapeHtml(copy().quality)}
${escapeHtml(row.ticker || "")}${escapeHtml(row.signal_label || "")}${escapeHtml(fmt(row.final_score))}${escapeHtml(fmt(row.peer_relative?.relative_strength_score))}${escapeHtml(row.peer_relative?.rank || "-")}${escapeHtml(row.quality_level || "-")}
+
+
+ `; + } + + function qaAnswer(answer) { + return ` +
${escapeHtml(answer?.answer || copy().noAnswer)}
+ ${evidenceTable(answer?.evidence_metrics || [])} + ${listBlock(copy().caveats, answer?.caveats)} + `; + } + + function snapshotDiff(data) { + const rows = Array.isArray(data?.differences) ? data.differences : []; + return ` +
+ ${escapeHtml(copy().snapshotDiff)}: ${escapeHtml(String(data?.difference_count ?? rows.length))} ${escapeHtml(copy().changedFields)} +
+ ${rows.length ? ` +
+ + + ${rows.slice(0, 30).map((row) => ``).join("")} +
${escapeHtml(copy().path)}${escapeHtml(copy().before)}${escapeHtml(copy().after)}
${escapeHtml(row.path)}${escapeHtml(formatCell(row.before))}${escapeHtml(formatCell(row.after))}
+
+ ` : empty(copy().noDiff)} + `; + } + + function snapshotRetention(data) { + const items = Array.isArray(data?.items) ? data.items : []; + return ` +
+ ${escapeHtml(copy().retentionPreviewLabel)}: ${escapeHtml(copy().keepLast)} ${escapeHtml(String(data?.keep_last ?? "-"))}, ${escapeHtml(copy().prune)} ${escapeHtml(String(data?.prune_count ?? 0))} ${escapeHtml(copy().snapshots)} +
+ ${items.length ? ` +
+ + + ${items.slice(0, 20).map((item) => ``).join("")} +
${escapeHtml(copy().snapshot)}${escapeHtml(copy().ticker)}${escapeHtml(copy().created)}${escapeHtml(copy().signal)}
${escapeHtml(item.snapshot_id)}${escapeHtml(item.ticker)}${escapeHtml(item.created_at)}${escapeHtml(item.signal_label)}
+
+ ` : empty(copy().noSnapshotsPruned)} + `; + } + + function evidenceTable(items) { + if (!Array.isArray(items) || !items.length) return empty(copy().noEvidenceMetrics); + return ` +
+ + + ${items.map((item) => ``).join("")} +
${escapeHtml(copy().metric)}${escapeHtml(copy().value)}${escapeHtml(copy().source)}
${escapeHtml(item.label)}${escapeHtml(formatCell(item.value))}${escapeHtml(item.source)}
+
+ `; + } + + function objectTable(obj) { + const rows = flatten(obj).slice(0, 90); + if (!rows.length) return empty(copy().noValues); + return ` +
+ + + ${rows.map(([key, value]) => ``).join("")} +
${escapeHtml(copy().metric)}${escapeHtml(copy().value)}
${escapeHtml(key)}${escapeHtml(formatCell(value))}
+
+ `; + } + + function filingExcerptTable(items) { + if (!Array.isArray(items) || !items.length) return empty(copy().noFilingExcerpts); + return ` +
+ + + ${items.slice(0, 5).map((item) => ``).join("")} +
${escapeHtml(copy().form)}${escapeHtml(copy().filed)}${escapeHtml(copy().excerpt)}${escapeHtml(copy().source)}
${escapeHtml(item.form_type)}${escapeHtml(item.filed_at || "-")}${escapeHtml(item.excerpt || item.description || "-")}${item.url ? `SEC` : escapeHtml(item.source || "-")}
+
+ `; + } + + function conceptProvenanceTable(items) { + if (!Array.isArray(items) || !items.length) return empty(copy().noConceptProvenance); + return ` +
+ + + ${items.slice(0, 12).map((item) => ``).join("")} +
${escapeHtml(copy().field)}${escapeHtml(copy().concept)}${escapeHtml(copy().filed)}${escapeHtml(copy().value)}
${escapeHtml(item.field)}${escapeHtml(item.concept)}${escapeHtml(item.filed_at || "-")}${escapeHtml(formatCell(item.value))}
+
+ `; + } + + function flatten(obj, prefix = "") { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) return []; + const out = []; + Object.keys(obj).forEach((key) => { + const value = obj[key]; + const next = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === "object" && !Array.isArray(value)) out.push(...flatten(value, next)); + else out.push([next, value]); + }); + return out; + } + + function formatCell(value) { + if (typeof value === "number") { + if (Math.abs(value) < 1 && value !== 0) return fmtPct(value); + return fmt(value); + } + if (Array.isArray(value)) return value.join(", "); + if (value && typeof value === "object") return JSON.stringify(value); + return value ?? "-"; + } + + function compact(value) { + const num = Number(value); + if (!Number.isFinite(num)) return "-"; + if (Math.abs(num) >= 1_000_000_000_000) return `${fmt(num / 1_000_000_000_000)}T`; + if (Math.abs(num) >= 1_000_000_000) return `${fmt(num / 1_000_000_000)}B`; + if (Math.abs(num) >= 1_000_000) return `${fmt(num / 1_000_000)}M`; + return fmt(num); + } + + function listBlock(title, values) { + const items = Array.isArray(values) ? values : values ? [values] : []; + if (!items.length) return ""; + return `
${escapeHtml(title)}
    ${items.slice(0, 10).map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
`; + } + + function chartCard(title, body, note = "") { + return ` +
+ ${escapeHtml(title)} + ${body} + ${note ? `

${escapeHtml(note)}

` : ""} +
+ `; + } + + function lineChart(rows, key, extraKeys = [], options = {}) { + const seriesKeys = [key, ...extraKeys].filter(Boolean); + const values = []; + rows.forEach((row, idx) => { + seriesKeys.forEach((candidate) => { + const value = Number(row?.[candidate]); + if (Number.isFinite(value)) values.push({ idx, value }); + }); + }); + if (!values.length) return empty(copy().chartUnavailable); + const dims = chartDims(); + const min = Math.min(...values.map((item) => item.value)); + const max = Math.max(...values.map((item) => item.value)); + const span = max - min || 1; + const colors = ["#2563eb", "#16a34a", "#dc2626", "#7c3aed"]; + const polylines = seriesKeys.map((seriesKey, seriesIdx) => { + const points = rows.map((row, idx) => { + const value = Number(row?.[seriesKey]); + if (!Number.isFinite(value)) return ""; + const x = dims.left + (rows.length <= 1 ? 0 : (idx / (rows.length - 1)) * dims.innerWidth); + const y = dims.top + dims.innerHeight - ((value - min) / span) * dims.innerHeight; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).filter(Boolean).join(" "); + return points ? `` : ""; + }).join(""); + return chartShell({ + rows, + min, + max, + yLabel: options.yLabel || key, + yFormat: options.yFormat || "number", + legendLabels: options.legendLabels || seriesKeys, + colors, + body: polylines, + ariaLabel: `${key} chart`, + }); + } + + function barChart(rows, key, altKey = "", options = {}) { + const seriesKeys = [key, altKey].filter(Boolean); + const values = rows.flatMap((row) => seriesKeys.map((seriesKey) => Number(row?.[seriesKey]))).filter(Number.isFinite); + if (!values.length) return empty(copy().chartUnavailable); + const dims = chartDims(); + const min = Math.min(0, ...values); + const max = Math.max(...values, 1); + const span = max - min || 1; + const colors = ["#2563eb", "#16a34a"]; + const groupWidth = dims.innerWidth / Math.max(rows.length, 1); + const barWidth = Math.max(3, (groupWidth - 4) / Math.max(seriesKeys.length, 1)); + const zeroY = dims.top + dims.innerHeight - ((0 - min) / span) * dims.innerHeight; + const bars = rows.map((row, idx) => { + const x0 = dims.left + idx * groupWidth + 2; + return seriesKeys.map((seriesKey, seriesIdx) => { + const value = Number(row?.[seriesKey]); + if (!Number.isFinite(value)) return ""; + const y = dims.top + dims.innerHeight - ((value - min) / span) * dims.innerHeight; + const h = Math.max(1, Math.abs(zeroY - y)); + const x = x0 + seriesIdx * barWidth; + return ``; + }).join(""); + }).join(""); + return chartShell({ + rows, + min, + max, + yLabel: options.yLabel || key, + yFormat: options.yFormat || "number", + legendLabels: options.legendLabels || seriesKeys, + colors, + body: bars, + ariaLabel: `${key} bars`, + }); + } + + function chartDims() { + return { width: 360, height: 178, left: 46, right: 14, top: 16, bottom: 32, innerWidth: 300, innerHeight: 130 }; + } + + function chartShell({ rows, min, max, yLabel, yFormat, legendLabels, colors, body, ariaLabel }) { + const dims = chartDims(); + const yTicks = [max, min + (max - min) / 2, min]; + const xStart = rows?.[0]?.date || ""; + const xEnd = rows?.[rows.length - 1]?.date || ""; + const grid = yTicks.map((value, idx) => { + const y = dims.top + (idx / 2) * dims.innerHeight; + return ` + + ${escapeHtml(formatAxis(value, yFormat))} + `; + }).join(""); + const legend = legendLabels.length ? `
${legendLabels.map((label, idx) => `${escapeHtml(label)}`).join("")}
` : ""; + return ` + + ${grid} + + + ${body} + ${escapeHtml(xStart)} + ${escapeHtml(xEnd)} + ${escapeHtml(copy().xDate)} + +
${escapeHtml(copy().yAxis)}: ${escapeHtml(yLabel)}${escapeHtml(copy().xAxis)}: ${escapeHtml(copy().xDate)}
+ ${legend} + `; + } + + function formatAxis(value, format) { + const num = Number(value); + if (!Number.isFinite(num)) return "-"; + if (format === "percent") return fmtPct(num); + if (format === "compact") return compact(num); + return fmt(num); + } + + global.FinGPTQuantamentalUi = { + starter, + loading, + error, + companyHeader, + signalCard, + scoreDashboard, + factorGrid, + mainPanel, + dataQuality, + topSignals, + scoreScreen, + qaAnswer, + comparisonTable, + snapshotDiff, + snapshotRetention, + }; +})(window); diff --git a/app/web/styles.css b/app/web/styles.css index d0f23314..db45e5d2 100644 --- a/app/web/styles.css +++ b/app/web/styles.css @@ -78,6 +78,34 @@ a:hover { color: var(--accent-hover); } .brand-title { font-size: 16px; font-weight: 700; letter-spacing: 0.2px; } .brand-sub { font-size: 11px; color: var(--text-mute); text-transform: uppercase; letter-spacing: 1px; } .topnav { display: flex; align-items: center; gap: 14px; } +.language-toggle { + display: inline-grid; + grid-template-columns: repeat(2, minmax(34px, 1fr)); + align-items: center; + gap: 2px; + min-height: 30px; + padding: 2px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--bg-elev); +} +.language-toggle button { + min-width: 34px; + min-height: 24px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-mute); + font-size: 11px; + font-weight: 700; + letter-spacing: 0; + cursor: pointer; +} +.language-toggle button[aria-pressed="true"], +.language-toggle button.active { + background: var(--accent-soft); + color: var(--text-main); +} .pipeline-pill { font-size: 11px; color: var(--text-dim); @@ -714,23 +742,33 @@ input[type="range"]::-moz-range-thumb { gap: 16px; } .dashboard-surface-grid[data-dashboard-tab="market"] .quant-surface, +.dashboard-surface-grid[data-dashboard-tab="market"] .quantamental-surface, .dashboard-surface-grid[data-dashboard-tab="market"] .ai-portfolio-surface, .dashboard-surface-grid[data-dashboard-tab="market"] .macro-surface, .dashboard-surface-grid[data-dashboard-tab="market"] .forecast-surface, .dashboard-surface-grid[data-dashboard-tab="quant"] .market-surface, +.dashboard-surface-grid[data-dashboard-tab="quant"] .quantamental-surface, .dashboard-surface-grid[data-dashboard-tab="quant"] .ai-portfolio-surface, .dashboard-surface-grid[data-dashboard-tab="quant"] .macro-surface, .dashboard-surface-grid[data-dashboard-tab="quant"] .forecast-surface, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .market-surface, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quant-surface, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .ai-portfolio-surface, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .macro-surface, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .forecast-surface, .dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .market-surface, .dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .quant-surface, +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .quantamental-surface, .dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .macro-surface, .dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .forecast-surface, .dashboard-surface-grid[data-dashboard-tab="macro"] .market-surface, .dashboard-surface-grid[data-dashboard-tab="macro"] .quant-surface, +.dashboard-surface-grid[data-dashboard-tab="macro"] .quantamental-surface, .dashboard-surface-grid[data-dashboard-tab="macro"] .ai-portfolio-surface, .dashboard-surface-grid[data-dashboard-tab="macro"] .forecast-surface, .dashboard-surface-grid[data-dashboard-tab="forecast"] .market-surface, .dashboard-surface-grid[data-dashboard-tab="forecast"] .quant-surface, +.dashboard-surface-grid[data-dashboard-tab="forecast"] .quantamental-surface, .dashboard-surface-grid[data-dashboard-tab="forecast"] .macro-surface, .dashboard-surface-grid[data-dashboard-tab="forecast"] .ai-portfolio-surface { display: none; @@ -792,306 +830,694 @@ input[type="range"]::-moz-range-thumb { .dashboard-surface-grid[data-dashboard-tab="quant"] .signal-card { min-height: 360px; } -.dashboard-surface-grid[data-dashboard-tab="forecast"] { - grid-template-columns: minmax(340px, 0.9fr) minmax(0, 1.3fr); +.dashboard-surface-grid[data-dashboard-tab="quantamental"] { + grid-template-columns: minmax(340px, 0.9fr) minmax(0, 1.1fr); } -.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-setup-card, -.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-viz-card, -.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-jobs-card, -.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-history-card, -.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-registry-card { +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-card { + min-height: 260px; +} +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-main-card, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-quality-card, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-score-screen-card, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-compare-card { grid-column: 1 / -1; } -.forecast-form { - grid-template-columns: repeat(5, minmax(130px, 1fr)); +.quantamental-form { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + align-items: end; } -.forecast-metric-grid { - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +.quantamental-score-screen-form { + grid-template-columns: minmax(150px, 0.8fr) minmax(120px, 0.45fr) minmax(120px, 0.45fr); + align-items: end; + margin-bottom: 10px; } -.forecast-chart-grid { +.quantamental-ai-control { + display: grid; + grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); + gap: 10px; + align-items: end; + margin: 0 0 12px; +} +.quantamental-ai-control label { + display: grid; + gap: 6px; + color: var(--text-secondary); +} +.quantamental-ai-control .form-notice { + margin: 0; + min-height: 36px; + align-content: center; +} +.quantamental-company { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} +.quantamental-company-header { display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; } -.forecast-chart-card { +.quantamental-company-header > div:first-child { + display: grid; + gap: 4px; +} +.quantamental-company-header > div:first-child strong { + font-size: 18px; +} +.quantamental-company-header > div:first-child span { + color: var(--text-mute); + font-size: 12px; +} +.quantamental-company h3 { + margin: 6px 0 4px; + font-size: 18px; +} +.quantamental-company p { + margin: 0; + color: var(--text-mute); + font-size: 12px; +} +.quantamental-company-metrics, +.quantamental-metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px; + min-width: min(480px, 100%); +} +.quantamental-signal-card { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; border: 1px solid var(--border); + background: var(--bg-elev-2); border-radius: 8px; - padding: 10px; - background: rgba(15, 23, 42, 0.28); - min-height: 190px; + padding: 14px; + margin-bottom: 12px; } -.forecast-chart-card strong { - display: block; - margin-bottom: 8px; +.quantamental-signal-card h2 { + margin: 6px 0 4px; + font-size: 22px; } -.forecast-svg { - width: 100%; - height: 150px; - color: var(--accent); - overflow: visible; +.quantamental-signal-card p { + margin: 0; + color: var(--text-mute); } -.forecast-scatter { - position: relative; - height: 150px; +.quantamental-signal-card.ok { border-color: rgba(106, 163, 122, 0.55); } +.quantamental-signal-card.warn { border-color: rgba(201, 164, 92, 0.55); } +.quantamental-signal-card.fail { border-color: rgba(194, 109, 109, 0.55); } +.quantamental-signal-score { + min-width: 120px; + text-align: right; +} +.quantamental-signal-score strong { + display: block; + font-size: 28px; +} +.quantamental-score-ring { + min-width: 112px; + min-height: 84px; + display: grid; + place-items: center; border: 1px solid var(--border); - background: - linear-gradient(90deg, transparent 49%, rgba(148, 163, 184, 0.25) 50%, transparent 51%), - linear-gradient(0deg, transparent 49%, rgba(148, 163, 184, 0.25) 50%, transparent 51%); + border-radius: 8px; + background: rgba(15, 23, 42, 0.38); } -.forecast-scatter span { - position: absolute; - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--accent); - transform: translate(-50%, -50%); - opacity: 0.75; +.quantamental-score-ring strong { + display: block; + font-size: 26px; } -.forecast-bars { +.quantamental-score-ring span { + color: var(--text-mute); + font-size: 11px; +} +.quantamental-signal-score span { + color: var(--text-mute); + font-size: 11px; +} +.quantamental-score-dashboard { display: grid; - gap: 7px; + gap: 10px; } -.forecast-bars div { +.quantamental-score-row { display: grid; - grid-template-columns: minmax(92px, 0.9fr) minmax(80px, 1.5fr); + grid-template-columns: minmax(160px, 0.7fr) minmax(160px, 1fr) 58px; align-items: center; - gap: 8px; - font-size: 11px; + gap: 10px; +} +.quantamental-score-row span { + display: block; + color: var(--text-mute); + font-size: 10px; +} +.quantamental-score-row em { + font-style: normal; + text-align: right; + font-family: var(--font-mono); color: var(--text-dim); } -.forecast-bars b { +.quantamental-score-track { + height: 7px; + border: 1px solid var(--border); + background: rgba(15, 23, 42, 0.6); + border-radius: 999px; + overflow: hidden; +} +.quantamental-score-track b { display: block; - height: 8px; - border-radius: 4px; + height: 100%; background: var(--accent); } -.forecast-signal-timeline { - display: flex; - align-items: stretch; - gap: 2px; - min-height: 96px; +.quantamental-score-row.ok .quantamental-score-track b, +.quantamental-factor-tile.ok .quantamental-score-track b { background: var(--bull); } +.quantamental-score-row.warn .quantamental-score-track b, +.quantamental-factor-tile.warn .quantamental-score-track b { background: var(--warn); } +.quantamental-score-row.fail .quantamental-score-track b, +.quantamental-factor-tile.fail .quantamental-score-track b { background: var(--bear); } +.quantamental-conflict { + margin-top: 12px; + font-size: 12px; + color: var(--text-dim); } -.forecast-signal-timeline span { - flex: 1; - min-width: 2px; - border-radius: 3px; - background: rgba(148, 163, 184, 0.35); +.quantamental-factor-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 10px; } -.forecast-signal-timeline .strong-bullish, -.forecast-signal-timeline .moderate-bullish { - background: var(--bull); +.quantamental-factor-tile, +.quantamental-factor { + border: 1px solid var(--border); + background: var(--bg-elev-2); + border-radius: 8px; + padding: 10px; } -.forecast-signal-timeline .strong-bearish, -.forecast-signal-timeline .moderate-bearish { - background: var(--bear); +.quantamental-factor-tile span, +.quantamental-factor span { + display: block; + color: var(--text-mute); + font-size: 11px; } -.forecast-heatmap { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(78px, 1fr)); +.quantamental-factor-tile strong, +.quantamental-factor strong { + display: block; + margin: 6px 0; + font-size: 22px; +} +.quantamental-subtabs, +.quantamental-tabs { + display: flex; + flex-wrap: wrap; gap: 6px; + margin-bottom: 12px; } -.forecast-heatmap span { +.quantamental-subtabs button, +.quantamental-tabs button { border: 1px solid var(--border); - border-radius: 6px; - color: #0f172a; - display: grid; - font-size: 0.72rem; - font-weight: 700; - min-height: 44px; - padding: 6px; + background: transparent; + color: var(--text-dim); + border-radius: 4px; + padding: 5px 9px; + cursor: pointer; } -.forecast-heatmap b { - font-size: 0.78rem; - font-weight: 800; +.quantamental-subtabs button.active, +.quantamental-tabs button.active { + color: var(--text); + border-color: var(--border-strong); + background: var(--accent-soft); } -.forecast-confusion { +.quantamental-overview-grid, +.quantamental-list-grid, +.quantamental-chart-grid { display: grid; - grid-template-columns: repeat(2, minmax(90px, 1fr)); - gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; } -.forecast-confusion span { +.quantamental-overview-grid section, +.quantamental-list-block, +.quantamental-metric-section, +.quantamental-ai-report section { border: 1px solid var(--border); - border-radius: 6px; - display: grid; - min-height: 54px; - padding: 8px; -} -.forecast-confusion b { - font-size: 1.1rem; + border-radius: 8px; + background: rgba(15, 23, 42, 0.28); + padding: 10px; } -.forecast-ai-text { - white-space: pre-wrap; - line-height: 1.6; - font-size: 12px; +.quantamental-overview-grid h4, +.quantamental-list-block h4, +.quantamental-ai-report h4 { + margin: 0 0 8px; } -.forecast-note-list { - margin: 10px 0 0; +.quantamental-list-block ul, +.quantamental-overview-grid ul { + margin: 0; padding-left: 18px; - color: var(--text-dim); } -.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] { - grid-template-columns: minmax(360px, 0.95fr) minmax(0, 1.35fr); +.quantamental-metric-section { + margin-top: 10px; } -.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-overview-card { - grid-column: 1 / -1; - order: 1; +.quantamental-metric-section summary { + cursor: pointer; + font-weight: 700; + margin-bottom: 10px; } -.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-ops-card { - grid-column: 1 / -1; - order: 2; +.quantamental-chart-card { + min-height: 230px; } -.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-operations-card { - grid-column: 1 / -1; - order: 3; +.quantamental-chart { + width: 100%; + height: 190px; + display: block; } -.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-create-card { - grid-column: 1 / -1; - order: 4; +.quantamental-tab-panel { + min-width: 0; } -.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-recommendation-card { - grid-column: 1 / -1; - order: 5; +.quantamental-warning-list { + margin: 10px 0 0; + padding-left: 18px; + color: var(--text-dim); } -.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-performance-card { - order: 6; +.quantamental-company-header { + display: grid; + grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.2fr); + gap: 12px; + align-items: start; } -.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-compliance-card { - order: 7; +.quantamental-company-header strong { + display: block; + font-size: 18px; + margin-bottom: 4px; } -.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-rebalance-card { - order: 8; +.quantamental-company-header span, +.quantamental-signal-card .muted { + color: var(--text-mute); + font-size: 12px; } -.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-reports-card { - order: 9; +.quantamental-score-ring { + min-width: 96px; + aspect-ratio: 1; + border: 1px solid var(--border); + border-radius: 999px; + display: grid; + place-items: center; + text-align: center; + background: var(--bg-elev-2); } -.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-history-card { - grid-column: 1 / -1; - order: 10; +.quantamental-score-ring span { + display: block; + font-family: var(--font-mono); + font-size: 24px; + font-weight: 700; } -.dashboard-surface-grid[data-dashboard-tab="macro"] { - grid-template-columns: minmax(340px, 0.95fr) minmax(0, 1.35fr); +.quantamental-score-ring small { + display: block; + color: var(--text-mute); + font-size: 10px; + text-transform: uppercase; } -.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-overview-card { - grid-column: 1 / -1; - order: 1; +.quantamental-factor { + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + background: var(--bg-elev-2); + min-height: 104px; } -.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-coverage-card { - grid-column: 1 / -1; - order: 2; +.quantamental-factor span { + display: block; + color: var(--text-mute); + font-size: 11px; } -.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-indicators-card { - grid-column: 1 / -1; - order: 3; +.quantamental-factor strong { + display: block; + margin: 8px 0; + font-size: 22px; } -.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-chart-card { - grid-column: 1 / -1; - order: 4; +.quantamental-factor b { + display: block; + height: 7px; + border-radius: 999px; + background: var(--accent); } -.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-category-card { - order: 5; +.quantamental-tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; } -.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-regime-card { - grid-column: 1 / -1; - order: 6; +.quantamental-tabs button { + border: 1px solid var(--border); + background: transparent; + color: var(--text-dim); + border-radius: 4px; + padding: 5px 9px; + cursor: pointer; } -.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-impact-card { - grid-column: 1 / -1; - order: 7; +.quantamental-tabs button.active { + color: var(--text); + border-color: var(--border-strong); + background: var(--accent-soft); } -.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-hints-card { - grid-column: 1 / -1; - order: 8; +.quantamental-tab-panel h4 { + margin: 0 0 10px; } -.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-brief-card { - grid-column: 1 / -1; - order: 9; +.quantamental-chart { + width: 100%; + height: 150px; + display: block; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-elev-2); } -.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-quality-card { - grid-column: 1 / -1; - order: 10; +.quantamental-warning-list { + margin: 8px 0 0; + padding-left: 18px; + color: var(--text-mute); } -.macro-quick-actions { +.quantamental-chart-legend { display: flex; flex-wrap: wrap; gap: 8px; - margin: 0 0 10px; + margin-top: 6px; + color: var(--text-mute); + font-size: 10px; } -.macro-status-strip, -.macro-signal-grid, -.macro-quality-grid { +.quantamental-chart-legend b { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 999px; + margin-right: 4px; +} +.quantamental-qa-form { display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + grid-template-columns: minmax(0, 1fr) auto; gap: 8px; + align-items: start; + margin-bottom: 10px; } -.macro-status-item { +.quantamental-qa-form textarea { + width: 100%; + resize: vertical; +} +.dashboard-surface-grid[data-dashboard-tab="forecast"] { + grid-template-columns: minmax(340px, 0.9fr) minmax(0, 1.3fr); +} +.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-setup-card, +.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-viz-card, +.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-jobs-card, +.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-history-card, +.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-registry-card { + grid-column: 1 / -1; +} +.forecast-form { + grid-template-columns: repeat(5, minmax(130px, 1fr)); +} +.forecast-metric-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} +.forecast-chart-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; +} +.forecast-chart-card { border: 1px solid var(--border); - border-radius: 6px; - background: rgba(15,23,42,0.52); + border-radius: 8px; padding: 10px; + background: rgba(15, 23, 42, 0.28); + min-height: 190px; } -.macro-status-item span { +.forecast-chart-card strong { display: block; - color: var(--text-mute); - font-family: var(--font-mono); - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 5px; + margin-bottom: 8px; } -.macro-status-item strong { - color: var(--text); - font-size: 13px; - overflow-wrap: anywhere; +.forecast-svg { + width: 100%; + height: 150px; + color: var(--accent); + overflow: visible; } -.macro-chart-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; +.forecast-scatter { + position: relative; + height: 150px; + border: 1px solid var(--border); + background: + linear-gradient(90deg, transparent 49%, rgba(148, 163, 184, 0.25) 50%, transparent 51%), + linear-gradient(0deg, transparent 49%, rgba(148, 163, 184, 0.25) 50%, transparent 51%); } -.macro-chart-grid .decision-chart { - min-height: 220px; +.forecast-scatter span { + position: absolute; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent); + transform: translate(-50%, -50%); + opacity: 0.75; } -.macro-coverage-grid { +.forecast-bars { display: grid; - grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 7px; +} +.forecast-bars div { + display: grid; + grid-template-columns: minmax(92px, 0.9fr) minmax(80px, 1.5fr); + align-items: center; gap: 8px; + font-size: 11px; + color: var(--text-dim); } -.macro-coverage-section { - margin-top: 12px; +.forecast-bars b { + display: block; + height: 8px; + border-radius: 4px; + background: var(--accent); } -.macro-coverage-chips { +.forecast-signal-timeline { display: flex; - flex-wrap: wrap; - gap: 7px; + align-items: stretch; + gap: 2px; + min-height: 96px; } -.macro-coverage-chip { - display: inline-flex; - align-items: center; - gap: 7px; - min-height: 28px; - border: 1px solid rgba(148, 163, 184, 0.20); - border-radius: 6px; - background: rgba(20, 24, 31, 0.52); - padding: 5px 8px; +.forecast-signal-timeline span { + flex: 1; + min-width: 2px; + border-radius: 3px; + background: rgba(148, 163, 184, 0.35); } -.macro-coverage-chip strong { - color: var(--text); - font-size: 11px; +.forecast-signal-timeline .strong-bullish, +.forecast-signal-timeline .moderate-bullish { + background: var(--bull); } -.macro-coverage-chip em { - color: var(--accent-hover); - font-family: var(--font-mono); - font-size: 11px; - font-style: normal; +.forecast-signal-timeline .strong-bearish, +.forecast-signal-timeline .moderate-bearish { + background: var(--bear); } -.macro-coverage-meta { +.forecast-heatmap { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - margin-top: 10px; + grid-template-columns: repeat(auto-fit, minmax(78px, 1fr)); + gap: 6px; } -.macro-coverage-meta div { - border: 1px solid rgba(148, 163, 184, 0.14); +.forecast-heatmap span { + border: 1px solid var(--border); + border-radius: 6px; + color: #0f172a; + display: grid; + font-size: 0.72rem; + font-weight: 700; + min-height: 44px; + padding: 6px; +} +.forecast-heatmap b { + font-size: 0.78rem; + font-weight: 800; +} +.forecast-confusion { + display: grid; + grid-template-columns: repeat(2, minmax(90px, 1fr)); + gap: 8px; +} +.forecast-confusion span { + border: 1px solid var(--border); + border-radius: 6px; + display: grid; + min-height: 54px; + padding: 8px; +} +.forecast-confusion b { + font-size: 1.1rem; +} +.forecast-ai-text { + white-space: pre-wrap; + line-height: 1.6; + font-size: 12px; +} +.forecast-note-list { + margin: 10px 0 0; + padding-left: 18px; + color: var(--text-dim); +} +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] { + grid-template-columns: minmax(360px, 0.95fr) minmax(0, 1.35fr); +} +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-overview-card { + grid-column: 1 / -1; + order: 1; +} +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-ops-card { + grid-column: 1 / -1; + order: 2; +} +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-operations-card { + grid-column: 1 / -1; + order: 3; +} +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-create-card { + grid-column: 1 / -1; + order: 4; +} +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-recommendation-card { + grid-column: 1 / -1; + order: 5; +} +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-performance-card { + order: 6; +} +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-compliance-card { + order: 7; +} +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-rebalance-card { + order: 8; +} +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-reports-card { + order: 9; +} +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-history-card { + grid-column: 1 / -1; + order: 10; +} +.dashboard-surface-grid[data-dashboard-tab="macro"] { + grid-template-columns: minmax(340px, 0.95fr) minmax(0, 1.35fr); +} +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-overview-card { + grid-column: 1 / -1; + order: 1; +} +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-coverage-card { + grid-column: 1 / -1; + order: 2; +} +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-indicators-card { + grid-column: 1 / -1; + order: 3; +} +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-chart-card { + grid-column: 1 / -1; + order: 4; +} +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-category-card { + order: 5; +} +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-regime-card { + grid-column: 1 / -1; + order: 6; +} +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-impact-card { + grid-column: 1 / -1; + order: 7; +} +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-hints-card { + grid-column: 1 / -1; + order: 8; +} +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-brief-card { + grid-column: 1 / -1; + order: 9; +} +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-quality-card { + grid-column: 1 / -1; + order: 10; +} +.macro-quick-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 10px; +} +.macro-status-strip, +.macro-signal-grid, +.macro-quality-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; +} +.macro-status-item { + border: 1px solid var(--border); + border-radius: 6px; + background: rgba(15,23,42,0.52); + padding: 10px; +} +.macro-status-item span { + display: block; + color: var(--text-mute); + font-family: var(--font-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 5px; +} +.macro-status-item strong { + color: var(--text); + font-size: 13px; + overflow-wrap: anywhere; +} +.macro-chart-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.macro-chart-grid .decision-chart { + min-height: 220px; +} +.macro-coverage-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 8px; +} +.macro-coverage-section { + margin-top: 12px; +} +.macro-coverage-chips { + display: flex; + flex-wrap: wrap; + gap: 7px; +} +.macro-coverage-chip { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 28px; + border: 1px solid rgba(148, 163, 184, 0.20); + border-radius: 6px; + background: rgba(20, 24, 31, 0.52); + padding: 5px 8px; +} +.macro-coverage-chip strong { + color: var(--text); + font-size: 11px; +} +.macro-coverage-chip em { + color: var(--accent-hover); + font-family: var(--font-mono); + font-size: 11px; + font-style: normal; +} +.macro-coverage-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} +.macro-coverage-meta div { + border: 1px solid rgba(148, 163, 184, 0.14); border-radius: 6px; background: rgba(2, 6, 23, 0.22); padding: 9px; @@ -1830,6 +2256,22 @@ input[type="range"]::-moz-range-thumb { .input-action-row.compact { grid-template-columns: minmax(0, 1fr) minmax(56px, auto); } +.input-action-row.compact-actions { + grid-template-columns: minmax(120px, auto) minmax(96px, auto) minmax(120px, 1fr) minmax(120px, 1fr) repeat(3, minmax(64px, auto)); + align-items: end; +} +.inline-check { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 38px; + white-space: nowrap; +} +.inline-check input[type="checkbox"] { + width: 16px; + min-width: 16px; + height: 16px; +} .input-action-row input { min-width: 0; } @@ -4629,6 +5071,36 @@ input[type="date"]:focus { display: none; } +.dashboard-surface-grid[data-panel-view="all"] [data-panel-tier] .home-card-head { + flex-wrap: wrap; +} + +.dashboard-surface-grid[data-panel-view="all"] [data-panel-tier] .home-card-head::before { + order: -1; + flex: 0 0 100%; + width: fit-content; + margin-bottom: 2px; + color: var(--accent-hover); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 800; + line-height: 1; + letter-spacing: 0; + text-transform: uppercase; +} + +.dashboard-surface-grid[data-panel-view="all"] [data-panel-tier="primary"] .home-card-head::before { + content: "Core"; +} + +.dashboard-surface-grid[data-panel-view="all"] [data-panel-tier="details"] .home-card-head::before { + content: "Diagnostics"; +} + +.dashboard-surface-grid[data-panel-view="all"] [data-panel-tier="operations"] .home-card-head::before { + content: "Operations"; +} + .home-card-head { align-items: flex-start; margin-bottom: 12px; @@ -4735,6 +5207,7 @@ input[type="date"]:focus { .control-panel { position: static; max-height: none; } .home-grid { grid-template-columns: 1fr; } .dashboard-surface-grid[data-dashboard-tab="quant"], + .dashboard-surface-grid[data-dashboard-tab="quantamental"], .dashboard-surface-grid[data-dashboard-tab="ai-portfolio"], .dashboard-surface-grid[data-dashboard-tab="macro"], .dashboard-surface-grid[data-dashboard-tab="forecast"] { grid-template-columns: 1fr; } @@ -4904,6 +5377,7 @@ input[type="date"]:focus { } .topnav .ghost-btn, + .language-toggle, .preflight-pill, .health-pill { justify-content: center; @@ -7525,6 +7999,9 @@ input[type="range"]::-webkit-slider-thumb { } @media (max-width: 900px) { + .input-action-row.compact-actions { + grid-template-columns: 1fr 1fr; + } .topbar { position: relative; flex: 0 0 auto; @@ -7670,6 +8147,7 @@ input[type="range"]::-webkit-slider-thumb { @media (max-width: 380px) { .topnav .ghost-btn, + .language-toggle, .health-pill, .preflight-pill { flex: 1 1 auto; @@ -7714,51 +8192,1797 @@ html[data-theme="light"] { --accent-primary: #2563eb; --accent-secondary: #0891b2; --accent-ink: #ffffff; - --positive: #047857; - --negative: #b91c1c; - --warning: #b45309; - --neutral: #64748b; - --shadow-soft: 0 18px 45px rgba(15, 23, 42, 0.08); - --shadow-panel: 0 1px 0 rgba(15, 23, 42, 0.04), 0 18px 50px rgba(15, 23, 42, 0.08); - --bg: var(--bg-primary); - --bg-elev: var(--surface-primary); - --bg-elev-2: var(--surface-secondary); - --bg-hover: rgba(37, 99, 235, 0.08); - --border: var(--border-subtle); - --text: var(--text-primary); - --text-dim: var(--text-secondary); - --text-mute: var(--text-muted); - --accent: var(--accent-primary); - --accent-hover: #1d4ed8; - --accent-soft: rgba(37, 99, 235, 0.11); - --bull: var(--positive); - --bear: var(--negative); - --warn: var(--warning); - --info: var(--accent-primary); - --shadow: var(--shadow-panel); + --positive: #047857; + --negative: #b91c1c; + --warning: #b45309; + --neutral: #64748b; + --shadow-soft: 0 18px 45px rgba(15, 23, 42, 0.08); + --shadow-panel: 0 1px 0 rgba(15, 23, 42, 0.04), 0 18px 50px rgba(15, 23, 42, 0.08); + --bg: var(--bg-primary); + --bg-elev: var(--surface-primary); + --bg-elev-2: var(--surface-secondary); + --bg-hover: rgba(37, 99, 235, 0.08); + --border: var(--border-subtle); + --text: var(--text-primary); + --text-dim: var(--text-secondary); + --text-mute: var(--text-muted); + --accent: var(--accent-primary); + --accent-hover: #1d4ed8; + --accent-soft: rgba(37, 99, 235, 0.11); + --bull: var(--positive); + --bear: var(--negative); + --warn: var(--warning); + --info: var(--accent-primary); + --shadow: var(--shadow-panel); +} + +html[data-theme="light"], +html[data-theme="light"] body { + background: #f6f8fb; + color: var(--text-primary); +} + +html[data-theme="light"] .topbar { + background: rgba(255, 255, 255, 0.92); + border-bottom-color: var(--border-subtle); + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04); +} + +html[data-theme="light"] .control-panel, +html[data-theme="light"] .results-panel, +html[data-theme="light"] .home-card, +html[data-theme="light"] .home-hero, +html[data-theme="light"] .tab-panel, +html[data-theme="light"] .preflight-panel, +html[data-theme="light"] .symbol-picker-panel, +html[data-theme="light"] .forecast-detail-panel, +html[data-theme="light"] .dashboard-card, +html[data-theme="light"] .decision-surface, +html[data-theme="light"] .market-tape-card, +html[data-theme="light"] .market-signal-row, +html[data-theme="light"] .macro-series-result, +html[data-theme="light"] .macro-playbook-card, +html[data-theme="light"] .macro-portfolio-step, +html[data-theme="light"] .forecast-chart-card, +html[data-theme="light"] .ai-investment-card, +html[data-theme="light"] .ai-coverage-cell, +html[data-theme="light"] .ai-snapshot-point { + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + border-color: var(--border-subtle); + color: var(--text-primary); + box-shadow: var(--shadow-panel); +} + +html[data-theme="light"] input, +html[data-theme="light"] select, +html[data-theme="light"] textarea, +html[data-theme="light"] .mode-toggle label, +html[data-theme="light"] .ticker-chips button, +html[data-theme="light"] .preset-chips button, +html[data-theme="light"] .dashboard-tab, +html[data-theme="light"] .tab, +html[data-theme="light"] .dashboard-view-controls button, +html[data-theme="light"] .ghost-btn, +html[data-theme="light"] .health-pill, +html[data-theme="light"] .pipeline-pill, +html[data-theme="light"] .preflight-pill { + background: #ffffff; + border-color: var(--border-subtle); + color: var(--text-primary); +} + +html[data-theme="light"] .brand-sub, +html[data-theme="light"] .muted, +html[data-theme="light"] .hint, +html[data-theme="light"] .small, +html[data-theme="light"] .home-card-head span, +html[data-theme="light"] .field-header-row .hint { + color: var(--text-muted); +} + +html[data-theme="light"] .run-btn, +html[data-theme="light"] .dashboard-tab.active, +html[data-theme="light"] .tab.active, +html[data-theme="light"] .dashboard-view-controls button.active { + background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%); + border-color: #1d4ed8; + color: #ffffff; +} + +html[data-theme="light"] .report-md, +html[data-theme="light"] .code-block, +html[data-theme="light"] .mini-code, +html[data-theme="light"] pre, +html[data-theme="light"] code { + background: #0f172a; + color: #e5edf8; +} + +html[data-theme="light"] .chart-control-bar { + background: #f8fafc; + border-color: var(--border-subtle); +} + +html[data-theme="light"] .internal-chart-shell, +html[data-theme="light"] .internal-chart-shell .decision-chart { + background: #ffffff; + border-color: var(--border-subtle); +} + +.layout { + width: min(100%, var(--app-shell-max)); + grid-template-columns: minmax(0, 1fr); + padding-left: calc(var(--control-rail-width) + 32px); +} + +.control-panel { + position: fixed; + left: 18px; + top: 86px; + width: min(var(--control-dock-width), calc(100vw - 36px)); + max-height: calc(100vh - 104px); + z-index: 36; + transition: width 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease; +} + +.control-panel.is-collapsed { + width: var(--control-rail-width); + padding: 10px; + overflow: hidden; +} + +.control-panel.is-collapsed > #analysisForm, +.control-panel.is-collapsed > form, +.control-panel.is-collapsed > .history-block, +.control-panel.is-collapsed > .watchlist-block { + display: none !important; +} + +.control-panel.is-collapsed .command-panel-toggle { + min-height: 220px; + padding: 10px 0; + writing-mode: vertical-rl; + text-orientation: mixed; + align-items: center; + justify-content: center; + gap: 10px; + border-bottom: 0; +} + +.control-panel.is-collapsed .command-panel-label { + display: none; +} + +.control-panel.is-collapsed .command-panel-state { + white-space: nowrap; +} + +.results-panel { + width: 100%; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .home-chart-card { + order: 1; + grid-column: 1 / -1; + min-height: 560px; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .market-overview-card { + order: 2; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .market-signals-card { + order: 3; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .home-heatmap-card { + order: 4; + grid-column: 1 / -1; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .home-market-panel { + display: none !important; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .data-mart-card { + order: 5; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .home-news-card { + order: 6; +} + +.chart-control-bar { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)) auto; + gap: 10px; + align-items: end; + margin: 12px 0 14px; + padding: 12px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: rgba(8, 11, 16, 0.36); +} + +.chart-control-bar label { + display: grid; + gap: 6px; + min-width: 0; +} + +.chart-control-bar label span { + color: var(--text-muted); + font-size: 11px; + font-weight: 740; + letter-spacing: 0.04em; +} + +.chart-control-bar select, +.chart-control-bar button { + min-height: 36px; +} + +.internal-chart-shell { + display: grid; + gap: 12px; + height: 100%; + min-height: 420px; + padding: 14px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: linear-gradient(180deg, rgba(8, 11, 16, 0.64), rgba(8, 11, 16, 0.36)); +} + +.internal-chart-head, +.internal-chart-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.internal-chart-head strong { + display: block; + color: var(--text-primary); + font-size: 14px; +} + +.internal-chart-head span, +.internal-chart-foot span { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; +} + +.internal-chart-head b { + font-family: var(--font-mono); + font-size: 15px; +} + +.internal-chart-head b.ok { + color: var(--positive); +} + +.internal-chart-head b.warn { + color: var(--negative); +} + +.internal-ohlc-chart { + width: 100%; + min-height: 300px; + color: var(--accent-secondary); +} + +.internal-candle line { + stroke: currentColor; + stroke-width: 1.4; + vector-effect: non-scaling-stroke; +} + +.internal-candle rect { + stroke: currentColor; + stroke-width: 1.1; + vector-effect: non-scaling-stroke; +} + +.internal-candle.up { + color: var(--positive); +} + +.internal-candle.up rect { + fill: rgba(84, 198, 139, 0.32); +} + +.internal-candle.down { + color: var(--negative); +} + +.internal-candle.down rect { + fill: rgba(224, 113, 113, 0.32); +} + +.internal-close-line { + fill: none; + stroke: var(--accent-primary); + stroke-width: 2; + vector-effect: non-scaling-stroke; +} + +.internal-chart-shell .decision-chart { + margin-top: 2px; + background: rgba(8, 11, 16, 0.34); +} + +.market-tape-returns { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + margin-top: 4px; +} + +.market-tape-returns .market-tape-return { + margin-top: 0; +} + +.market-tape-return.warn { + color: var(--warning); +} + +.market-tape-meta span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cross-asset-workbench, +.news-search-workbench { + display: grid; + grid-template-columns: minmax(220px, 1.4fr) auto minmax(110px, 0.5fr) minmax(220px, 1fr) auto; + gap: 10px; + align-items: end; + margin: 12px 0; + padding: 12px; + border: 1px solid rgba(198, 196, 181, 0.12); + border-radius: var(--radius-md); + background: rgba(7, 8, 6, 0.52); +} + +.news-search-workbench { + grid-template-columns: minmax(110px, 0.45fr) auto minmax(240px, 1fr) auto; +} + +.cross-asset-workbench label, +.news-search-workbench label { + display: grid; + gap: 6px; + min-width: 0; +} + +.cross-asset-workbench label span, +.news-search-workbench label span { + color: var(--text-muted); + font-size: 11px; + font-weight: 740; +} + +.cross-asset-status, +.news-search-status { + grid-column: 1 / -1; + min-height: 18px; + color: var(--text-muted); + font-size: 11px; +} + +.market-signal-subhead { + margin: 14px 0 8px; + color: var(--text-muted); + font-size: 11px; + font-weight: 780; +} + +.cross-asset-analysis-surface { + display: grid; + gap: 12px; +} + +.cross-asset-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.cross-asset-summary > div { + min-width: 0; + padding: 10px; + border: 1px solid rgba(198, 196, 181, 0.12); + border-radius: var(--radius-sm); + background: rgba(9, 12, 10, 0.62); +} + +.cross-asset-summary span, +.cross-asset-row-meta, +.focused-news-head span, +.developer-detail summary { + color: var(--text-muted); + font-size: 11px; +} + +.cross-asset-summary strong { + display: block; + margin-top: 4px; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 17px; +} + +.cross-asset-narrative { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.cross-asset-narrative section, +.developer-detail { + border: 1px solid rgba(198, 196, 181, 0.12); + border-radius: var(--radius-sm); + background: rgba(9, 12, 10, 0.42); +} + +.cross-asset-narrative section { + padding: 12px; +} + +.cross-asset-narrative h4 { + margin: 0 0 6px; + color: var(--text-primary); + font-size: 13px; +} + +.cross-asset-narrative p { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} + +.cross-asset-role-strip, +.cross-asset-watch { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.cross-asset-role-strip span, +.cross-asset-watch span { + padding: 6px 8px; + border: 1px solid rgba(198, 196, 181, 0.12); + border-radius: var(--radius-sm); + background: rgba(7, 8, 6, 0.52); + color: var(--text-secondary); + font-size: 11px; +} + +.cross-asset-bars { + display: grid; + gap: 8px; +} + +.cross-asset-row { + padding: 10px; + border: 1px solid rgba(198, 196, 181, 0.12); + border-radius: var(--radius-sm); + background: rgba(7, 8, 6, 0.5); +} + +.cross-asset-row-head { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) auto; + gap: 8px; + align-items: baseline; +} + +.cross-asset-row-head strong, +.focused-news-head strong { + color: var(--text-primary); +} + +.cross-asset-row-head span { + min-width: 0; + overflow: hidden; + color: var(--text-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cross-asset-row-head b { + font-family: var(--font-mono); +} + +.cross-asset-row.up .cross-asset-row-head b, +.cross-asset-row.up .cross-asset-bar-track i { + color: var(--positive); + background: linear-gradient(90deg, rgba(84, 198, 139, 0.42), rgba(84, 198, 139, 0.82)); +} + +.cross-asset-row.down .cross-asset-row-head b, +.cross-asset-row.down .cross-asset-bar-track i { + color: var(--negative); + background: linear-gradient(90deg, rgba(224, 113, 113, 0.4), rgba(224, 113, 113, 0.82)); +} + +.cross-asset-bar-track { + height: 8px; + margin: 8px 0; + overflow: hidden; + border-radius: 999px; + background: rgba(198, 196, 181, 0.08); +} + +.cross-asset-bar-track i { + display: block; + height: 100%; + border-radius: inherit; +} + +.cross-asset-row-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + font-family: var(--font-mono); +} + +.developer-detail { + padding: 8px 10px; +} + +.developer-detail pre { + max-height: 180px; + overflow: auto; + margin: 8px 0 0; + color: var(--text-muted); + font-size: 11px; +} + +.internal-chart-scroll { + width: 100%; + overflow-x: auto; + overflow-y: hidden; + border: 1px solid rgba(198, 196, 181, 0.1); + border-radius: var(--radius-sm); + background: rgba(4, 6, 5, 0.38); + scrollbar-color: rgba(103, 212, 188, 0.46) rgba(198, 196, 181, 0.08); +} + +.internal-chart-scroll .internal-ohlc-chart { + display: block; + width: max-content; + min-width: 100%; + overflow: visible; +} + +.internal-chart-hint { + margin-top: -4px; + color: var(--text-muted); + font-size: 11px; +} + +.focused-news-list { + margin: 10px 0 12px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(198, 196, 181, 0.1); +} + +.focused-news-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} + +.home-news-card.focused { + border-color: rgba(103, 212, 188, 0.2); + background: rgba(9, 12, 10, 0.62); +} + +.home-news-summary { + margin: 6px 0 0; + color: var(--text-muted); + font-size: 11px; + line-height: 1.55; +} + +.home-stock-heatmap.finviz-treemap { + height: min(74vh, 820px); + padding: 4px 4px 42px; + background: #11151c; + border-color: rgba(198, 196, 181, 0.22); +} + +.finviz-sector.stock-heatmap-sector { + border-radius: 2px; + border: 1px solid rgba(4, 6, 10, 0.92); + background: #161b23; +} + +.finviz-sector-title.stock-sector-title { + min-height: 24px; + padding: 3px 6px; + background: linear-gradient(180deg, #303845, #242b35); +} + +.finviz-sector-title.stock-sector-title small { + display: inline; + color: #aeb7c5; + font-size: 9px; +} + +.finviz-sector-body { + top: 25px; +} + +.finviz-heatmap-tile.stock-heatmap-tile { + border-radius: 1px; + border: 1px solid rgba(2, 6, 23, 0.72); + background: var(--heat-bg); +} + +.finviz-heatmap-tile.stock-heatmap-tile:hover { + z-index: 4; + outline: 2px solid rgba(255, 255, 255, 0.42); +} + +@media (max-width: 1100px) { + .cross-asset-workbench, + .news-search-workbench { + grid-template-columns: 1fr 1fr; + } + + .cross-asset-workbench .wide, + .news-search-workbench .wide, + .cross-asset-status, + .news-search-status { + grid-column: 1 / -1; + } + + .cross-asset-summary, + .cross-asset-narrative { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 720px) { + .cross-asset-workbench, + .news-search-workbench, + .cross-asset-summary, + .cross-asset-narrative { + grid-template-columns: 1fr; + } + + .cross-asset-row-head { + grid-template-columns: 64px minmax(0, 1fr); + } + + .cross-asset-row-head b { + grid-column: 1 / -1; + } + + .focused-news-head { + align-items: flex-start; + flex-direction: column; + } +} + +.theme-toggle { + min-width: 58px; +} + +.theme-toggle[aria-pressed="true"] { + color: var(--accent-ink); + background: linear-gradient(180deg, #f8fafc, #dbe7ff); + border-color: rgba(143, 180, 255, 0.6); +} + +@media (max-width: 1180px) { + .layout { + grid-template-columns: minmax(0, 1fr); + padding-left: calc(var(--control-rail-width) + 22px); + } + + .control-panel { + top: 92px; + } +} + +@media (max-width: 900px) { + .layout { + grid-template-columns: 1fr; + padding-left: 18px; + } + + .control-panel { + position: static; + width: auto; + max-height: none; + transform: none; + } + + .control-panel.is-collapsed { + width: auto; + padding: 14px; + } + + .control-panel.is-collapsed .command-panel-toggle { + min-height: 44px; + writing-mode: horizontal-tb; + flex-direction: row; + justify-content: space-between; + } + + .control-panel.is-collapsed .command-panel-label { + display: inline; + } + + .chart-control-bar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .chart-control-bar button { + grid-column: 1 / -1; + } +} + +@media (max-width: 640px) { + .layout { + padding-left: 14px; + } + + .dashboard-surface-grid[data-dashboard-tab="market"] .home-chart-card { + min-height: 420px; + } + + .chart-control-bar { + grid-template-columns: 1fr; + } + + .chart-control-bar button { + width: 100%; + } +} + +/* 2026-05-15/16: decision-priority tab layout and dashboard decision context. */ +body.command-panel-expanded .layout { + padding-left: calc(var(--control-dock-width) + 32px); +} + +.home-dashboard-tabs { + flex-wrap: nowrap; + max-width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; +} + +.home-dashboard-tabs::-webkit-scrollbar { + display: none; +} + +.home-dashboard-tabs .dashboard-tab { + flex: 0 0 auto; +} + +.dashboard-context-strip .decision-card-chip { + display: flex; + min-height: 54px; + flex-direction: column; + justify-content: flex-start; +} + +.dashboard-context-strip .decision-card-chip.ok { + border-color: rgba(94, 194, 164, 0.28); +} + +.dashboard-context-strip .decision-card-chip.warn { + border-color: rgba(245, 184, 82, 0.34); +} + +.dashboard-context-strip .decision-card-chip small { + display: block; + margin-top: 4px; + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 10px; + line-height: 1.25; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .market-overview-card { + order: 1; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .market-signals-card { + order: 2; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .data-mart-card { + order: 3; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .home-heatmap-card { + order: 4; + grid-column: 1 / -1; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .home-chart-card { + order: 5; + grid-column: 1 / -1; + min-height: 440px; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .tv-overview { + height: 360px; +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .home-news-card { + order: 6; +} + +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-overview-card { + order: 1; +} + +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-quality-card { + order: 2; +} + +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-coverage-card { + order: 3; +} + +.dashboard-surface-grid[data-dashboard-tab="macro"] .macro-search-card { + order: 4; + grid-column: 1 / -1; +} + +.dashboard-surface-grid[data-dashboard-tab="quant"] .backtest-card { + order: 1; +} + +.dashboard-surface-grid[data-dashboard-tab="quant"] .portfolio-card { + order: 2; +} + +.dashboard-surface-grid[data-dashboard-tab="quant"] .asset-detail-card { + order: 3; +} + +.dashboard-surface-grid[data-dashboard-tab="quant"] .feature-card { + order: 4; +} + +.dashboard-surface-grid[data-dashboard-tab="quant"] .signal-card { + order: 5; +} + +.dashboard-surface-grid[data-dashboard-tab="quant"] .strategy-governance-card { + order: 6; +} + +.dashboard-surface-grid[data-dashboard-tab="quant"] .quant-run-history-card { + order: 7; +} + +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-card { + min-height: 220px; +} + +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-signal-card-shell, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-score-card, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-screen-card, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-score-screen-card { + min-height: 220px; +} + +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-screen-card, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-score-screen-card, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-factor-card, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-main-card, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-quality-card, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-compare-card { + grid-column: 1 / -1; +} + +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-screen-card { + order: 4; +} + +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-score-screen-card { + order: 5; +} + +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-factor-card { + order: 6; +} + +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-main-card { + order: 7; +} + +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-quality-card { + order: 8; +} + +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-compare-card { + order: 9; +} + +.quantamental-overview-brief { + display: grid; + gap: 10px; + margin-bottom: 12px; +} + +.quantamental-coverage-strip { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 12px; + color: var(--text-mute); + font-size: 12px; +} + +.quantamental-coverage-strip strong, +.quantamental-coverage-strip span { + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 8px; + background: var(--bg-elev-2); +} + +.quantamental-chart-card { + display: grid; + gap: 8px; + align-content: start; +} + +.quantamental-chart text { + fill: var(--text-mute); + font-size: 10px; +} + +.quantamental-chart-axis { + display: flex; + justify-content: space-between; + gap: 8px; + color: var(--text-mute); + font-size: 11px; +} + +.quantamental-chart-note { + margin: 0; + color: var(--text-dim); + font-size: 12px; + line-height: 1.45; +} + +.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-setup-card { + order: 1; +} + +.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-dataset-card { + order: 2; +} + +.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-leakage-card { + order: 3; +} + +.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-result-card { + order: 4; +} + +.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-feature-card { + order: 5; +} + +.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-signal-card { + order: 6; +} + +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-overview-card { + order: 1; +} + +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-recommendation-card { + order: 2; +} + +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-create-card { + order: 3; +} + +@media (max-width: 1180px) { + body.command-panel-expanded .layout { + padding-left: calc(var(--control-dock-width) + 22px); + } +} + +@media (max-width: 900px) { + body.command-panel-expanded .layout { + padding-left: 18px; + } +} + +@media (max-width: 640px) { + .home-dashboard-tabs { + gap: 6px; + padding-bottom: 8px; + } + + .home-dashboard-tabs .dashboard-tab { + min-width: max-content; + } + + .dashboard-surface-grid[data-dashboard-tab="market"] .home-chart-card { + min-height: 360px; + } + + .dashboard-surface-grid[data-dashboard-tab="market"] .tv-overview { + height: 300px; + } +} + +/* ============================================================= + 2026-05-19 Product Polish Layer + UI-only treatment for an institutional finance workbench. + Preserves DOM ids, routes, API calls, data-testid hooks, and JS bindings. + ============================================================= */ + +:root { + color-scheme: dark; + --bg-primary: #070806; + --bg-secondary: #0b0d0b; + --bg-tertiary: #11140f; + --surface-primary: #10130f; + --surface-secondary: #151914; + --surface-elevated: #1b201a; + --surface-inset: #080a08; + --text-primary: #f3f1e8; + --text-secondary: #c8c7bb; + --text-muted: #8c9186; + --border-subtle: rgba(198, 196, 181, 0.14); + --border-strong: rgba(198, 196, 181, 0.3); + --accent-primary: #67d4bc; + --accent-secondary: #b6c7ff; + --accent-ink: #06120f; + --positive: #61bf86; + --negative: #d7766d; + --warning: #d3a24c; + --neutral: #a4a89c; + --shadow-soft: 0 16px 32px rgba(0, 0, 0, 0.22); + --shadow-panel: 0 1px 0 rgba(255, 255, 255, 0.035), 0 20px 42px rgba(0, 0, 0, 0.26); + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --bg: var(--bg-primary); + --bg-elev: var(--surface-primary); + --bg-elev-2: var(--surface-secondary); + --bg-hover: rgba(103, 212, 188, 0.08); + --border: var(--border-subtle); + --border-strong: rgba(198, 196, 181, 0.3); + --text: var(--text-primary); + --text-dim: var(--text-secondary); + --text-mute: var(--text-muted); + --accent: var(--accent-primary); + --accent-hover: #dbfff5; + --accent-soft: rgba(103, 212, 188, 0.1); + --bull: var(--positive); + --bear: var(--negative); + --warn: var(--warning); + --info: var(--accent-secondary); + --radius: var(--radius-md); + --shadow: var(--shadow-panel); +} + +html { + background: #070806; +} + +body { + background: + linear-gradient(180deg, rgba(8, 9, 7, 0.98) 0%, rgba(11, 13, 11, 0.98) 52%, rgba(8, 9, 7, 1) 100%), + repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.012) 0 1px, transparent 1px 64px); + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.topbar { + min-height: 64px; + padding: 12px clamp(16px, 2vw, 30px); + background: rgba(7, 8, 6, 0.92); + border-bottom: 1px solid rgba(198, 196, 181, 0.13); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.brand { + min-width: 204px; + gap: 11px; +} + +.logo-dot { + width: 30px; + height: 30px; + border-radius: 7px; + background: + linear-gradient(135deg, rgba(243, 241, 232, 0.88), rgba(103, 212, 188, 0.68)), + #11140f; + border: 1px solid rgba(243, 241, 232, 0.22); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22); +} + +.logo-dot::after { + inset: 8px; + border-color: rgba(7, 8, 6, 0.48); + border-radius: 3px; +} + +.brand-title { + font-size: 16px; + font-weight: 780; +} + +.brand-sub { + color: var(--text-muted); + font-size: 10.5px; + font-weight: 680; + letter-spacing: 0.06em; +} + +.topnav { + gap: 7px; +} + +.pipeline-pill, +.health-pill, +.preflight-pill, +.topnav .ghost-btn, +.language-toggle { + min-height: 30px; + border-color: rgba(198, 196, 181, 0.16); + background: rgba(16, 19, 15, 0.78); + color: var(--text-secondary); + box-shadow: none; +} + +.pipeline-pill { + color: #dedbd0; + font-size: 10.5px; +} + +.preflight-pill.ok, +.health-pill.ok { + color: #7be0a6; + border-color: rgba(97, 191, 134, 0.34); + background: rgba(97, 191, 134, 0.08); +} + +.preflight-pill.warn { + color: #e5b866; + border-color: rgba(211, 162, 76, 0.36); + background: rgba(211, 162, 76, 0.08); +} + +.preflight-pill.err, +.health-pill.err { + color: #ef958d; + border-color: rgba(215, 118, 109, 0.34); + background: rgba(215, 118, 109, 0.08); +} + +.language-toggle { + border-radius: 999px; + padding: 3px; +} + +.language-toggle button { + border-radius: 999px; + color: var(--text-muted); +} + +.language-toggle button[aria-pressed="true"], +.language-toggle button.active { + background: rgba(182, 199, 255, 0.16); + color: var(--text-primary); +} + +.layout { + width: min(100%, 1500px); + gap: clamp(16px, 1.8vw, 26px); + padding: clamp(16px, 2vw, 28px); +} + +.panel, +.preflight-panel, +.symbol-picker-panel, +.forecast-detail-panel { + border-color: rgba(198, 196, 181, 0.14); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, rgba(18, 22, 17, 0.97), rgba(10, 12, 10, 0.97)); + box-shadow: var(--shadow-panel); +} + +.control-panel { + padding: 16px; + border-color: rgba(198, 196, 181, 0.13); + background: linear-gradient(180deg, rgba(17, 20, 15, 0.98), rgba(11, 13, 11, 0.98)); +} + +.control-panel.is-collapsed { + background: linear-gradient(180deg, rgba(16, 19, 15, 0.96), rgba(9, 11, 9, 0.96)); +} + +.results-panel { + padding: clamp(16px, 2vw, 28px); + background: linear-gradient(180deg, rgba(15, 18, 14, 0.98), rgba(10, 12, 10, 0.98)); +} + +.command-panel-toggle { + min-height: 40px; + padding-bottom: 12px; + border-bottom-color: rgba(198, 196, 181, 0.12); +} + +.command-panel-toggle span, +.command-panel-label { + color: #9ba192; + font-size: 10.5px; + font-weight: 760; + letter-spacing: 0.07em; +} + +.command-panel-toggle strong, +.command-panel-state { + color: var(--accent-primary); + font-size: 11px; +} + +.field-label, +.home-card-head h3, +.home-card-title h3, +.panel-subtitle, +.block-header h3 { + color: var(--text-primary); + font-weight: 760; +} + +input[type="text"], +input[type="search"], +input[type="number"], +input[type="date"], +textarea, +select, +.decision-form select, +.decision-form input, +.qdrant-purge-row input { + min-height: 40px; + border-color: rgba(198, 196, 181, 0.15); + border-radius: var(--radius-sm); + background: rgba(7, 8, 6, 0.74); + color: var(--text-primary); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025); +} + +input:hover, +textarea:hover, +select:hover { + border-color: rgba(198, 196, 181, 0.24); +} + +input:focus, +textarea:focus, +select:focus { + border-color: rgba(103, 212, 188, 0.5); + background: rgba(9, 12, 10, 0.9); + box-shadow: 0 0 0 3px rgba(103, 212, 188, 0.08); +} + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible, +summary:focus-visible, +[role="tab"]:focus-visible { + outline: 2px solid rgba(103, 212, 188, 0.82); + outline-offset: 2px; +} + +.primary-btn, +.run-btn, +button.primary-btn { + min-height: 42px; + border-radius: var(--radius-sm); + border: 1px solid rgba(103, 212, 188, 0.46); + background: linear-gradient(180deg, #86e4ce, #55c6ad); + color: #07110e; + box-shadow: 0 10px 24px rgba(33, 115, 97, 0.16); +} + +.primary-btn:hover, +.run-btn:hover, +button.primary-btn:hover { + border-color: rgba(154, 241, 221, 0.64); + background: linear-gradient(180deg, #9af1dd, #61d2b9); +} + +.ghost-btn, +.linkish, +.dashboard-view-controls button, +.tab, +.dashboard-tab, +.ticker-chips button, +.preset-chips button, +.mode-toggle label, +.toggle { + border-color: rgba(198, 196, 181, 0.14); + border-radius: var(--radius-sm); + background: rgba(12, 15, 12, 0.7); + color: var(--text-secondary); + box-shadow: none; +} + +.ghost-btn:hover, +.linkish:hover, +.dashboard-view-controls button:hover, +.tab:hover, +.dashboard-tab:hover, +.ticker-chips button:hover, +.preset-chips button:hover, +.mode-toggle label:hover, +.toggle:hover { + border-color: rgba(103, 212, 188, 0.34); + background: rgba(103, 212, 188, 0.075); + color: var(--text-primary); +} + +.home-hero { + min-height: 0; + padding: clamp(22px, 3vw, 34px); + border: 1px solid rgba(198, 196, 181, 0.13); + border-radius: var(--radius-lg); + background: + linear-gradient(135deg, rgba(24, 29, 22, 0.94), rgba(10, 12, 10, 0.92) 62%), + linear-gradient(90deg, rgba(103, 212, 188, 0.1), transparent 42%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035); +} + +.home-hero::before { + display: none; +} + +.home-hero .eyebrow { + margin-bottom: 10px; + color: var(--accent-primary); + font-size: 11px; + font-weight: 780; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.home-hero h2 { + margin-bottom: 10px; + color: var(--text-primary); + font-size: clamp(28px, 3vw, 42px); + line-height: 1.05; + letter-spacing: 0; +} + +.home-hero p { + max-width: 760px; + color: #d8d6cb; + font-size: 15px; + line-height: 1.65; +} + +.home-status { + align-items: flex-start; + min-width: min(260px, 100%); +} + +.home-status span, +.dashboard-context-strip span, +.workbench-guardrail-strip span { + border-color: rgba(198, 196, 181, 0.14); + background: rgba(8, 10, 8, 0.58); + color: var(--text-secondary); +} + +.home-dashboard-tabs { + gap: 4px; + padding: 4px; + border: 1px solid rgba(198, 196, 181, 0.12); + border-radius: var(--radius-lg); + background: rgba(7, 8, 6, 0.72); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025); +} + +.dashboard-tab { + min-height: 42px; + padding: 0 18px; + border: 1px solid transparent; + background: transparent; + color: #aeb4a9; + font-size: 12px; + font-weight: 760; +} + +.dashboard-tab.active, +.dashboard-tab[aria-selected="true"] { + border-color: rgba(103, 212, 188, 0.5); + background: linear-gradient(180deg, rgba(103, 212, 188, 0.14), rgba(182, 199, 255, 0.08)); + color: var(--text-primary); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.dashboard-context-strip, +.workbench-guardrail-strip { + gap: 8px; + margin-top: 8px; +} + +.dashboard-context-strip span, +.workbench-guardrail-strip span, +.dashboard-context-strip .decision-card-chip { + min-height: 48px; + padding: 10px 12px; + border-radius: var(--radius-md); +} + +.dashboard-context-strip strong, +.workbench-guardrail-strip strong { + color: var(--accent-primary); + font-weight: 780; +} + +.dashboard-view-controls { + min-height: 48px; + padding: 6px; + border: 1px solid rgba(198, 196, 181, 0.12); + border-radius: var(--radius-lg); + background: rgba(7, 8, 6, 0.68); +} + +.dashboard-view-controls button { + min-height: 34px; + padding: 0 14px; + border-color: transparent; + background: transparent; +} + +.dashboard-view-controls button.active, +.dashboard-view-controls button[aria-pressed="true"] { + border-color: rgba(182, 199, 255, 0.42); + background: rgba(182, 199, 255, 0.13); + color: var(--text-primary); +} + +.home-grid { + gap: 14px; + align-items: start; +} + +.home-card, +.dashboard-card, +.tab-panel, +.decision-surface, +.market-tape-card, +.market-signal-row, +.macro-series-result, +.macro-playbook-card, +.macro-portfolio-step, +.forecast-chart-card, +.ai-investment-card, +.ai-coverage-cell, +.ai-snapshot-point { + border-color: rgba(198, 196, 181, 0.12); + border-radius: var(--radius-md); + background: rgba(13, 16, 12, 0.68); + box-shadow: none; +} + +.home-card { + padding: 16px; + background: + linear-gradient(180deg, rgba(17, 21, 16, 0.92), rgba(11, 13, 11, 0.92)); +} + +.home-card::before { + height: 1px; + background: linear-gradient(90deg, rgba(103, 212, 188, 0.38), rgba(182, 199, 255, 0.18), transparent); + opacity: 0.72; +} + +.home-card:hover { + border-color: rgba(198, 196, 181, 0.2); + background: + linear-gradient(180deg, rgba(19, 23, 18, 0.94), rgba(11, 13, 11, 0.94)); + transform: none; +} + +.home-card:hover::before { + opacity: 0.9; +} + +.home-card-head { + min-height: 32px; + padding-bottom: 11px; + margin-bottom: 13px; + border-bottom: 1px solid rgba(198, 196, 181, 0.1); +} + +.home-card-head h3 { + font-size: 14px; + letter-spacing: 0; +} + +.home-card-head span, +.home-card-head small, +.home-card-title span { + color: var(--text-muted); + font-size: 11px; +} + +.decision-surface, +.compact-surface { + padding: 14px; + background: rgba(7, 8, 6, 0.5); + border-style: solid; +} + +.home-news-empty, +.form-notice, +.empty-note, +.loading-note { + border-radius: var(--radius-sm); + color: var(--text-muted); +} + +.form-notice.info, +.status-badge, +.cache-badge { + border-color: rgba(182, 199, 255, 0.2); + background: rgba(182, 199, 255, 0.08); + color: #cfd9ff; +} + +.form-notice.success, +.status-badge.ok { + border-color: rgba(97, 191, 134, 0.22); + background: rgba(97, 191, 134, 0.08); + color: #97e4b4; +} + +.form-notice.warn, +.status-badge.warn { + border-color: rgba(211, 162, 76, 0.28); + background: rgba(211, 162, 76, 0.08); + color: #eac375; +} + +.form-notice.error, +.error-banner, +.status-badge.err { + border-color: rgba(215, 118, 109, 0.28); + background: rgba(215, 118, 109, 0.08); + color: #efa09a; +} + +.source-toggles { + gap: 7px; +} + +.toggle { + min-height: 38px; + padding: 8px 10px; +} + +.toggle em { + color: var(--accent-primary); +} + +.ticker-chips, +.preset-chips { + gap: 6px; +} + +.ticker-chips button, +.preset-chips button { + min-height: 30px; + padding: 0 10px; +} + +.mode-toggle { + gap: 6px; + padding: 3px; + border: 1px solid rgba(198, 196, 181, 0.12); + border-radius: var(--radius-md); + background: rgba(7, 8, 6, 0.5); +} + +.mode-toggle label { + min-height: 32px; + border-color: transparent; + background: transparent; +} + +.mode-toggle label:has(input:checked) { + border-color: rgba(103, 212, 188, 0.36); + background: rgba(103, 212, 188, 0.1); + color: var(--text-primary); +} + +.chart-control-bar, +.internal-chart-shell, +.symbol-picker-search, +.symbol-picker-filters, +.forecast-detail-panel, +.symbol-picker-panel { + border-color: rgba(198, 196, 181, 0.12); + background: rgba(7, 8, 6, 0.52); +} + +.kpi, +.thesis-col, +.diag-table, +.metric-table-wrap, +.compare-table, +.risk-panel, +.quant-panel, +.scenario-panel, +.evidence-item, +.report-md, +.code-block, +.mini-code { + border-color: rgba(198, 196, 181, 0.12); + border-radius: var(--radius-md); + background: rgba(7, 8, 6, 0.54); +} + +.kpi-value, +.metric-value, +.market-tape-price, +.ai-allocation-weight, +.quantamental-score-value { + color: var(--text-primary); + font-weight: 780; +} + +.bull, +.ok, +.positive, +.market-tape-return.ok { + color: var(--positive); +} + +.bear, +.err, +.negative, +.market-tape-return.err { + color: var(--negative); +} + +.warn, +.warning, +.market-tape-return.warn { + color: var(--warning); +} + +.result-header, +.loading-header, +.preflight-head, +.symbol-picker-head, +.forecast-detail-head { + border-bottom-color: rgba(198, 196, 181, 0.1); +} + +.tabs { + gap: 4px; + padding: 4px; + border: 1px solid rgba(198, 196, 181, 0.12); + border-radius: var(--radius-md); + background: rgba(7, 8, 6, 0.62); +} + +.tab { + min-height: 34px; + border-color: transparent; + background: transparent; +} + +.tab.active, +.tab[aria-selected="true"] { + border-color: rgba(103, 212, 188, 0.38); + background: rgba(103, 212, 188, 0.1); + color: var(--text-primary); +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .market-overview-card, +.dashboard-surface-grid[data-dashboard-tab="market"] .market-signals-card, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-signal-card-shell, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-score-card, +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-overview-card, +.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-result-card { + border-color: rgba(103, 212, 188, 0.2); +} + +.dashboard-surface-grid[data-dashboard-tab="market"] .home-chart-card, +.dashboard-surface-grid[data-dashboard-tab="quant"] .backtest-card, +.dashboard-surface-grid[data-dashboard-tab="quantamental"] .quantamental-screen-card, +.dashboard-surface-grid[data-dashboard-tab="ai-portfolio"] .ai-portfolio-create-card, +.dashboard-surface-grid[data-dashboard-tab="forecast"] .forecast-setup-card { + border-color: rgba(182, 199, 255, 0.18); +} + +.quantamental-score-screen-form, +.decision-form, +.portfolio-form-grid, +.forecast-form-grid, +.macro-series-controls, +.input-action-row, +.compact-actions { + gap: 10px; +} + +.quantamental-score-screen-form label, +.decision-form label, +.portfolio-form-grid label, +.forecast-form-grid label { + color: var(--text-secondary); +} + +html[data-theme="light"] { + color-scheme: light; + --bg-primary: #f6f4ec; + --bg-secondary: #eeebe1; + --bg-tertiary: #e7e3d7; + --surface-primary: #fffdf6; + --surface-secondary: #f7f4eb; + --surface-elevated: #ffffff; + --surface-inset: #f1eee5; + --text-primary: #171914; + --text-secondary: #44483f; + --text-muted: #6d7267; + --border-subtle: rgba(52, 56, 48, 0.14); + --border-strong: rgba(52, 56, 48, 0.26); + --accent-primary: #0d7f6e; + --accent-secondary: #3859a8; + --accent-ink: #ffffff; + --positive: #187a4c; + --negative: #b34036; + --warning: #9d6a13; } -html[data-theme="light"], html[data-theme="light"] body { - background: #f6f8fb; - color: var(--text-primary); + background: + linear-gradient(180deg, #f6f4ec 0%, #eeebe1 100%), + repeating-linear-gradient(90deg, rgba(23, 25, 20, 0.018) 0 1px, transparent 1px 64px); } html[data-theme="light"] .topbar { - background: rgba(255, 255, 255, 0.92); - border-bottom-color: var(--border-subtle); - box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04); + background: rgba(246, 244, 236, 0.9); + border-bottom-color: rgba(52, 56, 48, 0.14); } -html[data-theme="light"] .control-panel, -html[data-theme="light"] .results-panel, -html[data-theme="light"] .home-card, -html[data-theme="light"] .home-hero, -html[data-theme="light"] .tab-panel, +html[data-theme="light"] .panel, html[data-theme="light"] .preflight-panel, html[data-theme="light"] .symbol-picker-panel, html[data-theme="light"] .forecast-detail-panel, -html[data-theme="light"] .dashboard-card, +html[data-theme="light"] .home-card, +html[data-theme="light"] .home-hero, +html[data-theme="light"] .tab-panel, html[data-theme="light"] .decision-surface, html[data-theme="light"] .market-tape-card, html[data-theme="light"] .market-signal-row, @@ -7769,368 +9993,480 @@ html[data-theme="light"] .forecast-chart-card, html[data-theme="light"] .ai-investment-card, html[data-theme="light"] .ai-coverage-cell, html[data-theme="light"] .ai-snapshot-point { - background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); - border-color: var(--border-subtle); - color: var(--text-primary); - box-shadow: var(--shadow-panel); + background: rgba(255, 253, 246, 0.86); + border-color: rgba(52, 56, 48, 0.13); + box-shadow: none; +} + +html[data-theme="light"] .home-hero { + background: + linear-gradient(135deg, rgba(255, 253, 246, 0.96), rgba(241, 238, 229, 0.92)), + linear-gradient(90deg, rgba(13, 127, 110, 0.08), transparent 42%); } html[data-theme="light"] input, html[data-theme="light"] select, html[data-theme="light"] textarea, -html[data-theme="light"] .mode-toggle label, -html[data-theme="light"] .ticker-chips button, -html[data-theme="light"] .preset-chips button, -html[data-theme="light"] .dashboard-tab, -html[data-theme="light"] .tab, -html[data-theme="light"] .dashboard-view-controls button, -html[data-theme="light"] .ghost-btn, -html[data-theme="light"] .health-pill, -html[data-theme="light"] .pipeline-pill, -html[data-theme="light"] .preflight-pill { - background: #ffffff; - border-color: var(--border-subtle); +html[data-theme="light"] .home-dashboard-tabs, +html[data-theme="light"] .dashboard-view-controls, +html[data-theme="light"] .tabs, +html[data-theme="light"] .mode-toggle, +html[data-theme="light"] .chart-control-bar, +html[data-theme="light"] .internal-chart-shell { + background: rgba(255, 255, 250, 0.78); + border-color: rgba(52, 56, 48, 0.14); color: var(--text-primary); } -html[data-theme="light"] .brand-sub, -html[data-theme="light"] .muted, -html[data-theme="light"] .hint, -html[data-theme="light"] .small, -html[data-theme="light"] .home-card-head span, -html[data-theme="light"] .field-header-row .hint { - color: var(--text-muted); +html[data-theme="light"] .pipeline-pill, +html[data-theme="light"] .health-pill, +html[data-theme="light"] .preflight-pill, +html[data-theme="light"] .topnav .ghost-btn, +html[data-theme="light"] .language-toggle, +html[data-theme="light"] .ghost-btn, +html[data-theme="light"] .linkish, +html[data-theme="light"] .dashboard-tab, +html[data-theme="light"] .tab, +html[data-theme="light"] .dashboard-view-controls button, +html[data-theme="light"] .ticker-chips button, +html[data-theme="light"] .preset-chips button, +html[data-theme="light"] .mode-toggle label, +html[data-theme="light"] .toggle { + background: rgba(255, 253, 246, 0.72); + border-color: rgba(52, 56, 48, 0.13); + color: var(--text-secondary); } -html[data-theme="light"] .run-btn, html[data-theme="light"] .dashboard-tab.active, +html[data-theme="light"] .dashboard-tab[aria-selected="true"], html[data-theme="light"] .tab.active, -html[data-theme="light"] .dashboard-view-controls button.active { - background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%); - border-color: #1d4ed8; - color: #ffffff; -} - -html[data-theme="light"] .report-md, -html[data-theme="light"] .code-block, -html[data-theme="light"] .mini-code, -html[data-theme="light"] pre, -html[data-theme="light"] code { - background: #0f172a; - color: #e5edf8; -} - -html[data-theme="light"] .chart-control-bar { - background: #f8fafc; - border-color: var(--border-subtle); -} - -html[data-theme="light"] .internal-chart-shell, -html[data-theme="light"] .internal-chart-shell .decision-chart { - background: #ffffff; - border-color: var(--border-subtle); -} - -.layout { - width: min(100%, var(--app-shell-max)); - grid-template-columns: minmax(0, 1fr); - padding-left: calc(var(--control-rail-width) + 32px); +html[data-theme="light"] .tab[aria-selected="true"], +html[data-theme="light"] .dashboard-view-controls button.active, +html[data-theme="light"] .dashboard-view-controls button[aria-pressed="true"] { + background: rgba(13, 127, 110, 0.1); + border-color: rgba(13, 127, 110, 0.28); + color: var(--text-primary); } -.control-panel { - position: fixed; - left: 18px; - top: 86px; - width: min(var(--control-dock-width), calc(100vw - 36px)); - max-height: calc(100vh - 104px); - z-index: 36; - transition: width 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease; +html[data-theme="light"] .primary-btn, +html[data-theme="light"] .run-btn, +html[data-theme="light"] button.primary-btn { + color: #ffffff; + background: linear-gradient(180deg, #0f917d, #0a735f); + border-color: rgba(10, 115, 95, 0.48); } -.control-panel.is-collapsed { - width: var(--control-rail-width); - padding: 10px; +.global-quality-summary { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 7px; + min-height: 32px; + max-width: min(720px, 56vw); + padding: 5px 10px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + background: rgba(15, 23, 42, 0.72); + color: var(--text-dim); + cursor: pointer; + font-size: 11px; + font-family: var(--font-mono); + line-height: 1.35; + white-space: normal; overflow: hidden; } -.control-panel.is-collapsed > #analysisForm, -.control-panel.is-collapsed > form, -.control-panel.is-collapsed > .history-block, -.control-panel.is-collapsed > .watchlist-block { - display: none !important; +.global-quality-summary:hover { + border-color: rgba(159, 179, 200, 0.48); + color: var(--text); } -.control-panel.is-collapsed .command-panel-toggle { - min-height: 220px; - padding: 10px 0; - writing-mode: vertical-rl; - text-orientation: mixed; - align-items: center; - justify-content: center; - gap: 10px; - border-bottom: 0; +.global-quality-summary .quality-status { + font-weight: 800; + color: var(--text); } -.control-panel.is-collapsed .command-panel-label { - display: none; +.global-quality-summary .quality-meta { + color: var(--text-mute); + min-width: 0; } -.control-panel.is-collapsed .command-panel-state { +.global-quality-summary .quality-ai { + max-width: 190px; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } -.results-panel { - width: 100%; -} - -.dashboard-surface-grid[data-dashboard-tab="market"] .home-chart-card { - order: 1; - grid-column: 1 / -1; - min-height: 560px; -} - -.dashboard-surface-grid[data-dashboard-tab="market"] .market-overview-card { - order: 2; -} - -.dashboard-surface-grid[data-dashboard-tab="market"] .market-signals-card { - order: 3; -} - -.dashboard-surface-grid[data-dashboard-tab="market"] .home-heatmap-card { - order: 4; - grid-column: 1 / -1; +.global-quality-summary.ok { + border-color: rgba(106, 163, 122, 0.55); } -.dashboard-surface-grid[data-dashboard-tab="market"] .home-market-panel { - display: none !important; +.global-quality-summary.warn { + border-color: rgba(201, 164, 92, 0.58); } -.dashboard-surface-grid[data-dashboard-tab="market"] .data-mart-card { - order: 5; +.global-quality-summary.fail { + border-color: rgba(194, 109, 109, 0.58); } -.dashboard-surface-grid[data-dashboard-tab="market"] .home-news-card { - order: 6; -} +.global-quality-summary.ok .quality-status { color: var(--bull); } +.global-quality-summary.warn .quality-status { color: var(--warn); } +.global-quality-summary.fail .quality-status { color: var(--bear); } -.chart-control-bar { - display: grid; - grid-template-columns: repeat(4, minmax(120px, 1fr)) auto; - gap: 10px; - align-items: end; - margin: 12px 0 14px; +.quality-context-summary { + margin: 10px 0 14px; padding: 12px; - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - background: rgba(8, 11, 16, 0.36); -} - -.chart-control-bar label { - display: grid; - gap: 6px; - min-width: 0; -} - -.chart-control-bar label span { - color: var(--text-muted); - font-size: 11px; - font-weight: 740; - letter-spacing: 0.04em; -} - -.chart-control-bar select, -.chart-control-bar button { - min-height: 36px; -} - -.internal-chart-shell { - display: grid; - gap: 12px; - height: 100%; - min-height: 420px; - padding: 14px; - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - background: linear-gradient(180deg, rgba(8, 11, 16, 0.64), rgba(8, 11, 16, 0.36)); + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 8px; + background: rgba(15, 23, 42, 0.42); } -.internal-chart-head, -.internal-chart-foot { +.quality-context-head { display: flex; - align-items: center; + align-items: baseline; justify-content: space-between; gap: 10px; - flex-wrap: wrap; + margin-bottom: 10px; } -.internal-chart-head strong { - display: block; - color: var(--text-primary); - font-size: 14px; +.quality-context-head strong { + color: var(--text); + font-size: 13px; } -.internal-chart-head span, -.internal-chart-foot span { - color: var(--text-muted); - font-family: var(--font-mono); +.quality-context-head span { + color: var(--text-mute); font-size: 11px; + line-height: 1.45; +} + +.quality-context-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(155px, 1fr)); + gap: 8px; } -.internal-chart-head b { - font-family: var(--font-mono); - font-size: 15px; +.quality-context-item { + display: grid; + gap: 3px; + min-width: 0; + padding: 8px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 7px; + background: rgba(2, 6, 23, 0.24); } -.internal-chart-head b.ok { - color: var(--positive); +.quality-context-item strong { + color: var(--text-mute); + font-size: 10px; + font-weight: 700; } -.internal-chart-head b.warn { - color: var(--negative); +.quality-context-item em { + color: var(--text); + font-style: normal; + font-size: 12px; + line-height: 1.35; + overflow-wrap: anywhere; } -.internal-ohlc-chart { - width: 100%; - min-height: 300px; - color: var(--accent-secondary); +.quality-context-item-wide { + grid-column: 1 / -1; } -.internal-candle line { - stroke: currentColor; - stroke-width: 1.4; - vector-effect: non-scaling-stroke; +.quality-context-summary.ok { + border-color: rgba(106, 163, 122, 0.38); } -.internal-candle rect { - stroke: currentColor; - stroke-width: 1.1; - vector-effect: non-scaling-stroke; +.quality-context-summary.warn { + border-color: rgba(201, 164, 92, 0.42); } -.internal-candle.up { - color: var(--positive); +.quality-context-summary.fail { + border-color: rgba(194, 109, 109, 0.42); } -.internal-candle.up rect { - fill: rgba(84, 198, 139, 0.32); +.dashboard-range-controls { + display: grid; + grid-template-columns: minmax(150px, 190px) repeat(2, minmax(132px, 160px)) minmax(220px, 1fr); + gap: 10px; + align-items: end; + margin: 12px 0; + padding: 10px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 8px; + background: rgba(15, 23, 42, 0.54); } -.internal-candle.down { - color: var(--negative); +.dashboard-range-controls label { + display: grid; + gap: 5px; + min-width: 0; } -.internal-candle.down rect { - fill: rgba(224, 113, 113, 0.32); +.dashboard-range-controls label span, +.dashboard-range-support { + color: var(--text-mute); + font-size: 11px; } -.internal-close-line { - fill: none; - stroke: var(--accent-primary); - stroke-width: 2; - vector-effect: non-scaling-stroke; +.dashboard-range-controls select, +.dashboard-range-controls input { + min-height: 34px; + width: 100%; } -.internal-chart-shell .decision-chart { - margin-top: 2px; - background: rgba(8, 11, 16, 0.34); +.dashboard-range-controls:not(.custom-active) .custom-range-field { + opacity: 0.52; } -.market-tape-returns { - display: flex; - flex-wrap: wrap; - gap: 6px 10px; - margin-top: 4px; +.dashboard-range-controls:not(.custom-active) .custom-range-field input { + pointer-events: none; } -.market-tape-returns .market-tape-return { - margin-top: 0; +.dashboard-range-controls.range-warning { + border-color: rgba(201, 164, 92, 0.54); } -.market-tape-return.warn { - color: var(--warning); +.dashboard-range-controls.range-warning .dashboard-range-support { + color: var(--warn); } -.market-tape-meta span:first-child { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.dashboard-range-controls input[aria-invalid="true"] { + border-color: rgba(201, 164, 92, 0.68); + box-shadow: 0 0 0 1px rgba(201, 164, 92, 0.18); } -.theme-toggle { - min-width: 58px; +.dashboard-range-support { + align-self: center; + line-height: 1.45; + overflow-wrap: anywhere; } -.theme-toggle[aria-pressed="true"] { - color: var(--accent-ink); - background: linear-gradient(180deg, #f8fafc, #dbe7ff); - border-color: rgba(143, 180, 255, 0.6); +html[data-theme="light"] .global-quality-summary, +html[data-theme="light"] .dashboard-range-controls, +html[data-theme="light"] .quality-context-summary { + background: rgba(255, 253, 246, 0.76); + border-color: rgba(52, 56, 48, 0.14); + color: var(--text-secondary); +} + +html[data-theme="light"] .quality-context-item { + background: rgba(255, 255, 255, 0.58); + border-color: rgba(52, 56, 48, 0.12); } @media (max-width: 1180px) { - .layout { - grid-template-columns: minmax(0, 1fr); - padding-left: calc(var(--control-rail-width) + 22px); + .topbar { + align-items: flex-start; + gap: 10px; } - .control-panel { - top: 92px; + .topnav { + max-width: calc(100vw - 32px); } -} -@media (max-width: 900px) { .layout { - grid-template-columns: 1fr; - padding-left: 18px; + padding-left: calc(var(--control-rail-width) + 20px); } +} - .control-panel { +@media (max-width: 900px) { + .topbar { position: static; - width: auto; - max-height: none; - transform: none; } - .control-panel.is-collapsed { - width: auto; + .brand { + min-width: 0; + } + + .topnav { + justify-content: flex-start; + } + + .pipeline-pill { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .layout, + body.command-panel-expanded .layout { padding: 14px; } - .control-panel.is-collapsed .command-panel-toggle { - min-height: 44px; - writing-mode: horizontal-tb; - flex-direction: row; - justify-content: space-between; + .home-hero { + padding: 22px; } - .control-panel.is-collapsed .command-panel-label { - display: inline; + .home-hero h2 { + font-size: clamp(26px, 8vw, 34px); } - .chart-control-bar { - grid-template-columns: repeat(2, minmax(0, 1fr)); + .home-status { + flex-direction: row; + flex-wrap: wrap; } - .chart-control-bar button { - grid-column: 1 / -1; + .dashboard-context-strip, + .dashboard-range-controls, + .workbench-guardrail-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 640px) { - .layout { - padding-left: 14px; + body { + font-size: 13px; } - .dashboard-surface-grid[data-dashboard-tab="market"] .home-chart-card { - min-height: 420px; + .topbar { + padding: 12px; } - .chart-control-bar { + .brand-title { + font-size: 15px; + } + + .brand-sub { + font-size: 10px; + } + + .pipeline-pill, + .health-pill, + .preflight-pill, + .topnav .ghost-btn, + .language-toggle { + min-height: 28px; + font-size: 10px; + } + + .home-hero { + padding: 18px; + } + + .home-hero p { + font-size: 13px; + } + + .home-dashboard-tabs { + gap: 4px; + padding: 4px; + } + + .dashboard-tab { + min-height: 38px; + padding: 0 12px; + } + + .dashboard-context-strip, + .dashboard-range-controls, + .workbench-guardrail-strip { grid-template-columns: 1fr; } - .chart-control-bar button { + .quality-context-head { + display: grid; + gap: 5px; + } + + .quality-context-grid { + grid-template-columns: 1fr; + } + + .home-card { + padding: 14px; + } + + .decision-surface, + .compact-surface { + padding: 12px; + } + + .button-row, + .input-action-row, + .compact-actions, + .quantamental-ai-control { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .topbar { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + padding: 12px; + } + + .topnav { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; width: 100%; } + + .pipeline-pill { + display: none !important; + } + + .topnav .ghost-btn, + .global-quality-summary, + .language-toggle, + .preflight-pill, + .health-pill { + width: auto; + min-width: 0; + min-height: 30px; + padding-inline: 8px; + justify-content: center; + white-space: nowrap; + } + + .preflight-pill { + grid-column: span 2; + } + + .global-quality-summary { + grid-column: 1 / -1; + max-width: 100%; + justify-content: flex-start; + } + + .global-quality-summary .quality-status { + flex: 1 1 100%; + text-align: center; + } + + .global-quality-summary .quality-meta { + flex: 1 1 calc(50% - 8px); + text-align: center; + } + + .global-quality-summary .quality-ai { + flex-basis: 100%; + max-width: 100%; + } + + .health-pill { + grid-column: span 1; + } +} + +@media (max-width: 430px) { + .topnav { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .preflight-pill, + .language-toggle { + grid-column: span 2; + } + + .health-pill { + grid-column: span 1; + font-size: 9.5px; + } } diff --git a/docs/CONTINUOUS_ENHANCEMENT_LOG.md b/docs/CONTINUOUS_ENHANCEMENT_LOG.md new file mode 100644 index 00000000..974cec0e --- /dev/null +++ b/docs/CONTINUOUS_ENHANCEMENT_LOG.md @@ -0,0 +1,720 @@ +# Continuous Enhancement Log + +## Current Project Summary +- Project purpose: Local financial research workstation that combines market data, macro data, quant/backtest workflows, Quantamental analysis, ML Forecast, AI Portfolio, and local LLM briefing surfaces. +- Main frontend structure: Static FastAPI-served UI under `app/web/index.html`, `app/web/app.js`, `app/web/styles.css`, plus domain modules under `app/web/modules/`. +- Main backend structure: FastAPI routers under `app/api/routers/`, shared request/response contracts under `core/schemas/`, and orchestration/services under `pipelines/`. +- Data flow: UI calls `/api/v1/*` routes; routers delegate to pipeline services; data-mart, macro, price, portfolio, forecast, and quantamental services normalize provider/cache output before rendering. +- AI/LLM flow: Primary research requests route through configured inference aliases such as `qwen`; experimental Gemma routes are exposed only from config when supported. Quantamental AI interprets deterministic engine payloads and must preserve deterministic scores/signals. +- Visualization flow: The static UI renders HTML/SVG/table surfaces, internal price charts, TradingView fallback/option widgets, heatmaps, Quant Lab charts, Forecast charts, Quantamental factor/score visualizations, and AI Portfolio dashboard surfaces. +- Testing flow: Python/pytest contract tests validate static UI markers, API contracts, quantamental behavior, and smoke scripts; browser smoke scripts cover the static `/ui/` surface when a local server is running. + +## Current Problems +- Compatibility: The worktree already contains many unrelated pending changes, so this run must avoid broad rewrites and preserve existing static UI/API contracts. +- Data consistency: Period controls exist in several feature panels, but there is no single dashboard-level range selector that synchronizes the main KPI/chart/table/briefing inputs. +- UI consistency: `Core / Diagnostics / Operations / All` exists, but non-market tabs can still default to narrower persisted views, hiding important surfaces on first entry. +- Visualization: Chart/data surfaces expose range and freshness details unevenly; titles and status text are not always tied to the selected global period. +- AI briefing: Quantamental AI already has deterministic-signal guardrails, but the briefing context does not consistently carry a user-readable data snapshot summary. +- Data freshness: Detailed diagnostics exist, but a concise top-right quality summary is not always visible without opening the deeper quality panel. +- Translation quality: Korean/English UI output exists and should keep financial terms, tickers, dates, numbers, and units stable. +- Performance: Several dashboards can refetch independently; this run should keep global range updates explicit and avoid hidden background loops. +- Code structure: Existing static UI is large and stateful; improvements should add small adapter-style helpers instead of moving major surfaces. +- User experience: First-time dashboard entry should show all relevant sections, plus a simple quality/range context that reduces navigation friction. + +## Enhancement Plan +- Priority 1: Make `All` the default dashboard panel view for all dashboard tabs while preserving Core/Diagnostics/Operations as filters. +- Priority 2: Add a top-right quality summary and dashboard-level range selector, then synchronize existing tab controls from the selected period where safely supported. +- Priority 3: Add Quantamental AI briefing data-snapshot guardrails and document verified model availability truthfully without fake Gemma/Qwen status. + +## Validation Plan +- Build: No frontend package manifest is present in the repo root; validate with Python contract tests and import/runtime smoke instead of `npm run build`. +- Lint: No repo-level JS lint command is configured; use targeted static contract tests and UI contract script. +- Unit test: Run targeted pytest for UI routing/static contracts and Quantamental AI API behavior. +- Integration test: Run Quantamental API tests and local FastAPI UI smoke where available. +- UI test: Start the supported local web launcher and verify `/ui/` through the available browser/smoke tooling. +- Data quality test: Check that the quality summary renders from data-health, macro quality, and Quantamental quality payloads without exposing raw diagnostic failures. +- AI hallucination guard test: Verify Quantamental AI fallback/report includes source period, basis date/source, observation count or `Unavailable`/`확인 불가`, and preserves deterministic signal labels. + +## Completion Checklist + +### Compatibility +- [x] Existing features still work +- [x] Existing API contracts are not broken +- [x] Existing UI flow is preserved +- [x] No unauthorized strategy logic change +- [x] No secret or env file exposure + +### Data +- [x] Date range selection works +- [x] KPI/chart/table use the same selected period +- [x] Data source and 기준일 are displayed +- [x] Missing data is handled +- [x] Data quality summary is visible at top-right +- [x] Cache/fresh data distinction is clear + +### UI +- [x] Default view is All +- [x] Core/Diagnostics/Operations filters still exist +- [x] Font sizes are readable +- [x] Layout spacing is consistent +- [x] Cards/tables/charts are aligned +- [x] Mobile layout is acceptable +- [x] Loading state exists +- [x] Empty state exists +- [x] Error state exists + +### Visualization +- [x] Chart titles are meaningful +- [x] Axis labels are readable +- [x] Tooltips are useful +- [x] Legends are not confusing +- [x] Period selection updates charts +- [x] No chart overflow or label collision + +### AI Briefing +- [x] Gemma/Qwen availability is checked +- [x] Model selection is not fake +- [x] AI output includes used data period +- [x] AI output includes 기준일/source/observation count +- [x] AI does not invent unsupported numbers +- [x] Unverified facts are marked as 확인 불가 +- [x] Translation preserves numbers/dates/units + +### Validation +- [x] Lint executed or reason documented +- [x] Build executed or reason documented +- [x] Tests executed or reason documented +- [x] UI validation executed or reason documented +- [x] Data validation executed or reason documented +- [x] AI briefing validation executed or reason documented + +### Documentation +- [x] docs/CONTINUOUS_ENHANCEMENT_LOG.md updated +- [x] README updated if needed +- [x] PR summary includes changed files +- [x] PR summary includes validation result + +## Validation Results + +| Check | Command / Tool | Result | Notes | +|---|---|---|---| +| Python syntax | `python -m py_compile pipelines/quantamental/ai_service.py app/api/routers/system.py` | Passed | Used `venv311` when available. | +| JS syntax | `node --check app/web/app.js` | Passed | Static JavaScript syntax only. | +| Static UI/API tests | `pytest tests/test_ui_routing_contract.py tests/test_quantamental_api.py -q` | Passed | `59 passed, 4 subtests passed`. | +| UI contract | `python scripts/check_ui_contract.py` | Passed | New global quality/range markers included. | +| Browser smoke | `python scripts/ai_portfolio_ui_smoke.py --timeout-s 120 --output reports/ai_portfolio_ui_smoke_continuous_20260519.json` | Passed | Versioned scripts, dashboard tab matrix, Quantamental language/top-5/score smoke, no console errors. | +| Live browser DOM | Playwright MCP at `http://host.docker.internal:8351/ui/?range=1Y#quantamental` | Passed | Quantamental tab rendered with `panelView=all`, global range visible, no horizontal overflow. | +| Mobile DOM | Playwright MCP resized to `390x900` | Passed | Top quality summary and range controls fit without horizontal overflow. | +| npm/pnpm build/lint/test | Not run | Excluded | Repo root has no `package.json`/`pnpm-lock.yaml`; this static UI is served by FastAPI and validated through Python/Playwright smoke. | + +## 2026-05-19 Continuous Enhancement Run + +- Branch: `automation/continuous-enhancement-20260519-0402`. +- Scope: preserved the current app structure and added only incremental dashboard/global-control and Quantamental AI-guardrail improvements. +- All default: dashboard panel defaults now reset to `All` for all tabs via a layout version key while keeping Core/Diagnostics/Operations filters. +- Quality summary: top-right `globalQualitySummary` shows status, 기준일, update time, and selected period; detailed source/observation/missing/AI snapshot fields are available in the tooltip and refreshed from data health, macro quality, market overview, and Quantamental analysis. +- Period selection: `dashboardRangeSelect` supports `1D`, `1W`, `1M`, `3M`, `6M`, `YTD`, `1Y`, `3Y`, `5Y`, `MAX`, and `custom`, writes URL query state, and synchronizes existing research, asset detail, backtest, portfolio, forecast, cross-asset, AI Portfolio, and Quantamental controls where those surfaces support the range. +- AI briefing: Quantamental AI context now includes `used_data`/`data_snapshot`; deterministic fallback and LLM outputs are forced to include used data, key changes, interpretation, scenarios, user actions, guardrails, and unavailable-value handling. +- Model selection: `/api/v1/config` now marks Qwen/Gemma routes as runtime-checked instead of implying local model availability without request-time verification. + +## 2026-05-19 Continuous Enhancement Run 05:02 + +- Branch: `automation/continuous-enhancement-20260519-0502`. +- Current status: the previous run already added All-default panel behavior, a global range selector, and Quantamental AI used-data guardrails. This run kept that architecture intact and narrowed scope to the top-right quality summary UX. +- Problem found: the quality summary carried observation count, missing-data status, and AI snapshot time in tooltip/detail text, but the always-visible top-right badge only showed status, basis date, update time, and period. +- Change: the `globalQualitySummary` badge now directly renders `관측치`, `결측`, and `AI 기준` alongside quality status, 기준일, 업데이트, and 기간. Missing counts are normalized to user-readable labels such as `없음`, `있음`, or `n개`; long timestamps are compacted to avoid layout overflow. +- UI resilience: the badge now wraps predictably on desktop and 390px mobile, keeps an accessible Korean `aria-label`, and preserves the click-through quality panel behavior. +- Contract coverage: static UI contract checks now require the observation, missing-data, and AI-snapshot markers so future regressions do not hide these fields again. + +### 05:02 Validation Results + +| Check | Command / Tool | Result | Notes | +|---|---|---|---| +| JS syntax | `node --check app/web/app.js` | Passed | Static JavaScript syntax. | +| Python syntax | `python -m py_compile scripts/check_ui_contract.py` | Passed | Contract script remains importable. | +| UI contract | `python scripts/check_ui_contract.py` | Passed | New quality summary markers included. | +| UI routing tests | `python -m pytest tests/test_ui_routing_contract.py -q` | Passed | `39 passed, 4 subtests passed`. | +| UI module tests | `python -m pytest tests/test_ui_modules.py -q` | Passed | `2 passed`. | +| AI briefing guard regression | `python -m pytest tests/test_quantamental_api.py -q` | Passed | `20 passed`; used-data guard contract preserved. | +| Diff hygiene | `git diff --check -- app/web/index.html app/web/app.js app/web/styles.css tests/test_ui_routing_contract.py scripts/check_ui_contract.py` | Passed | No whitespace errors in touched files. | +| Live desktop UI | `playwright-cli` at `http://127.0.0.1:8352/ui/?range=1Y#quantamental` | Passed | Quality badge exposes all seven fields in the accessibility snapshot. | +| Live mobile UI | `playwright-cli resize 390 900` + DOM check | Passed | `horizontalOverflow=false`, `panelView=all`, quality fields remain visible. | +| npm/pnpm build/lint/test | Not run | Excluded | Repo root has no frontend package manifest; static UI is validated through Python contracts and Playwright. | + +## 2026-05-19 Continuous Enhancement Run 06:02 + +- Branch: `automation/continuous-enhancement-20260519-0602`. +- Current status: the prior automation PRs already cover All-default selection, the top-right quality summary, global range state, and Quantamental AI used-data guardrails. This run kept those contracts intact and focused on two practical UX gaps: custom range safety and All-view category clarity. +- Compatibility: no API contract, schema, strategy entry/exit, trading/order, secret, or environment-file behavior was changed. The existing Core/Diagnostics/Operations/All filter remains unchanged, with All still the default. +- Data consistency: custom dashboard ranges now normalize reversed start/end dates before they propagate into KPI, chart, table, and AI briefing controls. Incomplete custom ranges show a user-readable warning instead of silently looking like a valid exact date range. +- UI/UX: All view now gives cards a lightweight Core, Diagnostics, or Operations label derived from their existing `data-panel-tier`, so the full view is easier to scan without hiding any surface. +- Mobile: desktop and 390px browser checks showed no horizontal overflow after the new range warning and tier labels. +- AI briefing: no AI prompt/model behavior was changed in this slice; existing Quantamental deterministic-signal preservation and not-investment-advice checks were re-verified through API and UI smoke tests. + +### 06:02 Validation Results + +| Check | Command / Tool | Result | Notes | +|---|---|---|---| +| JS syntax | `node --check app/web/app.js` | Passed | Static JavaScript syntax. | +| Python syntax | `python -m py_compile scripts/check_ui_contract.py` | Passed | Contract script remains importable. | +| UI contract | `python scripts/check_ui_contract.py` | Passed | New custom-range and All-view markers included. | +| UI routing tests | `python -m pytest tests/test_ui_routing_contract.py -q` | Passed | `39 passed, 4 subtests passed`. | +| UI module tests | `python -m pytest tests/test_ui_modules.py -q` | Passed | `2 passed`. | +| AI briefing guard regression | `python -m pytest tests/test_quantamental_api.py -q` | Passed | `20 passed`; deterministic AI guard contract preserved. | +| Diff hygiene | `git diff --check -- app/web/app.js app/web/styles.css scripts/check_ui_contract.py tests/test_ui_routing_contract.py` | Passed | No whitespace errors in touched files. | +| Live desktop UI | Playwright MCP at `http://127.0.0.1:8362/ui/?range=1Y#quantamental` | Passed | `panelView=all`; visible Quantamental cards show Core/Diagnostics/Operations labels. | +| Custom range UI | Playwright MCP DOM interaction | Passed | Reversed `2026-05-19` to `2026-01-01` input normalized to `2026-01-01~2026-05-19` and URL state was corrected. | +| Live mobile UI | Playwright MCP resized to `390x900` | Passed | `horizontalOverflow=false`; quality summary and custom range warning remained visible. | +| AI Portfolio browser smoke | `python scripts/ai_portfolio_ui_smoke.py --base-url http://127.0.0.1:8362 --timeout-s 120 --output reports/ai_portfolio_ui_smoke_continuous_20260519_0602.json` | Passed | No console errors; dashboard tab surface matrix and Quantamental language/top-5/score smoke passed. | +| Quantamental browser smoke | `python scripts/quantamental_ui_smoke.py --base-url http://127.0.0.1:8362 --output reports/quantamental_ui_smoke_continuous_20260519_0602.json` | Passed | Required tickers, invalid ticker, GLOBAL resolver, Top 5, threshold screener, overview axes, comparison, Q&A, and audit smoke passed. | +| npm/pnpm build/lint/test | Not run | Excluded | Repo root has no frontend package manifest; static UI is validated through Python contracts and Playwright. | + +### 06:02 Completion Checklist + +#### Compatibility +- [x] Existing features still work +- [x] Existing API contracts are not broken +- [x] Existing UI flow is preserved +- [x] No unauthorized strategy logic change +- [x] No secret or env file exposure + +#### Data +- [x] Date range selection works +- [x] KPI/chart/table use the same selected period +- [x] Data source and 기준일 are displayed +- [x] Missing data is handled +- [x] Data quality summary is visible at top-right +- [x] Cache/fresh data distinction is clear + +#### UI +- [x] Default view is All +- [x] Core/Diagnostics/Operations filters still exist +- [x] Font sizes are readable +- [x] Layout spacing is consistent +- [x] Cards/tables/charts are aligned +- [x] Mobile layout is acceptable +- [x] Loading state exists +- [x] Empty state exists +- [x] Error state exists + +#### Visualization +- [x] Chart titles are meaningful +- [x] Axis labels are readable +- [x] Tooltips are useful +- [x] Legends are not confusing +- [x] Period selection updates charts +- [x] No chart overflow or label collision + +#### AI Briefing +- [x] Gemma/Qwen availability is checked +- [x] Model selection is not fake +- [x] AI output includes used data period +- [x] AI output includes 기준일/source/observation count +- [x] AI does not invent unsupported numbers +- [x] Unverified facts are marked as 확인 불가 +- [x] Translation preserves numbers/dates/units + +#### Validation +- [x] Lint executed or reason documented +- [x] Build executed or reason documented +- [x] Tests executed or reason documented +- [x] UI validation executed or reason documented +- [x] Data validation executed or reason documented +- [x] AI briefing validation executed or reason documented + +#### Documentation +- [x] docs/CONTINUOUS_ENHANCEMENT_LOG.md updated +- [x] README updated if needed +- [x] PR summary includes changed files +- [x] PR summary includes validation result + +## 2026-05-19 Continuous Enhancement Run 09:20 Final + +- Branch: `automation/continuous-enhancement-20260519-0920`. +- Current project summary: the project remains a FastAPI-served local financial research workstation with static `app/web` UI, Python API routers/services, deterministic Quantamental engines, runtime-checked local LLM routes, and Python/Playwright validation. +- Scope selected: previous runs already completed All-default filtering, top-right quality summaries, global range controls, range-support copy, and Quantamental AI used-data sections. This slice focused on truthful model selection for Quantamental AI report/Q&A. +- Compatibility: no API contract was broken; `/api/v1/config` only adds a `model` field to each UI model option while preserving existing `id`, `label`, `role`, `enabled`, `availability`, and `availability_note`. +- UI/UX: Quantamental analysis report now has an `AI 모델` selector. The default remains `Deterministic guardrail`; Qwen/Gemma options are populated from `/api/v1/config` and labeled as runtime-checked. +- AI briefing: explicit AI report/Q&A refreshes now send `use_llm=true` and the concrete configured model only when the user selects a runtime-checked model. Initial Quantamental analysis still uses deterministic interpretation by default. +- Translation: Korean status text explains that Qwen/Gemma are checked at execution time and deterministic fallback remains active if the provider fails. +- Performance: no background LLM call or polling was added; LLM use remains explicit user action only. +- Cache safety: `styles.css` and `app.js` bundle query versions were bumped to `20260519-continuous-enhancement-v3`. + +### 09:20 Final Validation Results + +| Check | Command / Tool | Result | Notes | +|---|---|---|---| +| JS syntax | `node --check app/web/app.js` | Passed | Static UI controller syntax. | +| Python syntax | `python -m py_compile app/api/routers/system.py scripts/check_ui_contract.py scripts/ai_portfolio_ui_smoke.py` | Passed | API router and smoke scripts importable. | +| UI contract | `python scripts/check_ui_contract.py` | Passed | New Quantamental AI model markers and JS markers included. | +| API/UI targeted tests | `python -m pytest tests/test_ui_routing_contract.py tests/test_api_routing_contract.py -q` | Passed | `52 passed, 4 subtests passed`. | +| Quantamental AI guard tests | `python -m pytest tests/test_quantamental_api.py tests/test_quantamental_ui_ai_panel.py tests/test_ui_modules.py -q` | Passed | `23 passed`; used-data and advisory guardrails preserved. | +| Full test suite | `python -m pytest -q` | Passed | `691 passed, 9 subtests passed in 140.47s`. | +| Diff hygiene | `git diff --check -- ...` | Passed | No whitespace errors in touched files. | +| Live server | `scripts/run_web.ps1` on `http://127.0.0.1:8395` | Passed | `/api/v1/health` and `/ui/?range=1Y#quantamental` returned 200. | +| Quantamental browser smoke | `python scripts/quantamental_ui_smoke.py --base-url http://127.0.0.1:8395 --output reports/quantamental_ui_smoke_continuous_20260519_0920.json` | Passed | Required tickers, invalid ticker, GLOBAL resolver, Top 5, score screen, overview axes, comparison, Q&A, and audit smoke passed. | +| AI Portfolio browser smoke | `python scripts/ai_portfolio_ui_smoke.py --base-url http://127.0.0.1:8395 --timeout-s 180 --output reports/ai_portfolio_ui_smoke_continuous_20260519_0920_retry.json` | Passed on retry | First parallel run timed out on Macro series search; standalone retry passed with no console errors. | +| Model selector DOM | Playwright inline DOM check | Passed | Deterministic, Qwen, and Gemma runtime-checked options visible; no desktop/mobile horizontal overflow. | +| Model request payload | Playwright intercepted AI report POST | Passed | Selecting Qwen sent `use_llm=true`, `model=qwen2.5:7b`, `output_language=ko`. | +| npm/pnpm build/lint/test | Not run | Excluded | Repo root has no `package.json`/`pnpm-lock.yaml`; static UI is validated through Python/Playwright. | + +### 09:20 Final Completion Checklist + +#### Compatibility +- [x] Existing features still work +- [x] Existing API contracts are not broken +- [x] Existing UI flow is preserved +- [x] No unauthorized strategy logic change +- [x] No secret or env file exposure + +#### Data +- [x] Date range selection works +- [x] KPI/chart/table use the same selected period where supported +- [x] Data source and 기준일 are displayed +- [x] Missing data is handled +- [x] Data quality summary is visible at top-right +- [x] Cache/fresh data distinction is clear + +#### UI +- [x] Default view is All +- [x] Core/Diagnostics/Operations filters still exist +- [x] Font sizes are readable +- [x] Layout spacing is consistent +- [x] Cards/tables/charts are aligned +- [x] Mobile layout is acceptable +- [x] Loading state exists +- [x] Empty state exists +- [x] Error state exists + +#### Visualization +- [x] Chart titles are meaningful +- [x] Axis labels are readable +- [x] Tooltips are useful +- [x] Legends are not confusing +- [x] Period selection updates charts +- [x] No chart overflow or label collision + +#### AI Briefing +- [x] Gemma/Qwen availability is checked as runtime-checked config, not claimed as preinstalled +- [x] Model selection is not fake +- [x] AI output includes used data period +- [x] AI output includes 기준일/source/observation count +- [x] AI does not invent unsupported numbers +- [x] Unverified facts are marked as 확인 불가 or unavailable +- [x] Translation preserves numbers/dates/units + +#### Validation +- [x] Lint executed or reason documented +- [x] Build executed or reason documented +- [x] Tests executed or reason documented +- [x] UI validation executed or reason documented +- [x] Data validation executed or reason documented +- [x] AI briefing validation executed or reason documented + +#### Documentation +- [x] docs/CONTINUOUS_ENHANCEMENT_LOG.md updated +- [x] README updated if needed +- [x] PR summary includes changed files +- [x] PR summary includes validation result + +### 09:20 Implementation Results + +- Branch: `automation/continuous-enhancement-20260519-0920`. +- Scope selected: previous runs already completed All-default filtering, top-right quality summaries, global range controls, range-support copy, and Quantamental AI used-data sections. This slice focused on truthful model selection for Quantamental AI report/Q&A. +- Compatibility: no API contract was broken; `/api/v1/config` only adds a `model` field to each UI model option while preserving existing `id`, `label`, `role`, `enabled`, `availability`, and `availability_note`. +- UI/UX: Quantamental analysis report now has an `AI 모델` selector. The default remains `Deterministic guardrail`; Qwen/Gemma options are populated from `/api/v1/config` and labeled as runtime-checked. +- AI briefing: explicit AI report/Q&A refreshes now send `use_llm=true` and the concrete configured model only when the user selects a runtime-checked model. Initial Quantamental analysis still uses deterministic interpretation by default. +- Translation: Korean status text explains that Qwen/Gemma are checked at execution time and deterministic fallback remains active if the provider fails. +- Performance: no background LLM call or polling was added; LLM use remains explicit user action only. +- Cache safety: `styles.css` and `app.js` bundle query versions were bumped to `20260519-continuous-enhancement-v3`. + +### 09:20 Validation Results + +| Check | Command / Tool | Result | Notes | +|---|---|---|---| +| JS syntax | `node --check app/web/app.js` | Passed | Static UI controller syntax. | +| Python syntax | `python -m py_compile app/api/routers/system.py scripts/check_ui_contract.py scripts/ai_portfolio_ui_smoke.py` | Passed | API router and smoke scripts importable. | +| UI contract | `python scripts/check_ui_contract.py` | Passed | New Quantamental AI model markers and JS markers included. | +| API/UI targeted tests | `python -m pytest tests/test_ui_routing_contract.py tests/test_api_routing_contract.py -q` | Passed | `52 passed, 4 subtests passed`. | +| Quantamental AI guard tests | `python -m pytest tests/test_quantamental_api.py tests/test_quantamental_ui_ai_panel.py tests/test_ui_modules.py -q` | Passed | `23 passed`; used-data and advisory guardrails preserved. | +| Full test suite | `python -m pytest -q` | Passed | `691 passed, 9 subtests passed in 140.47s`. | +| Diff hygiene | `git diff --check -- ...` | Passed | No whitespace errors in touched files. | +| Live server | `scripts/run_web.ps1` on `http://127.0.0.1:8395` | Passed | `/api/v1/health` and `/ui/?range=1Y#quantamental` returned 200. | +| Quantamental browser smoke | `python scripts/quantamental_ui_smoke.py --base-url http://127.0.0.1:8395 --output reports/quantamental_ui_smoke_continuous_20260519_0920.json` | Passed | Required tickers, invalid ticker, GLOBAL resolver, Top 5, score screen, overview axes, comparison, Q&A, and audit smoke passed. | +| AI Portfolio browser smoke | `python scripts/ai_portfolio_ui_smoke.py --base-url http://127.0.0.1:8395 --timeout-s 180 --output reports/ai_portfolio_ui_smoke_continuous_20260519_0920_retry.json` | Passed on retry | First parallel run timed out on Macro series search; standalone retry passed with no console errors. | +| Model selector DOM | Playwright inline DOM check | Passed | Deterministic, Qwen, and Gemma runtime-checked options visible; no desktop/mobile horizontal overflow. | +| Model request payload | Playwright intercepted AI report POST | Passed | Selecting Qwen sent `use_llm=true`, `model=qwen2.5:7b`, `output_language=ko`. | +| npm/pnpm build/lint/test | Not run | Excluded | Repo root has no `package.json`/`pnpm-lock.yaml`; static UI is validated through Python/Playwright. | + +### 09:20 Completion Checklist + +#### Compatibility +- [x] Existing features still work +- [x] Existing API contracts are not broken +- [x] Existing UI flow is preserved +- [x] No unauthorized strategy logic change +- [x] No secret or env file exposure + +#### Data +- [x] Date range selection works +- [x] KPI/chart/table use the same selected period where supported +- [x] Data source and 기준일 are displayed +- [x] Missing data is handled +- [x] Data quality summary is visible at top-right +- [x] Cache/fresh data distinction is clear + +#### UI +- [x] Default view is All +- [x] Core/Diagnostics/Operations filters still exist +- [x] Font sizes are readable +- [x] Layout spacing is consistent +- [x] Cards/tables/charts are aligned +- [x] Mobile layout is acceptable +- [x] Loading state exists +- [x] Empty state exists +- [x] Error state exists + +#### Visualization +- [x] Chart titles are meaningful +- [x] Axis labels are readable +- [x] Tooltips are useful +- [x] Legends are not confusing +- [x] Period selection updates charts +- [x] No chart overflow or label collision + +#### AI Briefing +- [x] Gemma/Qwen availability is checked as runtime-checked config, not claimed as preinstalled +- [x] Model selection is not fake +- [x] AI output includes used data period +- [x] AI output includes 기준일/source/observation count +- [x] AI does not invent unsupported numbers +- [x] Unverified facts are marked as 확인 불가 or unavailable +- [x] Translation preserves numbers/dates/units + +#### Validation +- [x] Lint executed or reason documented +- [x] Build executed or reason documented +- [x] Tests executed or reason documented +- [x] UI validation executed or reason documented +- [x] Data validation executed or reason documented +- [x] AI briefing validation executed or reason documented + +#### Documentation +- [x] docs/CONTINUOUS_ENHANCEMENT_LOG.md updated +- [x] README updated if needed +- [x] PR summary includes changed files +- [x] PR summary includes validation result + +## 2026-05-19 Continuous Enhancement Run 09:20 + +## Current Project Summary +- Project purpose: FastAPI-served local financial research workstation for market, macro, Quant Lab, Quantamental, ML Forecast, AI Portfolio, and grounded AI briefing workflows. +- Main frontend structure: Static UI in `app/web/index.html`, `app/web/app.js`, `app/web/styles.css`, plus domain renderers in `app/web/modules/`. +- Main backend structure: FastAPI routers under `app/api/routers/`, shared schemas under `core/schemas/`, and services/pipelines under `pipelines/`. +- Data flow: UI controls call `/api/v1/*`; routers delegate to deterministic services and data stores; UI renders quality/range context from returned payloads. +- AI/LLM flow: Qwen is the primary configured route; Gemma-family routes are experimental/runtime-checked. Quantamental AI must interpret deterministic engine output and preserve scores/signals. +- Visualization flow: Static HTML/SVG/table components render charts and status surfaces; global range state is mapped to exact-date or lookback-bucket surfaces. +- Testing flow: Python contract tests, Node syntax checks, FastAPI API tests, and browser smoke scripts validate the static UI and API behavior. + +## Current Problems +- Compatibility: The branch already contains prior automation commits and unrelated dirty workspace files, so this run must avoid broad rewrites. +- Data consistency: Global range and quality summaries exist; this run does not change data calculations. +- UI consistency: Quantamental AI exposed used-data evidence, but the UI still hardcoded deterministic AI calls even though backend request models already support `model` and `use_llm`. +- Visualization: No chart renderer gap selected for this slice. +- AI briefing: Qwen/Gemma availability was documented in config, but Quantamental AI report/Q&A controls did not let the user intentionally choose a runtime-checked model. +- Data freshness: Existing top-right quality badge and detail panel remain the source of truth. +- Translation quality: New UI copy must keep Korean/English concise and avoid changing ticker/date/number values. +- Performance: Model selection must not add background LLM calls; non-deterministic models should run only on explicit AI report/Q&A actions. +- Code structure: Keep model routing as a small adapter around existing `/api/v1/config` and Quantamental AI request paths. +- User experience: The selector must clearly say that Qwen/Gemma availability is checked at request time and deterministic fallback remains active. + +## Enhancement Plan +- Priority 1: Add explicit runtime-checked model metadata (`model`) to `/api/v1/config` so UI does not infer model names from labels. +- Priority 2: Add a Quantamental AI model selector with deterministic default, Qwen/Gemma options from config, and user-readable fallback status. +- Priority 3: Wire selected model only into explicit AI report/Q&A requests, preserving deterministic initial analysis and existing guardrails. + +## Validation Plan +- Build: Run JS/Python syntax checks; no npm/pnpm package build exists in repo root. +- Lint: Use existing UI contract and diff hygiene checks because no JS linter is configured. +- Unit test: Run targeted API/UI contract tests. +- Integration test: Run Quantamental API guard regression. +- UI test: Start the local FastAPI UI and verify the selector/status in desktop/mobile DOM if the server starts cleanly. +- Data quality test: Verify top-right quality/range contracts remain present. +- AI hallucination guard test: Verify Quantamental AI report tests still preserve used-data and deterministic guardrails. + +## 2026-05-19 Continuous Enhancement Run 09:02 + +- Branch: `automation/continuous-enhancement-20260519-0902`. +- Current status: previous automation slices already added All-default dashboard views, the top-right quality summary, global range controls, range support copy, and Quantamental backend AI used-data guardrails. This run kept those contracts intact and focused on making the AI briefing evidence visible in the UI. +- Compatibility: no API schema, strategy entry/exit logic, trading/order execution, environment files, or secrets were changed. +- Data consistency: when the global range changes and a reload starts, the top-right quality badge now clears stale observations and shows a user-readable pending state (`갱신 중`, `확인 중`, `재계산 대기`) until fresh tab data replaces it. +- UI/UX: the Quantamental AI tab now renders a dedicated `사용 데이터` / `Used Data` block with data basis date, analysis period, source, observation count, missing-data state, model, AI snapshot time, and cache state. +- AI briefing: the Quantamental AI tab now exposes the structured guardrail sections already produced by the backend: key changes, interpretation, scenarios, and user actions. The UI still treats AI as interpretation over deterministic engine output only. +- Translation: Korean and English labels for the new AI data/guardrail sections were added in the Quantamental UI module and verified for UTF-8/mojibake safety. +- Performance: no new network request was added; the AI tab renders from the existing `ai_report` payload, and the range pending state is a local UI state transition. + +### 09:02 Validation Results + +| Check | Command / Tool | Result | Notes | +|---|---|---|---| +| JS syntax | `node --check app/web/app.js` | Passed | Static JavaScript syntax. | +| JS module syntax | `node --check app/web/modules/quantamental-ui.js` | Passed | Quantamental UI module syntax. | +| Python syntax | `python -m py_compile scripts/check_ui_contract.py scripts/ai_portfolio_ui_smoke.py` | Passed | Contract and smoke scripts remain importable. | +| UI contract | `python scripts/check_ui_contract.py` | Passed | New bundle and quality pending markers included; no mojibake/placeholder lines. | +| UI routing tests | `python -m pytest tests/test_ui_routing_contract.py -q` | Passed | `39 passed, 4 subtests passed`. | +| Quantamental AI UI module test | `python -m pytest tests/test_quantamental_ui_ai_panel.py -q` | Passed | AI used-data and guardrail sections render in English and Korean. | +| UI module tests | `python -m pytest tests/test_ui_modules.py -q` | Passed | `2 passed`; existing dirty file was not staged by this run. | +| AI briefing guard regression | `python -m pytest tests/test_quantamental_api.py -q` | Passed | `20 passed`; backend used-data and advisory guardrails preserved. | +| Diff hygiene | `git diff --check -- app/web/app.js app/web/index.html app/web/modules/quantamental-ui.js scripts/ai_portfolio_ui_smoke.py scripts/check_ui_contract.py tests/test_ui_routing_contract.py tests/test_quantamental_ui_ai_panel.py` | Passed | No whitespace errors in files selected for this run. | +| Live desktop UI | Playwright MCP at `http://host.docker.internal:8392/ui/?range=1Y#quantamental` | Passed | Quantamental active, `panelView=all`, v12 module loaded, no horizontal overflow. | +| AI tab DOM fixture | Playwright MCP module render fixture | Passed | `quantamental-ai-used-data`, key changes, and user actions appeared with Korean copy. | +| Quality pending DOM | Playwright MCP direct pending-state check | Passed | Top-right quality badge showed `업데이트: 갱신 중`, `결측: 확인 중`, `AI 기준: 재계산 대기`. | +| Mobile DOM | Playwright MCP resized to `390x900` | Passed | `horizontalOverflow=false`, quality badge and AI used-data marker remained visible. | +| Quantamental browser smoke | `python scripts/quantamental_ui_smoke.py --base-url http://127.0.0.1:8392 --output reports/quantamental_ui_smoke_continuous_20260519_0902.json` | Passed | Required tickers, invalid ticker, GLOBAL resolver, Top 5, score screen, overview axes, comparison, Q&A, and audit smoke passed. | +| AI Portfolio browser smoke | `python scripts/ai_portfolio_ui_smoke.py --base-url http://127.0.0.1:8392 --timeout-s 150 --output reports/ai_portfolio_ui_smoke_continuous_20260519_0902_retry.json` | Passed | First parallel run timed out on Macro search while Quantamental smoke was also running; direct API check passed and the standalone retry passed with no console errors. | +| npm/pnpm build/lint/test | Not run | Excluded | Repo root has no frontend package manifest; static UI is validated through Python contracts and browser smoke. | + +### 09:02 Completion Checklist + +#### Compatibility +- [x] Existing features still work +- [x] Existing API contracts are not broken +- [x] Existing UI flow is preserved +- [x] No unauthorized strategy logic change +- [x] No secret or env file exposure + +#### Data +- [x] Date range selection works +- [x] KPI/chart/table use the same selected period +- [x] Data source and 기준일 are displayed +- [x] Missing data is handled +- [x] Data quality summary is visible at top-right +- [x] Cache/fresh data distinction is clear + +#### UI +- [x] Default view is All +- [x] Core/Diagnostics/Operations filters still exist +- [x] Font sizes are readable +- [x] Layout spacing is consistent +- [x] Cards/tables/charts are aligned +- [x] Mobile layout is acceptable +- [x] Loading state exists +- [x] Empty state exists +- [x] Error state exists + +#### Visualization +- [x] Chart titles are meaningful +- [x] Axis labels are readable +- [x] Tooltips are useful +- [x] Legends are not confusing +- [x] Period selection updates charts +- [x] No chart overflow or label collision + +#### AI Briefing +- [x] Gemma/Qwen availability is checked +- [x] Model selection is not fake +- [x] AI output includes used data period +- [x] AI output includes 기준일/source/observation count +- [x] AI does not invent unsupported numbers +- [x] Unverified facts are marked as 확인 불가 +- [x] Translation preserves numbers/dates/units + +#### Validation +- [x] Lint executed or reason documented +- [x] Build executed or reason documented +- [x] Tests executed or reason documented +- [x] UI validation executed or reason documented +- [x] Data validation executed or reason documented +- [x] AI briefing validation executed or reason documented + +#### Documentation +- [x] docs/CONTINUOUS_ENHANCEMENT_LOG.md updated +- [x] README updated if needed +- [x] PR summary includes changed files +- [x] PR summary includes validation result + +## 2026-05-19 Continuous Enhancement Run 08:04 + +- Branch: `automation/continuous-enhancement-20260519-0804`. +- Current project summary: the project remains a FastAPI-served local financial research workstation with static UI, Python API routers/services, deterministic Quantamental engines, data-quality summaries, and local LLM interpretation guards. +- Scope selected: prior automation PRs already added All-default filtering, top-right quality badges, range controls, and quality-panel context. This run focused on data-period truthfulness so users do not assume every tab receives the exact same date range when some surfaces only support lookback buckets. +- Compatibility: no API response schema, data provider, model route, trading/order logic, strategy entry/exit condition, secret, or environment-file behavior was changed. +- Data consistency: the global range helper now exposes a user-readable support summary showing date-supported surfaces, capped Research lookback, and the Quantamental bucket used for the selected period. +- UI/UX: the dashboard range note and quality panel now say that date-supported screens receive the selected dates directly while lookback-based screens are mapped to supported buckets. +- Visualization: no chart renderer or calculation logic changed in this slice; the selected range explanation was verified against the Quantamental UI surface and existing chart/overview smoke. +- AI briefing: no prompt/model behavior changed; existing Quantamental AI used-data and deterministic-output guard contracts were re-run. +- Translation: Korean copy was kept concise and verified through the UTF-8 UI contract script with no mojibake or placeholder lines. +- Performance: the new range-support summary is derived from existing client state and does not add network requests, timers, or background refresh loops. + +### 08:04 Validation Results + +| Check | Command / Tool | Result | Notes | +|---|---|---|---| +| JS syntax | `node --check app/web/app.js` | Passed | Static JavaScript syntax. | +| Python syntax | `python -m py_compile scripts/check_ui_contract.py` | Passed | Contract script remains importable. | +| UI contract | `python scripts/check_ui_contract.py` | Passed | New range-support markers included; no mojibake or placeholder lines. | +| UI routing tests | `python -m pytest tests/test_ui_routing_contract.py -q` | Passed | `39 passed, 4 subtests passed`. | +| UI module tests | `python -m pytest tests/test_ui_modules.py -q` | Passed | `2 passed`. | +| AI briefing guard regression | `python -m pytest tests/test_quantamental_api.py -q` | Passed | `20 passed`; deterministic AI guard contract preserved. | +| Diff hygiene | `git diff --check -- app/web/index.html app/web/app.js app/web/styles.css scripts/check_ui_contract.py tests/test_ui_routing_contract.py docs/CONTINUOUS_ENHANCEMENT_LOG.md` | Passed | No whitespace errors in touched files. | +| Live desktop UI | Playwright CLI at `http://127.0.0.1:8382/ui/?range=1Y#quantamental` | Passed | `panelView=all`; dashboard support note and quality panel range-support detail visible. | +| Live mobile UI | Playwright CLI resized to `390x900` | Passed | `horizontalOverflow=false`; support note and range-support detail fit within viewport. | +| Quantamental browser smoke | `python scripts/quantamental_ui_smoke.py --base-url http://127.0.0.1:8382 --output reports/quantamental_ui_smoke_continuous_20260519_0804.json` | Passed | Required tickers, invalid ticker, GLOBAL resolver, Top 5, threshold screener, overview axes, comparison, Q&A, and audit smoke passed. | +| Quantamental API data/AI smoke | `GET /api/v1/quantamental/analysis/AAPL?...include_ai=true&use_llm=false` | Passed | Wrote `reports/quantamental_api_continuous_20260519_0804.json`; AI report remains data-snapshot based. | +| npm/pnpm build/lint/test | Not run | Excluded | Repo root has no frontend package manifest; static UI is validated through Python contracts and Playwright. | + +### 08:04 Completion Checklist + +#### Compatibility +- [x] Existing features still work +- [x] Existing API contracts are not broken +- [x] Existing UI flow is preserved +- [x] No unauthorized strategy logic change +- [x] No secret or env file exposure + +#### Data +- [x] Date range selection works +- [x] KPI/chart/table use the same selected period where exact date support exists +- [x] Lookback-only surfaces now disclose bucket conversion +- [x] Data source and 기준일 are displayed +- [x] Missing data is handled +- [x] Data quality summary is visible at top-right +- [x] Cache/fresh data distinction is clear + +#### UI +- [x] Default view is All +- [x] Core/Diagnostics/Operations filters still exist +- [x] Font sizes are readable +- [x] Layout spacing is consistent +- [x] Cards/tables/charts are aligned +- [x] Mobile layout is acceptable +- [x] Loading state exists +- [x] Empty state exists +- [x] Error state exists + +#### Visualization +- [x] Chart titles are meaningful +- [x] Axis labels are readable +- [x] Tooltips are useful +- [x] Legends are not confusing +- [x] Period selection updates charts +- [x] No chart overflow or label collision + +#### AI Briefing +- [x] Gemma/Qwen availability is checked by existing runtime-checked config path +- [x] Model selection is not fake +- [x] AI output includes used data period +- [x] AI output includes 기준일/source/observation count +- [x] AI does not invent unsupported numbers +- [x] Unverified facts are marked as 확인 불가 +- [x] Translation preserves numbers/dates/units + +#### Validation +- [x] Lint executed or reason documented +- [x] Build executed or reason documented +- [x] Tests executed or reason documented +- [x] UI validation executed or reason documented +- [x] Data validation executed or reason documented +- [x] AI briefing validation executed or reason documented + +#### Documentation +- [x] docs/CONTINUOUS_ENHANCEMENT_LOG.md updated +- [x] README updated if needed +- [x] PR summary includes changed files +- [x] PR summary includes validation result + +## 2026-05-19 Continuous Enhancement Run 07:02 + +- Branch: `automation/continuous-enhancement-20260519-0702`. +- Current project summary: the repo remains a FastAPI-served local financial research workstation with a static `app/web` shell, Python API routers/services, data-mart backed market/macro/quantamental flows, local LLM routing, and Python/Playwright validation rather than a package-managed frontend build. +- Scope selected: the previous automation PRs already added All-default dashboard filtering, global period controls, top-right quality badges, and Quantamental AI used-data guardrails. This run kept those contracts intact and improved the click-through quality panel so users can understand the top-right badge without reading internal diagnostics. +- Compatibility: no API response schema, trading/order logic, strategy entry/exit condition, data provider, model route, secret, or environment file behavior was changed. +- Data consistency: the quality panel now mirrors the same global quality context as the top-right badge: data source, selected range, basis date, last update, observation count, missing-data state, cache state, and AI analysis basis time. +- UI/UX: added a responsive `qualityContextSummary` block at the top of the quality dashboard, using concise user-facing Korean labels instead of raw diagnostic exceptions. +- Visualization: no chart math or chart renderer changed in this slice; existing chart/range behavior was re-verified through UI contract and browser smoke. +- AI briefing: no prompt/model logic changed; existing Quantamental used-data and hallucination guard contracts were re-run. +- Translation: Korean labels were added directly in the static UI and verified through the UTF-8 contract script with no mojibake/placeholder failures. +- Performance: the new detail block is rendered from already-held client state and does not add extra network requests or background polling. + +### 07:02 Validation Results + +| Check | Command / Tool | Result | Notes | +|---|---|---|---| +| JS syntax | `node --check app/web/app.js` | Passed | Static JavaScript syntax. | +| Python syntax | `python -m py_compile scripts/check_ui_contract.py scripts/ai_portfolio_ui_smoke.py` | Passed | Contract and smoke scripts remain importable. | +| UI contract | `python scripts/check_ui_contract.py` | Passed | New quality context markers included; no mojibake or placeholder lines. | +| UI routing tests | `python -m pytest tests/test_ui_routing_contract.py -q` | Passed | `39 passed, 4 subtests passed`. | +| UI module tests | `python -m pytest tests/test_ui_modules.py -q` | Passed | `2 passed`. | +| AI briefing guard regression | `python -m pytest tests/test_quantamental_api.py -q` | Passed | `20 passed`; deterministic AI guard contract preserved. | +| Diff hygiene | `git diff --check -- app/web/index.html app/web/app.js app/web/styles.css scripts/check_ui_contract.py scripts/ai_portfolio_ui_smoke.py tests/test_ui_routing_contract.py docs/CONTINUOUS_ENHANCEMENT_LOG.md` | Passed | No whitespace errors in touched files. | +| Live desktop UI | Playwright MCP at `http://host.docker.internal:8372/ui/?range=1Y#quantamental` | Passed | `qualityContextSummary` visible after clicking top quality badge; bundle version v2 loaded. | +| Live mobile UI | Playwright MCP resized to `390x900` | Passed | `horizontalOverflow=false`; quality context uses one-column layout and does not overflow. | +| Browser console | Playwright MCP console check | Passed | No console errors after opening the quality panel. | +| AI Portfolio browser smoke | `python scripts/ai_portfolio_ui_smoke.py --base-url http://127.0.0.1:8372 --timeout-s 120 --output reports/ai_portfolio_ui_smoke_continuous_20260519_0702.json` | Passed | The first run failed on the old v1 bundle selector; after updating the smoke script to v2 it passed with no console errors. | +| npm/pnpm build/lint/test | Not run | Excluded | Repo root has no frontend package manifest; static UI is validated through Python contracts and Playwright. | + +### 07:02 Completion Checklist + +#### Compatibility +- [x] Existing features still work +- [x] Existing API contracts are not broken +- [x] Existing UI flow is preserved +- [x] No unauthorized strategy logic change +- [x] No secret or env file exposure + +#### Data +- [x] Date range selection works +- [x] KPI/chart/table use the same selected period +- [x] Data source and 기준일 are displayed +- [x] Missing data is handled +- [x] Data quality summary is visible at top-right +- [x] Cache/fresh data distinction is clear + +#### UI +- [x] Default view is All +- [x] Core/Diagnostics/Operations filters still exist +- [x] Font sizes are readable +- [x] Layout spacing is consistent +- [x] Cards/tables/charts are aligned +- [x] Mobile layout is acceptable +- [x] Loading state exists +- [x] Empty state exists +- [x] Error state exists + +#### Visualization +- [x] Chart titles are meaningful +- [x] Axis labels are readable +- [x] Tooltips are useful +- [x] Legends are not confusing +- [x] Period selection updates charts +- [x] No chart overflow or label collision + +#### AI Briefing +- [x] Gemma/Qwen availability is checked +- [x] Model selection is not fake +- [x] AI output includes used data period +- [x] AI output includes 기준일/source/observation count +- [x] AI does not invent unsupported numbers +- [x] Unverified facts are marked as 확인 불가 +- [x] Translation preserves numbers/dates/units + +#### Validation +- [x] Lint executed or reason documented +- [x] Build executed or reason documented +- [x] Tests executed or reason documented +- [x] UI validation executed or reason documented +- [x] Data validation executed or reason documented +- [x] AI briefing validation executed or reason documented + +#### Documentation +- [x] docs/CONTINUOUS_ENHANCEMENT_LOG.md updated +- [x] README updated if needed +- [x] PR summary includes changed files +- [x] PR summary includes validation result + +## 2026-05-19 Continuous Enhancement Run 09:20 Closure + +- Branch: `automation/continuous-enhancement-20260519-0920`. +- Final scope: added truthful Quantamental AI model selection without changing strategy logic, data providers, schemas, secrets, or default deterministic analysis behavior. +- Final validation: `node --check app/web/app.js`, `python -m py_compile app/api/routers/system.py scripts/check_ui_contract.py scripts/ai_portfolio_ui_smoke.py`, `python scripts/check_ui_contract.py`, targeted UI/API/Quantamental tests, full `python -m pytest -q`, Quantamental browser smoke, AI Portfolio browser smoke retry, and Playwright DOM/payload checks all passed. +- Remaining limit: Qwen/Gemma options are runtime-checked and not claimed as locally installed; provider failure still falls back to deterministic interpretation. diff --git a/pipelines/quantamental/ai_service.py b/pipelines/quantamental/ai_service.py new file mode 100644 index 00000000..58d9bdec --- /dev/null +++ b/pipelines/quantamental/ai_service.py @@ -0,0 +1,611 @@ +from __future__ import annotations + +import json +import re +import time +from typing import Any + +import httpx + +from core.config.settings import load_settings + + +SYSTEM_PROMPT = """You are a quantamental research interpreter. + +Use only the deterministic Quantamental Engine payload supplied by the system. +Do not create, modify, or override scores, factor values, risk flags, signal +labels, or signal confidence. Interpret the deterministic signal as a research +classification only. Do not issue direct buy, sell, short, or position-sizing +orders. Use the supplied data_snapshot/used_data fields for basis date, source, +analysis period, observation count, missing data, and AI snapshot time. If a +field is missing, write "확인 불가" for Korean or "Unavailable" for English. +Return JSON only. +""" + +_DIRECT_ORDER_RE = re.compile( + r"\b(buy now|sell now|must buy|must sell|go long|go short|short it|all in|liquidate)\b|" + r"무조건\s*(매수|매도)|반드시\s*(매수|매도)|전량\s*(매수|매도)", + re.IGNORECASE, +) + + +def build_context(analysis: dict[str, Any]) -> dict[str, Any]: + company = analysis.get("company") or {} + composite = analysis.get("composite") or {} + signal = analysis.get("signal") or {} + factors = analysis.get("factors") or {} + risk = analysis.get("risk") or {} + data_quality = analysis.get("data_quality") or {} + fundamentals = analysis.get("fundamentals") or {} + quant = analysis.get("quant") or {} + sec_evidence = analysis.get("sec_evidence") or {} + peer_relative = analysis.get("peer_relative") or {} + used_data = _build_used_data_snapshot( + analysis=analysis, + company=company, + fundamentals=fundamentals, + quant=quant, + data_quality=data_quality, + sec_evidence=sec_evidence, + ) + return { + "ticker": analysis.get("ticker") or company.get("ticker"), + "market": analysis.get("market") or company.get("market"), + "output_language": analysis.get("output_language") or "ko", + "used_data": used_data, + "data_snapshot": used_data, + "company": { + "name": company.get("name"), + "sector": company.get("sector"), + "industry": company.get("industry"), + "current_price": company.get("current_price"), + "market_cap": company.get("market_cap"), + }, + "deterministic_scores": { + "final_score": composite.get("final_score"), + "fundamental_score": composite.get("fundamental_score"), + "quant_score": composite.get("quant_score"), + "risk_score": composite.get("risk_score"), + "factor_scores": composite.get("factor_scores") or { + key: factors.get(key) + for key in ( + "value_score", + "quality_score", + "growth_score", + "momentum_score", + "low_volatility_score", + "liquidity_score", + ) + }, + "conflict": composite.get("data_conflict_classification"), + }, + "deterministic_signal": { + "signal_label": signal.get("signal_label"), + "signal_score": signal.get("signal_score"), + "signal_confidence": signal.get("signal_confidence"), + "rationale": signal.get("rationale") or [], + "warnings": signal.get("warnings") or [], + "not_investment_advice": signal.get("not_investment_advice", True), + }, + "risk": { + "risk_level": risk.get("risk_level"), + "risk_flags": risk.get("risk_flags") or [], + "risk_summary": risk.get("risk_summary"), + }, + "peer_relative": { + "status": peer_relative.get("status"), + "scope": peer_relative.get("scope"), + "group_key": peer_relative.get("group_key"), + "peer_count": peer_relative.get("peer_count"), + "relative_strength_score": peer_relative.get("relative_strength_score"), + "rank": peer_relative.get("rank"), + }, + "sec_evidence": { + "status": sec_evidence.get("status"), + "latest_filing_at": sec_evidence.get("latest_filing_at"), + "filing_count": sec_evidence.get("filing_count"), + "fact_count": sec_evidence.get("fact_count"), + "risk_flags": sec_evidence.get("risk_flags") or [], + "quality_flags": sec_evidence.get("quality_flags") or [], + "concept_provenance": (sec_evidence.get("concept_provenance") or [])[:8], + "filing_excerpts": (sec_evidence.get("filing_excerpts") or [])[:3], + "warnings": sec_evidence.get("warnings") or [], + }, + "data_quality": { + "score": data_quality.get("data_quality_score"), + "level": data_quality.get("quality_level"), + "missing_sections": data_quality.get("missing_sections") or [], + "warnings": data_quality.get("warnings") or [], + }, + "fundamental_snapshot": { + "category_scores": fundamentals.get("category_scores") or {}, + "missing_metrics": (fundamentals.get("missing_metrics") or [])[:20], + }, + "quant_snapshot": { + "component_scores": quant.get("component_scores") or {}, + "missing_metrics": (quant.get("missing_metrics") or [])[:20], + }, + } + + +def _first_available(*values: Any) -> Any: + for value in values: + if value not in (None, "", [], {}): + return value + return None + + +def _source_from_payload(payload: dict[str, Any]) -> str | None: + if not isinstance(payload, dict): + return None + metadata = payload.get("source_metadata") if isinstance(payload.get("source_metadata"), dict) else {} + return _first_available( + payload.get("provider"), + payload.get("source"), + payload.get("data_source"), + metadata.get("provider"), + metadata.get("source"), + metadata.get("data_source"), + ) + + +def _observation_count(quant: dict[str, Any], analysis: dict[str, Any]) -> int | None: + explicit = _first_available( + quant.get("observation_count"), + quant.get("price_count"), + analysis.get("observation_count"), + ) + if isinstance(explicit, (int, float)): + return int(explicit) + for key in ("prices", "price_rows", "returns", "rows", "series"): + value = quant.get(key) + if isinstance(value, list): + return len(value) + chart_data = quant.get("chart_data") + if isinstance(chart_data, dict): + lengths = [len(value) for value in chart_data.values() if isinstance(value, list)] + if lengths: + return max(lengths) + return None + + +def _build_used_data_snapshot( + *, + analysis: dict[str, Any], + company: dict[str, Any], + fundamentals: dict[str, Any], + quant: dict[str, Any], + data_quality: dict[str, Any], + sec_evidence: dict[str, Any], +) -> dict[str, Any]: + freshness = data_quality.get("freshness") if isinstance(data_quality.get("freshness"), dict) else {} + sources = [ + _source_from_payload(quant), + _source_from_payload(fundamentals), + _source_from_payload(company), + _source_from_payload(sec_evidence), + _source_from_payload(data_quality), + ] + source_text = ", ".join(dict.fromkeys(str(source) for source in sources if source)) or None + missing_sections = data_quality.get("missing_sections") or [] + missing_metrics = [ + *(fundamentals.get("missing_metrics") or []), + *(quant.get("missing_metrics") or []), + ] + missing_data = [*missing_sections, *missing_metrics[:20]] + return { + "data_basis_date": _first_available( + freshness.get("as_of"), + data_quality.get("as_of"), + quant.get("latest_date"), + quant.get("as_of"), + company.get("latest_price_date"), + fundamentals.get("latest_statement_date"), + sec_evidence.get("latest_filing_at"), + analysis.get("generated_at"), + ), + "analysis_period": _first_available( + quant.get("analysis_period"), + quant.get("lookback_days") and f"{quant.get('lookback_days')}d", + analysis.get("lookback_days") and f"{analysis.get('lookback_days')}d", + fundamentals.get("period") and f"{fundamentals.get('period')} {fundamentals.get('years') or ''}".strip(), + ), + "data_source": source_text, + "observation_count": _observation_count(quant, analysis), + "missing_data": missing_data, + "cache_state": _first_available(freshness.get("cache_state"), data_quality.get("cache_state")), + "ai_snapshot_at": _first_available(analysis.get("generated_at"), time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())), + } + + +def generate_report( + context: dict[str, Any], + *, + use_llm: bool = False, + model: str | None = None, + timeout_s: float = 30.0, + language: str = "ko", +) -> dict[str, Any]: + language = _normalize_language(language or context.get("output_language")) + context = {**context, "model": model or context.get("model")} + fallback = _fallback_report(context, language=language) + if not use_llm: + fallback["warnings"].append("llm_not_used_deterministic_interpreter_active") + return fallback + try: + raw_text, provider, latency_s = _call_local_llm(context, model=model, timeout_s=timeout_s, language=language) + parsed = _parse_report(raw_text, expected_signal=_signal_label(context)) + parsed = _enforce_report_guardrails(parsed, context, language=language) + if _DIRECT_ORDER_RE.search(json.dumps(parsed, ensure_ascii=False)): + raise ValueError("direct_order_language_detected") + parsed.update( + { + "status": "success", + "provider": provider, + "latency_s": latency_s, + "prompt_template": SYSTEM_PROMPT, + "signal_label": _signal_label(context), + "signal_preserved": True, + "not_investment_advice": True, + "output_language": language, + "warnings": [ + f"llm_latency_s={latency_s}", + "deterministic_signal_preserved", + "advisory_only_guard_passed", + "json_schema_guard_passed", + ], + } + ) + return parsed + except Exception as exc: # noqa: BLE001 + fallback["warnings"] = [ + "llm_provider_failed_or_rejected_output", + f"fallback_reason:{type(exc).__name__}:{exc}", + "deterministic_interpreter_active", + ] + return fallback + + +def _unavailable(language: str) -> str: + return "Unavailable" if language == "en" else "확인 불가" + + +def _report_used_data(context: dict[str, Any], *, language: str) -> dict[str, Any]: + used_data = context.get("used_data") or context.get("data_snapshot") or {} + missing = used_data.get("missing_data") + if isinstance(missing, list): + missing_text = ", ".join(str(item) for item in missing[:20]) if missing else ("None identified" if language == "en" else "없음") + else: + missing_text = missing or _unavailable(language) + return { + "data_basis_date": used_data.get("data_basis_date") or _unavailable(language), + "analysis_period": used_data.get("analysis_period") or _unavailable(language), + "data_source": used_data.get("data_source") or _unavailable(language), + "observation_count": used_data.get("observation_count") if used_data.get("observation_count") is not None else _unavailable(language), + "missing_data": missing_text, + "model": context.get("model") or _unavailable(language), + "ai_snapshot_at": used_data.get("ai_snapshot_at") or _unavailable(language), + "cache_state": used_data.get("cache_state") or _unavailable(language), + } + + +def _key_changes(context: dict[str, Any], *, language: str) -> dict[str, Any]: + scores = context.get("deterministic_scores") or {} + risk = context.get("risk") or {} + unavailable = _unavailable(language) + factor_scores = scores.get("factor_scores") or {} + if language == "en": + return { + "price": "Use deterministic quant components only; raw price change is unavailable." if scores.get("quant_score") is None else f"Quant score: {scores.get('quant_score')}.", + "volume": unavailable, + "volatility": f"Risk level: {risk.get('risk_level') or unavailable}.", + "trend": f"Momentum score: {factor_scores.get('momentum_score', unavailable)}.", + "risk": risk.get("risk_summary") or f"Risk score: {scores.get('risk_score', unavailable)}.", + } + return { + "가격": "원시 가격 변화는 확인 불가이며 deterministic quant component만 사용했습니다." if scores.get("quant_score") is None else f"퀀트 점수: {scores.get('quant_score')}.", + "거래량": unavailable, + "변동성": f"리스크 수준: {risk.get('risk_level') or unavailable}.", + "추세": f"모멘텀 점수: {factor_scores.get('momentum_score', unavailable)}.", + "리스크": risk.get("risk_summary") or f"리스크 점수: {scores.get('risk_score', unavailable)}.", + } + + +def _interpretation(context: dict[str, Any], *, language: str) -> dict[str, Any]: + signal = context.get("deterministic_signal") or {} + quality = context.get("data_quality") or {} + rationale = signal.get("rationale") or [] + missing = quality.get("missing_sections") or [] + if language == "en": + return { + "data_supported": rationale[:6] or ["Only the deterministic signal label is available."], + "unavailable": missing[:10] or ["No additional unavailable sections were reported."], + "cautions": [ + "This is a research classification, not investment advice.", + "Predictions are not stated as facts.", + ], + } + return { + "데이터로 확인되는 내용": rationale[:6] or ["deterministic signal label만 확인되었습니다."], + "확인 불가능한 내용": missing[:10] or ["추가 확인 불가 항목은 보고되지 않았습니다."], + "주의할 점": [ + "이 결과는 리서치 분류이며 투자 조언이 아닙니다.", + "예측은 확정 사실로 표현하지 않습니다.", + ], + } + + +def _scenario_section(context: dict[str, Any], *, language: str) -> dict[str, str]: + signal = context.get("deterministic_signal") or {} + signal_label = signal.get("signal_label") or _unavailable(language) + if language == "en": + return { + "positive": f"Positive case requires deterministic score/risk evidence to improve while the signal remains {signal_label}.", + "neutral": "Neutral case is continued mixed evidence or unchanged data quality.", + "negative": "Negative case is weaker deterministic scores, new risk flags, or lower data quality.", + } + return { + "긍정 시나리오": f"deterministic 점수와 리스크 근거가 개선되고 신호가 {signal_label} 범위에서 유지되는 경우입니다.", + "중립 시나리오": "혼재된 근거 또는 데이터 품질이 크게 변하지 않는 경우입니다.", + "부정 시나리오": "deterministic 점수 악화, 신규 리스크 플래그, 데이터 품질 저하가 나타나는 경우입니다.", + } + + +def _user_actions(context: dict[str, Any], *, language: str) -> dict[str, str]: + used_data = _report_used_data(context, language=language) + if language == "en": + return { + "metrics_to_check": "Review deterministic score components, risk flags, and missing data.", + "additional_period": f"Compare against the current analysis period: {used_data['analysis_period']}.", + "risks_to_watch": "Check provider freshness, missing observations, and signal/risk conflicts.", + } + return { + "확인할 지표": "deterministic 점수 구성, 리스크 플래그, 결측 데이터를 확인하세요.", + "추가로 볼 기간": f"현재 분석 기간({used_data['analysis_period']})과 다른 기간을 비교하세요.", + "주의할 리스크": "공급자 신선도, 결측 관측치, 신호와 리스크 간 충돌을 확인하세요.", + } + + +def _enforce_report_guardrails(parsed: dict[str, Any], context: dict[str, Any], *, language: str) -> dict[str, Any]: + report = parsed.setdefault("report", {}) + report.setdefault("used_data", _report_used_data(context, language=language)) + report.setdefault("key_changes", _key_changes(context, language=language)) + report.setdefault("interpretation", _interpretation(context, language=language)) + report.setdefault("scenarios", _scenario_section(context, language=language)) + report.setdefault("user_actions", _user_actions(context, language=language)) + parsed.setdefault("data_snapshot", context.get("used_data") or context.get("data_snapshot") or {}) + parsed.setdefault( + "guardrails", + [ + "deterministic_inputs_only", + "unsupported_values_marked_unavailable", + "advisory_only_no_direct_orders", + ], + ) + return parsed + + +def _fallback_report(context: dict[str, Any], *, language: str = "ko") -> dict[str, Any]: + signal = context.get("deterministic_signal") or {} + scores = context.get("deterministic_scores") or {} + risk = context.get("risk") or {} + quality = context.get("data_quality") or {} + company = context.get("company") or {} + signal_label = _signal_label(context) + if language == "en": + report = { + "summary": ( + f"{context.get('ticker') or 'ticker'} is classified as {signal_label} by the " + "deterministic Quantamental Signal Engine." + ), + "signal_interpretation": { + "label": signal_label, + "confidence": signal.get("signal_confidence"), + "score": signal.get("signal_score"), + "rationale": signal.get("rationale") or [], + }, + "bull_case": _bull_case(scores, company, language=language), + "bear_case": _bear_case(scores, risk, quality, language=language), + "conflict_analysis": ( + f"Conflict classification: {scores.get('conflict') or 'mixed_or_insufficient_data'}." + ), + "missing_data_warning": _missing_data_warning(quality, language=language), + "safety_note": ( + "This report interprets deterministic research classifications only. " + "It is not investment advice and does not instruct buy or sell orders." + ), + } + else: + report = { + "summary": ( + f"{context.get('ticker') or 'ticker'}는 deterministic Quantamental Signal Engine 기준 " + f"{signal_label} 리서치 분류입니다." + ), + "signal_interpretation": { + "label": signal_label, + "confidence": signal.get("signal_confidence"), + "score": signal.get("signal_score"), + "rationale": signal.get("rationale") or [], + }, + "bull_case": _bull_case(scores, company, language=language), + "bear_case": _bear_case(scores, risk, quality, language=language), + "conflict_analysis": ( + f"충돌 분류: {scores.get('conflict') or 'mixed_or_insufficient_data'}." + ), + "missing_data_warning": _missing_data_warning(quality, language=language), + "safety_note": ( + "이 보고서는 deterministic 리서치 분류만 해석합니다. " + "투자 자문이 아니며 매수/매도 지시를 제공하지 않습니다." + ), + } + report.update( + { + "used_data": _report_used_data(context, language=language), + "key_changes": _key_changes(context, language=language), + "interpretation": _interpretation(context, language=language), + "scenarios": _scenario_section(context, language=language), + "user_actions": _user_actions(context, language=language), + } + ) + return { + "status": "partial", + "provider": "deterministic_interpreter", + "prompt_template": SYSTEM_PROMPT, + "signal_label": signal_label, + "signal_preserved": True, + "output_language": language, + "report": report, + "data_snapshot": context.get("used_data") or context.get("data_snapshot") or {}, + "guardrails": [ + "deterministic_inputs_only", + "unsupported_values_marked_unavailable", + "advisory_only_no_direct_orders", + ], + "warnings": [], + "not_investment_advice": True, + "source_policy": "ai_interprets_deterministic_engine_only", + } + + +def _bull_case(scores: dict[str, Any], company: dict[str, Any], *, language: str = "ko") -> list[str]: + out = [] + if scores.get("fundamental_score") is not None: + out.append( + f"Fundamental score: {scores.get('fundamental_score')}." + if language == "en" + else f"펀더멘털 점수: {scores.get('fundamental_score')}." + ) + if scores.get("quant_score") is not None: + out.append( + f"Quant score: {scores.get('quant_score')}." + if language == "en" + else f"퀀트 점수: {scores.get('quant_score')}." + ) + factor_scores = scores.get("factor_scores") or {} + best = sorted( + [(key, value) for key, value in factor_scores.items() if isinstance(value, (int, float))], + key=lambda item: item[1], + reverse=True, + )[:3] + if best: + prefix = "Stronger factors: " if language == "en" else "강한 팩터: " + out.append(prefix + ", ".join(f"{key}={value}" for key, value in best) + ".") + if company.get("sector"): + out.append( + f"Company context: {company.get('sector')} / {company.get('industry') or 'industry unavailable'}." + if language == "en" + else f"기업 맥락: {company.get('sector')} / {company.get('industry') or '산업 정보 없음'}." + ) + return out or (["Bull case is limited by missing deterministic inputs."] if language == "en" else ["상승 근거는 누락된 deterministic 입력 때문에 제한적입니다."]) + + +def _bear_case(scores: dict[str, Any], risk: dict[str, Any], quality: dict[str, Any], *, language: str = "ko") -> list[str]: + out = [] + if scores.get("risk_score") is not None: + out.append( + f"Risk score: {scores.get('risk_score')} ({risk.get('risk_level') or 'unknown'})." + if language == "en" + else f"리스크 점수: {scores.get('risk_score')} ({risk.get('risk_level') or 'unknown'})." + ) + if risk.get("risk_flags"): + prefix = "Risk flags: " if language == "en" else "리스크 플래그: " + out.append(prefix + ", ".join(str(item) for item in risk.get("risk_flags")[:6]) + ".") + if quality.get("level") in {"poor", "limited"}: + out.append( + f"Data quality is {quality.get('level')} with score {quality.get('score')}." + if language == "en" + else f"데이터 품질은 {quality.get('level')}이며 점수는 {quality.get('score')}입니다." + ) + return out or (["No major deterministic risk flags were available."] if language == "en" else ["주요 deterministic 리스크 플래그는 확인되지 않았습니다."]) + + +def _missing_data_warning(quality: dict[str, Any], *, language: str = "ko") -> str: + missing = quality.get("missing_sections") or [] + warnings = quality.get("warnings") or [] + if not missing and not warnings: + return ( + "No major data-quality warning from available deterministic checks." + if language == "en" + else "현재 deterministic 점검에서 주요 데이터 품질 경고는 없습니다." + ) + prefix = "Missing or limited data: " if language == "en" else "누락 또는 제한 데이터: " + return prefix + ", ".join(str(item) for item in [*missing, *warnings][:10]) + + +def _call_local_llm(context: dict[str, Any], *, model: str | None, timeout_s: float, language: str) -> tuple[str, str, float]: + settings = load_settings() + selected_model = model or str(settings.primary_model or "qwen2.5:7b") + language_instruction = ( + "Write all human-readable report fields in Korean." + if language == "ko" + else "Write all human-readable report fields in English." + ) + prompt = ( + SYSTEM_PROMPT + + f"\n{language_instruction}\nReturn JSON with keys: report.summary, report.signal_interpretation, report.bull_case, " + + "report.bear_case, report.conflict_analysis, report.missing_data_warning, report.safety_note, " + + "report.used_data, report.key_changes, report.interpretation, report.scenarios, report.user_actions. " + + "Use context.used_data exactly for basis date/source/period/observations; do not calculate unsupported values. " + + "Preserve deterministic_signal.signal_label exactly.\n\nQUANTAMENTAL_CONTEXT_JSON:\n" + + json.dumps(context, ensure_ascii=False, sort_keys=True, default=str) + ) + started = time.time() + response = httpx.post( + f"{settings.ollama_base_url.rstrip('/')}/api/generate", + json={ + "model": selected_model, + "system": SYSTEM_PROMPT, + "prompt": prompt, + "stream": False, + "format": "json", + "options": {"temperature": 0, "num_ctx": 8192, "num_predict": 1000}, + "keep_alive": "5m", + }, + timeout=max(1.0, min(float(timeout_s or 30.0), 90.0)), + ) + response.raise_for_status() + body = response.json() + text = str(body.get("response") or "").strip() + if not text: + raise ValueError("empty_llm_response") + return text, f"ollama:{selected_model}", round(time.time() - started, 2) + + +def _parse_report(raw_text: str, *, expected_signal: str | None) -> dict[str, Any]: + try: + parsed = json.loads(raw_text) + except json.JSONDecodeError as exc: + raise ValueError(f"malformed_json:{exc}") from exc + if not isinstance(parsed, dict): + raise ValueError("json_root_not_object") + report = parsed.get("report") + if not isinstance(report, dict): + raise ValueError("missing_report_object") + required = [ + "summary", + "signal_interpretation", + "bull_case", + "bear_case", + "conflict_analysis", + "missing_data_warning", + "safety_note", + ] + missing = [key for key in required if key not in report] + if missing: + raise ValueError(f"missing_report_keys:{','.join(missing)}") + signal_interpretation = report.get("signal_interpretation") or {} + if isinstance(signal_interpretation, dict): + output_signal = signal_interpretation.get("label") or parsed.get("signal_label") + if expected_signal and output_signal and output_signal != expected_signal: + raise ValueError("ai_signal_override_detected") + return {"report": report} + + +def _signal_label(context: dict[str, Any]) -> str | None: + signal = context.get("deterministic_signal") or {} + return signal.get("signal_label") + + +def _normalize_language(value: Any) -> str: + clean = str(value or "ko").strip().lower() + return "en" if clean in {"en", "eng", "english"} else "ko" diff --git a/scripts/ai_portfolio_ui_smoke.py b/scripts/ai_portfolio_ui_smoke.py index 6d0eb9d0..6096e0e2 100644 --- a/scripts/ai_portfolio_ui_smoke.py +++ b/scripts/ai_portfolio_ui_smoke.py @@ -10,20 +10,23 @@ import time from pathlib import Path from typing import Any -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse from urllib.request import urlopen PROJECT_ROOT = Path(__file__).resolve().parents[1] REPORTS_DIR = PROJECT_ROOT / "reports" -STATIC_BUNDLE_VERSION = "20260514-domain-modules" +DOMAIN_BUNDLE_VERSION = "20260514-domain-modules" +QUANTAMENTAL_BUNDLE_VERSION = "20260519-quantamental-v12" +APP_BUNDLE_VERSION = "20260519-continuous-enhancement-v3" VERSIONED_SCRIPT_SELECTORS = [ - f'script[src="modules/market-ui.js?v={STATIC_BUNDLE_VERSION}"]', - f'script[src="modules/macro-ui.js?v={STATIC_BUNDLE_VERSION}"]', - f'script[src="modules/forecast-ui.js?v={STATIC_BUNDLE_VERSION}"]', - f'script[src="modules/quant-ui.js?v={STATIC_BUNDLE_VERSION}"]', - f'script[src="modules/ai-portfolio-ui.js?v={STATIC_BUNDLE_VERSION}"]', - f'script[src="app.js?v={STATIC_BUNDLE_VERSION}"]', + f'script[src="modules/market-ui.js?v={DOMAIN_BUNDLE_VERSION}"]', + f'script[src="modules/macro-ui.js?v={DOMAIN_BUNDLE_VERSION}"]', + f'script[src="modules/forecast-ui.js?v={DOMAIN_BUNDLE_VERSION}"]', + f'script[src="modules/quant-ui.js?v={DOMAIN_BUNDLE_VERSION}"]', + f'script[src="modules/ai-portfolio-ui.js?v={DOMAIN_BUNDLE_VERSION}"]', + f'script[src="modules/quantamental-ui.js?v={QUANTAMENTAL_BUNDLE_VERSION}"]', + f'script[src="app.js?v={APP_BUNDLE_VERSION}"]', ] DOMAIN_MODULE_GLOBALS = [ "typeof window.FinGPTMarketUi?.marketTape === 'function'", @@ -33,13 +36,15 @@ "typeof window.FinGPTQuantUi?.exportStorageReport === 'function'", "typeof window.FinGPTAiPortfolioUi?.dashboardMeta === 'function'", "typeof window.FinGPTAiPortfolioUi?.operationList === 'function'", + "typeof window.FinGPTQuantamentalUi?.topSignals === 'function'", + "typeof window.FinGPTQuantamentalUi?.scoreScreen === 'function'", ] DASHBOARD_TAB_CHECKS = [ ( "market-dashboard-tab", "market", "#market-dashboard", - ["#marketTapeSurface", "#marketSignalSurface", "#marketOverviewMeta"], + ["#marketTapeSurface", "#marketSignalSurface", "#crossAssetAnalysisSurface", "#marketOverviewMeta"], ), ( "macro-dashboard-tab", @@ -59,6 +64,12 @@ "#ml-forecast", ["#mlForecastSurface", "#forecastJobsSurface", "#forecastRegistrySurface"], ), + ( + "quantamental-tab", + "quantamental", + "#quantamental", + ["#quantamentalSurface", "#quantamentalScreenSurface", "#quantamentalDataQualitySurface"], + ), ( "ai-portfolio-tab", "ai-portfolio", @@ -82,6 +93,7 @@ def run_ai_portfolio_ui_smoke( base_url = f"http://127.0.0.1:{port}" started_server = True _wait_for_health(base_url, timeout_s=min(45, timeout_s)) + base_url = _normalize_base_url(base_url) try: result = _run_playwright_flow(base_url, timeout_s=timeout_s, screenshot_dir=screenshot_dir) @@ -92,6 +104,14 @@ def run_ai_portfolio_ui_smoke( _stop_server(proc) +def _normalize_base_url(base_url: str) -> str: + clean = str(base_url or "").strip().rstrip("/") + parsed = urlparse(clean) + if parsed.path.rstrip("/") == "/ui": + return urlunparse(parsed._replace(path="", params="", query="", fragment="")).rstrip("/") + return clean + + def _run_playwright_flow(base_url: str, *, timeout_s: int, screenshot_dir: Path) -> dict[str, Any]: from playwright.sync_api import sync_playwright @@ -118,7 +138,7 @@ def _run_playwright_flow(base_url: str, *, timeout_s: int, screenshot_dir: Path) page.on("console", lambda msg: console_errors.append(msg.text) if msg.type in {"error", "warning"} else None) try: page.goto( - f"{base_url.rstrip('/')}/ui/?v={STATIC_BUNDLE_VERSION}#ai-portfolio", + f"{base_url.rstrip('/')}/ui/?v={APP_BUNDLE_VERSION}#ai-portfolio", wait_until="domcontentloaded", timeout=timeout_ms, ) @@ -168,7 +188,7 @@ def _run_playwright_flow(base_url: str, *, timeout_s: int, screenshot_dir: Path) page.locator(selector).wait_for(state="visible", timeout=timeout_ms) _mark(checked, "dashboard tab surface matrix") - _run_dashboard_action_smoke(page, timeout_ms=timeout_ms) + _run_dashboard_action_smoke(page, checked=checked, timeout_ms=timeout_ms) _mark(checked, "dashboard action smoke") page.get_by_test_id("ai-portfolio-tab").click() @@ -218,6 +238,15 @@ def _select_dashboard_tab(page: Any, tab_test_id: str, tab_value: str, hash_valu raise AssertionError(f"{tab_test_id} did not update hash to {hash_value}: {page.url}") +def _show_all_dashboard_panels(page: Any, timeout_ms: int) -> None: + page.wait_for_function("typeof window.setDashboardPanelView === 'function'", timeout=timeout_ms) + page.evaluate("window.setDashboardPanelView('all')") + page.wait_for_function( + "document.querySelector('#homeSurfaceGrid')?.getAttribute('data-panel-view') === 'all'", + timeout=timeout_ms, + ) + + def _wait_surface_settled(page: Any, selector: str, loading_text: str, timeout_ms: int) -> str: page.wait_for_function( """ @@ -232,8 +261,9 @@ def _wait_surface_settled(page: Any, selector: str, loading_text: str, timeout_m return page.locator(selector).inner_text(timeout=timeout_ms) -def _run_dashboard_action_smoke(page: Any, *, timeout_ms: int) -> None: +def _run_dashboard_action_smoke(page: Any, *, checked: list[str], timeout_ms: int) -> None: _select_dashboard_tab(page, "market-dashboard-tab", "market", "#market-dashboard", timeout_ms) + _show_all_dashboard_panels(page, timeout_ms) page.locator("#tvChartSource").select_option("internal", timeout=timeout_ms) page.locator("#tvChartSymbol").select_option("SPY", timeout=timeout_ms) page.locator("#tvChartInterval").select_option("D", timeout=timeout_ms) @@ -251,31 +281,84 @@ def _run_dashboard_action_smoke(page: Any, *, timeout_ms: int) -> None: page.locator("#tvOverviewMeta").wait_for(state="visible", timeout=timeout_ms) _select_dashboard_tab(page, "macro-dashboard-tab", "macro", "#macro", timeout_ms) + _show_all_dashboard_panels(page, timeout_ms) + page.locator("#macroProviderFilter").select_option("", timeout=timeout_ms) + page.locator("#macroCategoryFilter").select_option("", timeout=timeout_ms) page.locator("#macroSeriesSearchInput").fill("US 10Y", timeout=timeout_ms) page.get_by_test_id("macro-series-search-run").click() page.locator("#macroSeriesSearchResults .macro-series-result").first.wait_for(state="visible", timeout=timeout_ms) page.locator("#macroSeriesDetailSurface .macro-detail-head").first.wait_for(state="visible", timeout=timeout_ms) _select_dashboard_tab(page, "quant-lab-tab", "quant", "#quant-lab", timeout_ms) + _show_all_dashboard_panels(page, timeout_ms) page.get_by_test_id("quant-run-history-refresh").click() - quant_text = _wait_surface_settled(page, "#quantRunHistorySurface", "불러오는 중", timeout_ms) - if "실행 이력 로드 실패" in quant_text: + quant_text = _wait_surface_settled(page, "#quantRunHistorySurface", "\ubd88\ub7ec\uc624\ub294 \uc911", timeout_ms) + if "\uc2e4\ud589 \uc774\ub825 \ub85c\ub4dc \uc2e4\ud328" in quant_text: raise AssertionError(quant_text) _select_dashboard_tab(page, "ml-forecast-tab", "forecast", "#ml-forecast", timeout_ms) + _show_all_dashboard_panels(page, timeout_ms) page.get_by_test_id("ml-forecast-ai-provider-check").click() - provider_text = _wait_surface_settled(page, "#forecastAiProviderSurface", "확인", timeout_ms) - if "AI provider 상태 확인 실패" in provider_text: + provider_text = _wait_surface_settled(page, "#forecastAiProviderSurface", "\ud655\uc778", timeout_ms) + if "AI provider \uc0c1\ud0dc \ud655\uc778 \uc2e4\ud328" in provider_text: raise AssertionError(provider_text) page.get_by_test_id("ml-forecast-jobs-refresh").click() - jobs_text = _wait_surface_settled(page, "#forecastJobsSurface", "로드", timeout_ms) - if "Forecast job 로드 실패" in jobs_text: + jobs_text = _wait_surface_settled(page, "#forecastJobsSurface", "\ub85c\ub4dc", timeout_ms) + if "Forecast job \ub85c\ub4dc \uc2e4\ud328" in jobs_text: raise AssertionError(jobs_text) + _select_dashboard_tab(page, "quantamental-tab", "quantamental", "#quantamental", timeout_ms) + _show_all_dashboard_panels(page, timeout_ms) + page.locator('#languageToggle [data-language="en"]').click(timeout=timeout_ms) + page.wait_for_function( + """ + () => document.querySelector('#languageToggle [data-language="en"]')?.getAttribute('aria-pressed') === 'true' + """, + timeout=timeout_ms, + ) + page.locator('#languageToggle [data-language="ko"]').click(timeout=timeout_ms) + page.wait_for_function( + """ + () => document.querySelector('#languageToggle [data-language="ko"]')?.getAttribute('aria-pressed') === 'true' + """, + timeout=timeout_ms, + ) + page.get_by_test_id("quantamental-screen-run").click(timeout=timeout_ms) + page.wait_for_function( + """ + () => { + const text = document.querySelector('#quantamentalScreenSurface')?.textContent || ''; + const rows = document.querySelectorAll('#quantamentalScreenSurface [data-testid="quantamental-screen-table"] tbody tr').length; + return rows > 0 && rows <= 5 && !/failed|error|Not Found/i.test(text); + } + """, + timeout=timeout_ms, + ) + page.locator('#quantamentalScreenSurface [data-testid="quantamental-screen-table"]').wait_for( + state="visible", + timeout=timeout_ms, + ) + page.locator("#quantamentalScoreMetric").select_option("momentum", timeout=timeout_ms) + page.locator("#quantamentalScoreThreshold").fill("0", timeout=timeout_ms) + page.locator("#quantamentalScoreScreenLimit").select_option("10", timeout=timeout_ms) + page.get_by_test_id("quantamental-score-screen-run").click(timeout=timeout_ms) + page.wait_for_function( + """ + () => { + const text = document.querySelector('#quantamentalScoreScreenSurface')?.textContent || ''; + const rows = document.querySelectorAll('#quantamentalScoreScreenSurface [data-testid="quantamental-score-screen-table"] tbody tr').length; + return rows > 0 && rows <= 10 && text.includes('>=') && (text.includes('Momentum') || text.includes('\ubaa8\uba58\ud140')); + } + """, + timeout=timeout_ms, + ) + _mark(checked, "quantamental language toggle top 5 and score screen") + _select_dashboard_tab(page, "ai-portfolio-tab", "ai-portfolio", "#ai-portfolio", timeout_ms) + _show_all_dashboard_panels(page, timeout_ms) page.locator("#aiPortfolioOpsRefresh").click() - ops_text = _wait_surface_settled(page, "#aiPortfolioOpsSurface", "확인하는 중", timeout_ms) - if "운영 상태 조회 실패" in ops_text: + ops_text = _wait_surface_settled(page, "#aiPortfolioOpsSurface", "\ud655\uc778\ud558\ub294 \uc911", timeout_ms) + if "\uc6b4\uc601 \uc0c1\ud0dc \uc870\ud68c \uc2e4\ud328" in ops_text: raise AssertionError(ops_text) @@ -288,6 +371,8 @@ def _find_free_port() -> int: def _start_server(port: int) -> subprocess.Popen[str]: env = os.environ.copy() env["PYTHONPATH"] = str(PROJECT_ROOT) + env["PYTHONUTF8"] = "1" + env["PYTHONIOENCODING"] = "utf-8" return subprocess.Popen( # nosec B603 [ sys.executable, @@ -301,9 +386,8 @@ def _start_server(port: int) -> subprocess.Popen[str]: ], cwd=PROJECT_ROOT, env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) @@ -313,7 +397,8 @@ def _wait_for_health(base_url: str, *, timeout_s: int) -> None: last_error = "" while time.time() < deadline: try: - with urlopen(url, timeout=2) as response: # nosec B310 - trusted local URL. + # The smoke target is the local FastAPI server started by this script. + with urlopen(url, timeout=2) as response: # nosec B310 if response.status == 200: return except Exception as exc: # noqa: BLE001 diff --git a/scripts/check_ui_contract.py b/scripts/check_ui_contract.py index 4c5b9dd2..90cde26a 100644 --- a/scripts/check_ui_contract.py +++ b/scripts/check_ui_contract.py @@ -1,7 +1,8 @@ -from __future__ import annotations +from __future__ import annotations import argparse import json +import re import sys from pathlib import Path from typing import Any @@ -14,20 +15,44 @@ from app.api import server as api_server +HTML_MOJIBAKE_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\ufffd]") +HTML_PLACEHOLDER_RE = re.compile(r"\?{2,}") + REQUIRED_UI_MARKERS: dict[str, str] = { "analysis form": 'id="analysisForm"', "ticker input": 'id="ticker"', "ticker search picker": 'id="tickerSearchOpen"', "question textarea": 'id="question"', + "language toggle": 'id="languageToggle"', + "language toggle korean": 'data-language="ko"', + "language toggle english": 'data-language="en"', "result view": 'id="resultView"', "home dashboard": 'id="emptyState"', "dashboard tabs": 'id="homeDashboardTabs"', + "dashboard decision context strip": 'id="dashboardContextStrip"', + "global quality summary": 'id="globalQualitySummary"', + "global quality observations": 'data-summary-field="observations"', + "global quality missing": 'data-summary-field="missing"', + "global quality ai snapshot": 'data-summary-field="ai-snapshot"', + "global quality context": 'id="qualityContextSummary"', + "global quality context source": 'data-quality-detail="source"', + "global quality context cache": 'data-quality-detail="cache"', + "global quality context range support": 'data-quality-detail="range-support"', + "global dashboard range controls": 'id="dashboardRangeControls"', + "global dashboard range select": 'id="dashboardRangeSelect"', + "global dashboard range start": 'id="dashboardRangeStart"', + "global dashboard range end": 'id="dashboardRangeEnd"', + "quantamental ai model selector": 'id="quantamentalAiModel"', + "quantamental ai model status": 'id="quantamentalAiModelStatus"', + "quantamental ai model contract": 'data-testid="quantamental-ai-model-control"', "market dashboard tab": 'id="marketDashboardTab"', "market dashboard tab testid": 'data-testid="market-dashboard-tab"', "macro dashboard tab": 'id="macroDashboardTab"', "macro dashboard tab testid": 'data-testid="macro-dashboard-tab"', "quant lab tab": 'id="quantLabTab"', "quant lab tab testid": 'data-testid="quant-lab-tab"', + "quantamental tab": 'id="quantamentalTab"', + "quantamental tab testid": 'data-testid="quantamental-tab"', "ml forecast tab": 'id="mlForecastTab"', "ml forecast tab testid": 'data-testid="ml-forecast-tab"', "ai portfolio tab": 'id="aiPortfolioTab"', @@ -37,7 +62,12 @@ "market overview meta": 'id="marketOverviewMeta"', "market tape": 'id="marketTapeSurface"', "market signals": 'id="marketSignalSurface"', + "cross asset symbols": 'id="crossAssetSymbols"', + "cross asset analysis": 'id="crossAssetAnalysisSurface"', + "cross asset run": 'data-testid="cross-asset-run"', "home news": 'id="homeNewsList"', + "focused news": 'id="homeNewsFocusedList"', + "news search run": 'data-testid="market-news-search-run"', "tradingview chart": 'id="tvOverviewWidget"', "market chart source": 'id="tvChartSource"', "tradingview chart symbol": 'id="tvChartSymbol"', @@ -149,6 +179,24 @@ "quant export storage report testid": 'data-testid="quant-export-storage-report"', "quant cross-run cleanup preview": 'id="quantCrossRunCleanupPreview"', "quant cross-run cleanup preview testid": 'data-testid="quant-cross-run-cleanup-preview"', + "quantamental surface": 'id="quantamentalSurface"', + "quantamental ticker": 'id="quantamentalTicker"', + "quantamental ticker picker": 'id="quantamentalTickerOpen"', + "quantamental market": 'id="quantamentalMarket"', + "quantamental period": 'id="quantamentalPeriod"', + "quantamental years": 'id="quantamentalYears"', + "quantamental lookback": 'id="quantamentalLookback"', + "quantamental style": 'id="quantamentalStyle"', + "quantamental analyze": 'id="quantamentalAnalyze"', + "quantamental analyze testid": 'data-testid="quantamental-analyze"', + "quantamental company": 'id="quantamentalCompanySurface"', + "quantamental signal": 'id="quantamentalSignalSurface"', + "quantamental score": 'id="quantamentalScoreSurface"', + "quantamental factors": 'id="quantamentalFactorSurface"', + "quantamental main": 'id="quantamentalMainSurface"', + "quantamental ai report": 'id="quantamentalAiRefresh"', + "quantamental ai report testid": 'data-testid="quantamental-ai-report"', + "quantamental data quality": 'id="quantamentalDataQualitySurface"', "ml forecast surface": 'id="mlForecastSurface"', "ml forecast ticker": 'id="forecastTicker"', "ml forecast ticker picker": 'id="forecastTickerOpen"', @@ -208,11 +256,39 @@ "ai portfolio universe status": 'id="aiPortfolioUniverseStatus"', "ai portfolio ops": 'id="aiPortfolioOpsSurface"', "ai portfolio ops refresh": 'id="aiPortfolioOpsRefresh"', + "quantamental composite": 'id="quantamentalScoreSurface"', + "quantamental factor": 'id="quantamentalFactorSurface"', + "quantamental ai action": 'id="quantamentalAiRefresh"', + "quantamental ai testid": 'data-testid="quantamental-ai-report"', + "quantamental quality": 'id="quantamentalDataQualitySurface"', + "quantamental compare tickers": 'id="quantamentalCompareTickers"', + "quantamental compare action": 'id="quantamentalCompareRun"', + "quantamental compare testid": 'data-testid="quantamental-compare-run"', + "quantamental compare surface": 'id="quantamentalCompareSurface"', + "quantamental expand peers": 'id="quantamentalExpandPeers"', + "quantamental peer limit": 'id="quantamentalPeerLimit"', + "quantamental compare watchlist name": 'id="quantamentalWatchlistName"', + "quantamental compare watchlist select": 'id="quantamentalWatchlistSelect"', + "quantamental compare watchlist save": 'data-testid="quantamental-watchlist-save"', + "quantamental compare watchlist load": 'data-testid="quantamental-watchlist-load"', + "quantamental compare csv": 'data-testid="quantamental-compare-csv"', + "quantamental screen action": 'id="quantamentalScreenRun"', + "quantamental screen action testid": 'data-testid="quantamental-screen-run"', + "quantamental screen status": 'id="quantamentalScreenStatus"', + "quantamental screen surface": 'id="quantamentalScreenSurface"', + "quantamental score screen threshold": 'id="quantamentalScoreThreshold"', + "quantamental score screen metric": 'id="quantamentalScoreMetric"', + "quantamental score screen limit": 'id="quantamentalScoreScreenLimit"', + "quantamental score screen action": 'id="quantamentalScoreScreenRun"', + "quantamental score screen action testid": 'data-testid="quantamental-score-screen-run"', + "quantamental score screen status": 'id="quantamentalScoreScreenStatus"', + "quantamental score screen surface": 'id="quantamentalScoreScreenSurface"', "market ui module": 'modules/market-ui.js?v=20260514-domain-modules', "macro ui module": 'modules/macro-ui.js?v=20260514-domain-modules', "forecast ui module": 'modules/forecast-ui.js?v=20260514-domain-modules', "quant ui module": 'modules/quant-ui.js?v=20260514-domain-modules', "ai portfolio ui module": 'modules/ai-portfolio-ui.js?v=20260514-domain-modules', + "quantamental ui module": 'modules/quantamental-ui.js?v=20260519-quantamental-v12', "ai portfolio operation hydrate": 'id="aiPortfolioHydrateData"', "ai portfolio operation retry": 'id="aiPortfolioRetryMissing"', "ai portfolio snapshot job": 'id="aiPortfolioSnapshotJob"', @@ -232,6 +308,20 @@ } REQUIRED_APP_JS_MARKERS: dict[str, str] = { + "all default panel views": "DEFAULT_DASHBOARD_PANEL_VIEWS", + "all default panel version": "DASHBOARD_PANEL_LAYOUT_VERSION", + "global range storage": "fingpt.dashboardRange.v1", + "global range setter": "function setGlobalRange", + "global range date order guard": "function normalizeCustomGlobalDateOrder", + "global range validation copy": "function globalRangeValidationMessage", + "global range support summary": "function globalRangeSupportSummary", + "global quality summary": "function updateGlobalQualitySummary", + "global quality range pending": "function markGlobalQualityRangePending", + "global quality context summary": "function renderGlobalQualityContextSummary", + "global quality missing summary": "function displayMissingSummary", + "global quality ai basis label": "AI 기준:", + "dashboard decision cards api": "dashboardDecisionCards", + "dashboard decision cards loader": "function loadDashboardDecisionCards", "dashboard intraday chart api": "dashboardIntraday", "internal chart payload loader": "function fetchInternalChartPayload", "internal chart intraday intervals": "TV_INTERNAL_INTRADAY_INTERVALS", @@ -244,7 +334,7 @@ "macro load status renderer": "function renderMacroLoadStatus", "macro panel failure renderer": "function renderMacroPanelFailure", "macro action pane starter renderer": "function renderMacroActionPaneStarters", - "macro refresh preserves dashboard": "기존 대시보드 화면을 유지", + "macro refresh preserves dashboard": "\uae30\uc874 \ub300\uc2dc\ubcf4\ub4dc \ud654\uba74\uc744 \uc720\uc9c0", "macro provider health renderer": "function renderMacroProviderHealth", "macro scenario runner": "function runMacroScenario", "macro research preview runner": "function runMacroResearchPreview", @@ -254,12 +344,47 @@ "forecast ui module bridge": "window.FinGPTForecastUi", "quant ui module bridge": "window.FinGPTQuantUi", "ai portfolio ui module bridge": "window.FinGPTAiPortfolioUi", + "quantamental ui module bridge": "window.FinGPTQuantamentalUi", + "quantamental api": "quantamentalAnalysis", + "quantamental ai model options": "function renderQuantamentalAiModelOptions", + "quantamental ai request options": "function quantamentalAiRequestOptions", + "quantamental compare api": "quantamentalCompare", + "quantamental top signal screen api": "quantamentalTopSignals", + "quantamental score screen api": "quantamentalScoreScreen", + "quantamental score screen score key": "score_key", + "quantamental compare server watchlist api": "quantamentalCompareWatchlists", + "quantamental snapshot export api": "quantamentalSnapshotExport", + "quantamental snapshot diff api": "quantamentalSnapshotDiff", + "quantamental snapshot retention api": "quantamentalSnapshotRetention", + "output language storage": "fingpt.outputLanguage.v1", + "output language request field": "output_language: selectedOutputLanguage()", + "ui language applier": "function applyUiLanguage", + "ui language binder": "function bindLanguageToggle", + "quantamental loader": "function loadQuantamental", + "quantamental runner": "function runQuantamentalAnalysis", + "quantamental compare runner": "function runQuantamentalCompare", + "quantamental compare watchlist persistence": "function saveQuantamentalCompareWatchlist", + "quantamental compare watchlist loader": "function loadQuantamentalCompareWatchlists", + "quantamental compare csv exporter": "function exportQuantamentalCompareCsv", + "quantamental screen loader": "function loadQuantamentalScreen", + "quantamental score screen runner": "function runQuantamentalScoreScreen", + "quantamental snapshot action exporter": "function exportQuantamentalSnapshot", } +def _matching_lines(text: str, pattern: re.Pattern[str]) -> list[dict[str, Any]]: + return [ + {"line": line_no, "text": line.strip()[:240]} + for line_no, line in enumerate(text.splitlines(), start=1) + if pattern.search(line) + ] + + def run_check() -> dict[str, Any]: with TestClient(api_server.app) as client: ui_response = client.get("/ui/") + ui_quantamental_response = client.get("/ui/quantamental") + ui_missing_asset_response = client.get("/ui/not-found-bundle.js") health_response = client.get("/api/v1/health") html = ui_response.text @@ -267,13 +392,28 @@ def run_check() -> dict[str, Any]: app_js = app_js_path.read_text(encoding="utf-8") missing = [name for name, marker in REQUIRED_UI_MARKERS.items() if marker not in html] missing_js = [name for name, marker in REQUIRED_APP_JS_MARKERS.items() if marker not in app_js] - passed = ui_response.status_code == 200 and health_response.status_code == 200 and not missing and not missing_js + mojibake_lines = _matching_lines(html, HTML_MOJIBAKE_RE) + placeholder_lines = _matching_lines(html, HTML_PLACEHOLDER_RE) + passed = ( + ui_response.status_code == 200 + and ui_quantamental_response.status_code == 200 + and ui_missing_asset_response.status_code == 404 + and health_response.status_code == 200 + and not missing + and not missing_js + and not mojibake_lines + and not placeholder_lines + ) return { "status": "passed" if passed else "failed", "ui_status": ui_response.status_code, + "ui_quantamental_route_status": ui_quantamental_response.status_code, + "ui_missing_asset_status": ui_missing_asset_response.status_code, "health_status": health_response.status_code, "missing_markers": missing, "missing_js_markers": missing_js, + "mojibake_lines": mojibake_lines, + "placeholder_lines": placeholder_lines, "checked_markers": sorted(REQUIRED_UI_MARKERS), "checked_js_markers": sorted(REQUIRED_APP_JS_MARKERS), "html_bytes": len(html.encode("utf-8", errors="ignore")), diff --git a/tests/test_api_routing_contract.py b/tests/test_api_routing_contract.py index f89bf141..ed291080 100644 --- a/tests/test_api_routing_contract.py +++ b/tests/test_api_routing_contract.py @@ -67,6 +67,24 @@ def test_config_exposes_fingpt_integration_status(self): ) self.assertEqual(fingpt.get("default_behavior"), "disabled_fail_open") + def test_config_model_options_expose_runtime_checked_model_names(self): + client = TestClient(api_server.app) + resp = client.get("/api/v1/config") + + self.assertEqual(resp.status_code, 200) + models = resp.json().get("models") + self.assertIsInstance(models, list) + self.assertTrue(models) + qwen = next((item for item in models if item.get("id") == "qwen"), None) + self.assertIsNotNone(qwen) + self.assertEqual(qwen.get("availability"), "runtime_checked") + self.assertTrue(qwen.get("model")) + self.assertIn("availability_note", qwen) + for item in models: + if "gemma" in str(item.get("id", "")).lower(): + self.assertEqual(item.get("availability"), "runtime_checked") + self.assertTrue(item.get("model")) + def test_direct_analyze_rejects_missing_ticker_before_pipeline(self): client = TestClient(api_server.app) with patch.object(research_router, "run_pipeline_async", new=AsyncMock()) as run_pipeline: diff --git a/tests/test_quantamental_api.py b/tests/test_quantamental_api.py new file mode 100644 index 00000000..b1c6ca98 --- /dev/null +++ b/tests/test_quantamental_api.py @@ -0,0 +1,678 @@ +from __future__ import annotations + +import sqlite3 +from datetime import date, timedelta + +from fastapi.testclient import TestClient + +from app.api import server as api_server +from pipelines.quantamental import service +from pipelines.quantamental.cache import quantamental_cache +from pipelines.quantamental import snapshot_store + + +def _days_ago(days: int) -> str: + return (date.today() - timedelta(days=days)).isoformat() + + +class FakeQuantamentalProvider: + def company(self, ticker: str): + return { + "status": "ok", + "company": { + "ticker": ticker, + "market": "US", + "name": f"{ticker} Corp", + "sector": "Technology", + "industry": "Software", + "current_price": 120.0, + "market_cap": 120_000_000_000.0, + "enterprise_value": 118_000_000_000.0, + "shares_outstanding": 1_000_000_000.0, + "average_volume": 30_000_000.0, + "raw_info_metrics": { + "trailing_pe": 24.0, + "price_to_book": 6.0, + "price_to_sales_ttm": 8.0, + "enterprise_to_ebitda": 18.0, + "profit_margin": 0.22, + "gross_margin": 0.62, + "operating_margin": 0.30, + "return_on_equity": 0.28, + "return_on_assets": 0.15, + "revenue_growth": 0.12, + "earnings_growth": 0.14, + "total_revenue": 50_000_000_000.0, + "ebitda": 18_000_000_000.0, + "free_cashflow": 12_000_000_000.0, + "operating_cashflow": 15_000_000_000.0, + "total_cash": 20_000_000_000.0, + "total_debt": 8_000_000_000.0, + "debt_to_equity": 20.0, + "current_ratio": 1.8, + "quick_ratio": 1.5, + "trailing_eps": 5.0, + "book_value": 20.0, + }, + "last_updated": service.now_iso(), + "source_metadata": {"provider": "fake", "fetched_at": service.now_iso()}, + }, + "source_metadata": {"provider": "fake", "fetched_at": service.now_iso()}, + "warnings": [], + } + + def fundamentals(self, ticker: str, *, period: str = "annual", years: int = 5): + return { + "status": "ok", + "ticker": ticker, + "market": "US", + "period": period, + "years": years, + "items": [ + { + "date": _days_ago(90), + "revenue": 50_000_000_000.0, + "gross_profit": 31_000_000_000.0, + "operating_income": 15_000_000_000.0, + "net_income": 11_000_000_000.0, + "ebitda": 18_000_000_000.0, + "total_assets": 100_000_000_000.0, + "total_equity": 40_000_000_000.0, + "current_assets": 35_000_000_000.0, + "current_liabilities": 20_000_000_000.0, + "cash": 20_000_000_000.0, + "inventory": 1_000_000_000.0, + "receivables": 6_000_000_000.0, + "total_debt": 8_000_000_000.0, + "operating_cash_flow": 15_000_000_000.0, + "capital_expenditure": -3_000_000_000.0, + "free_cash_flow": 12_000_000_000.0, + }, + { + "date": _days_ago(455), + "revenue": 44_000_000_000.0, + "operating_income": 12_000_000_000.0, + "net_income": 9_500_000_000.0, + "free_cash_flow": 10_500_000_000.0, + }, + ], + "info_metrics": self.company(ticker)["company"]["raw_info_metrics"], + "warnings": [], + } + + def prices(self, ticker: str, *, lookback=252, benchmark: str = "SPY"): + rows = [] + for idx in range(260): + close = 100 + idx * 0.2 + rows.append( + { + "date": _days_ago(259 - idx), + "open": close - 0.3, + "high": close + 1, + "low": close - 1, + "close": close, + "adjusted_close": close, + "volume": 30_000_000 + idx * 1000, + } + ) + return { + "status": "ok", + "ticker": ticker, + "market": "US", + "lookback_days": 252, + "items": rows, + "benchmark_ticker": benchmark, + "benchmark_items": rows, + "source_metadata": {"provider": "fake", "fetched_at": service.now_iso()}, + "warnings": [], + } + + +class StalePriceProvider(FakeQuantamentalProvider): + def prices(self, ticker: str, *, lookback=252, benchmark: str = "SPY"): + payload = super().prices(ticker, lookback=lookback, benchmark=benchmark) + stale_rows = [] + for idx, row in enumerate(payload["items"]): + stale_rows.append({**row, "date": _days_ago(700 - idx)}) + payload["items"] = stale_rows + payload["benchmark_items"] = stale_rows + return payload + + +def test_quantamental_analysis_endpoint_shape(monkeypatch): + quantamental_cache.clear() + monkeypatch.setattr(service, "provider_for_market", lambda market: FakeQuantamentalProvider()) + client = TestClient(api_server.app) + + resp = client.get("/api/v1/quantamental/analysis/AAPL?style=quality_growth&period=annual&years=5&lookback=252") + + assert resp.status_code == 200 + body = resp.json() + assert body["ticker"] == "AAPL" + assert body["composite"]["style"] == "quality_growth" + assert body["signal"]["not_investment_advice"] is True + assert body["ai_report"]["signal_preserved"] is True + assert "data_snapshot" in body["ai_report"] + assert "used_data" in body["ai_report"]["report"] + assert body["ai_report"]["report"]["used_data"]["analysis_period"] != "" + assert body["ai_report"]["report"]["used_data"]["data_source"] != "" + assert body["execution_policy"] == "scores_and_signal_from_deterministic_engines_ai_interprets_only" + + +def test_quantamental_analysis_includes_freshness_audit_and_refresh_attempt(monkeypatch): + quantamental_cache.clear() + monkeypatch.setattr(service, "provider_for_market", lambda market: StalePriceProvider()) + client = TestClient(api_server.app) + + resp = client.get("/api/v1/quantamental/analysis/AAPL?include_ai=false") + + assert resp.status_code == 200 + body = resp.json() + freshness = body["freshness"] + assert freshness["sections"]["company"]["status"] in {"fresh", "unknown"} + assert freshness["sections"]["fundamentals"]["basis"] == "latest_statement_date" + assert freshness["sections"]["prices"]["basis"] == "latest_price_date" + assert body["data_quality"]["freshness"]["status"] == freshness["status"] + assert freshness.get("refresh_attempted") is True + assert "prices" in freshness["stale_sections"] + assert body["data_integrity"]["status"] == "blocked" + assert body["data_integrity"]["usable_for_signal"] is False + assert body["signal"]["signal_label"] == "Insufficient Data" + + +def test_quantamental_top_signal_screen_returns_ranked_top_five(monkeypatch): + quantamental_cache.clear() + monkeypatch.setattr(service, "provider_for_market", lambda market: FakeQuantamentalProvider()) + monkeypatch.setattr( + service, + "build_sec_evidence", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("Top signal screen should not block on SEC overlay")), + ) + client = TestClient(api_server.app) + + resp = client.get( + "/api/v1/quantamental/screen/top-signals" + "?tickers=AAPL%20MSFT%20NVDA%20TSLA%20AMD%20CRM&limit=5&include_ai=false" + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["requested_count"] == 6 + assert body["scored_count"] == 6 + assert body["eligible_count"] == 6 + assert body["top_count"] == 5 + assert len(body["top_signals"]) == 5 + assert body["top"] == body["top_signals"] + assert body["top_signals"] == body["ranked_rows"][:5] + assert body["screened_rows"] == body["rows"] + assert [row["rank"] for row in body["top_signals"]] == [1, 2, 3, 4, 5] + assert [row["rank"] for row in body["ranked_rows"]] == [1, 2, 3, 4, 5, 6] + assert body["ranked_rows"][0]["final_score"] >= body["ranked_rows"][-1]["final_score"] + assert all(row["final_score"] is not None for row in body["top_signals"]) + assert all(row["usable_for_signal"] is True for row in body["top_signals"]) + assert all("freshness_status" in row for row in body["top_signals"]) + assert body["freshness_summary"]["total"] == 6 + assert body["freshness"] == body["freshness_summary"] + assert body["summary"]["top_count"] == 5 + assert body["screening_policy"] == "rank_only_fresh_complete_core_data_after_retry_sec_overlay_skipped_for_speed" + assert "screening_fast_path_sec_overlay_skipped" in body["warnings"] + + +def test_quantamental_score_screen_filters_by_min_score(monkeypatch): + quantamental_cache.clear() + + composite_scores = {"AAA": 81.0, "BBB": 92.0, "CCC": 74.0} + quality_scores = {"AAA": 81.0, "BBB": 66.0, "CCC": 74.0} + + def fake_analysis(request): + ticker = request.ticker + score = composite_scores[ticker] + quality_score = quality_scores[ticker] + return { + "status": "ok", + "ticker": ticker, + "market": request.market, + "company": {"ticker": ticker, "name": f"{ticker} Corp", "sector": "Technology", "industry": "Software"}, + "composite": { + "final_score": score, + "fundamental_score": score - 3, + "quant_score": score + 2, + "risk_score": score - 5, + }, + "factors": { + "value_score": score - 10, + "quality_score": quality_score, + "growth_score": 55.0, + "momentum_score": 60.0, + "low_volatility_score": 65.0, + "liquidity_score": 70.0, + }, + "signal": { + "signal_label": "Buy Candidate" if score >= 75 else "Accumulate Watch", + "signal_confidence": "medium", + }, + "data_quality": {"data_quality_score": 0.92, "quality_level": "good", "missing_sections": []}, + "freshness": {"status": "fresh", "freshness_score": 1.0, "stale_sections": [], "warnings": []}, + "data_integrity": {"status": "usable", "usable_for_signal": True, "blocking_sections": []}, + "warnings": [], + } + + monkeypatch.setattr(service, "analysis", fake_analysis) + client = TestClient(api_server.app) + + resp = client.get( + "/api/v1/quantamental/screen/by-score" + "?tickers=AAA%20BBB%20CCC&score_key=quality&min_score=70&limit=10&include_ai=false" + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["score_key"] == "quality" + assert body["score_label"] == "Quality" + assert body["min_score"] == 70.0 + assert body["requested_count"] == 3 + assert body["scored_count"] == 3 + assert body["matched_count"] == 2 + assert [row["ticker"] for row in body["matches"]] == ["AAA", "CCC"] + assert all(row["screen_score"] >= 70 for row in body["matches"]) + assert all(row["screen_score_key"] == "quality" for row in body["matches"]) + assert body["matches"][0]["quality_score"] >= body["matches"][1]["quality_score"] + assert "BBB" not in [row["ticker"] for row in body["matches"]] + assert body["screening_policy"] == "rank_fresh_complete_core_data_then_filter_min_score_sec_overlay_skipped_for_speed" + assert "screening_fast_path_sec_overlay_skipped" in body["warnings"] + + +def test_quantamental_score_screen_default_universe_respects_limit(monkeypatch): + quantamental_cache.clear() + tickers = [f"T{i:02d}" for i in range(12)] + monkeypatch.setitem(service.DEFAULT_SCREENING_UNIVERSES, "default_us_large_cap", tickers) + + def fake_analysis(request): + index = tickers.index(request.ticker) + score = 100.0 - index + return { + "status": "ok", + "ticker": request.ticker, + "market": request.market, + "company": {"ticker": request.ticker, "name": f"{request.ticker} Corp", "sector": "Technology", "industry": "Software"}, + "composite": { + "final_score": score, + "fundamental_score": score - 3, + "quant_score": score + 2, + "risk_score": score - 5, + }, + "factors": { + "value_score": score - 10, + "quality_score": score - 8, + "growth_score": score - 6, + "momentum_score": score, + "low_volatility_score": score - 4, + "liquidity_score": score - 2, + }, + "signal": {"signal_label": "Buy Candidate", "signal_confidence": "medium"}, + "data_quality": {"data_quality_score": 0.92, "quality_level": "good", "missing_sections": []}, + "freshness": {"status": "fresh", "freshness_score": 1.0, "stale_sections": [], "warnings": []}, + "data_integrity": {"status": "usable", "usable_for_signal": True, "blocking_sections": []}, + "warnings": [], + } + + monkeypatch.setattr(service, "analysis", fake_analysis) + client = TestClient(api_server.app) + + resp = client.get( + "/api/v1/quantamental/screen/by-score" + "?score_key=momentum&min_score=0&limit=10&include_ai=false" + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["requested_count"] == 10 + assert body["matched_count"] == 10 + assert body["returned_count"] == 10 + assert len(body["matches"]) == 10 + assert [row["ticker"] for row in body["matches"]] == tickers[:10] + assert all(row["screen_score_key"] == "momentum" for row in body["matches"]) + + +def test_quantamental_health_lists_global_as_supported_and_quant_uses_global_benchmark(monkeypatch): + quantamental_cache.clear() + captured: dict[str, str] = {} + + class CaptureProvider(FakeQuantamentalProvider): + def __init__(self, market: str): + self.market = market + + def prices(self, ticker: str, *, lookback=252, benchmark: str = "SPY"): + captured["benchmark"] = benchmark + payload = super().prices(ticker, lookback=lookback, benchmark=benchmark) + payload["market"] = self.market + return payload + + monkeypatch.setattr(service, "provider_for_market", lambda market: CaptureProvider(market)) + client = TestClient(api_server.app) + + health = client.get("/api/v1/quantamental/health").json() + quant = service.quant("ASML.AS", market="GLOBAL", lookback=252) + + assert "GLOBAL" in health["supported_markets"] + assert "GLOBAL" not in health["unsupported_markets"] + assert "global_yfinance_provider" in health["enhancements"] + assert captured["benchmark"] == "ACWI" + assert quant["market"] == "GLOBAL" + assert quant["benchmark_ticker"] == "ACWI" + + +def test_quantamental_resolve_endpoint_and_global_sec_hydration_dry_run(): + client = TestClient(api_server.app) + + resolved = client.get("/api/v1/quantamental/resolve/7203?market=GLOBAL") + assert resolved.status_code == 200 + body = resolved.json() + assert body["provider_ticker"] == "7203.T" + assert body["sec_ticker"] == "TM" + assert "global_symbol_resolved_to_yfinance:7203.T" in body["warnings"] + + hydration = client.post( + "/api/v1/quantamental/sec/global/hydrate?dry_run=true", + json={"tickers": ["ASML.AS", "7203", "9999.T"]}, + ) + assert hydration.status_code == 200 + plan = hydration.json()["plan"] + assert hydration.json()["status"] == "dry_run" + assert plan["sec_tickers"] == ["ASML", "TM"] + assert any(item["ticker"] == "9999.T" for item in plan["skipped"]) + + +def test_quantamental_legacy_prefix_and_section_endpoints(monkeypatch): + quantamental_cache.clear() + monkeypatch.setattr(service, "provider_for_market", lambda market: FakeQuantamentalProvider()) + client = TestClient(api_server.app) + + for path in [ + "/api/quantamental/company/MSFT", + "/api/quantamental/fundamentals/MSFT", + "/api/quantamental/quant/MSFT", + "/api/quantamental/factors/MSFT", + "/api/quantamental/risk/MSFT", + "/api/quantamental/composite/MSFT", + "/api/quantamental/signal/MSFT", + ]: + resp = client.get(path) + assert resp.status_code == 200, path + assert resp.json() + + +def test_quantamental_ai_report_and_qa_do_not_override_signal(monkeypatch): + quantamental_cache.clear() + monkeypatch.setattr(service, "provider_for_market", lambda market: FakeQuantamentalProvider()) + client = TestClient(api_server.app) + analysis = client.get("/api/v1/quantamental/analysis/NVDA").json() + + report = client.post("/api/v1/quantamental/ai/report", json={"context": analysis, "use_llm": False, "output_language": "en"}).json() + answer = client.post( + "/api/v1/quantamental/ai/qa", + json={"context": analysis, "question": "what is the sell risk?", "use_llm": False, "output_language": "en"}, + ).json() + + assert report["signal_label"] == analysis["signal"]["signal_label"] + assert report["not_investment_advice"] is True + assert report["output_language"] == "en" + assert report["report"]["used_data"]["data_basis_date"] != "" + assert report["report"]["used_data"]["analysis_period"] != "" + assert "key_changes" in report["report"] + assert "interpretation" in report["report"] + assert "user_actions" in report["report"] + assert "advisory_only_no_direct_orders" in report["guardrails"] + assert answer["not_investment_advice"] is True + assert answer["output_language"] == "en" + assert "instruction to sell" in answer["answer"].lower() + + +def test_quantamental_ai_report_and_qa_support_korean_output(monkeypatch): + quantamental_cache.clear() + monkeypatch.setattr(service, "provider_for_market", lambda market: FakeQuantamentalProvider()) + client = TestClient(api_server.app) + analysis = client.get("/api/v1/quantamental/analysis/NVDA?include_ai=true&output_language=ko").json() + + answer = client.post( + "/api/v1/quantamental/ai/qa", + json={"context": analysis, "question": "리스크를 설명해주세요", "use_llm": False, "output_language": "ko"}, + ).json() + + assert analysis["output_language"] == "ko" + assert analysis["ai_report"]["output_language"] == "ko" + assert "투자 자문" in analysis["ai_report"]["report"]["safety_note"] + assert "used_data" in analysis["ai_report"]["report"] + assert "확인 불가" not in str(analysis["ai_report"]["report"]["used_data"]["analysis_period"]) + assert answer["output_language"] == "ko" + assert "매도 지시가 아닙니다" in answer["answer"] + + +def test_quantamental_invalid_ticker_returns_structured_insufficient_data(): + quantamental_cache.clear() + client = TestClient(api_server.app) + + resp = client.get("/api/v1/quantamental/analysis/INVALID_TEST_TICKER_123") + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "failed" + assert body["signal"]["signal_label"] == "Insufficient Data" + assert body["ai_report"]["signal_label"] == "Insufficient Data" + assert body["ai_report"]["provider"] == "deterministic_interpreter" + assert "ticker_validation_failed" in body["warnings"] + assert body["data_quality"]["quality_level"] == "poor" + + +def test_quantamental_compare_adds_peer_relative_scores_and_snapshots(tmp_path, monkeypatch): + monkeypatch.setenv("QUANTAMENTAL_DATA_DIR", str(tmp_path / "quantamental")) + quantamental_cache.clear() + monkeypatch.setattr(service, "provider_for_market", lambda market: FakeQuantamentalProvider()) + client = TestClient(api_server.app) + + resp = client.post( + "/api/v1/quantamental/compare", + json={"tickers": ["AAPL", "MSFT", "NVDA"], "style": "balanced", "include_ai": False}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["count"] == 3 + assert body["peer_groups"] + assert all("peer_relative" in row for row in body["rows"]) + assert any(row["peer_relative"]["peer_count"] >= 2 for row in body["rows"]) + snapshot_id = body["analyses"][0]["snapshot"]["snapshot_id"] + history = client.get("/api/v1/quantamental/snapshots?ticker=AAPL").json() + replay = client.get(f"/api/v1/quantamental/snapshots/{snapshot_id}").json() + assert history["count"] >= 1 + assert replay["status"] == "ok" + assert replay["payload"]["ticker"] == "AAPL" + export_resp = client.get(f"/api/v1/quantamental/snapshots/{snapshot_id}/export?format=csv") + assert export_resp.status_code == 200 + assert "text/csv" in export_resp.headers["content-type"] + assert "signal_label" in export_resp.text + + +def test_quantamental_snapshot_diff_and_retention_preview(tmp_path, monkeypatch): + monkeypatch.setenv("QUANTAMENTAL_DATA_DIR", str(tmp_path / "quantamental")) + quantamental_cache.clear() + monkeypatch.setattr(service, "provider_for_market", lambda market: FakeQuantamentalProvider()) + client = TestClient(api_server.app) + + first = client.get("/api/v1/quantamental/analysis/AAPL?style=balanced&include_ai=false").json() + second = client.get("/api/v1/quantamental/analysis/AAPL?style=value&include_ai=false").json() + first_id = first["snapshot"]["snapshot_id"] + second_id = second["snapshot"]["snapshot_id"] + + diff = client.get(f"/api/v1/quantamental/snapshots/diff?base_snapshot_id={first_id}&target_snapshot_id={second_id}").json() + retention = client.post("/api/v1/quantamental/snapshots/retention?ticker=AAPL&keep_last=1&dry_run=true").json() + + assert diff["status"] == "ok" + assert any(item["path"] == "style" for item in diff["differences"]) + assert retention["status"] == "ok" + assert retention["dry_run"] is True + assert retention["prune_count"] >= 1 + + +def test_quantamental_snapshot_retention_delete_uses_temp_store(tmp_path, monkeypatch): + monkeypatch.setenv("QUANTAMENTAL_DATA_DIR", str(tmp_path)) + for idx in range(3): + snapshot = snapshot_store.save_snapshot( + { + "status": "ok", + "ticker": "AAPL", + "market": "US", + "style": "balanced", + "generated_at": f"2026-05-15T00:00:0{idx}+00:00", + "signal": {"signal_label": "Accumulate Watch"}, + "composite": {"final_score": 60 + idx}, + "data_quality": {"quality_level": "good"}, + } + ) + with sqlite3.connect(snapshot_store.db_path()) as conn: + conn.execute( + "UPDATE quantamental_snapshots SET created_at=? WHERE snapshot_id=?", + (f"2026-05-15T00:00:0{idx}+00:00", snapshot["snapshot_id"]), + ) + + preview = snapshot_store.prune_snapshots("AAPL", keep_last=1, dry_run=True) + deleted = snapshot_store.prune_snapshots("AAPL", keep_last=1, dry_run=False) + remaining = snapshot_store.list_snapshots("AAPL", limit=10) + + assert preview["prune_count"] == 2 + assert deleted["dry_run"] is False + assert deleted["prune_count"] == 2 + assert remaining["count"] == 1 + assert remaining["items"][0]["final_score"] == 62 + + +def test_quantamental_compare_can_expand_peer_universe(monkeypatch): + quantamental_cache.clear() + monkeypatch.setattr(service, "provider_for_market", lambda market: FakeQuantamentalProvider()) + monkeypatch.setattr( + service, + "expand_peer_universe", + lambda tickers, analyses, market="US", max_total=8: { + "status": "ok", + "method": "fixture", + "requested_tickers": tickers, + "added_tickers": ["ADBE"], + "candidates": [{"ticker": "ADBE", "sector": "Technology", "industry": "Software"}], + "warnings": [], + }, + ) + client = TestClient(api_server.app) + + resp = client.post( + "/api/v1/quantamental/compare", + json={"tickers": ["AAPL", "MSFT"], "expand_peer_universe": True, "peer_limit": 3}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["peer_universe"]["added_tickers"] == ["ADBE"] + assert body["count"] == 3 + assert any(row["ticker"] == "ADBE" for row in body["rows"]) + + +def test_quantamental_compare_watchlists_persist_server_side(tmp_path, monkeypatch): + monkeypatch.setenv("QUANTAMENTAL_DATA_DIR", str(tmp_path)) + client = TestClient(api_server.app) + + created = client.post( + "/api/v1/quantamental/compare/watchlists", + json={ + "name": "Core Tech", + "tickers": ["aapl", "msft", "aapl"], + "market": "US", + "style": "quality_growth", + "expand_peer_universe": True, + "peer_limit": 6, + }, + ) + assert created.status_code == 200 + item = created.json()["item"] + assert item["tickers"] == ["AAPL", "MSFT"] + assert item["style"] == "quality_growth" + assert item["expand_peer_universe"] is True + + listed = client.get("/api/v1/quantamental/compare/watchlists") + assert listed.status_code == 200 + assert listed.json()["count"] == 1 + + updated = client.put( + f"/api/v1/quantamental/compare/watchlists/{item['id']}", + json={"name": "Core Tech", "tickers": ["NVDA", "TSLA"], "peer_limit": 4}, + ) + assert updated.status_code == 200 + assert updated.json()["item"]["tickers"] == ["NVDA", "TSLA"] + + deleted = client.delete(f"/api/v1/quantamental/compare/watchlists/{item['id']}") + assert deleted.status_code == 200 + assert client.get("/api/v1/quantamental/compare/watchlists").json()["count"] == 0 + + +def test_quantamental_compare_get_accepts_space_separated_tickers(monkeypatch): + quantamental_cache.clear() + monkeypatch.setattr(service, "provider_for_market", lambda market: FakeQuantamentalProvider()) + client = TestClient(api_server.app) + + resp = client.get("/api/v1/quantamental/compare?tickers=AAPL%20MSFT&include_ai=false") + + assert resp.status_code == 200 + body = resp.json() + assert body["count"] == 2 + assert [row["ticker"] for row in body["rows"]] == ["AAPL", "MSFT"] + + +def test_quantamental_get_validation_errors_return_400(monkeypatch): + quantamental_cache.clear() + monkeypatch.setattr(service, "provider_for_market", lambda market: FakeQuantamentalProvider()) + client = TestClient(api_server.app) + + resp = client.get("/api/v1/quantamental/analysis/AAPL?style=quality") + + assert resp.status_code == 400 + assert resp.json()["detail"][0]["loc"][-1] == "style" + + +def test_quantamental_sec_evidence_is_attached_to_risk(monkeypatch): + quantamental_cache.clear() + monkeypatch.setattr(service, "provider_for_market", lambda market: FakeQuantamentalProvider()) + monkeypatch.setattr( + service, + "build_sec_evidence", + lambda ticker, market="US": { + "status": "ok", + "ticker": ticker, + "market": market, + "filing_count": 2, + "fact_count": 12, + "risk_flags": ["sec_low_cash_conversion"], + "quality_flags": ["sec_companyfacts_available"], + "warnings": [], + }, + ) + client = TestClient(api_server.app) + + body = client.get("/api/v1/quantamental/analysis/AAPL?include_ai=false").json() + + assert body["sec_evidence"]["status"] == "ok" + assert "sec_low_cash_conversion" in body["risk"]["risk_flags"] + assert body["data_quality"]["evidence_sources"]["sec_edgar"]["fact_count"] == 12 + + +def test_quantamental_kr_dart_provider_fails_closed_without_key(monkeypatch): + quantamental_cache.clear() + monkeypatch.setenv("DART_API_KEY", "") + client = TestClient(api_server.app) + + resp = client.get("/api/v1/quantamental/company/005930?market=KR") + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "failed" + assert "dart_api_key_missing" in body["warnings"] diff --git a/tests/test_quantamental_ui_ai_panel.py b/tests/test_quantamental_ui_ai_panel.py new file mode 100644 index 00000000..75301af9 --- /dev/null +++ b/tests/test_quantamental_ui_ai_panel.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import json +import subprocess +import textwrap +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +NODE_CONTRACT = r""" +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const root = process.argv[1]; +const context = { window: {}, console }; +vm.runInNewContext( + fs.readFileSync(path.join(root, "app/web/modules/quantamental-ui.js"), "utf8"), + context, + { filename: "app/web/modules/quantamental-ui.js" }, +); + +const englishAi = context.window.FinGPTQuantamentalUi.mainPanel({ + ai_report: { + status: "partial", + provider: "deterministic_interpreter", + signal_preserved: true, + report: { + summary: "AAPL is a deterministic research classification.", + used_data: { + data_basis_date: "2026-05-15", + analysis_period: "1Y", + data_source: "yfinance + SEC", + observation_count: 252, + missing_data: "None identified", + model: "qwen2.5:7b", + ai_snapshot_at: "2026-05-15T09:30:00Z", + cache_state: "fresh", + }, + key_changes: { price: "Quant score: 72.", risk: "Risk level: medium." }, + interpretation: { data_supported: ["deterministic score only"], unavailable: ["No unsupported values were inferred."] }, + scenarios: { positive: "Scores improve.", neutral: "Evidence remains mixed.", negative: "Risk flags rise." }, + user_actions: { metrics_to_check: "Review score components.", additional_period: "Compare 3M and 1Y." }, + bull_case: ["Fundamental score: 78."], + bear_case: ["Risk score: 45."], + safety_note: "Research classification only.", + }, + }, +}, "ai"); +assert.match(englishAi, /data-testid="quantamental-ai-used-data"/); +assert.match(englishAi, /Used Data/); +assert.match(englishAi, /Basis Date/); +assert.match(englishAi, /2026-05-15/); +assert.match(englishAi, /yfinance \+ SEC/); +assert.match(englishAi, /qwen2\.5:7b/); +assert.match(englishAi, /data-testid="quantamental-ai-key-changes"/); +assert.match(englishAi, /data-testid="quantamental-ai-interpretation"/); +assert.match(englishAi, /data-testid="quantamental-ai-scenarios"/); +assert.match(englishAi, /data-testid="quantamental-ai-user-actions"/); +assert.doesNotMatch(englishAi, /