diff --git a/app/web/app.js b/app/web/app.js index 7adbbfc9..0ddab5f9 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -3336,6 +3336,7 @@ const UI_LANGUAGE_COPY = { momentum: "모멘텀", low_volatility: "저변동성", liquidity: "유동성", + drawdown_resilience: "낙폭 회복", }, }, }, @@ -3444,6 +3445,7 @@ const UI_LANGUAGE_COPY = { momentum: "Momentum", low_volatility: "Low Volatility", liquidity: "Liquidity", + drawdown_resilience: "Drawdown Resilience", }, }, }, @@ -12242,7 +12244,7 @@ function quantamentalScoreScreenLimit() { function quantamentalScoreScreenMetric() { const raw = String(els.quantamentalScoreMetric?.value || "composite"); - return ["composite", "value", "quality", "growth", "momentum", "low_volatility", "liquidity"].includes(raw) ? raw : "composite"; + return ["composite", "value", "quality", "growth", "momentum", "low_volatility", "liquidity", "drawdown_resilience"].includes(raw) ? raw : "composite"; } function quantamentalScoreMetricLabel(scoreKey) { diff --git a/app/web/index.html b/app/web/index.html index 8e0ff996..2b40c728 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -1299,7 +1299,7 @@

Quantamental

- +
@@ -1613,7 +1613,7 @@

심볼 찾기

- - + + diff --git a/app/web/modules/quantamental-ui.js b/app/web/modules/quantamental-ui.js index eed78a00..cd400ec9 100644 --- a/app/web/modules/quantamental-ui.js +++ b/app/web/modules/quantamental-ui.js @@ -1,6 +1,7 @@ (function initQuantamentalUi(global) { const QUALITY_ADJUSTED_MOMENTUM_ID = "quality_adjusted_momentum_v1"; const VOLATILITY_ADJUSTED_BREAKOUT_ID = "volatility_adjusted_breakout_v1"; + const DRAWDOWN_RECOVERY_RESILIENCE_ID = "drawdown_recovery_resilience_v1"; function escapeHtml(value) { return String(value ?? "") @@ -73,6 +74,9 @@ vabScore: "VAB Score", vabClass: "VAB Class", vabNotInComposite: "Volatility-adjusted breakout is a secondary quant diagnostic and is not used in the composite score.", + drsScore: "DRS Score", + drsClass: "DRS Class", + drsNotInComposite: "Drawdown recovery resilience is a secondary quant diagnostic and is not used in the composite score.", maxDrawdown: "Max Drawdown", latestFiling: "Latest Filing", revenue: "Revenue", @@ -193,6 +197,7 @@ momentum: "Momentum", lowVolatility: "Low Volatility", liquidity: "Liquidity", + drawdownResilience: "Drawdown Resilience", }, chart: { priceTitle: "Price + SMA", @@ -288,6 +293,9 @@ vabScore: "VAB 점수", vabClass: "VAB 분류", vabNotInComposite: "변동성 조정 돌파는 보조 퀀트 진단 지표이며 복합 점수에는 반영하지 않습니다.", + drsScore: "DRS 점수", + drsClass: "DRS 분류", + drsNotInComposite: "낙폭 회복 탄력성은 보조 퀀트 진단 지표이며 복합 점수에는 반영하지 않습니다.", maxDrawdown: "최대 낙폭", latestFiling: "최근 공시", revenue: "매출", @@ -408,6 +416,7 @@ momentum: "모멘텀", lowVolatility: "저변동성", liquidity: "유동성", + drawdownResilience: "낙폭 회복", }, chart: { priceTitle: "가격 + SMA", @@ -567,8 +576,9 @@ const key = String(value || "").toLowerCase(); if (key.includes("no_confirmed")) return "warn"; if (key.includes("confirmed")) return "ok"; + if (key.includes("resilient")) return "ok"; if (key.includes("strong") || key.includes("constructive")) return "ok"; - if (key.includes("weak")) return "fail"; + if (key.includes("weak") || key.includes("fragile")) return "fail"; if (key.includes("mixed") || key.includes("insufficient")) return "warn"; return "neutral"; } @@ -605,9 +615,11 @@ const algorithm = metrics.algorithm || {}; const algorithms = metrics.algorithms || {}; const breakout = algorithms.volatility_adjusted_breakout || metrics.volatility_adjusted_breakout || {}; - if (!algorithm.algorithm_id && !breakout.algorithm_id) return ""; + const resilience = algorithms.drawdown_recovery_resilience || metrics.drawdown_recovery_resilience || {}; + if (!algorithm.algorithm_id && !breakout.algorithm_id && !resilience.algorithm_id) return ""; const algorithmId = algorithm.algorithm_id || QUALITY_ADJUSTED_MOMENTUM_ID; const breakoutId = breakout.algorithm_id || VOLATILITY_ADJUSTED_BREAKOUT_ID; + const resilienceId = resilience.algorithm_id || DRAWDOWN_RECOVERY_RESILIENCE_ID; return ` ${algorithm.algorithm_id ? `
@@ -621,6 +633,12 @@
${escapeHtml(cpy.vabNotInComposite)}
` : ""} + ${resilience.algorithm_id ? ` +
+ ${escapeHtml(resilienceId)} / ${escapeHtml(cpy.drsScore)} ${escapeHtml(fmt(resilience.drawdown_recovery_resilience_score))} / ${escapeHtml(cpy.drsClass)} ${escapeHtml(resilience.classification || cpy.unavailable)} +
${escapeHtml(cpy.drsNotInComposite)} +
+ ` : ""} `; } @@ -678,6 +696,7 @@ ]; const algorithm = qMetrics?.algorithm || {}; const breakout = qMetrics?.algorithms?.volatility_adjusted_breakout || {}; + const resilience = qMetrics?.algorithms?.drawdown_recovery_resilience || {}; return `
@@ -690,6 +709,8 @@ ${metric(cpy.qamClass, algorithm.classification || "-", algorithmStatusClass(algorithm.classification))} ${metric(cpy.vabScore, fmt(breakout.volatility_adjusted_breakout_score), scoreClass(breakout.volatility_adjusted_breakout_score))} ${metric(cpy.vabClass, breakout.classification || "-", algorithmStatusClass(breakout.classification))} + ${metric(cpy.drsScore, fmt(resilience.drawdown_recovery_resilience_score), scoreClass(resilience.drawdown_recovery_resilience_score))} + ${metric(cpy.drsClass, resilience.classification || "-", algorithmStatusClass(resilience.classification))} ${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")} @@ -1023,6 +1044,7 @@ momentum: labels.momentum, low_volatility: labels.lowVolatility, liquidity: labels.liquidity, + drawdown_resilience: labels.drawdownResilience, }[String(scoreKey || "composite")] || copy().composite; } diff --git a/core/schemas/quantamental.py b/core/schemas/quantamental.py index bd4b6867..81e5c4ea 100644 --- a/core/schemas/quantamental.py +++ b/core/schemas/quantamental.py @@ -11,7 +11,16 @@ QuantamentalPeriod = Literal["annual", "quarterly"] QuantamentalStyle = Literal["balanced", "quality_growth", "value", "momentum", "defensive"] QuantamentalOutputLanguage = Literal["ko", "en"] -QuantamentalScoreKey = Literal["composite", "value", "quality", "growth", "momentum", "low_volatility", "liquidity"] +QuantamentalScoreKey = Literal[ + "composite", + "value", + "quality", + "growth", + "momentum", + "low_volatility", + "liquidity", + "drawdown_resilience", +] class QuantamentalFlexibleModel(BaseModel): diff --git a/docs/CONTINUOUS_ENHANCEMENT_LOG.md b/docs/CONTINUOUS_ENHANCEMENT_LOG.md index 0d52b892..050e8e78 100644 --- a/docs/CONTINUOUS_ENHANCEMENT_LOG.md +++ b/docs/CONTINUOUS_ENHANCEMENT_LOG.md @@ -228,6 +228,129 @@ - [x] PR summary includes changed files - [x] PR summary includes validation result +## 2026-05-19 Continuous Enhancement Run 15:01 + +### Current Project Summary +- Project purpose: FastAPI-served local financial research workstation with deterministic market, macro, Quant Lab, Quantamental, ML Forecast, and AI Portfolio workflows. AI is an interpreter over grounded data, not a replacement for deterministic scoring. +- Main frontend structure: static `app/web/index.html`, `app/web/app.js`, shared CSS, and domain modules under `app/web/modules`; dashboard panels keep `Core / Diagnostics / Operations / All` with `All` as the default. +- Main backend structure: FastAPI routes under `app/api/routers`, schema contracts under `core/schemas`, and domain services under `pipelines`. +- Data flow: UI controls call `/api/v1/*`; routers validate requests; services fetch/cache provider data; deterministic engines produce typed payloads; UI quality/range summaries render from returned payloads. +- AI/LLM flow: Quantamental AI report/Q&A use deterministic engine snapshots and runtime-checked model selection; default remains deterministic guardrail unless an explicit model request is made. +- Visualization flow: Quantamental overview renders price, return, volatility, drawdown, volume, fundamentals, and annotated algorithm diagnostics with clear axis notes and missing-value handling. +- Testing flow: Python/pytest-first with `scripts/check_ui_contract.py`, JS syntax checks, targeted pytest, full pytest, live API smoke, Browser checks, and Playwright smoke scripts. There is still no `package.json`/pnpm build surface. + +### Current Problems +- Compatibility: Existing API contracts are stable; new score-screen keys must stay additive. +- Data consistency: Current data quality, range, and observation summaries work; new diagnostic algorithms must expose required/available observations and avoid composite-score side effects. +- UI consistency: Existing QAM/VAB algorithm summaries were visible; additional algorithms need the same concise treatment without cluttering normal tabs. +- Visualization: Overview already explains axes; new diagnostic values should appear as compact KPI/summary rows. +- AI briefing: AI context already includes QAM/VAB; new deterministic diagnostics must be added to AI used-data/key-change context without letting AI invent signals. +- Data freshness: Top-right quality badge remains the user-facing status surface; no freshness rules changed in this run. +- Translation quality: English/Korean labels must preserve ticker, numeric, date, and unit values. +- Performance: No background polling or new provider calls should be added for the diagnostic. +- Code structure: Keep the algorithm inside `pipelines/quantamental/quant_engine.py` and expose it through existing adapters. +- User experience: Score Threshold Screener should allow screening on the new risk-recovery diagnostic while preserving the single-ticker flow. + +### Enhancement Plan +- Priority 1: Add an additive `drawdown_recovery_resilience_v1` Quantamental diagnostic using existing price/volume/risk-adjusted inputs only. +- Priority 2: Expose the diagnostic through `quant.metrics.algorithms`, component scores, health metadata, AI context, and score-threshold screening. +- Priority 3: Add UI labels, summary rows, score-screen option, tests, and live Browser verification without changing strategy/order/composite logic. + +### Validation Plan +- Build: no npm/pnpm build exists; run JS syntax and Python compile/static gates. +- Lint: run ruff on changed Python surfaces. +- Unit test: run targeted Quantamental engine/API/UI tests and full pytest. +- Integration test: smoke `/api/v1/quantamental/health`, `/analysis/AAPL`, and score-screen paths. +- UI test: Browser desktop/mobile plus `scripts/quantamental_ui_smoke.py`. +- Data quality test: verify top-right quality summary still shows status, basis date, update time, period, observations, missing, and AI basis after analysis. +- AI hallucination guard test: verify deterministic AI report includes the new algorithm in `key_changes` and remains advisory-only. + +### Changes Made +- Compatibility: Added `drawdown_resilience` as an additive `QuantamentalScoreKey`; existing score keys and default composite screening remain unchanged. +- Data consistency: Added `drawdown_recovery_resilience_v1` with required/available observations, input provenance, component scores, warnings, and `used_in_composite_score=false`. +- UI/UX: Added DRS score/class rows to Quantamental overview and score summary, plus a Score Threshold Screener option labeled `Drawdown Resilience` / `낙폭 회복`. +- Visualization: DRS now appears next to QAM/VAB in the compact overview metric strip and algorithm summary blocks. +- AI Briefing: Added DRS to `quant_snapshot` and deterministic AI `key_changes` as `drawdown_recovery_algorithm`; AI still interprets deterministic outputs only. +- Translation: Added concise English/Korean labels; ticker/date/number/unit handling was not changed. +- Performance: Reused already-loaded price, return, volatility, drawdown, risk-adjusted, and liquidity vectors; no provider call or background polling was added. +- Safety: No trading/order execution code, strategy entry/exit logic, secrets, `.env`, provider selection, or composite scoring weights were changed. + +### 15:01 Validation Results + +| Check | Command / Tool | Result | Notes | +|---|---|---|---| +| Python syntax | `python -m py_compile pipelines\quantamental\quant_engine.py pipelines\quantamental\service.py pipelines\quantamental\ai_service.py core\schemas\quantamental.py scripts\check_ui_contract.py scripts\quantamental_ui_smoke.py scripts\ai_portfolio_ui_smoke.py` | Passed | Changed Python surfaces compile. | +| JS syntax | `node --check app\web\modules\quantamental-ui.js` and `node --check app\web\app.js` | Passed | Static UI JavaScript syntax. | +| Lint | `python -m ruff check pipelines\quantamental\quant_engine.py pipelines\quantamental\service.py pipelines\quantamental\ai_service.py core\schemas\quantamental.py tests\test_quantamental_api.py tests\test_quantamental_engines.py` | Passed | Changed Python implementation/tests pass lint. | +| UI contract | `python scripts\check_ui_contract.py` | Passed | v14 Quantamental bundle, DRS markers, and UI contracts present. | +| Targeted tests | `python -m pytest tests\test_quantamental_engines.py tests\test_quantamental_api.py tests\test_ui_modules.py tests\test_ui_routing_contract.py -q` | Passed | `81 passed, 4 subtests passed`. | +| Full regression | `python -m pytest -q` | Passed | `693 passed, 9 subtests passed`. | +| Live health/API | `GET /api/v1/quantamental/health`; `GET /api/v1/quantamental/analysis/AAPL?include_ai=true&use_llm=false&lookback=252&output_language=en` on `127.0.0.1:8000` | Passed | Health lists `drawdown_recovery_resilience_v1`; AAPL returned DRS score and AI `drawdown_recovery_algorithm`. | +| Browser desktop UI | Browser at `http://127.0.0.1:8000/ui/?range=1Y#quantamental` | Passed | `panelView=all`, range `1Y`, quality badge populated, DRS visible, Drawdown Resilience screen returned 10 rows, no console errors or horizontal overflow. | +| Browser mobile UI | Browser viewport `390x900` | Passed | All view, top quality summary, DRS score option, and no horizontal overflow confirmed. | +| Quantamental browser smoke | `python scripts\quantamental_ui_smoke.py --base-url http://127.0.0.1:8000 --output reports\quantamental_ui_smoke_continuous_20260519_1501.json` | Passed | Required ticker set, invalid ticker, GLOBAL resolver, Top 5, Drawdown Resilience score screen, overview axes, AI/Q&A, and audit smoke passed. | +| Cross-tab browser smoke | `python scripts\ai_portfolio_ui_smoke.py --base-url http://127.0.0.1:8000 --timeout-s 180 --output reports\ai_portfolio_ui_smoke_continuous_20260519_1501.json` | Retried | First run timed out on Macro series search after 180s; console errors were empty. | +| Cross-tab browser smoke retry | `python scripts\ai_portfolio_ui_smoke.py --base-url http://127.0.0.1:8000 --timeout-s 240 --output reports\ai_portfolio_ui_smoke_continuous_20260519_1501_retry.json` | Passed | Versioned scripts, dashboard tab matrix, Quantamental language/top5/score screen, and action smoke passed. | +| npm/pnpm build/lint/test | Not run | Excluded | Repo has no `package.json`, `pnpm-lock.yaml`, or frontend build manifest. | + +### 15:01 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 in the checked Quantamental flow +- [x] Data source and basis date 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 in the checked Quantamental surface +- [x] Layout spacing is consistent in checked desktop/mobile surfaces +- [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/legends remain useful +- [x] Period selection updates checked Quantamental results +- [x] No chart overflow or label collision observed in Browser checks + +#### AI Briefing +- [x] Gemma/Qwen availability remains runtime-checked +- [x] Model selection is not fake +- [x] AI output includes used data period +- [x] AI output includes basis/source/observation count +- [x] AI does not invent unsupported numbers +- [x] Unverified facts are marked unavailable by existing guardrails +- [x] Translation preserves numbers/dates/units in tested API/UI paths + +#### 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`. diff --git a/pipelines/quantamental/ai_service.py b/pipelines/quantamental/ai_service.py index 15b085b3..1ee57b90 100644 --- a/pipelines/quantamental/ai_service.py +++ b/pipelines/quantamental/ai_service.py @@ -129,6 +129,7 @@ def build_context(analysis: dict[str, Any]) -> dict[str, Any]: "component_scores": quant.get("component_scores") or {}, "quality_adjusted_momentum": quant_algorithm, "volatility_adjusted_breakout": quant_algorithms.get("volatility_adjusted_breakout") or {}, + "drawdown_recovery_resilience": quant_algorithms.get("drawdown_recovery_resilience") or {}, "algorithms": quant_algorithms, "missing_metrics": (quant.get("missing_metrics") or [])[:20], }, @@ -329,6 +330,8 @@ def _algorithm_change_text(algorithm: dict[str, Any], *, unavailable: str, langu if "quality_adjusted_momentum_score" in algorithm else "volatility_adjusted_breakout_score" if "volatility_adjusted_breakout_score" in algorithm + else "drawdown_recovery_resilience_score" + if "drawdown_recovery_resilience_score" in algorithm else "score" ) score = algorithm.get(score_key) @@ -499,6 +502,14 @@ def _fallback_report(context: dict[str, Any], *, language: str = "ko") -> dict[s _algorithm_change_text(breakout_algorithm, unavailable=_unavailable(language), language=language), ) report["key_changes"] = key_changes + resilience_algorithm = ((context.get("quant_snapshot") or {}).get("drawdown_recovery_resilience") or {}) + if resilience_algorithm: + key_changes = dict(report.get("key_changes") or {}) + key_changes.setdefault( + "drawdown_recovery_algorithm", + _algorithm_change_text(resilience_algorithm, unavailable=_unavailable(language), language=language), + ) + report["key_changes"] = key_changes return { "status": "partial", "provider": "deterministic_interpreter", diff --git a/pipelines/quantamental/quant_engine.py b/pipelines/quantamental/quant_engine.py index 72683f72..d9c31b87 100644 --- a/pipelines/quantamental/quant_engine.py +++ b/pipelines/quantamental/quant_engine.py @@ -111,6 +111,15 @@ def calculate_quant(payload: dict[str, Any]) -> dict[str, Any]: drawdown=drawdown, liquidity=liquidity, ) + resilience_algorithm = _drawdown_recovery_resilience_algorithm( + rows=rows, + returns=returns, + trend=trend, + volatility=volatility, + drawdown=drawdown, + risk_adjusted=risk_adjusted, + liquidity=liquidity, + ) chart_data = { "price": _price_chart(rows), "cumulative_return": _cumulative_return_chart(rows), @@ -131,6 +140,7 @@ def calculate_quant(payload: dict[str, Any]) -> dict[str, Any]: "algorithms": { "quality_adjusted_momentum": algorithm, "volatility_adjusted_breakout": breakout_algorithm, + "drawdown_recovery_resilience": resilience_algorithm, }, } return { @@ -628,6 +638,108 @@ def _volatility_adjusted_breakout_algorithm( } +def _drawdown_recovery_resilience_algorithm( + *, + rows: list[dict[str, Any]], + returns: list[float], + trend: dict[str, Any], + volatility: dict[str, Any], + drawdown: dict[str, Any], + risk_adjusted: dict[str, Any], + liquidity: dict[str, Any], +) -> dict[str, Any]: + closes = [_price(row) for row in rows] + available_observations = len(rows) + required_observations = 120 + warnings: list[str] = [] + if available_observations < required_observations: + warnings.append("insufficient_price_history_for_drawdown_recovery_resilience") + + latest_close = closes[-1] if closes else None + recent_low_126d = _recent_low(closes, 126) + recovery_from_recent_low = ( + safe_divide((latest_close or 0.0) - recent_low_126d, recent_low_126d) + if latest_close is not None and recent_low_126d is not None + else None + ) + return_120d = _window_return(closes, 120) + positive_share_60d = _positive_return_share(returns, 60) + current_drawdown_abs = abs(drawdown.get("current_drawdown")) if drawdown.get("current_drawdown") is not None else None + max_drawdown_abs = abs(drawdown.get("max_drawdown")) if drawdown.get("max_drawdown") is not None else None + drawdown_duration = _finite(drawdown.get("drawdown_duration")) + + recovery_score = _score_high(recovery_from_recent_low, 0.00, 0.35) + current_drawdown_score = _score_low(current_drawdown_abs, 0.00, 0.30) + max_drawdown_score = _score_low(max_drawdown_abs, 0.05, 0.60) + duration_score = _score_low(drawdown_duration, 0.0, 80.0) + trend_score = _avg([ + _score_high(return_120d, -0.15, 0.35), + _trend_regime_score(trend.get("trend_regime")), + 100.0 if trend.get("price_above_sma_200") is True else 30.0 if trend.get("price_above_sma_200") is False else None, + ]) + stability_score = _avg([ + _score_low(volatility.get("realized_volatility_60d"), 0.12, 0.70), + _score_low(volatility.get("downside_volatility"), 0.08, 0.60), + _score_high(positive_share_60d, 0.42, 0.62), + _score_high(risk_adjusted.get("sortino_ratio"), -0.5, 3.0), + ]) + liquidity_score = _score_high( + math.log10(liquidity.get("average_dollar_volume")) if liquidity.get("average_dollar_volume") else None, + 6.0, + 9.0, + ) + score = _weighted_score( + [ + (recovery_score, 0.24), + (current_drawdown_score, 0.18), + (max_drawdown_score, 0.16), + (duration_score, 0.14), + (trend_score, 0.14), + (stability_score, 0.10), + (liquidity_score, 0.04), + ], + min_components=4, + ) + if available_observations < required_observations: + score = None + + return { + "algorithm_id": "drawdown_recovery_resilience_v1", + "drawdown_recovery_resilience_score": score, + "classification": _drawdown_recovery_resilience_classification(score, current_drawdown_abs), + "score_direction": "higher means the price path recovered from drawdowns with lower current impairment and steadier trend evidence", + "required_observations": required_observations, + "available_observations": available_observations, + "recent_low_window": "126d", + "recent_low": recent_low_126d, + "latest_close": latest_close, + "recovery_from_recent_low": recovery_from_recent_low, + "return_120d": return_120d, + "current_drawdown_abs": current_drawdown_abs, + "max_drawdown_abs": max_drawdown_abs, + "drawdown_duration": drawdown_duration, + "positive_return_share_60d": positive_share_60d, + "component_scores": { + "recovery": recovery_score, + "current_drawdown": current_drawdown_score, + "max_drawdown": max_drawdown_score, + "duration": duration_score, + "trend": trend_score, + "stability": stability_score, + "liquidity": liquidity_score, + }, + "inputs": { + "recovery_basis": "latest_close_vs_recent_126d_low", + "trend_window": "120d", + "stability_window": "60d", + "drawdown_basis": "current_and_max_drawdown", + }, + "warnings": warnings, + "not_investment_advice": True, + "used_in_composite_score": False, + } + + def _rolling_prior_high(closes: list[float | None], window: int) -> float | None: if len(closes) <= window: return None @@ -635,6 +747,13 @@ def _rolling_prior_high(closes: list[float | None], window: int) -> float | None return max(nums) if nums else None +def _recent_low(closes: list[float | None], window: int) -> float | None: + if len(closes) < 2: + return None + nums = [value for value in closes[-window:] if value is not None] + return min(nums) if nums else None + + def _volatility_adjusted_breakout_classification(score: float | None, breakout_strength: float | None) -> str: if score is None: return "insufficient_data" @@ -649,6 +768,20 @@ def _volatility_adjusted_breakout_classification(score: float | None, breakout_s return "weak_breakout_setup" +def _drawdown_recovery_resilience_classification(score: float | None, current_drawdown_abs: float | None) -> str: + if score is None: + return "insufficient_data" + if current_drawdown_abs is not None and current_drawdown_abs >= 0.25 and score < 60: + return "fragile_drawdown_recovery" + if score >= 75: + return "resilient_drawdown_recovery" + if score >= 60: + return "constructive_drawdown_recovery" + if score >= 45: + return "mixed_drawdown_resilience" + return "weak_drawdown_resilience" + + def _first_numeric(*values: Any) -> float | None: for value in values: parsed = _finite(value) @@ -763,6 +896,7 @@ def _component_scores(metrics: dict[str, Any]) -> dict[str, float | None]: algorithm = metrics.get("algorithm") or {} algorithms = metrics.get("algorithms") or {} breakout_algorithm = algorithms.get("volatility_adjusted_breakout") or {} + resilience_algorithm = algorithms.get("drawdown_recovery_resilience") or {} return { "momentum": _avg([ _score_high(momentum.get("momentum_3m"), -0.10, 0.20), @@ -792,6 +926,7 @@ def _component_scores(metrics: dict[str, Any]) -> dict[str, float | None]: ]), "quality_adjusted_momentum": _finite(algorithm.get("quality_adjusted_momentum_score")), "volatility_adjusted_breakout": _finite(breakout_algorithm.get("volatility_adjusted_breakout_score")), + "drawdown_recovery_resilience": _finite(resilience_algorithm.get("drawdown_recovery_resilience_score")), } diff --git a/pipelines/quantamental/service.py b/pipelines/quantamental/service.py index 1276668e..499a691e 100644 --- a/pipelines/quantamental/service.py +++ b/pipelines/quantamental/service.py @@ -142,6 +142,7 @@ def health() -> dict[str, Any]: "axis_annotated_overview_charts", "quality_adjusted_momentum_v1", "volatility_adjusted_breakout_v1", + "drawdown_recovery_resilience_v1", ], } @@ -1184,6 +1185,7 @@ def _score_screen_label(score_key: str) -> str: "momentum": "Momentum", "low_volatility": "Low Volatility", "liquidity": "Liquidity", + "drawdown_resilience": "Drawdown Resilience", }.get(str(score_key or "composite"), "Composite") @@ -1196,6 +1198,7 @@ def _screening_score_value(row: dict[str, Any], score_key: str) -> Any: "momentum": row.get("momentum_score"), "low_volatility": row.get("low_volatility_score"), "liquidity": row.get("liquidity_score"), + "drawdown_resilience": row.get("drawdown_resilience_score"), }.get(str(score_key or "composite"), row.get("final_score")) @@ -1207,6 +1210,9 @@ def _screening_row(payload: dict[str, Any], *, score_key: str = "composite") -> quality = payload.get("data_quality") or {} freshness = payload.get("freshness") or {} integrity = payload.get("data_integrity") or quality.get("data_integrity") or {} + quant = payload.get("quant") or {} + quant_algorithms = ((quant.get("metrics") or {}).get("algorithms") or {}) if isinstance(quant, dict) else {} + drawdown_resilience = quant_algorithms.get("drawdown_recovery_resilience") or {} row = { "ticker": payload.get("ticker"), "market": payload.get("market"), @@ -1225,6 +1231,7 @@ def _screening_row(payload: dict[str, Any], *, score_key: str = "composite") -> "momentum_score": factors.get("momentum_score"), "low_volatility_score": factors.get("low_volatility_score"), "liquidity_score": factors.get("liquidity_score"), + "drawdown_resilience_score": drawdown_resilience.get("drawdown_recovery_resilience_score"), "data_quality_score": quality.get("data_quality_score"), "quality_level": quality.get("quality_level"), "freshness_status": freshness.get("status"), diff --git a/scripts/ai_portfolio_ui_smoke.py b/scripts/ai_portfolio_ui_smoke.py index 4ed02af3..f133d209 100644 --- a/scripts/ai_portfolio_ui_smoke.py +++ b/scripts/ai_portfolio_ui_smoke.py @@ -17,8 +17,8 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] REPORTS_DIR = PROJECT_ROOT / "reports" DOMAIN_BUNDLE_VERSION = "20260514-domain-modules" -QUANTAMENTAL_BUNDLE_VERSION = "20260519-quantamental-v13" -APP_BUNDLE_VERSION = "20260519-continuous-enhancement-v3" +QUANTAMENTAL_BUNDLE_VERSION = "20260519-quantamental-v14" +APP_BUNDLE_VERSION = "20260519-continuous-enhancement-v4" VERSIONED_SCRIPT_SELECTORS = [ f'script[src="modules/market-ui.js?v={DOMAIN_BUNDLE_VERSION}"]', f'script[src="modules/macro-ui.js?v={DOMAIN_BUNDLE_VERSION}"]', diff --git a/scripts/check_ui_contract.py b/scripts/check_ui_contract.py index a2aa6f36..65c1bce0 100644 --- a/scripts/check_ui_contract.py +++ b/scripts/check_ui_contract.py @@ -289,7 +289,7 @@ "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-v13', + "quantamental ui module": 'modules/quantamental-ui.js?v=20260519-quantamental-v14', "ai portfolio operation hydrate": 'id="aiPortfolioHydrateData"', "ai portfolio operation retry": 'id="aiPortfolioRetryMissing"', "ai portfolio snapshot job": 'id="aiPortfolioSnapshotJob"', @@ -378,6 +378,8 @@ "quantamental quality adjusted momentum algorithm": "quality_adjusted_momentum_v1", "quantamental volatility adjusted breakout summary": "quantamental-volatility-breakout-algorithm", "quantamental volatility adjusted breakout algorithm": "volatility_adjusted_breakout_v1", + "quantamental drawdown resilience summary": "quantamental-drawdown-resilience-algorithm", + "quantamental drawdown resilience algorithm": "drawdown_recovery_resilience_v1", } diff --git a/scripts/quantamental_ui_smoke.py b/scripts/quantamental_ui_smoke.py index 0c6ffbf5..559cff10 100644 --- a/scripts/quantamental_ui_smoke.py +++ b/scripts/quantamental_ui_smoke.py @@ -114,7 +114,7 @@ def main() -> int: "row_count": page.locator('#quantamentalScreenSurface [data-testid="quantamental-screen-table"] tbody tr').count(), } - page.locator("#quantamentalScoreMetric").select_option("momentum", timeout=10_000) + page.locator("#quantamentalScoreMetric").select_option("drawdown_resilience", timeout=10_000) page.locator("#quantamentalScoreThreshold").fill("0", timeout=10_000) page.locator("#quantamentalScoreScreenLimit").select_option("10", timeout=10_000) page.get_by_test_id("quantamental-score-screen-run").click(timeout=10_000) @@ -122,7 +122,7 @@ def main() -> int: """() => { const text = document.querySelector("#quantamentalScoreScreenSurface")?.textContent || ""; const rows = document.querySelectorAll('#quantamentalScoreScreenSurface [data-testid="quantamental-score-screen-table"] tbody tr').length; - return (text.includes("Momentum") || text.includes("모멘텀")) && text.includes(">=") && rows > 0 && rows <= 10; + return (text.includes("Drawdown Resilience") || text.includes("낙폭 회복")) && text.includes(">=") && rows > 0 && rows <= 10; }""", timeout=180_000, ) diff --git a/tests/test_quantamental_api.py b/tests/test_quantamental_api.py index 6beaaf53..4a988695 100644 --- a/tests/test_quantamental_api.py +++ b/tests/test_quantamental_api.py @@ -158,6 +158,8 @@ def test_quantamental_analysis_endpoint_shape(monkeypatch): assert body["ai_report"]["report"]["used_data"]["data_source"] != "" assert body["quant"]["metrics"]["algorithms"]["volatility_adjusted_breakout"]["algorithm_id"] == "volatility_adjusted_breakout_v1" assert body["quant"]["metrics"]["algorithms"]["volatility_adjusted_breakout"]["used_in_composite_score"] is False + assert body["quant"]["metrics"]["algorithms"]["drawdown_recovery_resilience"]["algorithm_id"] == "drawdown_recovery_resilience_v1" + assert body["quant"]["metrics"]["algorithms"]["drawdown_recovery_resilience"]["used_in_composite_score"] is False assert body["execution_policy"] == "scores_and_signal_from_deterministic_engines_ai_interprets_only" @@ -286,6 +288,63 @@ def fake_analysis(request): assert "screening_fast_path_sec_overlay_skipped" in body["warnings"] +def test_quantamental_score_screen_supports_drawdown_resilience_score(monkeypatch): + quantamental_cache.clear() + resilience_scores = {"AAA": 82.0, "BBB": 57.0, "CCC": 74.0} + + def fake_analysis(request): + score = resilience_scores[request.ticker] + return { + "status": "ok", + "ticker": request.ticker, + "market": request.market, + "company": {"ticker": request.ticker, "name": f"{request.ticker} Corp"}, + "composite": {"final_score": score - 2, "fundamental_score": score - 4, "quant_score": score, "risk_score": score - 6}, + "factors": { + "value_score": 55.0, + "quality_score": 60.0, + "growth_score": 58.0, + "momentum_score": 62.0, + "low_volatility_score": 66.0, + "liquidity_score": 72.0, + }, + "quant": { + "metrics": { + "algorithms": { + "drawdown_recovery_resilience": { + "algorithm_id": "drawdown_recovery_resilience_v1", + "drawdown_recovery_resilience_score": score, + "classification": "constructive_drawdown_recovery", + "used_in_composite_score": False, + } + } + } + }, + "signal": {"signal_label": "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=drawdown_resilience&min_score=70&limit=10&include_ai=false" + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["score_key"] == "drawdown_resilience" + assert body["score_label"] == "Drawdown Resilience" + assert [row["ticker"] for row in body["matches"]] == ["AAA", "CCC"] + assert all(row["screen_score_key"] == "drawdown_resilience" for row in body["matches"]) + assert all(row["drawdown_resilience_score"] >= 70 for row in body["matches"]) + + def test_quantamental_score_screen_default_universe_respects_limit(monkeypatch): quantamental_cache.clear() tickers = [f"T{i:02d}" for i in range(12)] diff --git a/tests/test_quantamental_engines.py b/tests/test_quantamental_engines.py index 95de3164..1d90f9a0 100644 --- a/tests/test_quantamental_engines.py +++ b/tests/test_quantamental_engines.py @@ -190,6 +190,11 @@ def test_quant_engine_handles_insufficient_price_history_without_crash(): assert breakout["volatility_adjusted_breakout_score"] is None assert breakout["classification"] == "insufficient_data" assert breakout["used_in_composite_score"] is False + resilience = result["metrics"]["algorithms"]["drawdown_recovery_resilience"] + assert resilience["algorithm_id"] == "drawdown_recovery_resilience_v1" + assert resilience["drawdown_recovery_resilience_score"] is None + assert resilience["classification"] == "insufficient_data" + assert resilience["used_in_composite_score"] is False def test_factor_risk_hybrid_and_signal_are_deterministic(): @@ -199,6 +204,7 @@ def test_factor_risk_hybrid_and_signal_are_deterministic(): assert quant["component_scores"]["momentum"] is not None assert quant["component_scores"]["quality_adjusted_momentum"] is not None assert quant["component_scores"]["volatility_adjusted_breakout"] is not None + assert quant["component_scores"]["drawdown_recovery_resilience"] is not None assert quant["metrics"]["algorithm"]["algorithm_id"] == "quality_adjusted_momentum_v1" assert quant["metrics"]["algorithm"]["not_investment_advice"] is True assert quant["metrics"]["algorithm"]["used_in_composite_score"] is False @@ -206,6 +212,10 @@ def test_factor_risk_hybrid_and_signal_are_deterministic(): assert breakout["algorithm_id"] == "volatility_adjusted_breakout_v1" assert breakout["not_investment_advice"] is True assert breakout["used_in_composite_score"] is False + resilience = quant["metrics"]["algorithms"]["drawdown_recovery_resilience"] + assert resilience["algorithm_id"] == "drawdown_recovery_resilience_v1" + assert resilience["not_investment_advice"] is True + assert resilience["used_in_composite_score"] is False assert factors["score_method"] == "deterministic_rule_based_v1" assert risk["risk_level"] in {"low risk", "medium risk", "elevated risk", "high risk", "unknown"} assert composite["score_explanation"]["method"] == "deterministic_weighted_average_v1" @@ -604,6 +614,7 @@ def test_ai_and_qa_interpret_without_overriding_signal_or_giving_orders(): context = build_context(analysis) assert context["quant_snapshot"]["quality_adjusted_momentum"]["algorithm_id"] == "quality_adjusted_momentum_v1" assert context["quant_snapshot"]["volatility_adjusted_breakout"]["algorithm_id"] == "volatility_adjusted_breakout_v1" + assert context["quant_snapshot"]["drawdown_recovery_resilience"]["algorithm_id"] == "drawdown_recovery_resilience_v1" report = generate_report(context, use_llm=False) answer = answer_question("why Buy Candidate?", context, use_llm=False) @@ -612,6 +623,7 @@ def test_ai_and_qa_interpret_without_overriding_signal_or_giving_orders(): assert report["not_investment_advice"] is True assert "quality_adjusted_momentum_v1" in str(report["report"]["key_changes"]) assert "volatility_adjusted_breakout_v1" in str(report["report"]["key_changes"]) + assert "drawdown_recovery_resilience_v1" in str(report["report"]["key_changes"]) assert "buy now" not in str(report).lower() assert answer["not_investment_advice"] is True assert "must buy" not in answer["answer"].lower() diff --git a/tests/test_ui_modules.py b/tests/test_ui_modules.py index ea204b46..3bccf486 100644 --- a/tests/test_ui_modules.py +++ b/tests/test_ui_modules.py @@ -193,6 +193,12 @@ classification: "constructive_breakout_setup", used_in_composite_score: false, }, + drawdown_recovery_resilience: { + algorithm_id: "drawdown_recovery_resilience_v1", + drawdown_recovery_resilience_score: 73.1, + classification: "constructive_drawdown_recovery", + used_in_composite_score: false, + }, }, }, chart_data: { @@ -219,6 +225,8 @@ assert.match(quantamentalMain, /constructive_quality_adjusted_momentum/); assert.match(quantamentalMain, /volatility_adjusted_breakout_v1/); assert.match(quantamentalMain, /constructive_breakout_setup/); +assert.match(quantamentalMain, /drawdown_recovery_resilience_v1/); +assert.match(quantamentalMain, /constructive_drawdown_recovery/); const quantamentalTopSignals = context.window.FinGPTQuantamentalUi.topSignals({ status: "ok", @@ -290,6 +298,12 @@ classification: "constructive_breakout_setup", used_in_composite_score: false, }, + drawdown_recovery_resilience: { + algorithm_id: "drawdown_recovery_resilience_v1", + drawdown_recovery_resilience_score: 73.1, + classification: "constructive_drawdown_recovery", + used_in_composite_score: false, + }, }, }, chart_data: { diff --git a/tests/test_ui_routing_contract.py b/tests/test_ui_routing_contract.py index c39fc3ac..57ed3410 100644 --- a/tests/test_ui_routing_contract.py +++ b/tests/test_ui_routing_contract.py @@ -131,9 +131,9 @@ def test_ai_portfolio_ui_module_contract(self): self.assertIn('src="modules/forecast-ui.js?v=20260514-domain-modules"', html) self.assertIn('src="modules/quant-ui.js?v=20260514-domain-modules"', html) self.assertIn('src="modules/ai-portfolio-ui.js?v=20260514-domain-modules"', html) - self.assertIn('src="modules/quantamental-ui.js?v=20260519-quantamental-v13"', html) + self.assertIn('src="modules/quantamental-ui.js?v=20260519-quantamental-v14"', html) self.assertIn('href="styles.css?v=20260519-continuous-enhancement-v3"', html) - self.assertIn('src="app.js?v=20260519-continuous-enhancement-v3"', html) + self.assertIn('src="app.js?v=20260519-continuous-enhancement-v4"', html) self.assertIn('id="dashboardContextStrip"', html) self.assertIn("dashboardDecisionCards", self.source) self.assertIn("function loadDashboardDecisionCards", self.source) @@ -153,8 +153,8 @@ def test_ai_portfolio_ui_module_contract(self): def test_cross_dashboard_smoke_tracks_current_bundle_and_quantamental(self): smoke_source = AI_PORTFOLIO_UI_SMOKE.read_text(encoding="utf-8") self.assertIn('DOMAIN_BUNDLE_VERSION = "20260514-domain-modules"', smoke_source) - self.assertIn('QUANTAMENTAL_BUNDLE_VERSION = "20260519-quantamental-v13"', smoke_source) - self.assertIn('APP_BUNDLE_VERSION = "20260519-continuous-enhancement-v3"', smoke_source) + self.assertIn('QUANTAMENTAL_BUNDLE_VERSION = "20260519-quantamental-v14"', smoke_source) + self.assertIn('APP_BUNDLE_VERSION = "20260519-continuous-enhancement-v4"', smoke_source) self.assertIn("def _normalize_base_url", smoke_source) self.assertIn("modules/quantamental-ui.js", smoke_source) self.assertIn("FinGPTQuantamentalUi?.topSignals", smoke_source) @@ -653,6 +653,8 @@ def test_macro_static_ui_contract(self): self.assertIn("quality_adjusted_momentum_v1", module_source) self.assertIn("quantamental-volatility-breakout-algorithm", module_source) self.assertIn("volatility_adjusted_breakout_v1", module_source) + self.assertIn("quantamental-drawdown-resilience-algorithm", module_source) + self.assertIn("drawdown_recovery_resilience_v1", module_source) self.assertIn("model: aiOptions.model", self.source) self.assertIn("risk_adjusted_momentum_63d", self.source) self.assertIn("risk_adjusted_momentum", self.source) @@ -842,7 +844,7 @@ def test_quantamental_static_ui_contract(self): 'data-testid="quantamental-score-screen-run"', 'id="quantamentalScoreScreenStatus"', 'id="quantamentalScoreScreenSurface"', - 'src="modules/quantamental-ui.js?v=20260519-quantamental-v13"', + 'src="modules/quantamental-ui.js?v=20260519-quantamental-v14"', ]: self.assertIn(marker, html) for marker in [ @@ -853,6 +855,7 @@ def test_quantamental_static_ui_contract(self): "API.quantamentalTopSignals", "API.quantamentalScoreScreen", "score_key", + "drawdown_resilience", "function loadQuantamental", "function runQuantamentalAnalysis", "function runQuantamentalCompare",