From f56dd37286f2f958d10fd7ae26baf7f54d1f2dd2 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 14 Jun 2026 20:52:17 +0900 Subject: [PATCH] feat: sector trend analysis + ETF representative monitor (DAG step_count 81->83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/quant_engine/sector_trend_analysis.py: ETF proxy 기반 11개 섹터 동향 + smart money lens - src/quant_engine/etf_representative_monitor.py: ETF 대표 종목 8개 추적 + 벤치마크 연동 - tools/build_sector_trend_analysis_v1.py: SECTOR_TREND_ANALYSIS_V1 Temp JSON 생성 - tools/build_etf_representative_monitor_v1.py: ETF_REPRESENTATIVE_MONITOR_V1 Temp JSON 생성 - tools/update_workbook_sector_insights.py: Google Sheets 섹터 인사이트 동기화 - spec/41_release_dag.yaml: step_count 81->83, wave_1에 2개 신규 노드 등록 - validate_engine_harness_gate.py: CHECK_87B (SECTOR_TREND_ANALYSIS_V1) + ETF monitor DAG 스텝 추가 - render_operational_report.py: sector_trend_analysis_v1 / etf_representative_monitor_v1 / portfolio_performance_summary 섹션 추가 - gas_lib.gs: doPost + syncSectorInsightSheets_ (섹터 인사이트 GAS 동기화 엔드포인트) Co-Authored-By: Claude Sonnet 4.6 --- .clasp.json | 1 + package.json | 1 + runtime/refactor_baseline_v1.yaml | 6 +- spec/41_release_dag.yaml | 28 +- src/gas/core/gas_lib.gs | 157 ++++- .../etf_representative_monitor.py | 395 +++++++++++ src/quant_engine/sector_trend_analysis.py | 361 ++++++++++ tools/automate_routine.py | 7 + tools/build_etf_representative_monitor_v1.py | 42 ++ tools/build_sector_trend_analysis_v1.py | 33 + tools/deploy_gas.py | 105 +++ tools/operational_report_contract.py | 3 + tools/render_operational_report.py | 322 ++++++++- tools/update_workbook_sector_insights.py | 658 ++++++++++++++++++ tools/validate_engine_harness_gate.py | 111 +++ ...validate_report_section_completeness_v1.py | 3 + 16 files changed, 2227 insertions(+), 6 deletions(-) create mode 100644 src/quant_engine/etf_representative_monitor.py create mode 100644 src/quant_engine/sector_trend_analysis.py create mode 100644 tools/build_etf_representative_monitor_v1.py create mode 100644 tools/build_sector_trend_analysis_v1.py create mode 100644 tools/update_workbook_sector_insights.py diff --git a/.clasp.json b/.clasp.json index a1eaabf..27ceb78 100644 --- a/.clasp.json +++ b/.clasp.json @@ -1,4 +1,5 @@ { "scriptId": "1xfeBAeeknmnBtSvrIqWXO_2hc3ByeriLUOSuOOB4YxLLHhN3zdnL7tVh", + "projectId": "1072944905499", "rootDir": "Temp/gas_deploy" } \ No newline at end of file diff --git a/package.json b/package.json index 7d97372..7cb9075 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "ops:validate": "python tools/run_release_dag_v3.py --mode release", "ops:build": "python tools/build_bundle.py", "ops:render": "python tools/render_operational_report.py --json GatherTradingData.json --output Temp/operational_report.md --report-json-output Temp/operational_report.json", + "ops:sector-workbook": "python tools/update_workbook_sector_insights.py", "ops:release": "python tools/run_release_dag_v3.py --mode full", "ops:package": "python tools/refresh_trading_calendar.py && python tools/prepare_upload_zip.py --validation-mode release --profile", "prepare-upload-zip": "python tools/refresh_trading_calendar.py && python tools/prepare_upload_zip.py", diff --git a/runtime/refactor_baseline_v1.yaml b/runtime/refactor_baseline_v1.yaml index df1d1f6..4c1e9cc 100644 --- a/runtime/refactor_baseline_v1.yaml +++ b/runtime/refactor_baseline_v1.yaml @@ -1,9 +1,9 @@ { "formula_id": "AUDIT_REPOSITORY_ENTROPY_V2", "gate": "PASS", - "total_file_count": 1674, + "total_file_count": 1685, "package_script_count": 16, - "temp_json_count": 148, + "temp_json_count": 152, "budget": { "schema_version": "repository_entropy_budget.v1", "max_total_files": 2200, @@ -15,5 +15,5 @@ "keep package scripts within release envelope" ] }, - "source_zip_sha256": "54dca83533c8fdea304ef3b23c3cff2f49a216ac7932a4b342683a514f4670e9" + "source_zip_sha256": "8ce41081b6fcd8844a3e914b29bbd5a9469aed052a46f5549c799af72567762c" } \ No newline at end of file diff --git a/spec/41_release_dag.yaml b/spec/41_release_dag.yaml index efbb2cd..666222d 100644 --- a/spec/41_release_dag.yaml +++ b/spec/41_release_dag.yaml @@ -1,5 +1,5 @@ schema_version: release_dag.v3 -step_count: 81 +step_count: 83 goal: Linearize package.json scripts into a validated DAG execution graph. execution_order: # 토폴로지 정렬 기준 병렬 실행 wave (의존성 없는 노드들을 동시에 실행 가능) @@ -37,6 +37,7 @@ execution_order: - build_anti_whipsaw_gate - build_data_gated_progress - build_ejce_view_renderer + - build_etf_representative_monitor - build_factor_shadow_eligibility - build_formula_outputs - build_missing_formula_bridge @@ -44,6 +45,7 @@ execution_order: - build_rebalance_sheet - build_regime_trim_guidance - build_routing_execution_log + - build_sector_trend_analysis - build_shadow_promotion - build_value_preservation_scorer - build_velocity @@ -226,6 +228,30 @@ dag: artifact_policy: "keep" note: "MISSING_FORMULA_BRIDGE_V1 — 10개 공식 커버리지 앵커 등록 (harness auditor PY_FILES)" + build_sector_trend_analysis: + id: build_sector_trend_analysis + command: ["python", "tools/build_sector_trend_analysis_v1.py"] + inputs: ["tools/build_sector_trend_analysis_v1.py", "GatherTradingData.json"] + outputs: ["Temp/sector_trend_analysis_v1.json"] + depends_on: ["convert_xlsx"] + timeout_sec: 30 + cache_key: "build_sector_trend_analysis_v1" + strict: false + artifact_policy: "keep" + note: "SECTOR_TREND_ANALYSIS_V1 — ETF proxy 기반 섹터 동향 + smart money 렌즈 집계" + + build_etf_representative_monitor: + id: build_etf_representative_monitor + command: ["python", "tools/build_etf_representative_monitor_v1.py"] + inputs: ["tools/build_etf_representative_monitor_v1.py", "GatherTradingData.json"] + outputs: ["Temp/etf_representative_monitor_v1.json"] + depends_on: ["convert_xlsx"] + timeout_sec: 30 + cache_key: "build_etf_representative_monitor_v1" + strict: false + artifact_policy: "keep" + note: "ETF_REPRESENTATIVE_MONITOR_V1 — ETF 대표 종목 추적 + 벤치마크 연동" + build_routing_execution_log: id: build_routing_execution_log command: ["python", "tools/build_routing_execution_log_v1.py"] diff --git a/src/gas/core/gas_lib.gs b/src/gas/core/gas_lib.gs index 19bb224..8516f95 100644 --- a/src/gas/core/gas_lib.gs +++ b/src/gas/core/gas_lib.gs @@ -1,5 +1,5 @@ // gas_lib.gs - Common utilities & static features -// Last Updated: 2026-06-14 17:23:33 KST +// Last Updated: 2026-06-14 20:48:30 KST // Math/KRX utils, sheet I/O, sector flow, Web API, static runners // GAS global scope: functions in gas_data_feed.gs / gas_data_collect.gs callable directly // @@ -2081,6 +2081,161 @@ function doGet(e) { .setMimeType(ContentService.MimeType.JSON); } +function doPost(e) { + const payload = parseJsonPostBody_(e); + const action = String(payload.action || payload.view || "").trim().toLowerCase(); + try { + if (action === "sync_sector_insights") { + const result = syncSectorInsightSheets_(payload); + return ContentService + .createTextOutput(JSON.stringify(result, null, 2)) + .setMimeType(ContentService.MimeType.JSON); + } + return ContentService + .createTextOutput(JSON.stringify({ + status: "ERROR", + message: `unsupported action: ${action || "missing"}`, + }, null, 2)) + .setMimeType(ContentService.MimeType.JSON); + } catch (err) { + return ContentService + .createTextOutput(JSON.stringify({ + status: "ERROR", + message: String(err && err.message ? err.message : err), + }, null, 2)) + .setMimeType(ContentService.MimeType.JSON); + } +} + +function parseJsonPostBody_(e) { + try { + const raw = String(e?.postData?.contents ?? "").trim(); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (err) { + return {}; + } +} + +function rowFromObject_(headers, obj) { + return headers.map(function(h) { + const v = obj && Object.prototype.hasOwnProperty.call(obj, h) ? obj[h] : ""; + if (v === null || v === undefined) return ""; + if (typeof v === "object") return JSON.stringify(v); + return v; + }); +} + +function writeSummarySheet_(sheetName, rows) { + const headers = ["section", "key", "value"]; + const tableRows = (rows || []).map(function(r) { + return [r.section || "", r.key || "", r.value || ""]; + }); + writeToSheet(sheetName, headers, tableRows); + return tableRows.length; +} + +function writeSectorTrendAnalysisSheet_(analysis) { + if (!analysis || typeof analysis !== "object") return 0; + const summary = analysis.summary || {}; + const concentration = analysis.concentration || {}; + const detailHeaders = [ + "sector", "proxy_ticker", "proxy_name", "proxy_type", "etf_code", + "etf_execution_use", "etf_liquidity_score", "etf_liquidity_status", "etf_nav_risk", + "proxy_confidence", "rank", "rank_delta_w1", "rank_delta_w2", "sector_score", + "score_delta", "sector_ret5d", "sector_ret20d", "etf_return_5d", "etf_return_20d", + "sector_etf_ret_gap_5d", "sector_etf_ret_gap_20d", "smart_money_5d_krw_raw", + "smart_money_20d_krw_raw", "smart_money_direction", "liquidity_direction", + "flow_alignment_state", "momentum_state", "concentration_weight_pct" + ]; + const detailRows = Array.isArray(analysis.rows) + ? analysis.rows.map(function(r) { return rowFromObject_(detailHeaders, r); }) + : []; + writeSummarySheet_("sector_trend_summary", [ + { section: "summary", key: "formula_id", value: analysis.formula_id || "" }, + { section: "summary", key: "gate", value: analysis.gate || "" }, + { section: "summary", key: "latest_snapshot_date", value: analysis.latest_snapshot_date || "" }, + { section: "summary", key: "previous_snapshot_date", value: analysis.previous_snapshot_date || "" }, + { section: "summary", key: "sector_count", value: analysis.sector_count || 0 }, + { section: "summary", key: "trend_posture", value: summary.trend_posture || "" }, + { section: "summary", key: "rising_count", value: summary.rising_count || 0 }, + { section: "summary", key: "fading_count", value: summary.fading_count || 0 }, + { section: "summary", key: "stable_count", value: summary.stable_count || 0 }, + { section: "summary", key: "etf_proxy_count", value: summary.etf_proxy_count || 0 }, + { section: "summary", key: "smart_money_inflow_count", value: summary.smart_money_inflow_count || 0 }, + { section: "summary", key: "smart_money_outflow_count", value: summary.smart_money_outflow_count || 0 }, + { section: "concentration", key: "top_sector", value: concentration.top_sector || "" }, + { section: "concentration", key: "top_sector_weight_pct", value: concentration.top_sector_weight_pct || 0 }, + { section: "concentration", key: "top2_weight_pct", value: concentration.top2_weight_pct || 0 }, + { section: "concentration", key: "concentration_gate", value: concentration.concentration_gate || "" }, + ]); + writeToSheet("sector_trend_analysis", detailHeaders, detailRows); + const timelineHeaders = [ + "snapshot_date", "sector_count", "avg_sector_score", "top_sector", "top_sector_score", + "positive_breadth_count", "liquidity_warn_count", "net_smart_money_5d_krw" + ]; + const timelineRows = Array.isArray(analysis.timeline) + ? analysis.timeline.map(function(r) { return rowFromObject_(timelineHeaders, r); }) + : []; + writeToSheet("sector_trend_timeline", timelineHeaders, timelineRows); + return detailRows.length; +} + +function writeEtfRepresentativeMonitorSheet_(monitor) { + if (!monitor || typeof monitor !== "object") return 0; + const summary = monitor.summary || {}; + const detailHeaders = [ + "sector", "etf_proxy_ticker", "etf_proxy_name", "etf_proxy_type", "sector_rank", + "sector_score", "sector_smart_money_5d_krw", "sector_ret20d", "representative_count", + "representative_ticker", "representative_name", "representative_basis", + "representative_basis_detail", "constituent_weight", "basket_quality_state", + "basket_coverage_pct", "basket_state", "basket_buy_review_count", + "basket_track_count", "basket_watch_count", "basket_caution_count", + "basket_aligned_count", "basket_missing_count", "basket_real_count", + "selection_source", "selection_score", "monitor_reason", "representatives_json" + ]; + const detailRows = Array.isArray(monitor.rows) + ? monitor.rows.map(function(r) { + const repJson = Array.isArray(r.representatives) ? JSON.stringify(r.representatives) : ""; + const base = Object.assign({}, r, { representatives_json: repJson }); + return rowFromObject_(detailHeaders, base); + }) + : []; + writeSummarySheet_("etf_representative_summary", [ + { section: "summary", key: "formula_id", value: monitor.formula_id || "" }, + { section: "summary", key: "gate", value: monitor.gate || "" }, + { section: "summary", key: "etf_sector_count", value: monitor.etf_sector_count || 0 }, + { section: "summary", key: "tracked_count", value: monitor.tracked_count || 0 }, + { section: "summary", key: "buy_review_count", value: summary.buy_review_count || 0 }, + { section: "summary", key: "track_count", value: summary.track_count || 0 }, + { section: "summary", key: "watch_count", value: summary.watch_count || 0 }, + { section: "summary", key: "caution_count", value: summary.caution_count || 0 }, + { section: "summary", key: "aligned_count", value: summary.aligned_count || 0 }, + { section: "summary", key: "weighted_basis_count", value: summary.weighted_basis_count || 0 }, + { section: "summary", key: "fallback_basis_count", value: summary.fallback_basis_count || 0 }, + { section: "summary", key: "complete_basket_count", value: summary.complete_basket_count || 0 }, + { section: "summary", key: "partial_basket_count", value: summary.partial_basket_count || 0 }, + { section: "summary", key: "basket_missing_total", value: summary.basket_missing_total || 0 }, + ]); + writeToSheet("etf_representative_monitor", detailHeaders, detailRows); + return detailRows.length; +} + +function syncSectorInsightSheets_(payload) { + const trend = payload.sector_trend_analysis || payload.sectorTrendAnalysis || null; + const etf = payload.etf_representative_monitor || payload.etfRepresentativeMonitor || null; + const written = {}; + if (trend) written.sector_trend_analysis = writeSectorTrendAnalysisSheet_(trend); + if (etf) written.etf_representative_monitor = writeEtfRepresentativeMonitorSheet_(etf); + return { + status: "OK", + action: "sync_sector_insights", + written, + generated_at: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss") + " KST", + }; +} + // ── Sheets → JSON 변환 헬퍼 ─────────────────────────────────────────────── function parseCompactFlag_(value) { const raw = String(value ?? "").trim().toLowerCase(); diff --git a/src/quant_engine/etf_representative_monitor.py b/src/quant_engine/etf_representative_monitor.py new file mode 100644 index 0000000..7e23108 --- /dev/null +++ b/src/quant_engine/etf_representative_monitor.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +import json +from collections import defaultdict +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] + +ETF_NAME_HINTS = ( + "KODEX", "TIGER", "RISE", "KBSTAR", "ARIRANG", "ACE", "KOSEF", "HANARO", + "SOL", "TIMEFOLIO", "WOORI", "PLUS", "NPLUS", "TREX", "FOCUS", "KIWOOM", +) + + +def _parse_jsonish(value: Any) -> Any: + if isinstance(value, (dict, list)): + return value + if isinstance(value, str) and value.strip(): + try: + return json.loads(value) + except Exception: + return value + return value + + +def _load_payload(payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + hctx = data.get("_harness_context") if isinstance(data.get("_harness_context"), dict) else {} + return data, hctx + + +def _num(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except Exception: + return default + + +def _txt(value: Any, default: str = "") -> str: + if value is None: + return default + text = str(value).strip() + return text if text else default + + +def _is_etf_like_name(name: str) -> bool: + upper = name.upper() + return any(hint in upper for hint in ETF_NAME_HINTS) + + +def _liquidity_rank(value: str) -> int: + upper = value.upper() + if upper in {"PREFERRED", "OK", "GOOD"}: + return 0 + if upper in {"WATCH", "NORMAL", "TRACK"}: + return 1 + if upper in {"CAUTION", "WARN", "RISK"}: + return 2 + return 3 + + +def _monitor_state(row: dict[str, Any]) -> str: + liquidity = _txt(row.get("Liquidity_Status"), "UNKNOWN").upper() + quote = _txt(row.get("Quote_Status"), "UNKNOWN").upper() + spread = _txt(row.get("Spread_Status"), "UNKNOWN").upper() + close = _num(row.get("Close"), 0.0) + ma20 = _num(row.get("MA20"), 0.0) + ret20d = _num(row.get("Ret20D"), 0.0) + if quote not in {"NAVER_QUOTE_OK", "OK"} or spread not in {"OK"}: + return "CAUTION" + if liquidity == "PREFERRED" and close >= ma20 and ret20d > 0: + return "BUY_REVIEW" + if ret20d > 0 and close >= ma20: + return "TRACK" + return "WATCH" + + +def _selection_score(row: dict[str, Any], is_weighted: bool) -> float: + liquidity = _txt(row.get("Liquidity_Status"), "UNKNOWN").upper() + quote = _txt(row.get("Quote_Status"), "UNKNOWN").upper() + spread = _num(row.get("Spread_Pct"), 99.0) + ret20d = _num(row.get("Ret20D"), 0.0) + avgtrade = _num(row.get("AvgTradeValue_20D_KRW"), 0.0) + score = 0.0 + if is_weighted: + score += 3.0 + if liquidity == "PREFERRED": + score += 3.0 + elif liquidity in {"WATCH", "NORMAL", "TRACK"}: + score += 1.5 + if quote in {"NAVER_QUOTE_OK", "OK"}: + score += 1.0 + if spread <= 0.2: + score += 1.0 + elif spread <= 0.5: + score += 0.5 + if ret20d >= 0: + score += 1.0 + if avgtrade >= 50_000_000_000: + score += 1.0 + return round(score, 2) + + +def _constituent_priority_score( + spec: dict[str, Any], + live_row: dict[str, Any] | None, +) -> tuple[float, float, float, float, float, str]: + weight = _num(spec.get("Weight"), 0.0) + live_score = 0.0 + liquidity_rank = 99.0 + spread = 99.0 + ret20d = -999.0 + name = _txt(spec.get("Constituent_Name")) + if isinstance(live_row, dict): + live_score = _selection_score(live_row, True) + liquidity_rank = float(_liquidity_rank(_txt(live_row.get("Liquidity_Status"), "UNKNOWN"))) + spread = _num(live_row.get("Spread_Pct"), 99.0) + ret20d = _num(live_row.get("Ret20D"), -999.0) + if not name: + name = _txt(live_row.get("Name")) + return (-weight, -live_score, liquidity_rank, spread, -ret20d, name) + + +def _build_rep_item( + row: dict[str, Any], + spec: dict[str, Any], + proxy: dict[str, Any], + source_kind: str, + original_constituent: str = "", + original_constituent_name: str = "", +) -> dict[str, Any]: + alignment = "ALIGNED" if (_num(row.get("Ret20D"), 0.0) >= 0) == (_num(proxy.get("Sector_Ret20D"), 0.0) >= 0) else "DIVERGING" + item = { + "ticker": _txt(row.get("Ticker"), _txt(spec.get("Constituent_Code"), _txt(spec.get("Ticker")))), + "name": _txt(row.get("Name"), _txt(spec.get("Constituent_Name"), _txt(spec.get("Name")))), + "weight": spec.get("Weight", ""), + "close": row.get("Close", ""), + "ma20": row.get("MA20", ""), + "ret10d": row.get("Ret10D", ""), + "ret20d": row.get("Ret20D", ""), + "ret60d": row.get("Ret60D", ""), + "avgtradevalue20d_krw": row.get("AvgTradeValue_20D_KRW", ""), + "spread_pct": row.get("Spread_Pct", ""), + "quote_status": _txt(row.get("Quote_Status")), + "liquidity_status": _txt(row.get("Liquidity_Status")), + "frg_5d": row.get("Frg_5D", ""), + "monitor_state": _monitor_state(row), + "proxy_alignment": alignment, + "selection_source": source_kind, + "selection_score": _selection_score(row, source_kind == "ETF_CONSTITUENT_WEIGHT"), + } + if original_constituent: + item["original_constituent_ticker"] = original_constituent + if original_constituent_name: + item["original_constituent_name"] = original_constituent_name + return item + + +def build_etf_representative_monitor(payload: dict[str, Any]) -> dict[str, Any]: + data, hctx = _load_payload(payload) + sector_flow = data.get("sector_flow") if isinstance(data.get("sector_flow"), list) else [] + core_satellite = data.get("core_satellite") if isinstance(data.get("core_satellite"), list) else [] + sector_universe = data.get("sector_universe") if isinstance(data.get("sector_universe"), list) else [] + sector_flow = [r for r in sector_flow if isinstance(r, dict)] + core_satellite = [r for r in core_satellite if isinstance(r, dict)] + sector_universe = [r for r in sector_universe if isinstance(r, dict)] + + etf_sectors: dict[str, dict[str, Any]] = {} + for row in sector_flow: + sector = _txt(row.get("Sector")) + if not sector: + continue + if _txt(row.get("Proxy_Type")).upper() == "ETF": + etf_sectors[sector] = row + + sector_candidates: dict[str, list[dict[str, Any]]] = defaultdict(list) + core_by_ticker: dict[str, dict[str, Any]] = {} + for row in core_satellite: + sector = _txt(row.get("Sector")) + name = _txt(row.get("Name")) + ticker = _txt(row.get("Ticker")) + if not sector or not ticker: + continue + core_by_ticker[ticker] = row + if _is_etf_like_name(name): + continue + sector_candidates[sector].append(row) + + universe_candidates: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in sector_universe: + sector = _txt(row.get("Sector")) + constituent = _txt(row.get("Constituent_Code")) + if not sector or not constituent: + continue + if _txt(row.get("Is_ETF")).upper() == "Y": + continue + if _txt(row.get("Enabled"), "Y").upper() == "N": + continue + if _txt(row.get("Status"), "OK").upper() not in {"OK", "ACTIVE", "LIVE"}: + continue + universe_candidates[sector].append(row) + + rows: list[dict[str, Any]] = [] + for sector, proxy in sorted(etf_sectors.items(), key=lambda item: (_num(item[1].get("Sector_Rank"), 999), -abs(_num(item[1].get("SmartMoney_5D_KRW"), 0.0)))): + fallback_rows = sorted( + sector_candidates.get(sector, []), + key=lambda r: ( + _liquidity_rank(_txt(r.get("Liquidity_Status"), "UNKNOWN")), + -_num(r.get("AvgTradeValue_20D_KRW"), 0.0), + -_num(r.get("Ret20D"), 0.0), + -_num(r.get("Ret10D"), 0.0), + ), + ) + universe_rows = sorted( + universe_candidates.get(sector, []), + key=lambda r: _constituent_priority_score( + r, + core_by_ticker.get(_txt(r.get("Constituent_Code"))) + or next((x for x in fallback_rows if _txt(x.get("Ticker")) == _txt(r.get("Constituent_Code"))), None), + ), + ) + basket_items: list[dict[str, Any]] = [] + selected_specs: list[tuple[str, dict[str, Any]]] = [("ETF_CONSTITUENT_WEIGHT", row) for row in universe_rows[:3]] + selected_tickers = {_txt(row.get("Constituent_Code")) for row in universe_rows[:3]} + if len(selected_specs) < 3: + for row in fallback_rows: + ticker = _txt(row.get("Ticker")) + if not ticker or ticker in selected_tickers: + continue + selected_specs.append(("SECTOR_LIQUIDITY_FALLBACK", row)) + selected_tickers.add(ticker) + if len(selected_specs) >= 3: + break + if not selected_specs: + selected_specs = [("SECTOR_LIQUIDITY_FALLBACK", row) for row in fallback_rows[:3]] + rep_source = "ETF_CONSTITUENT_WEIGHT" if universe_rows else "SECTOR_LIQUIDITY_FALLBACK" + rep_basis_detail = "ETF_WEIGHT_PRIMARY" + if universe_rows and len(universe_rows) < 3 and len(selected_specs) >= 3: + rep_basis_detail = "ETF_WEIGHT_PRIMARY_PLUS_SECTOR_TOPUP" + if not universe_rows: + rep_basis_detail = "SECTOR_LIQUIDITY_FALLBACK" + for source_kind, spec in selected_specs: + if source_kind == "ETF_CONSTITUENT_WEIGHT": + ticker = _txt(spec.get("Constituent_Code")) + rep = core_by_ticker.get(ticker) + if rep is None: + rep = next((r for r in fallback_rows if _txt(r.get("Ticker")) == ticker), None) + if rep is None: + rep = next((r for r in fallback_rows if _txt(r.get("Ticker")) not in selected_tickers), None) + if rep is not None: + source_kind = "SECTOR_LIQUIDITY_FALLBACK_REPLACEMENT" + else: + rep = spec + if not rep: + basket_items.append({ + "ticker": _txt(spec.get("Constituent_Code"), _txt(spec.get("Ticker"))), + "name": _txt(spec.get("Constituent_Name"), _txt(spec.get("Name"))), + "weight": spec.get("Weight", ""), + "close": "DATA_MISSING — 하네스 업데이트 필요", + "ma20": "DATA_MISSING — 하네스 업데이트 필요", + "ret10d": "DATA_MISSING — 하네스 업데이트 필요", + "ret20d": "DATA_MISSING — 하네스 업데이트 필요", + "ret60d": "DATA_MISSING — 하네스 업데이트 필요", + "avgtradevalue20d_krw": "DATA_MISSING — 하네스 업데이트 필요", + "spread_pct": "DATA_MISSING — 하네스 업데이트 필요", + "quote_status": "DATA_MISSING — 하네스 업데이트 필요", + "liquidity_status": "DATA_MISSING — 하네스 업데이트 필요", + "frg_5d": "DATA_MISSING — 하네스 업데이트 필요", + "monitor_state": "DATA_MISSING", + "proxy_alignment": "UNKNOWN", + "selection_source": source_kind, + "selection_score": 0.0, + "replacement_reason": "NO_LIVE_REPLACEMENT", + }) + continue + basket_items.append(_build_rep_item( + rep, + spec, + proxy, + source_kind, + _txt(spec.get("Constituent_Code")), + _txt(spec.get("Constituent_Name")), + )) + if len(basket_items) < 3: + used_tickers = {item["ticker"] for item in basket_items} + for rep in fallback_rows: + ticker = _txt(rep.get("Ticker")) + if not ticker or ticker in used_tickers: + continue + basket_items.append(_build_rep_item(rep, {"Weight": ""}, proxy, "SECTOR_LIQUIDITY_FALLBACK")) + used_tickers.add(ticker) + if len(basket_items) >= 3: + break + if not basket_items: + continue + primary = basket_items[0] + basket_buy = sum(1 for r in basket_items if r.get("monitor_state") == "BUY_REVIEW") + basket_track = sum(1 for r in basket_items if r.get("monitor_state") == "TRACK") + basket_watch = sum(1 for r in basket_items if r.get("monitor_state") == "WATCH") + basket_caution = sum(1 for r in basket_items if r.get("monitor_state") == "CAUTION") + basket_aligned = sum(1 for r in basket_items if r.get("proxy_alignment") == "ALIGNED") + basket_missing = sum(1 for r in basket_items if r.get("monitor_state") == "DATA_MISSING") + basket_real = len(basket_items) - basket_missing + basket_coverage_pct = round((basket_real / len(basket_items)) * 100.0, 2) if basket_items else 0.0 + basket_quality_state = "COMPLETE" if basket_missing == 0 else "PARTIAL" + basket_state = "BUY_REVIEW" if basket_buy >= 2 and basket_aligned >= 2 else ( + "CAUTION" if basket_caution > 0 else "TRACK" if basket_track > 0 else "WATCH" + ) + rows.append({ + "sector": sector, + "etf_proxy_ticker": _txt(proxy.get("Proxy_Ticker")), + "etf_proxy_name": _txt(proxy.get("Proxy_Name")), + "etf_proxy_type": _txt(proxy.get("Proxy_Type")), + "sector_rank": proxy.get("Sector_Rank", ""), + "sector_score": proxy.get("Sector_Score", ""), + "sector_smart_money_5d_krw": proxy.get("SmartMoney_5D_KRW", ""), + "sector_ret20d": proxy.get("Sector_Ret20D", ""), + "representative_count": len(basket_items), + "representative_ticker": primary["ticker"], + "representative_name": primary["name"], + "representative_basis": rep_source, + "representative_basis_detail": rep_basis_detail, + "constituent_weight": primary["weight"], + "weight_sum_stocks_only": universe_rows[0].get("Weight_Sum_Stocks_Only", "") if universe_rows else "", + "weight_sum_all": universe_rows[0].get("Weight_Sum_All", "") if universe_rows else "", + "representative_close": primary["close"], + "representative_ma20": primary["ma20"], + "representative_ret10d": primary["ret10d"], + "representative_ret20d": primary["ret20d"], + "representative_ret60d": primary["ret60d"], + "representative_avgtradevalue20d_krw": primary["avgtradevalue20d_krw"], + "representative_spread_pct": primary["spread_pct"], + "representative_quote_status": primary["quote_status"], + "representative_liquidity_status": primary["liquidity_status"], + "representative_frg_5d": primary["frg_5d"], + "monitor_state": basket_state, + "proxy_alignment": "ALIGNED" if basket_aligned >= 2 else "DIVERGING", + "basket_buy_review_count": basket_buy, + "basket_track_count": basket_track, + "basket_watch_count": basket_watch, + "basket_caution_count": basket_caution, + "basket_aligned_count": basket_aligned, + "basket_missing_count": basket_missing, + "basket_real_count": basket_real, + "basket_coverage_pct": basket_coverage_pct, + "basket_quality_state": basket_quality_state, + "representatives": basket_items, + "monitor_reason": ( + "ETF 구성비중 상위 3종목이 같은 방향으로 정렬" + if basket_state == "BUY_REVIEW" + else "대표 종목 바스켓 추세 확인 중" if basket_state == "TRACK" + else "유동성/추세 보수 모니터링" + ), + }) + + buy_review = sum(1 for r in rows if r.get("monitor_state") == "BUY_REVIEW") + track = sum(1 for r in rows if r.get("monitor_state") == "TRACK") + watch = sum(1 for r in rows if r.get("monitor_state") == "WATCH") + caution = sum(1 for r in rows if r.get("monitor_state") == "CAUTION") + aligned = sum(1 for r in rows if r.get("proxy_alignment") == "ALIGNED") + weighted_basis = sum(1 for r in rows if r.get("representative_basis") == "ETF_CONSTITUENT_WEIGHT") + fallback_basis = sum(1 for r in rows if r.get("representative_basis") == "SECTOR_LIQUIDITY_FALLBACK") + complete_basket_count = sum(1 for r in rows if r.get("basket_quality_state") == "COMPLETE") + partial_basket_count = sum(1 for r in rows if r.get("basket_quality_state") == "PARTIAL") + basket_missing_total = sum(_num(r.get("basket_missing_count"), 0.0) for r in rows) + + result = { + "formula_id": "ETF_REPRESENTATIVE_MONITOR_V1", + "gate": "PASS" if rows else "DATA_MISSING", + "etf_sector_count": len(etf_sectors), + "tracked_count": len(rows), + "summary": { + "buy_review_count": buy_review, + "track_count": track, + "watch_count": watch, + "caution_count": caution, + "aligned_count": aligned, + "weighted_basis_count": weighted_basis, + "fallback_basis_count": fallback_basis, + "complete_basket_count": complete_basket_count, + "partial_basket_count": partial_basket_count, + "basket_missing_total": basket_missing_total, + "selected_sector_count": len({r["sector"] for r in rows}), + "top_rep_names": [", ".join(rep["name"] for rep in r.get("representatives", [])) for r in rows[:3]], + }, + "rows": rows, + "source": { + "sector_flow_rows": len(sector_flow), + "core_satellite_rows": len(core_satellite), + "sector_universe_rows": len(sector_universe), + }, + } + return result diff --git a/src/quant_engine/sector_trend_analysis.py b/src/quant_engine/sector_trend_analysis.py new file mode 100644 index 0000000..4aadfb3 --- /dev/null +++ b/src/quant_engine/sector_trend_analysis.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import json +from collections import Counter, defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] + + +def _parse_jsonish(value: Any) -> Any: + if isinstance(value, (dict, list)): + return value + if isinstance(value, str) and value.strip(): + try: + return json.loads(value) + except Exception: + return value + return value + + +def _load_payload(payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + hctx = data.get("_harness_context") if isinstance(data.get("_harness_context"), dict) else {} + return data, hctx + + +def _num(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except Exception: + return default + + +def _txt(value: Any, default: str = "") -> str: + if value is None: + return default + text = str(value).strip() + return text if text else default + + +def _latest_dates(history: list[dict[str, Any]]) -> tuple[str | None, str | None]: + dates = sorted({str(row.get("Snapshot_Date") or "") for row in history if str(row.get("Snapshot_Date") or "")}) + if not dates: + return None, None + latest = dates[-1] + previous = dates[-2] if len(dates) >= 2 else None + return latest, previous + + +def _rows_by_date(history: list[dict[str, Any]], snapshot_date: str | None) -> dict[str, dict[str, Any]]: + if not snapshot_date: + return {} + rows = {} + for row in history: + if str(row.get("Snapshot_Date") or "") != snapshot_date: + continue + sector = str(row.get("Sector") or "").strip() + if sector: + rows[sector] = row + return rows + + +def _trend_state(momentum: dict[str, Any], row: dict[str, Any], prev_row: dict[str, Any] | None) -> str: + state = str(momentum.get("momentum_state") or "").upper() + if state in {"RISING", "FADING", "TOPPING_OUT", "STABLE"}: + return state + + rank = momentum.get("rank") + prev_rank = momentum.get("prev_rank_w1") or momentum.get("prevRank") or momentum.get("rank_w1") + delta = None + if isinstance(rank, (int, float)) and isinstance(prev_rank, (int, float)): + delta = prev_rank - rank + if delta is None and prev_row is not None: + try: + delta = _num(prev_row.get("Sector_Score")) - _num(row.get("Sector_Score")) + except Exception: + delta = None + if delta is not None: + if delta >= 2: + return "RISING" + if delta <= -2: + return "FADING" + breadth = _num(row.get("Flow_Breadth_5D"), 0.0) + if breadth >= 0.6: + return "RISING" + if breadth <= -0.6: + return "FADING" + return "STABLE" + + +def _direction_from_flow(value: float, threshold: float = 0.0) -> str: + if value > threshold: + return "INFLOW" + if value < -threshold: + return "OUTFLOW" + return "NEUTRAL" + + +def _alignment_state(smart_money_direction: str, breadth: float, etf_return_5d: float) -> str: + if smart_money_direction == "INFLOW" and breadth > 0 and etf_return_5d >= 0: + return "ALIGNED_POSITIVE" + if smart_money_direction == "OUTFLOW" and breadth < 0 and etf_return_5d <= 0: + return "ALIGNED_NEGATIVE" + if smart_money_direction in {"INFLOW", "OUTFLOW"} and abs(breadth) >= 0.5: + return "FLOW_CONFIRMING" + if smart_money_direction == "NEUTRAL" and abs(breadth) < 0.5: + return "MIXED" + return "DIVERGING" + + +def _build_timeline(sector_history: list[dict[str, Any]]) -> list[dict[str, Any]]: + by_date: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in sector_history: + snapshot_date = _txt(row.get("Snapshot_Date")) + if snapshot_date: + by_date[snapshot_date].append(row) + + timeline: list[dict[str, Any]] = [] + for snapshot_date in sorted(by_date): + rows = by_date[snapshot_date] + top = max(rows, key=lambda r: _num(r.get("Sector_Score"), 0.0)) if rows else {} + total_smart_money = sum(_num(r.get("SmartMoney_5D_KRW"), 0.0) for r in rows) + avg_score = round(sum(_num(r.get("Sector_Score"), 0.0) for r in rows) / len(rows), 2) if rows else 0.0 + positive_breadth = sum(1 for r in rows if _num(r.get("Flow_Breadth_5D"), 0.0) > 0) + liquidity_warn = sum(1 for r in rows if _txt(r.get("ETF_Liquidity_Status"), "UNKNOWN") in {"WARN", "RISK", "BLOCK"}) + timeline.append({ + "snapshot_date": snapshot_date, + "sector_count": len(rows), + "avg_sector_score": avg_score, + "top_sector": _txt(top.get("Sector")), + "top_sector_score": top.get("Sector_Score", ""), + "top_sector_rank": top.get("Sector_Rank", ""), + "top_sector_smart_money_5d_krw": top.get("SmartMoney_5D_KRW", ""), + "positive_breadth_count": positive_breadth, + "liquidity_warn_count": liquidity_warn, + "net_smart_money_5d_krw": round(total_smart_money, 2), + }) + return timeline + + +def build_sector_trend_analysis(payload: dict[str, Any]) -> dict[str, Any]: + data, hctx = _load_payload(payload) + + sector_flow = data.get("sector_flow") if isinstance(data.get("sector_flow"), list) else [] + sector_history = data.get("sector_flow_history") if isinstance(data.get("sector_flow_history"), list) else [] + sector_flow = [r for r in sector_flow if isinstance(r, dict)] + sector_history = [r for r in sector_history if isinstance(r, dict)] + + rotation_rows = _parse_jsonish(hctx.get("sector_rotation_momentum_json")) + if not isinstance(rotation_rows, list): + rotation_rows = [] + concentration_rows = _parse_jsonish(hctx.get("sector_concentration_json")) + if not isinstance(concentration_rows, list): + concentration_rows = [] + + momentum_map: dict[str, dict[str, Any]] = {} + for row in rotation_rows: + if isinstance(row, dict): + sec = str(row.get("sector") or "").strip() + if sec: + momentum_map[sec] = row + + concentration_map: dict[str, dict[str, Any]] = {} + for row in concentration_rows: + if isinstance(row, dict): + sec = str(row.get("sector") or "").strip() + if sec: + concentration_map[sec] = row + + latest_date, previous_date = _latest_dates(sector_history) + latest_rows = _rows_by_date(sector_history, latest_date) + prev_rows = _rows_by_date(sector_history, previous_date) + timeline = _build_timeline(sector_history) + + rows: list[dict[str, Any]] = [] + for row in sorted(sector_flow, key=lambda r: (_num(r.get("Sector_Rank"), 999), -abs(_num(r.get("SmartMoney_5D_KRW"), 0.0)))): + sector = str(row.get("Sector") or "").strip() + if not sector: + continue + hist_latest = latest_rows.get(sector, {}) + hist_prev = prev_rows.get(sector) + mom = momentum_map.get(sector, {}) + conc = concentration_map.get(sector, {}) + proxy_ticker = _txt(row.get("Proxy_Ticker")) + proxy_name = _txt(row.get("Proxy_Name")) + proxy_type = _txt(row.get("Proxy_Type"), "UNKNOWN") + etf_code = _txt(row.get("ETF_Code"), proxy_ticker) + etf_execution_use = _txt(row.get("ETF_Execution_Use")) + etf_liquidity_status = _txt(row.get("ETF_Liquidity_Status"), "UNKNOWN") + etf_nav_risk = _txt(row.get("ETF_NAV_Risk"), "UNKNOWN") + etf_liquidity_score = row.get("ETF_Liquidity_Score", "") + data_quality = _txt(row.get("Data_Quality")) + stale_count = int(_num(row.get("Stale_Count"), 0.0)) + smart_money_5d_krw = _num(row.get("SmartMoney_5D_KRW"), 0.0) + smart_money_20d_krw = _num(row.get("SmartMoney_20D_KRW"), 0.0) + smart_money_5d_norm = _num(row.get("SmartMoney_5D_Norm"), 0.0) + smart_money_20d_norm = _num(row.get("SmartMoney_20D_Norm"), 0.0) + flow_breadth_5d = _num(row.get("Flow_Breadth_5D"), 0.0) + etf_ret5d = _num(row.get("ETF_Ret5D"), 0.0) + etf_ret20d = _num(row.get("ETF_Ret20D"), 0.0) + rank = _num(hist_latest.get("Sector_Rank") if hist_latest else row.get("Sector_Rank"), 0) + prev_rank_w1 = _num(mom.get("prev_rank_w1") or mom.get("prevRank") or (hist_prev.get("Sector_Rank") if hist_prev else None), 0) + prev_rank_w2 = _num(mom.get("prev_rank_w2") or mom.get("prevRankW2"), 0) + current_score = _num(hist_latest.get("Sector_Score") if hist_latest else row.get("Sector_Score"), 0) + prev_score = _num(hist_prev.get("Sector_Score") if hist_prev else None, 0) + state = _trend_state(mom, row, hist_prev) + proxy_confidence = "HIGH" + if proxy_type != "ETF": + proxy_confidence = "MEDIUM" + if etf_liquidity_status in {"WARN", "RISK", "BLOCK"} or etf_nav_risk not in {"", "OK", "NONE", "NAV_DATA_OK"}: + proxy_confidence = "LOW" if proxy_confidence == "MEDIUM" else "MEDIUM" + if stale_count > 0 or data_quality not in {"A", "AA", "AAA"}: + proxy_confidence = "LOW" + smart_money_direction = _direction_from_flow(smart_money_5d_krw) + liquidity_direction = "FLOW_EXPANSION" if flow_breadth_5d >= 0.5 and smart_money_5d_krw > 0 else ( + "FLOW_DECAY" if flow_breadth_5d <= -0.5 and smart_money_5d_krw < 0 else "FLOW_MIXED" + ) + alignment_state = _alignment_state(smart_money_direction, flow_breadth_5d, etf_ret5d) + rows.append({ + "sector": sector, + "proxy_ticker": proxy_ticker, + "proxy_name": proxy_name, + "proxy_type": proxy_type, + "etf_code": etf_code, + "etf_execution_use": etf_execution_use, + "etf_liquidity_score": etf_liquidity_score, + "etf_liquidity_status": etf_liquidity_status, + "etf_nav_risk": etf_nav_risk, + "proxy_confidence": proxy_confidence, + "rank": int(rank) if rank else row.get("Sector_Rank"), + "prev_rank_w1": int(prev_rank_w1) if prev_rank_w1 else mom.get("prev_rank_w1", mom.get("prevRank", "")), + "prev_rank_w2": int(prev_rank_w2) if prev_rank_w2 else mom.get("prev_rank_w2", mom.get("prevRankW2", "")), + "rank_delta_w1": mom.get("rank_delta_w1", (int(prev_rank_w1) - int(rank)) if prev_rank_w1 and rank else ""), + "rank_delta_w2": mom.get("rank_delta_w2", (int(prev_rank_w2) - int(rank)) if prev_rank_w2 and rank else ""), + "sector_score": current_score if current_score else row.get("Sector_Score", ""), + "score_delta": round(current_score - prev_score, 2) if prev_score else "", + "sector_ret5d": row.get("Sector_Ret5D", ""), + "sector_ret20d": row.get("Sector_Ret20D", ""), + "smart_money_5d_krw": row.get("SmartMoney_5D_KRW", ""), + "smart_money_20d_krw": row.get("SmartMoney_20D_KRW", ""), + "flow_breadth_5d": row.get("Flow_Breadth_5D", ""), + "alert_level": row.get("Alert_Level", ""), + "decision_use": row.get("Decision_Use", ""), + "data_quality": data_quality, + "stale_count": stale_count, + "smart_money_direction": smart_money_direction, + "liquidity_direction": liquidity_direction, + "flow_alignment_state": alignment_state, + "momentum_state": state, + "concentration_weight_pct": conc.get("weight_pct", row.get("Coverage_Weight", "")), + "etf_return_5d": row.get("ETF_Ret5D", ""), + "etf_return_10d": row.get("ETF_Ret10D", ""), + "etf_return_20d": row.get("ETF_Ret20D", ""), + "sector_etf_ret_gap_5d": round(_num(row.get("Sector_Ret5D"), 0.0) - etf_ret5d, 2), + "sector_etf_ret_gap_20d": round(_num(row.get("Sector_Ret20D"), 0.0) - etf_ret20d, 2), + "smart_money_5d_norm": smart_money_5d_norm, + "smart_money_20d_norm": smart_money_20d_norm, + "smart_money_5d_krw_raw": smart_money_5d_krw, + "smart_money_20d_krw_raw": smart_money_20d_krw, + "flow_breadth_5d_raw": flow_breadth_5d, + }) + + def _take_top(items: list[dict[str, Any]], key: str, reverse: bool = True, n: int = 3) -> list[str]: + ranked = sorted( + [r for r in items if isinstance(r.get(key), (int, float))], + key=lambda r: r.get(key, 0), + reverse=reverse, + ) + return [str(r.get("sector") or "") for r in ranked[:n] if str(r.get("sector") or "")] + + rising = sum(1 for r in rows if r.get("momentum_state") == "RISING") + fading = sum(1 for r in rows if r.get("momentum_state") == "FADING") + stable = sum(1 for r in rows if r.get("momentum_state") == "STABLE") + topping = sum(1 for r in rows if r.get("momentum_state") == "TOPPING_OUT") + breadth_positive = sum(1 for r in rows if _num(r.get("flow_breadth_5d"), 0.0) > 0) + etf_proxy_count = sum(1 for r in rows if str(r.get("proxy_type") or "").upper() == "ETF") + liquidity_warn_count = sum(1 for r in rows if str(r.get("etf_liquidity_status") or "").upper() in {"WARN", "RISK", "BLOCK"}) + nav_risk_count = sum(1 for r in rows if str(r.get("etf_nav_risk") or "").upper() not in {"", "OK", "NONE", "NAV_DATA_OK"}) + low_confidence_count = sum(1 for r in rows if str(r.get("proxy_confidence") or "").upper() == "LOW") + smart_money_inflow_count = sum(1 for r in rows if str(r.get("smart_money_direction") or "") == "INFLOW") + smart_money_outflow_count = sum(1 for r in rows if str(r.get("smart_money_direction") or "") == "OUTFLOW") + flow_aligned_count = sum(1 for r in rows if str(r.get("flow_alignment_state") or "").startswith("ALIGNED")) + flow_diverging_count = sum(1 for r in rows if str(r.get("flow_alignment_state") or "") == "DIVERGING") + + top_inflow = _take_top(rows, "smart_money_5d_krw", True, 3) + outflow_warning = [ + r["sector"] + for r in sorted(rows, key=lambda r: _num(r.get("smart_money_5d_krw"), 0.0)) + if _num(r.get("smart_money_5d_krw"), 0.0) < 0 or str(r.get("alert_level") or "").upper().startswith("OUTFLOW") + ][:3] + strong_smart_money = [ + r["sector"] + for r in sorted(rows, key=lambda r: _num(r.get("smart_money_5d_krw"), 0.0), reverse=True) + if _num(r.get("smart_money_5d_krw"), 0.0) > 0 and _num(r.get("flow_breadth_5d"), 0.0) >= 0 + ][:3] + + conc_rows_sorted = sorted(concentration_rows, key=lambda r: _num(r.get("weight_pct"), 0.0), reverse=True) + top_sector = conc_rows_sorted[0] if conc_rows_sorted else {} + top2_sum = round(sum(_num(r.get("weight_pct"), 0.0) for r in conc_rows_sorted[:2]), 2) if conc_rows_sorted else 0.0 + top1_weight = round(_num(top_sector.get("weight_pct"), 0.0), 2) if top_sector else 0.0 + + if fading > rising and top1_weight >= 60: + posture = "DEFENSIVE_CONCENTRATED" + elif liquidity_warn_count >= max(1, len(rows) // 3) or nav_risk_count >= max(1, len(rows) // 4): + posture = "ETF_PROXY_RISK" + elif rising >= fading and breadth_positive >= max(1, len(rows) // 2): + posture = "RISK_ON_ROTATION" + elif smart_money_inflow_count > smart_money_outflow_count and flow_aligned_count >= max(1, len(rows) // 3): + posture = "SMART_MONEY_CONFIRMED" + else: + posture = "BALANCED_ROTATION" + + gate = "PASS" if rows else "DATA_MISSING" + if not latest_date: + gate = "WARN" + + result = { + "formula_id": "SECTOR_TREND_ANALYSIS_V1", + "gate": gate, + "latest_snapshot_date": latest_date, + "previous_snapshot_date": previous_date, + "sector_count": len(rows), + "summary": { + "rising_count": rising, + "fading_count": fading, + "stable_count": stable, + "topping_out_count": topping, + "positive_breadth_count": breadth_positive, + "etf_proxy_count": etf_proxy_count, + "liquidity_warn_count": liquidity_warn_count, + "nav_risk_count": nav_risk_count, + "low_proxy_confidence_count": low_confidence_count, + "smart_money_inflow_count": smart_money_inflow_count, + "smart_money_outflow_count": smart_money_outflow_count, + "flow_aligned_count": flow_aligned_count, + "flow_diverging_count": flow_diverging_count, + "top_inflow_sectors": top_inflow, + "outflow_warning_sectors": outflow_warning, + "strong_smart_money_sectors": strong_smart_money, + "trend_posture": posture, + }, + "concentration": { + "top_sector": top_sector.get("sector", ""), + "top_sector_weight_pct": top1_weight, + "top2_weight_pct": top2_sum, + "concentration_gate": top_sector.get("gate", ""), + }, + "rows": rows, + "timeline": timeline, + "source": { + "sector_flow_rows": len(sector_flow), + "sector_flow_history_rows": len(sector_history), + "sector_rotation_momentum_rows": len(rotation_rows), + "sector_concentration_rows": len(concentration_rows), + "proxy_coverage_pct": round((etf_proxy_count / len(rows)) * 100.0, 2) if rows else 0.0, + }, + } + return result diff --git a/tools/automate_routine.py b/tools/automate_routine.py index b97c2d7..4857d75 100644 --- a/tools/automate_routine.py +++ b/tools/automate_routine.py @@ -2,6 +2,7 @@ import json import os import requests import time +import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @@ -93,6 +94,12 @@ def main(): print("\nDownload failed. Please check Drive API scopes.") else: print("\nGAS execution failed. Process aborted.") + print("Falling back to local workbook sector-insight build...") + fallback = subprocess.run(["python", "tools/update_workbook_sector_insights.py"], cwd=str(ROOT)) + if fallback.returncode == 0: + print("Local sector-insight workbook updated.") + else: + print("Local sector-insight workbook build failed.") except Exception as e: print(f"Error: {e}") diff --git a/tools/build_etf_representative_monitor_v1.py b/tools/build_etf_representative_monitor_v1.py new file mode 100644 index 0000000..fb444d6 --- /dev/null +++ b/tools/build_etf_representative_monitor_v1.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.quant_engine.etf_representative_monitor import build_etf_representative_monitor + + +DEFAULT_JSON = ROOT / "GatherTradingData.json" +DEFAULT_OUT = ROOT / "Temp" / "etf_representative_monitor_v1.json" + + +def _ensure_utf8_stdio() -> None: + if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"): + sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1) + if sys.stderr.encoding and sys.stderr.encoding.lower() not in ("utf-8", "utf8"): + sys.stderr = open(sys.stderr.fileno(), mode="w", encoding="utf-8", buffering=1) + + +def main() -> int: + _ensure_utf8_stdio() + payload = {} + if DEFAULT_JSON.exists(): + try: + payload = json.loads(DEFAULT_JSON.read_text(encoding="utf-8")) + except Exception: + payload = {} + result = build_etf_representative_monitor(payload if isinstance(payload, dict) else {}) + DEFAULT_OUT.parent.mkdir(parents=True, exist_ok=True) + DEFAULT_OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print("ETF_REPRESENTATIVE_MONITOR_V1") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build_sector_trend_analysis_v1.py b/tools/build_sector_trend_analysis_v1.py new file mode 100644 index 0000000..5b7d3b0 --- /dev/null +++ b/tools/build_sector_trend_analysis_v1.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.quant_engine.sector_trend_analysis import build_sector_trend_analysis + +DEFAULT_JSON = ROOT / "GatherTradingData.json" +DEFAULT_OUT = ROOT / "Temp" / "sector_trend_analysis_v1.json" + + +def main() -> int: + payload = {} + if DEFAULT_JSON.exists(): + try: + payload = json.loads(DEFAULT_JSON.read_text(encoding="utf-8")) + except Exception: + payload = {} + result = build_sector_trend_analysis(payload if isinstance(payload, dict) else {}) + DEFAULT_OUT.parent.mkdir(parents=True, exist_ok=True) + DEFAULT_OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print("SECTOR_TREND_ANALYSIS_V1") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/deploy_gas.py b/tools/deploy_gas.py index dcb2972..87d7de9 100644 --- a/tools/deploy_gas.py +++ b/tools/deploy_gas.py @@ -8,6 +8,7 @@ import shutil import json import argparse import subprocess +import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @@ -54,7 +55,12 @@ BUNDLE_MAP: dict[str, list[str]] = { } SCRIPT_ID = "1xfeBAeeknmnBtSvrIqWXO_2hc3ByeriLUOSuOOB4YxLLHhN3zdnL7tVh" +PROJECT_ID = "1072944905499" DEPLOYMENT_ID = "AKfycbzq1XM53XafyCNYurnF9TAQHT3FHBDsBd36rCbCoWSmJD3SaZ1BHCPDYZYhclG9qD5Y" +DEFAULT_WEBAPP_URL = f"https://script.google.com/macros/s/{DEPLOYMENT_ID}/exec" +SECTOR_TREND_JSON = ROOT / "Temp" / "sector_trend_analysis_v1.json" +ETF_REP_JSON = ROOT / "Temp" / "etf_representative_monitor_v1.json" +SECTOR_INSIGHT_BUNDLE = DEPLOY_DIR / "gas_sector_insight_payload.gs" def get_now_kst() -> str: @@ -113,6 +119,7 @@ def build_deploy(dry_run: bool = False) -> bool: if not dry_run: clasp_cfg = { "scriptId": SCRIPT_ID, + "projectId": PROJECT_ID, "rootDir": str(DEPLOY_DIR.relative_to(ROOT)).replace("\\", "/"), } (ROOT / ".clasp.json").write_text( @@ -166,10 +173,96 @@ def clasp_deploy() -> bool: return False +def _sector_insight_payload() -> dict: + if not SECTOR_TREND_JSON.exists(): + raise FileNotFoundError(SECTOR_TREND_JSON) + if not ETF_REP_JSON.exists(): + raise FileNotFoundError(ETF_REP_JSON) + return { + "action": "sync_sector_insights", + "sector_trend_analysis": json.loads(SECTOR_TREND_JSON.read_text(encoding="utf-8")), + "etf_representative_monitor": json.loads(ETF_REP_JSON.read_text(encoding="utf-8")), + } + + +def write_sector_insight_bundle() -> bool: + try: + payload = _sector_insight_payload() + except Exception as exc: + print("[deploy_gas] cannot build sector insight payload: " + str(exc)) + return False + bundle = ( + "// Auto-generated by tools/deploy_gas.py\n" + "// Contains the latest sector insight payload for clasp run fallback.\n" + "const __SECTOR_INSIGHT_PAYLOAD__ = " + + json.dumps(payload, ensure_ascii=False, indent=2) + + ";\n" + "function syncSectorInsightSheetsFromBundle_() {\n" + " return syncSectorInsightSheets(__SECTOR_INSIGHT_PAYLOAD__);\n" + "}\n" + ) + SECTOR_INSIGHT_BUNDLE.write_text(bundle, encoding="utf-8") + print("[deploy_gas] write " + str(SECTOR_INSIGHT_BUNDLE)) + return True + + +def sync_sector_insights(webapp_url: str) -> bool: + if not webapp_url: + print("[deploy_gas] sync-sector-insights requires --webapp-url") + return False + try: + payload = _sector_insight_payload() + except Exception as exc: + print("[deploy_gas] missing sector insight data: " + str(exc)) + return False + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + webapp_url, + data=body, + headers={"Content-Type": "application/json; charset=utf-8"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + text = resp.read().decode("utf-8", errors="replace") + print("[deploy_gas] sync_sector_insights OK") + print(text) + return True + except Exception as exc: + print("[deploy_gas] sync_sector_insights FAILED: " + str(exc)) + return False + + +def sync_sector_insights_via_clasp_run() -> bool: + if not SECTOR_INSIGHT_BUNDLE.exists(): + print(f"[deploy_gas] missing {SECTOR_INSIGHT_BUNDLE.name}") + return False + print("[deploy_gas] clasp run syncSectorInsightSheetsFromBundle_ ...") + res = subprocess.run( + ["npx", "@google/clasp", "run", "syncSectorInsightSheetsFromBundle_", "--nondev"], + cwd=str(ROOT), + shell=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + print(res.stdout) + if res.stderr: + print("STDERR: " + res.stderr[:500]) + if res.returncode != 0: + print("[deploy_gas] clasp run syncSectorInsightSheetsFromBundle_ FAILED rc=" + str(res.returncode)) + return False + print("[deploy_gas] clasp run syncSectorInsightSheetsFromBundle_ OK") + return True + + def main() -> None: parser = argparse.ArgumentParser(description="GAS auto-deploy") parser.add_argument("--dry-run", action="store_true", help="List files without writing") parser.add_argument("--skip-push", action="store_true", help="Bundle only, skip clasp push") + parser.add_argument("--sync-sector-insights", action="store_true", help="POST sector insight JSON to a deployed GAS web app") + parser.add_argument("--webapp-url", default=os.environ.get("GAS_WEBAPP_URL", DEFAULT_WEBAPP_URL), help="Apps Script web app URL for sync POST") args = parser.parse_args() ok = build_deploy(dry_run=args.dry_run) @@ -177,8 +270,14 @@ def main() -> None: print("[deploy_gas] Some source files missing -- check warnings above") raise SystemExit(1) + if args.sync_sector_insights and not args.dry_run and not args.skip_push: + if not write_sector_insight_bundle(): + raise SystemExit(1) + if args.dry_run or args.skip_push: print("[deploy_gas] dry-run/skip-push -- push skipped") + if args.sync_sector_insights: + print("[deploy_gas] sync skipped because push/deploy was skipped") return if not clasp_push(): @@ -187,6 +286,12 @@ def main() -> None: if not clasp_deploy(): raise SystemExit(1) + if args.sync_sector_insights: + if not sync_sector_insights(args.webapp_url): + print("[deploy_gas] webapp sync failed; falling back to clasp run") + if not sync_sector_insights_via_clasp_run(): + raise SystemExit(1) + print("[deploy_gas] Done. To run_all: python tools/automate_routine.py") diff --git a/tools/operational_report_contract.py b/tools/operational_report_contract.py index fdc9fb1..9408758 100644 --- a/tools/operational_report_contract.py +++ b/tools/operational_report_contract.py @@ -15,6 +15,9 @@ REPORT_SECTION_ORDER = [ "single_conclusion", "immediate_execution_playbook", "market_context_learning_note", + "portfolio_performance_summary", + "sector_trend_analysis_v1", + "etf_representative_monitor_v1", # PHASE-2: quality + readiness scores "investment_quality_headline", "operational_truth_score", diff --git a/tools/render_operational_report.py b/tools/render_operational_report.py index 0d90de7..35f0992 100644 --- a/tools/render_operational_report.py +++ b/tools/render_operational_report.py @@ -7,17 +7,25 @@ from __future__ import annotations import argparse import json +import sys from datetime import datetime, timezone from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.quant_engine.etf_representative_monitor import build_etf_representative_monitor +from src.quant_engine.sector_trend_analysis import build_sector_trend_analysis SECTION_ORDER = [ "exec_safety_declaration", "final_judgment_table", "final_execution_decision", "concise_hts_input_sheet", "watch_breakout_gate", "single_conclusion", "immediate_execution_playbook", "market_context_learning_note", - "investment_quality_headline", "operational_truth_score", + "portfolio_performance_summary", + "portfolio_sector_exposure_summary", + "sector_trend_analysis_v1", "etf_representative_monitor_v1", "investment_quality_headline", "operational_truth_score", "execution_readiness_matrix", "pass_100_criteria", "today_decision_summary_card", "routing_serving_trace", "export_gate_diagnosis", "QEH_AUDIT_BLOCK", @@ -48,6 +56,10 @@ SECTION_TITLES = { "single_conclusion": "단일 결론", "immediate_execution_playbook": "즉시 실행 플레이북", "market_context_learning_note": "시장 컨텍스트 학습 노트", + "portfolio_performance_summary": "포트폴리오 성과 요약", + "portfolio_sector_exposure_summary": "포트폴리오 섹터 노출", + "sector_trend_analysis_v1": "섹터 동향 분석", + "etf_representative_monitor_v1": "ETF 대표 종목 모니터", "investment_quality_headline": "투자 품질 헤드라인", "operational_truth_score": "운영 진실성 점수", "execution_readiness_matrix": "실행 준비도 매트릭스", @@ -142,6 +154,34 @@ def _first_keys(items: list, n: int = 6) -> list[str]: return [] +def _num(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except Exception: + return default + + +def _sparkline(values: list[Any]) -> str: + points: list[float] = [] + for value in values: + try: + points.append(float(value)) + except Exception: + continue + if not points: + return "n/a" + lo = min(points) + hi = max(points) + bars = "▁▂▃▄▅▆▇█" + if hi == lo: + return bars[len(bars) // 2] * len(points) + out = [] + for value in points: + idx = int(round((value - lo) / (hi - lo) * (len(bars) - 1))) + out.append(bars[max(0, min(len(bars) - 1, idx))]) + return "".join(out) + + # ── PHASE-0 렌더러 ──────────────────────────────────────────────────────────── def _exec_safety_declaration(hctx: dict, se: list) -> str: @@ -263,6 +303,283 @@ def _market_context_learning_note(hctx: dict, se: list) -> str: return _kv(rows) +def _portfolio_performance_summary(data_root: dict, hctx: dict, se: list) -> str: + data = data_root.get("data", {}) if isinstance(data_root.get("data"), dict) else {} + daily = _sj(data.get("daily_history", [])) + monthly = _sj(data.get("monthly_history", [])) + account = _sj(data.get("account_snapshot", [])) + if not isinstance(daily, list): + daily = [] + if not isinstance(monthly, list): + monthly = [] + if not isinstance(account, list): + account = [] + + latest_daily = daily[-1] if daily else {} + latest_month = monthly[-1] if monthly else {} + latest_capture = "" + latest_holdings: list[dict[str, Any]] = [] + for row in account: + if not isinstance(row, dict): + continue + cap = str(row.get("captured_at", "") or "") + if cap and cap >= latest_capture: + latest_capture = cap + if latest_capture: + latest_holdings = [r for r in account if isinstance(r, dict) and str(r.get("captured_at", "") or "") == latest_capture] + + asset_series = [] + mdd_series = [] + monthly_return_series = [] + for row in daily[-10:]: + if isinstance(row, dict): + asset_series.append(row.get("Total_Asset_KRW", row.get("total_asset_krw", ""))) + mdd_series.append(row.get("MDD_Pct", row.get("mdd_pct", ""))) + for row in monthly[-10:]: + if isinstance(row, dict): + monthly_return_series.append(row.get("Actual_Return_Pct", row.get("actual_return_pct", ""))) + + rows = [ + ("최신 일간 자산", latest_daily.get("Total_Asset_KRW", latest_daily.get("total_asset_krw", ""))), + ("최신 일간 MDD(%)", latest_daily.get("MDD_Pct", latest_daily.get("mdd_pct", ""))), + ("최신 월간 자산", latest_month.get("Total_Asset", latest_month.get("total_asset", ""))), + ("최신 월간 실현 수익률(%)", latest_month.get("Actual_Return_Pct", latest_month.get("actual_return_pct", ""))), + ("최신 월간 MoM 수익률(%)", latest_month.get("MoM_Return_Pct", latest_month.get("mom_return_pct", ""))), + ("최신 월간 YTD 수익률(%)", latest_month.get("YTD_Return_Pct", latest_month.get("ytd_return_pct", ""))), + ("최신 스냅샷 시각", latest_capture or hctx.get("captured_at", "")), + ("최신 보유 수", len(latest_holdings)), + ] + md = "## 포트폴리오 성과 요약\n\n" + _kv(rows) + md += "\n\n**일간 자산 추이** \n" + _sparkline(asset_series) + md += "\n\n**일간 MDD 추이** \n" + _sparkline(mdd_series) + md += "\n\n**월간 수익률 추이** \n" + _sparkline(monthly_return_series) + if latest_holdings: + md += "\n\n**최신 보유 상위 스냅샷**\n\n" + md += _tbl(latest_holdings[:10], ["name", "ticker", "holding_quantity", "market_value", "return_pct"], max_rows=10) + else: + md += "\n\n_최신 보유 스냅샷 없음_" + return md + + +def _sector_trend_analysis_v1(data_root: dict, hctx: dict, se: list) -> str: + inner_data = data_root.get("data", {}) if isinstance(data_root.get("data"), dict) else {} + payload = {"data": inner_data, "data_root": data_root, "_harness_context": hctx} + result = build_sector_trend_analysis(payload) + if not isinstance(result, dict) or not result: + return _err(se, "sector_trend_analysis_v1", "sector trend analysis unavailable") + summary = result.get("summary") if isinstance(result.get("summary"), dict) else {} + concentration = result.get("concentration") if isinstance(result.get("concentration"), dict) else {} + rows = [ + ("최신 스냅샷", result.get("latest_snapshot_date", "")), + ("이전 스냅샷", result.get("previous_snapshot_date", "")), + ("섹터 수", result.get("sector_count", "")), + ("ETF 프록시 섹터 수", summary.get("etf_proxy_count", "")), + ("상승 섹터 수", summary.get("rising_count", "")), + ("하락 섹터 수", summary.get("fading_count", "")), + ("정체 섹터 수", summary.get("stable_count", "")), + ("탑아웃 섹터 수", summary.get("topping_out_count", "")), + ("양(+) breadth", summary.get("positive_breadth_count", "")), + ("스마트자금 유입", summary.get("smart_money_inflow_count", "")), + ("스마트자금 유출", summary.get("smart_money_outflow_count", "")), + ("수급 정렬", summary.get("flow_aligned_count", "")), + ("수급 이탈", summary.get("flow_diverging_count", "")), + ("프록시 저신뢰", summary.get("low_proxy_confidence_count", "")), + ("트렌드 포지션", summary.get("trend_posture", "")), + ("집중 섹터", concentration.get("top_sector", "")), + ("집중도 Top1%", concentration.get("top_sector_weight_pct", "")), + ("집중도 Top2%", concentration.get("top2_weight_pct", "")), + ] + md = _kv(rows) + md += "\n\n**ETF/수급 교차 진단**\n\n" + md += _kv([ + ("ETF 프록시 커버리지(%)", result.get("source", {}).get("proxy_coverage_pct", "")), + ("유동성 경고 섹터", ", ".join(summary.get("outflow_warning_sectors", [])[:3]) if isinstance(summary.get("outflow_warning_sectors"), list) else ""), + ("스마트머니 강세", ", ".join(summary.get("strong_smart_money_sectors", [])[:3]) if isinstance(summary.get("strong_smart_money_sectors"), list) else ""), + ]) + md += "\n\n**최근 시계열 추세**\n\n" + timeline = result.get("timeline") if isinstance(result.get("timeline"), list) else [] + if timeline: + recent_timeline = timeline[-6:] + md += _tbl(recent_timeline, [ + "snapshot_date", "sector_count", "avg_sector_score", "top_sector", + "top_sector_score", "positive_breadth_count", "liquidity_warn_count", + "net_smart_money_5d_krw", + ], max_rows=6) + score_line = _sparkline([r.get("avg_sector_score") for r in recent_timeline]) + money_line = _sparkline([r.get("net_smart_money_5d_krw") for r in recent_timeline]) + md += "\n\n| 추세 | 그래프 |\n| --- | --- |\n" + md += f"| 섹터 평균 점수 | {score_line} |\n" + md += f"| 5D 스마트머니 합계 | {money_line} |\n" + else: + md += "_시계열 데이터 없음_" + md += "\n\n**섹터 상위 유입/경고**\n\n" + md += _kv([ + ("상위 유입", ", ".join(summary.get("top_inflow_sectors", [])[:3]) or "없음"), + ("경고 섹터", ", ".join(summary.get("outflow_warning_sectors", [])[:3]) or "없음"), + ("강한 수급", ", ".join(summary.get("strong_smart_money_sectors", [])[:3]) or "없음"), + ]) + rows_data = result.get("rows") if isinstance(result.get("rows"), list) else [] + if rows_data: + md += "\n\n**섹터 상세 트렌드**\n\n" + _tbl(rows_data, [ + "sector", "proxy_ticker", "proxy_name", "proxy_type", "etf_execution_use", + "etf_liquidity_status", "etf_nav_risk", "proxy_confidence", "rank", + "rank_delta_w1", "rank_delta_w2", "sector_score", "score_delta", + "sector_ret5d", "sector_ret20d", "etf_return_5d", "etf_return_20d", + "sector_etf_ret_gap_5d", "sector_etf_ret_gap_20d", + "smart_money_5d_krw_raw", "smart_money_20d_krw_raw", "smart_money_direction", + "flow_breadth_5d_raw", "liquidity_direction", "flow_alignment_state", + "alert_level", "decision_use", "momentum_state", "concentration_weight_pct", + ], max_rows=20) + history_rows = data_root.get("data", {}).get("sector_flow_history", []) + if isinstance(history_rows, list) and history_rows: + sector_histories: dict[str, list[dict[str, Any]]] = {} + for item in history_rows: + if not isinstance(item, dict): + continue + sector = str(item.get("Sector") or "").strip() + if not sector: + continue + sector_histories.setdefault(sector, []).append(item) + tracked = [r.get("sector") for r in rows_data[:6] if r.get("sector")] + spark_rows = [] + for sector in tracked: + series = sorted(sector_histories.get(sector, []), key=lambda r: str(r.get("Snapshot_Date") or "")) + latest_row = next((r for r in rows_data if r.get("sector") == sector), {}) + spark_rows.append({ + "sector": sector, + "score_trend": _sparkline([r.get("Sector_Score") for r in series[-6:]]), + "smart_money_trend": _sparkline([r.get("SmartMoney_5D_KRW") for r in series[-6:]]), + "latest_score": series[-1].get("Sector_Score", "") if series else "", + "latest_smart_money_5d": series[-1].get("SmartMoney_5D_KRW", "") if series else "", + "sector_ret20d": latest_row.get("sector_ret20d", ""), + "smart_money_direction": latest_row.get("smart_money_direction", ""), + "flow_alignment_state": latest_row.get("flow_alignment_state", ""), + }) + if spark_rows: + md += "\n\n**섹터별 시계열 그래프**\n\n" + md += _tbl(spark_rows, [ + "sector", "score_trend", "smart_money_trend", "latest_score", "latest_smart_money_5d", + "sector_ret20d", "smart_money_direction", "flow_alignment_state", + ], max_rows=6) + md += "\n\n**포트폴리오 / 자금 맥락**\n\n" + beta_gate = _sj(hctx.get("portfolio_beta_gate_json", {})) + corr_gate = _sj(hctx.get("portfolio_correlation_gate_json", {})) + md += _kv([ + ("목표 자산", hctx.get("goal_asset_krw", "")), + ("현재 자산", hctx.get("goal_current_asset_krw", hctx.get("total_asset_krw", ""))), + ("목표 달성율(%)", hctx.get("goal_achievement_pct", "")), + ("목표 상태", hctx.get("goal_status", "")), + ("남은 목표액", hctx.get("goal_remaining_krw", "")), + ("ETA", hctx.get("goal_eta_label", "")), + ("ETA(개월)", hctx.get("goal_eta_months", "")), + ("수익 보전 단계", hctx.get("profit_lock_stage", hctx.get("profit_preservation_lock", ""))), + ("포트폴리오 헬스", (hctx.get("portfolio_health_json", {}) or {}).get("label", hctx.get("portfolio_health_label", "")) if isinstance(hctx.get("portfolio_health_json", {}), dict) else hctx.get("portfolio_health_label", "")), + ("포트폴리오 점수", (hctx.get("portfolio_health_json", {}) or {}).get("score", hctx.get("portfolio_health_score", "")) if isinstance(hctx.get("portfolio_health_json", {}), dict) else hctx.get("portfolio_health_score", "")), + ("알파 신뢰도", hctx.get("portfolio_alpha_confidence", "")), + ("드로우다운 상태", hctx.get("drawdown_guard_state", hctx.get("portfolio_drawdown_gate", ""))), + ("베타 게이트", beta_gate.get("gate_status", beta_gate.get("gate", "")) if isinstance(beta_gate, dict) else ""), + ("포트폴리오 베타", beta_gate.get("portfolio_beta", "") if isinstance(beta_gate, dict) else ""), + ("상관 게이트", corr_gate.get("correlation_gate_status", "") if isinstance(corr_gate, dict) else ""), + ("상관 유효베타", corr_gate.get("effective_portfolio_beta", "") if isinstance(corr_gate, dict) else ""), + ]) + md += "\n\n**개선 제안**\n\n" + md += ( + "- 섹터 수급은 ETF 프록시와 직접 스마트머니를 분리해서 보여주고, 둘이 어긋날 때 경고를 강화해야 합니다.\n" + "- 현재 시계열은 스코어와 스마트머니 중심이므로, 다음 단계에서는 5D/20D 수익률 변화를 동일한 스파크라인 패널에 추가하는 것이 좋습니다.\n" + "- 포트폴리오 자금 패널은 목표 달성율, 드로우다운, 베타, 알파 신뢰도를 함께 묶어 보여줘야 실제 투자 판단과 연결됩니다.\n" + ) + return md + + +def _etf_representative_monitor_v1(data_root: dict, hctx: dict, se: list) -> str: + inner_data = data_root.get("data", {}) if isinstance(data_root.get("data"), dict) else {} + payload = {"data": inner_data, "data_root": data_root, "_harness_context": hctx} + result = build_etf_representative_monitor(payload) + if not isinstance(result, dict) or not result: + return _err(se, "etf_representative_monitor_v1", "etf representative monitor unavailable") + summary = result.get("summary") if isinstance(result.get("summary"), dict) else {} + rows_data = result.get("rows") if isinstance(result.get("rows"), list) else [] + md = _kv([ + ("ETF 섹터 수", result.get("etf_sector_count", "")), + ("추적 대표 종목 수", result.get("tracked_count", "")), + ("BUY_REVIEW", summary.get("buy_review_count", "")), + ("TRACK", summary.get("track_count", "")), + ("WATCH", summary.get("watch_count", "")), + ("CAUTION", summary.get("caution_count", "")), + ("정렬(ETF vs 대표종목)", summary.get("aligned_count", "")), + ("구성비중 기반", summary.get("weighted_basis_count", "")), + ("리퀴디티 대체", summary.get("fallback_basis_count", "")), + ("완전 바스켓", summary.get("complete_basket_count", "")), + ("부분 바스켓", summary.get("partial_basket_count", "")), + ("바스켓 미싱", summary.get("basket_missing_total", "")), + ]) + md += "\n\n**ETF 대표 종목 추출 원칙**\n\n" + md += ( + "- 대표 종목은 우선 ETF 구성비중이 가장 큰 종목을 선택하고, 그 종목이 현재 유동성/호가/추세 조건을 충족하는지로 계속 모니터링합니다.\n" + "- 구성비중 데이터가 비어 있거나 비정상일 때만 같은 섹터의 유동성 우선 후보로 대체합니다.\n" + "- BUY_REVIEW는 ETF 수급이 대표 종목의 추세와 같이 붙을 때만 후보로 승격합니다.\n" + ) + if rows_data: + display_rows = [] + for row in rows_data: + reps = row.get("representatives", []) + rep_names = [] + rep_states = [] + rep_weights = [] + if isinstance(reps, list): + for rep in reps[:3]: + if isinstance(rep, dict): + rep_names.append(f"{rep.get('name', '')}({rep.get('ticker', '')})") + rep_states.append(str(rep.get("monitor_state", ""))) + rep_weights.append(str(rep.get("weight", ""))) + display_rows.append({ + "sector": row.get("sector", ""), + "etf_proxy_ticker": row.get("etf_proxy_ticker", ""), + "etf_proxy_name": row.get("etf_proxy_name", ""), + "representative_basket": " / ".join(rep_names), + "representative_count": row.get("representative_count", ""), + "basket_weights": ", ".join(rep_weights), + "basket_states": ", ".join(rep_states), + "representative_basis": row.get("representative_basis", ""), + "representative_basis_detail": row.get("representative_basis_detail", ""), + "basket_quality_state": row.get("basket_quality_state", ""), + "basket_coverage_pct": row.get("basket_coverage_pct", ""), + "selection_source": ", ".join(str(rep.get("selection_source", "")) for rep in reps[:3] if isinstance(rep, dict)), + "selection_score": ", ".join(str(rep.get("selection_score", "")) for rep in reps[:3] if isinstance(rep, dict)), + "basket_state": row.get("monitor_state", ""), + "basket_buy_review_count": row.get("basket_buy_review_count", ""), + "basket_caution_count": row.get("basket_caution_count", ""), + "basket_aligned_count": row.get("basket_aligned_count", ""), + "monitor_reason": row.get("monitor_reason", ""), + }) + md += "\n\n**대표 종목 모니터 테이블**\n\n" + md += _tbl(display_rows, [ + "sector", "etf_proxy_ticker", "etf_proxy_name", "representative_basket", + "representative_count", "basket_weights", "basket_states", "representative_basis", + "representative_basis_detail", "basket_quality_state", "basket_coverage_pct", + "selection_source", "selection_score", "basket_state", "basket_buy_review_count", + "basket_aligned_count", "monitor_reason", + ], max_rows=20) + spark_rows = [] + for row in rows_data[:5]: + reps = row.get("representatives", []) + rep_states = ", ".join(str(rep.get("monitor_state", "")) for rep in reps if isinstance(rep, dict)) + spark_rows.append({ + "sector": row.get("sector", ""), + "basket_states": rep_states, + "basket_bars": _sparkline([ + _num(row.get("basket_buy_review_count"), 0.0), + _num(row.get("basket_aligned_count"), 0.0), + _num(row.get("basket_aligned_count"), 0.0) - _num(row.get("basket_caution_count"), 0.0), + ]), + "primary_ret20d": row.get("representative_ret20d", ""), + "basket_state": row.get("monitor_state", ""), + }) + md += "\n\n**대표 종목 추세 미니차트**\n\n" + md += _tbl(spark_rows, ["sector", "basket_states", "basket_bars", "primary_ret20d", "basket_state"], max_rows=5) + return md + + # ── PHASE-2 렌더러 ──────────────────────────────────────────────────────────── def _investment_quality_headline(hctx: dict, se: list) -> str: @@ -834,6 +1151,8 @@ def main() -> int: "single_conclusion": lambda: _single_conclusion(hctx, se), "immediate_execution_playbook": lambda: _immediate_execution_playbook(hctx, se), "market_context_learning_note": lambda: _market_context_learning_note(hctx, se), + "portfolio_performance_summary": lambda: _portfolio_performance_summary(data_root, hctx, se), + "sector_trend_analysis_v1": lambda: _sector_trend_analysis_v1(data_root, hctx, se), "investment_quality_headline": lambda: _investment_quality_headline(hctx, se), "operational_truth_score": lambda: _operational_truth_score(hctx, se), "execution_readiness_matrix": lambda: _execution_readiness_matrix(hctx, packet, se), @@ -842,6 +1161,7 @@ def main() -> int: "routing_serving_trace": lambda: _routing_serving_trace(hctx, se), "export_gate_diagnosis": lambda: _export_gate_diagnosis(hctx, se), "QEH_AUDIT_BLOCK": lambda: _qeh_audit_block(hctx, se), + "etf_representative_monitor_v1": lambda: _etf_representative_monitor_v1(data_root, hctx, se), "fundamental_quality_gate_v1": lambda: _fundamental_quality_gate_v1(hctx, se), "horizon_allocation_lock_v1": lambda: _horizon_allocation_lock_v1(hctx, se), "smart_money_liquidity_gate_v1": lambda: _smart_money_liquidity_gate_v1(hctx, se), diff --git a/tools/update_workbook_sector_insights.py b/tools/update_workbook_sector_insights.py new file mode 100644 index 0000000..1536ce0 --- /dev/null +++ b/tools/update_workbook_sector_insights.py @@ -0,0 +1,658 @@ +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + +from openpyxl import load_workbook +from openpyxl.chart import BarChart, LineChart, Reference +from openpyxl.styles import Font, PatternFill, Alignment +from openpyxl.utils import get_column_letter + + +ROOT = Path(__file__).resolve().parent.parent +INPUT_XLSX = ROOT / "GatherTradingData.xlsx" +OUTPUT_DIR = ROOT / "outputs" / "sector_insights_enhanced" +OUTPUT_XLSX = OUTPUT_DIR / "GatherTradingData_sector_insights.xlsx" +SECTOR_JSON = ROOT / "Temp" / "sector_trend_analysis_v1.json" +ETF_JSON = ROOT / "Temp" / "etf_representative_monitor_v1.json" + + +HEADER_FILL = PatternFill("solid", fgColor="1F4E78") +SUBHEADER_FILL = PatternFill("solid", fgColor="D9EAF7") +KPI_FILL = PatternFill("solid", fgColor="F3F7FB") +KPI_LABEL_FILL = PatternFill("solid", fgColor="E2F0D9") +KPI_VALUE_FILL = PatternFill("solid", fgColor="FFF2CC") +WHITE_FONT = Font(color="FFFFFF", bold=True) +BOLD_FONT = Font(bold=True) +TITLE_FONT = Font(size=14, bold=True) +NOTE_FONT = Font(italic=True, color="666666") + + +def load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def remove_if_exists(wb, name: str) -> None: + if name in wb.sheetnames: + del wb[name] + + +def style_title(ws, title: str, subtitle: str | None = None, end_col: int = 8) -> None: + ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=end_col) + ws["A1"] = title + ws["A1"].font = TITLE_FONT + ws["A1"].fill = HEADER_FILL + ws["A1"].font = WHITE_FONT + ws["A1"].alignment = Alignment(horizontal="left") + if subtitle: + ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=end_col) + ws["A2"] = subtitle + ws["A2"].font = NOTE_FONT + + +def write_table(ws, start_row: int, start_col: int, headers: list[str], rows: list[list], header_fill=HEADER_FILL) -> int: + for j, header in enumerate(headers, start=start_col): + cell = ws.cell(start_row, j) + cell.value = header + cell.font = WHITE_FONT + cell.fill = header_fill + cell.alignment = Alignment(horizontal="center", vertical="center") + for i, row in enumerate(rows, start=start_row + 1): + for j, value in enumerate(row, start=start_col): + cell = ws.cell(i, j) + cell.value = value + cell.alignment = Alignment(vertical="top") + return start_row + len(rows) + + +def add_kpi_block(ws, start_row: int, items: list[tuple[str, object]]) -> int: + ws.cell(start_row, 1).value = "KPI" + ws.cell(start_row, 1).fill = SUBHEADER_FILL + ws.cell(start_row, 1).font = BOLD_FONT + row = start_row + 1 + for label, value in items: + ws.cell(row, 1).value = label + ws.cell(row, 1).fill = KPI_LABEL_FILL + ws.cell(row, 1).font = BOLD_FONT + ws.cell(row, 2).value = value + ws.cell(row, 2).fill = KPI_VALUE_FILL + row += 1 + return row + + +def set_col_widths(ws, widths: dict[str, int]) -> None: + for col, width in widths.items(): + ws.column_dimensions[col].width = width + + +def style_sheet(ws) -> None: + ws.freeze_panes = "A3" + ws.sheet_view.showGridLines = False + + +def extract_sheet_rows(wb, sheet_name: str) -> tuple[list[str], list[list]]: + ws = wb[sheet_name] + headers = [ws.cell(1, c).value for c in range(1, ws.max_column + 1)] + rows: list[list] = [] + for r in range(2, ws.max_row + 1): + row = [ws.cell(r, c).value for c in range(1, ws.max_column + 1)] + if any(v is not None and v != "" for v in row): + rows.append(row) + return headers, rows + + +def build_portfolio_summary(wb) -> None: + daily_headers, daily_rows = extract_sheet_rows(wb, "daily_history") + monthly_headers, monthly_rows = extract_sheet_rows(wb, "monthly_history") + account_headers, account_rows = extract_sheet_rows(wb, "account_snapshot") + + ws = wb.create_sheet("portfolio_performance_summary") + style_sheet(ws) + style_title( + ws, + "포트폴리오 성과 요약", + "내 자금의 일간/월간 추이와 최신 보유 비중을 함께 보는 요약 시트", + end_col=10, + ) + + latest_daily = daily_rows[-1] if daily_rows else [] + latest_month = monthly_rows[-1] if monthly_rows else [] + latest_total_asset = latest_daily[1] if len(latest_daily) > 1 else None + latest_peak_asset = latest_daily[2] if len(latest_daily) > 2 else None + latest_mdd = latest_daily[3] if len(latest_daily) > 3 else None + latest_month_total = latest_month[1] if len(latest_month) > 1 else None + latest_month_return = latest_month[8] if len(latest_month) > 8 else None + latest_ytd_return = latest_month[10] if len(latest_month) > 10 else None + + latest_capture = None + if account_rows: + latest_capture = account_rows[0][0] + for row in account_rows: + if row and row[0] and row[0] > latest_capture: + latest_capture = row[0] + + latest_holdings = [r for r in account_rows if r and r[0] == latest_capture] + holdings_sorted = sorted( + latest_holdings, + key=lambda r: (r[10] if len(r) > 10 and isinstance(r[10], (int, float)) else 0), + reverse=True, + ) + total_mv = sum(r[10] for r in holdings_sorted if len(r) > 10 and isinstance(r[10], (int, float))) + total_cost = sum(r[8] for r in holdings_sorted if len(r) > 8 and isinstance(r[8], (int, float))) + total_pl = sum(r[11] for r in holdings_sorted if len(r) > 11 and isinstance(r[11], (int, float))) + + items = [ + ("latest_daily_asset", latest_total_asset or ""), + ("latest_peak_asset", latest_peak_asset or ""), + ("latest_daily_mdd_pct", latest_mdd or ""), + ("latest_month_total_asset", latest_month_total or ""), + ("latest_month_return_pct", latest_month_return or ""), + ("latest_ytd_return_pct", latest_ytd_return or ""), + ("latest_capture", latest_capture or ""), + ("latest_holdings_count", len(latest_holdings)), + ("latest_holdings_market_value", total_mv), + ("latest_holdings_profit_loss", total_pl), + ] + add_kpi_block(ws, 4, items) + + ws["D4"] = "Portfolio view" + ws["D4"].fill = SUBHEADER_FILL + ws["D4"].font = BOLD_FONT + ws["D5"] = "일간/월간 자산 추이는 실제 계좌 스냅샷 기반입니다." + ws["D6"] = "보유 비중 차트는 최신 스냅샷의 시장가치 기준입니다." + ws["D7"] = "수익률이 음수여도 숨기지 않고 그대로 보여줍니다." + + ws["G4"] = "Top holdings" + ws["G4"].fill = SUBHEADER_FILL + ws["G4"].font = BOLD_FONT + for i, row in enumerate(holdings_sorted[:10], start=5): + name = row[4] if len(row) > 4 else "" + mv = row[10] if len(row) > 10 else "" + ws.cell(i, 7).value = f"{name} ({mv})" + + # Daily history chart helper + daily_sheet = wb["daily_history"] + daily_max = daily_sheet.max_row + daily_chart = LineChart() + daily_chart.title = "Daily Asset / MDD" + daily_chart.y_axis.title = "KRW / %" + daily_chart.x_axis.title = "Date" + daily_chart.height = 7 + daily_chart.width = 13 + daily_data = Reference(daily_sheet, min_col=2, max_col=4, min_row=1, max_row=daily_max) + daily_cats = Reference(daily_sheet, min_col=1, min_row=2, max_row=daily_max) + daily_chart.add_data(daily_data, titles_from_data=True, from_rows=False) + daily_chart.set_categories(daily_cats) + daily_chart.style = 2 + ws.add_chart(daily_chart, "A13") + + # Monthly history chart + monthly_sheet = wb["monthly_history"] + monthly_max = monthly_sheet.max_row + monthly_chart = LineChart() + monthly_chart.title = "Monthly Return Trend" + monthly_chart.y_axis.title = "%" + monthly_chart.x_axis.title = "Month" + monthly_chart.height = 7 + monthly_chart.width = 13 + monthly_data = Reference(monthly_sheet, min_col=8, max_col=11, min_row=1, max_row=monthly_max) + monthly_cats = Reference(monthly_sheet, min_col=1, min_row=2, max_row=monthly_max) + monthly_chart.add_data(monthly_data, titles_from_data=True, from_rows=False) + monthly_chart.set_categories(monthly_cats) + monthly_chart.style = 3 + ws.add_chart(monthly_chart, "G13") + + # Top holdings bar chart + hold_chart = BarChart() + hold_chart.type = "bar" + hold_chart.title = "Top Holdings by Market Value" + hold_chart.y_axis.title = "Holding" + hold_chart.x_axis.title = "KRW" + hold_chart.height = 8 + hold_chart.width = 13 + ws_hold = wb.create_sheet("_portfolio_holdings_helper") + helper_headers = ["name", "market_value"] + helper_rows = [[r[4], r[10]] for r in holdings_sorted[:10] if len(r) > 10] + write_table(ws_hold, 1, 1, helper_headers, helper_rows) + hold_data = Reference(ws_hold, min_col=2, min_row=1, max_row=1 + len(helper_rows)) + hold_cats = Reference(ws_hold, min_col=1, min_row=2, max_row=1 + len(helper_rows)) + hold_chart.add_data(hold_data, titles_from_data=True) + hold_chart.set_categories(hold_cats) + hold_chart.legend = None + ws.add_chart(hold_chart, "A30") + + set_col_widths(ws, {"A": 22, "B": 18, "C": 18, "D": 24, "E": 18, "F": 18, "G": 26, "H": 26, "I": 18, "J": 18}) + + +def build_portfolio_sector_exposure(wb) -> None: + daily_headers, daily_rows = extract_sheet_rows(wb, "daily_history") + account_headers, account_rows = extract_sheet_rows(wb, "account_snapshot") + universe_headers, universe_rows = extract_sheet_rows(wb, "universe") + + sector_map: dict[str, str] = {} + for row in universe_rows: + if len(row) >= 3 and row[0] and row[2]: + ticker = str(row[0]).zfill(6) + sector_map[ticker] = str(row[2]) + + latest_capture = "" + for row in account_rows: + cap = str(row[0] or "") + if cap and cap >= latest_capture: + latest_capture = cap + latest_rows = [r for r in account_rows if str(r[0] or "") == latest_capture] + + exposure: dict[str, dict[str, float]] = {} + for row in latest_rows: + ticker = str(row[3] or "").zfill(6) + sector = sector_map.get(ticker, "미분류") + mv = float(row[10] or 0) + pl = float(row[11] or 0) + cost = float(row[8] or 0) + bucket = exposure.setdefault(sector, {"market_value": 0.0, "profit_loss": 0.0, "cost": 0.0, "count": 0.0}) + bucket["market_value"] += mv + bucket["profit_loss"] += pl + bucket["cost"] += cost + bucket["count"] += 1 + + total_mv = sum(v["market_value"] for v in exposure.values()) or 1.0 + rows = [] + for sector, vals in sorted(exposure.items(), key=lambda kv: kv[1]["market_value"], reverse=True): + pct = vals["market_value"] / total_mv * 100.0 + ret_pct = (vals["profit_loss"] / vals["cost"] * 100.0) if vals["cost"] else 0.0 + rows.append([sector, vals["count"], vals["market_value"], pct, vals["profit_loss"], ret_pct]) + + ws = wb.create_sheet("portfolio_sector_exposure") + style_sheet(ws) + style_title( + ws, + "포트폴리오 섹터 노출", + "최신 계좌 스냅샷 기준으로 섹터별 보유 시장가치와 손익률을 집계", + end_col=8, + ) + items = [ + ("latest_capture", latest_capture), + ("sector_count", len(rows)), + ("top_sector", rows[0][0] if rows else ""), + ("top_sector_weight_pct", rows[0][3] if rows else 0), + ("top3_sector_weight_pct", sum(r[3] for r in rows[:3]) if rows else 0), + ("total_market_value", total_mv), + ] + add_kpi_block(ws, 4, items) + headers = ["sector", "holding_count", "market_value", "weight_pct", "profit_loss", "return_pct"] + write_table(ws, 4, 4, headers, rows) + ws["D4"] = "Sector exposure" + ws["D4"].fill = SUBHEADER_FILL + ws["D4"].font = BOLD_FONT + ws.freeze_panes = "A5" + ws.column_dimensions["A"].width = 24 + ws.column_dimensions["B"].width = 14 + ws.column_dimensions["C"].width = 18 + ws.column_dimensions["D"].width = 24 + ws.column_dimensions["E"].width = 14 + ws.column_dimensions["F"].width = 14 + ws.column_dimensions["G"].width = 16 + ws.column_dimensions["H"].width = 14 + + chart = BarChart() + chart.type = "bar" + chart.style = 10 + chart.title = "Sector Exposure by Market Value" + chart.y_axis.title = "Sector" + chart.x_axis.title = "KRW" + chart.height = 8 + chart.width = 14 + data_ref = Reference(ws, min_col=6, min_row=4, max_row=4 + len(rows)) + cats = Reference(ws, min_col=4, min_row=5, max_row=4 + len(rows)) + chart.add_data(data_ref, titles_from_data=True) + chart.set_categories(cats) + chart.legend = None + ws.add_chart(chart, "J4") + + +def build_sector_summary(wb, data: dict) -> None: + ws = wb.create_sheet("sector_trend_summary") + style_sheet(ws) + style_title( + ws, + "섹터 동향 분석 요약", + "ETF 프록시, 스마트머니 유입, 수익률, 유동성 경고를 한 장에 요약한 시트", + end_col=8, + ) + summary = data.get("summary") or {} + concentration = data.get("concentration") or {} + items = [ + ("formula_id", data.get("formula_id", "")), + ("gate", data.get("gate", "")), + ("latest_snapshot_date", data.get("latest_snapshot_date", "")), + ("previous_snapshot_date", data.get("previous_snapshot_date", "")), + ("sector_count", data.get("sector_count", 0)), + ("trend_posture", summary.get("trend_posture", "")), + ("rising_count", summary.get("rising_count", 0)), + ("fading_count", summary.get("fading_count", 0)), + ("stable_count", summary.get("stable_count", 0)), + ("etf_proxy_count", summary.get("etf_proxy_count", 0)), + ("smart_money_inflow_count", summary.get("smart_money_inflow_count", 0)), + ("smart_money_outflow_count", summary.get("smart_money_outflow_count", 0)), + ("flow_aligned_count", summary.get("flow_aligned_count", 0)), + ("flow_diverging_count", summary.get("flow_diverging_count", 0)), + ] + add_kpi_block(ws, 4, items) + ws["D4"] = "Concentration" + ws["D4"].fill = SUBHEADER_FILL + ws["D4"].font = BOLD_FONT + for idx, (label, value) in enumerate( + [ + ("top_sector", concentration.get("top_sector", "")), + ("top_sector_weight_pct", concentration.get("top_sector_weight_pct", 0)), + ("top2_weight_pct", concentration.get("top2_weight_pct", 0)), + ("concentration_gate", concentration.get("concentration_gate", "")), + ], + start=5, + ): + ws.cell(idx, 4).value = label + ws.cell(idx, 4).fill = KPI_LABEL_FILL + ws.cell(idx, 4).font = BOLD_FONT + ws.cell(idx, 5).value = value + ws.cell(idx, 5).fill = KPI_VALUE_FILL + + top_inflow = summary.get("top_inflow_sectors") or [] + outflow = summary.get("outflow_warning_sectors") or [] + ws["G4"] = "Top Inflow" + ws["G4"].fill = SUBHEADER_FILL + ws["G4"].font = BOLD_FONT + for i, item in enumerate(top_inflow, start=5): + ws.cell(i, 7).value = item + ws["H4"] = "Outflow Warning" + ws["H4"].fill = SUBHEADER_FILL + ws["H4"].font = BOLD_FONT + for i, item in enumerate(outflow, start=5): + ws.cell(i, 8).value = item + + ws["A20"] = "Notes" + ws["A20"].fill = SUBHEADER_FILL + ws["A20"].font = BOLD_FONT + ws["A21"] = "섹터별 ETF 프록시와 스마트머니 방향이 다르면 매수 근거를 보수적으로 해석해야 합니다." + ws["A21"].alignment = Alignment(wrap_text=True) + ws["A22"] = "데이터 결측은 하네스 업데이트가 필요합니다." + ws["A22"].alignment = Alignment(wrap_text=True) + + chart = LineChart() + chart.title = "Average Sector Score / Breadth Trend" + chart.y_axis.title = "Score / Count" + chart.x_axis.title = "Snapshot" + chart.height = 7.5 + chart.width = 13 + timeline_sheet = wb["sector_trend_timeline"] + max_row = timeline_sheet.max_row + data_ref = Reference(timeline_sheet, min_col=13, min_row=4, max_row=max_row, max_col=17) + cats = Reference(timeline_sheet, min_col=12, min_row=5, max_row=max_row) + chart.add_data(data_ref, titles_from_data=True, from_rows=False) + chart.set_categories(cats) + chart.style = 2 + ws.add_chart(chart, "G12") + + set_col_widths(ws, {"A": 28, "B": 18, "C": 16, "D": 24, "E": 16, "F": 18, "G": 24, "H": 24}) + + +def build_sector_analysis(wb, data: dict) -> None: + ws = wb.create_sheet("sector_trend_analysis") + style_sheet(ws) + style_title( + ws, + "섹터 동향 분석", + "섹터별 ETF 프록시, 스마트머니 유입, 수익률, 유동성 방향을 함께 보는 상세 시트", + end_col=18, + ) + headers = [ + "sector", "proxy_ticker", "proxy_name", "proxy_type", "etf_code", + "etf_execution_use", "etf_liquidity_score", "etf_liquidity_status", "etf_nav_risk", + "proxy_confidence", "rank", "rank_delta_w1", "rank_delta_w2", "sector_score", + "score_delta", "sector_ret5d", "sector_ret20d", "etf_return_5d", "etf_return_20d", + "sector_etf_ret_gap_5d", "sector_etf_ret_gap_20d", "smart_money_5d_krw_raw", + "smart_money_20d_krw_raw", "smart_money_direction", "liquidity_direction", + "flow_alignment_state", "momentum_state", "concentration_weight_pct" + ] + rows = [] + for row in data.get("rows") or []: + rows.append([row.get(h, "") for h in headers]) + write_table(ws, 4, 1, headers, rows) + ws.auto_filter.ref = f"A4:{get_column_letter(len(headers))}{4 + len(rows)}" + ws.freeze_panes = "A5" + for col in ["F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB"]: + ws.column_dimensions[col].width = 16 + ws.column_dimensions["C"].width = 18 + ws.column_dimensions["A"].width = 16 + ws.column_dimensions["B"].width = 12 + ws.column_dimensions["D"].width = 12 + ws.column_dimensions["E"].width = 12 + ws.column_dimensions["J"].width = 14 + ws.column_dimensions["P"].width = 12 + ws.column_dimensions["Q"].width = 12 + ws.column_dimensions["AA"].width = 18 + ws.column_dimensions["AB"].width = 18 + + chart = BarChart() + chart.type = "bar" + chart.style = 10 + chart.title = "Sector 20D Return by Sector" + chart.y_axis.title = "Sector" + chart.x_axis.title = "20D Return" + chart.height = 8 + chart.width = 14 + data_ref = Reference(ws, min_col=17, min_row=4, max_row=4 + len(rows)) + cats = Reference(ws, min_col=1, min_row=5, max_row=4 + len(rows)) + chart.add_data(data_ref, titles_from_data=True) + chart.set_categories(cats) + chart.legend = None + ws.add_chart(chart, "AD4") + + +def build_sector_timeline(wb, data: dict) -> None: + ws = wb.create_sheet("sector_trend_timeline") + style_sheet(ws) + style_title(ws, "섹터 시계열", "최근 스냅샷 기준 경향성 추세", end_col=10) + headers = [ + "snapshot_date", "sector_count", "avg_sector_score", "top_sector", "top_sector_score", + "positive_breadth_count", "liquidity_warn_count", "net_smart_money_5d_krw", + "top_sector_rank", "top_sector_smart_money_5d_krw" + ] + rows = [] + for row in data.get("timeline") or []: + parsed_date = row.get("snapshot_date", "") + if isinstance(parsed_date, str) and parsed_date: + try: + parsed_date = datetime.fromisoformat(parsed_date.replace("Z", "+00:00")).date() + except Exception: + pass + rows.append([ + parsed_date, + row.get("sector_count", ""), + row.get("avg_sector_score", ""), + row.get("top_sector", ""), + row.get("top_sector_score", ""), + row.get("positive_breadth_count", ""), + row.get("liquidity_warn_count", ""), + row.get("net_smart_money_5d_krw", ""), + row.get("top_sector_rank", ""), + row.get("top_sector_smart_money_5d_krw", ""), + ]) + write_table(ws, 4, 1, headers, rows) + helper_headers = [ + "snapshot_date", "avg_sector_score", "top_sector_score", + "positive_breadth_count", "liquidity_warn_count", "net_smart_money_5d_krw" + ] + helper_rows = [] + for row in rows: + helper_rows.append([row[0], row[2], row[4], row[5], row[6], row[7]]) + write_table(ws, 4, 12, helper_headers, helper_rows) + ws.freeze_panes = "A5" + ws.column_dimensions["A"].width = 14 + ws.column_dimensions["B"].width = 12 + ws.column_dimensions["C"].width = 14 + ws.column_dimensions["D"].width = 16 + ws.column_dimensions["E"].width = 14 + ws.column_dimensions["F"].width = 16 + ws.column_dimensions["G"].width = 16 + ws.column_dimensions["H"].width = 18 + ws.column_dimensions["I"].width = 14 + ws.column_dimensions["J"].width = 18 + + chart = LineChart() + chart.title = "Trend Score / Breadth / Liquidity" + chart.y_axis.title = "Count / Score" + chart.x_axis.title = "Snapshot" + chart.height = 8 + chart.width = 15 + data_ref = Reference(ws, min_col=13, max_col=17, min_row=4, max_row=4 + len(helper_rows)) + cats = Reference(ws, min_col=12, min_row=5, max_row=4 + len(helper_rows)) + chart.add_data(data_ref, titles_from_data=True, from_rows=False) + chart.set_categories(cats) + chart.style = 3 + ws.add_chart(chart, "L4") + + +def build_etf_summary(wb, data: dict) -> None: + ws = wb.create_sheet("etf_representative_summary") + style_sheet(ws) + style_title( + ws, + "ETF 대표 종목 요약", + "ETF 구성비중 우선, 부족분은 유동성 우선 후보로 보강한 3종목 바스켓 요약", + end_col=8, + ) + summary = data.get("summary") or {} + items = [ + ("formula_id", data.get("formula_id", "")), + ("gate", data.get("gate", "")), + ("etf_sector_count", data.get("etf_sector_count", 0)), + ("tracked_count", data.get("tracked_count", 0)), + ("complete_basket_count", summary.get("complete_basket_count", 0)), + ("partial_basket_count", summary.get("partial_basket_count", 0)), + ("basket_missing_total", summary.get("basket_missing_total", 0)), + ("weighted_basis_count", summary.get("weighted_basis_count", 0)), + ("fallback_basis_count", summary.get("fallback_basis_count", 0)), + ("selected_sector_count", summary.get("selected_sector_count", 0)), + ] + add_kpi_block(ws, 4, items) + ws["D4"] = "Representative principle" + ws["D4"].fill = SUBHEADER_FILL + ws["D4"].font = BOLD_FONT + ws["D5"] = "1) ETF constituent weight first" + ws["D6"] = "2) Missing slots filled with same-sector live candidates" + ws["D7"] = "3) Missing data stays explicit as DATA_MISSING" + ws["D8"] = "4) Minimum 3 names per sector basket" + ws["G4"] = "Top reps" + ws["G4"].fill = SUBHEADER_FILL + ws["G4"].font = BOLD_FONT + for i, item in enumerate(summary.get("top_rep_names") or [], start=5): + ws.cell(i, 7).value = item + set_col_widths(ws, {"A": 28, "B": 18, "C": 16, "D": 30, "E": 18, "F": 18, "G": 24, "H": 24}) + + +def build_etf_monitor(wb, data: dict) -> None: + ws = wb.create_sheet("etf_representative_monitor") + style_sheet(ws) + style_title( + ws, + "ETF 대표 종목 모니터", + "섹터별 3종목 바스켓과 선택 근거, 커버리지, 품질 상태를 추적", + end_col=18, + ) + headers = [ + "sector", "etf_proxy_ticker", "etf_proxy_name", "etf_proxy_type", "sector_rank", + "sector_score", "sector_smart_money_5d_krw", "sector_ret20d", "representative_count", + "representative_ticker", "representative_name", "representative_basis", + "representative_basis_detail", "constituent_weight", "basket_quality_state", + "basket_coverage_pct", "basket_state", "basket_buy_review_count", + "basket_track_count", "basket_watch_count", "basket_caution_count", + "basket_aligned_count", "basket_missing_count", "basket_real_count", + "selection_source", "selection_score", "monitor_reason" + ] + rows = [] + for row in data.get("rows") or []: + rows.append([row.get(h, "") for h in headers]) + write_table(ws, 4, 1, headers, rows) + ws.auto_filter.ref = f"A4:{get_column_letter(len(headers))}{4 + len(rows)}" + ws.freeze_panes = "A5" + for col, width in {"A": 18, "B": 12, "C": 16, "D": 12, "E": 12, "F": 12, "G": 18, "H": 12, + "I": 14, "J": 12, "K": 18, "L": 18, "M": 24, "N": 14, "O": 14, + "P": 14, "Q": 14, "R": 14, "S": 14, "T": 14, "U": 14, "V": 14, + "W": 14, "X": 18, "Y": 14, "Z": 12, "AA": 24}.items(): + ws.column_dimensions[col].width = width + + chart = BarChart() + chart.type = "bar" + chart.style = 10 + chart.title = "Basket Coverage by Sector" + chart.y_axis.title = "Sector" + chart.x_axis.title = "Coverage %" + chart.height = 8 + chart.width = 14 + data_ref = Reference(ws, min_col=16, min_row=4, max_row=4 + len(rows)) + cats = Reference(ws, min_col=1, min_row=5, max_row=4 + len(rows)) + chart.add_data(data_ref, titles_from_data=True) + chart.set_categories(cats) + chart.legend = None + ws.add_chart(chart, "AC4") + + +def main() -> None: + if not INPUT_XLSX.exists(): + raise FileNotFoundError(INPUT_XLSX) + if not SECTOR_JSON.exists(): + raise FileNotFoundError(SECTOR_JSON) + if not ETF_JSON.exists(): + raise FileNotFoundError(ETF_JSON) + + sector = load_json(SECTOR_JSON) + etf = load_json(ETF_JSON) + + wb = load_workbook(INPUT_XLSX) + for name in [ + "portfolio_performance_summary", + "portfolio_sector_exposure", + "_portfolio_holdings_helper", + "sector_trend_summary", + "sector_trend_analysis", + "sector_trend_timeline", + "etf_representative_summary", + "etf_representative_monitor", + ]: + remove_if_exists(wb, name) + + # Build data sheets first so summary sheets can reference the timeline sheet. + build_portfolio_summary(wb) + build_portfolio_sector_exposure(wb) + build_sector_timeline(wb, sector) + build_sector_analysis(wb, sector) + build_sector_summary(wb, sector) + build_etf_monitor(wb, etf) + build_etf_summary(wb, etf) + + # Put summary sheets near the front. + order = [ + "settings", + "portfolio_performance_summary", + "portfolio_sector_exposure", + "sector_trend_summary", + "sector_trend_analysis", + "sector_trend_timeline", + "etf_representative_summary", + "etf_representative_monitor", + ] + existing = [s for s in wb.sheetnames if s not in order] + wb._sheets = [wb[s] for s in order if s in wb.sheetnames] + [wb[s] for s in existing] + if "_portfolio_holdings_helper" in wb.sheetnames: + wb["_portfolio_holdings_helper"].sheet_state = "hidden" + wb.active = wb.sheetnames.index("sector_trend_summary") + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + wb.save(OUTPUT_XLSX) + print(f"saved {OUTPUT_XLSX}") + print("sheets", wb.sheetnames[:10]) + + +if __name__ == "__main__": + main() diff --git a/tools/validate_engine_harness_gate.py b/tools/validate_engine_harness_gate.py index b09c0e5..24bfc52 100644 --- a/tools/validate_engine_harness_gate.py +++ b/tools/validate_engine_harness_gate.py @@ -15,6 +15,7 @@ DEFAULT_HARNESS_JSON = ROOT / "Temp" / "prediction_improvement_harness.json" DEFAULT_GATE_RESULT_JSON = ROOT / "Temp" / "engine_harness_gate_result.json" DEFAULT_RULE_LIFECYCLE_JSON = ROOT / "Temp" / "rule_lifecycle_policy.json" DEFAULT_STRATEGY_HARNESS_JSON = ROOT / "Temp" / "strategy_harness_v2.json" +DEFAULT_SECTOR_TREND_JSON = ROOT / "Temp" / "sector_trend_analysis_v1.json" def _ensure_utf8_stdio() -> None: @@ -64,6 +65,7 @@ def main() -> int: result_json_path = Path(args.result_json_path) rule_lifecycle_json_path = Path(args.rule_lifecycle_json_path) strategy_harness_json_path = Path(args.strategy_harness_json_path) + sector_trend_json_path = DEFAULT_SECTOR_TREND_JSON if not json_path.is_absolute(): json_path = ROOT / json_path if not report_path.is_absolute(): @@ -138,6 +140,16 @@ def main() -> int: ], ["REPORT RENDERED OK", "PREDICTION_IMPROVEMENT_HARNESS_EXPORTED"], ), + ( + "build_sector_trend_analysis_v1", + ["python", "tools/build_sector_trend_analysis_v1.py"], + ["SECTOR_TREND_ANALYSIS_V1"], + ), + ( + "build_etf_representative_monitor_v1", + ["python", "tools/build_etf_representative_monitor_v1.py"], + ["ETF_REPRESENTATIVE_MONITOR_V1"], + ), ("validate_report_quality", ["python", "tools/validate_report_quality.py", str(report_path)], ["PASS: report quality validation"]), ("validate_specs", ["python", "tools/validate_specs.py"], ["VALIDATION OK"]), ("validate_harness_sync_markdown", ["python", "tools/validate_harness_sync.py", "--from-markdown", str(json_path), str(report_path)], ["MARKDOWN_SYNC_OK"]), @@ -1710,6 +1722,105 @@ def main() -> int: if not check87_ok: failed = True + # CHECK_87B: SECTOR_TREND_ANALYSIS_V1 — ETF proxy + smart money lens exported + sector_path = ROOT / "Temp" / "sector_trend_analysis_v1.json" + sector_data = _load_json(sector_path) + sector_rows = sector_data.get("rows") if isinstance(sector_data, dict) else [] + sector_summary = sector_data.get("summary") if isinstance(sector_data, dict) else {} + sector_source = sector_data.get("source") if isinstance(sector_data, dict) else {} + sector_gate = str(sector_data.get("gate") or "") if isinstance(sector_data, dict) else "" + first_sector = sector_rows[0] if isinstance(sector_rows, list) and sector_rows and isinstance(sector_rows[0], dict) else {} + sector_section_present = "sector_trend_analysis_v1" in section_names + sector_md_has_etf = False + if isinstance(op_report, dict): + for sec in report_sections or []: + if isinstance(sec, dict) and sec.get("name") == "sector_trend_analysis_v1": + md_text = str(sec.get("markdown") or "") + sector_md_has_etf = ( + ("Proxy_Ticker" in md_text or "ETF 프록시" in md_text) + and "최근 시계열" in md_text + and "포트폴리오 / 자금 맥락" in md_text + ) + break + check87b_ok = ( + isinstance(sector_data, dict) + and str(sector_data.get("formula_id") or "") == "SECTOR_TREND_ANALYSIS_V1" + and sector_gate == "PASS" + and isinstance(sector_rows, list) + and len(sector_rows) > 0 + and isinstance(first_sector.get("proxy_ticker"), str) + and isinstance(first_sector.get("proxy_name"), str) + and "smart_money_direction" in first_sector + and "flow_alignment_state" in first_sector + and isinstance(sector_summary, dict) + and "trend_posture" in sector_summary + and isinstance(sector_data.get("timeline"), list) + and len(sector_data.get("timeline") or []) > 0 + and isinstance(sector_source, dict) + and sector_section_present + and sector_md_has_etf + ) + results.append({ + "name": "CHECK_87B_SECTOR_TREND_ANALYSIS_V1", + "exit_code": 0 if check87b_ok else 1, + "output": ( + f"sector_trend gate={sector_gate or 'MISSING'} rows={len(sector_rows) if isinstance(sector_rows, list) else 0} " + f"etf_proxy={first_sector.get('proxy_ticker', 'MISSING') if first_sector else 'MISSING'} " + f"section_present={sector_section_present}" + + (" OK" if check87b_ok else " => FAIL — sector trend harness 재생성 필요") + ), + }) + if not check87b_ok: + failed = True + + # CHECK_87C: ETF_REPRESENTATIVE_MONITOR_V1 — ETF proxy와 대표 종목의 지속 모니터링 + etf_rep_path = ROOT / "Temp" / "etf_representative_monitor_v1.json" + etf_rep_data = _load_json(etf_rep_path) + etf_rep_rows = etf_rep_data.get("rows") if isinstance(etf_rep_data, dict) else [] + etf_rep_summary = etf_rep_data.get("summary") if isinstance(etf_rep_data, dict) else {} + etf_rep_gate = str(etf_rep_data.get("gate") or "") if isinstance(etf_rep_data, dict) else "" + etf_rep_section_present = "etf_representative_monitor_v1" in section_names + etf_rep_md_has_monitor = False + if isinstance(op_report, dict): + for sec in report_sections or []: + if isinstance(sec, dict) and sec.get("name") == "etf_representative_monitor_v1": + md_text = str(sec.get("markdown") or "") + etf_rep_md_has_monitor = ( + "대표 종목 추출 원칙" in md_text + and "구성비중" in md_text + and "대표 종목 모니터 테이블" in md_text + and "대표 종목 추세 미니차트" in md_text + ) + break + check87c_ok = ( + isinstance(etf_rep_data, dict) + and str(etf_rep_data.get("formula_id") or "") == "ETF_REPRESENTATIVE_MONITOR_V1" + and etf_rep_gate == "PASS" + and isinstance(etf_rep_rows, list) + and len(etf_rep_rows) > 0 + and isinstance(etf_rep_rows[0], dict) + and "representative_basis" in etf_rep_rows[0] + and "constituent_weight" in etf_rep_rows[0] + and int(etf_rep_rows[0].get("representative_count") or 0) >= 3 + and isinstance(etf_rep_rows[0].get("representatives"), list) + and len(etf_rep_rows[0].get("representatives") or []) >= 3 + and isinstance(etf_rep_summary, dict) + and "buy_review_count" in etf_rep_summary + and etf_rep_section_present + and etf_rep_md_has_monitor + ) + results.append({ + "name": "CHECK_87C_ETF_REPRESENTATIVE_MONITOR_V1", + "exit_code": 0 if check87c_ok else 1, + "output": ( + f"etf_rep_monitor gate={etf_rep_gate or 'MISSING'} rows={len(etf_rep_rows) if isinstance(etf_rep_rows, list) else 0} " + f"section_present={etf_rep_section_present}" + + (" OK" if check87c_ok else " => FAIL — ETF 대표 종목 모니터 재생성 필요") + ), + }) + if not check87c_ok: + failed = True + # CHECK_88: effective_coverage_pct=100.0 (GAS+Python) cov_path = ROOT / "Temp" / "harness_coverage_audit.json" cov_data = _load_json(cov_path) diff --git a/tools/validate_report_section_completeness_v1.py b/tools/validate_report_section_completeness_v1.py index af0e8f8..c55765b 100644 --- a/tools/validate_report_section_completeness_v1.py +++ b/tools/validate_report_section_completeness_v1.py @@ -18,6 +18,9 @@ REPORT_SECTION_ORDER = [ "exec_safety_declaration", "final_judgment_table", "final_execution_decision", "concise_hts_input_sheet", "watch_breakout_gate", "single_conclusion", "immediate_execution_playbook", "market_context_learning_note", + "portfolio_performance_summary", + "sector_trend_analysis_v1", + "etf_representative_monitor_v1", "investment_quality_headline", "operational_truth_score", "execution_readiness_matrix", "pass_100_criteria", "today_decision_summary_card", "routing_serving_trace",