feat: sector trend analysis + ETF representative monitor (DAG step_count 81->83)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 20:52:17 +09:00
parent e5ef9f1d3b
commit f56dd37286
16 changed files with 2227 additions and 6 deletions
+1
View File
@@ -1,4 +1,5 @@
{ {
"scriptId": "1xfeBAeeknmnBtSvrIqWXO_2hc3ByeriLUOSuOOB4YxLLHhN3zdnL7tVh", "scriptId": "1xfeBAeeknmnBtSvrIqWXO_2hc3ByeriLUOSuOOB4YxLLHhN3zdnL7tVh",
"projectId": "1072944905499",
"rootDir": "Temp/gas_deploy" "rootDir": "Temp/gas_deploy"
} }
+1
View File
@@ -8,6 +8,7 @@
"ops:validate": "python tools/run_release_dag_v3.py --mode release", "ops:validate": "python tools/run_release_dag_v3.py --mode release",
"ops:build": "python tools/build_bundle.py", "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: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: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", "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", "prepare-upload-zip": "python tools/refresh_trading_calendar.py && python tools/prepare_upload_zip.py",
+3 -3
View File
@@ -1,9 +1,9 @@
{ {
"formula_id": "AUDIT_REPOSITORY_ENTROPY_V2", "formula_id": "AUDIT_REPOSITORY_ENTROPY_V2",
"gate": "PASS", "gate": "PASS",
"total_file_count": 1674, "total_file_count": 1685,
"package_script_count": 16, "package_script_count": 16,
"temp_json_count": 148, "temp_json_count": 152,
"budget": { "budget": {
"schema_version": "repository_entropy_budget.v1", "schema_version": "repository_entropy_budget.v1",
"max_total_files": 2200, "max_total_files": 2200,
@@ -15,5 +15,5 @@
"keep package scripts within release envelope" "keep package scripts within release envelope"
] ]
}, },
"source_zip_sha256": "54dca83533c8fdea304ef3b23c3cff2f49a216ac7932a4b342683a514f4670e9" "source_zip_sha256": "8ce41081b6fcd8844a3e914b29bbd5a9469aed052a46f5549c799af72567762c"
} }
+27 -1
View File
@@ -1,5 +1,5 @@
schema_version: release_dag.v3 schema_version: release_dag.v3
step_count: 81 step_count: 83
goal: Linearize package.json scripts into a validated DAG execution graph. goal: Linearize package.json scripts into a validated DAG execution graph.
execution_order: execution_order:
# 토폴로지 정렬 기준 병렬 실행 wave (의존성 없는 노드들을 동시에 실행 가능) # 토폴로지 정렬 기준 병렬 실행 wave (의존성 없는 노드들을 동시에 실행 가능)
@@ -37,6 +37,7 @@ execution_order:
- build_anti_whipsaw_gate - build_anti_whipsaw_gate
- build_data_gated_progress - build_data_gated_progress
- build_ejce_view_renderer - build_ejce_view_renderer
- build_etf_representative_monitor
- build_factor_shadow_eligibility - build_factor_shadow_eligibility
- build_formula_outputs - build_formula_outputs
- build_missing_formula_bridge - build_missing_formula_bridge
@@ -44,6 +45,7 @@ execution_order:
- build_rebalance_sheet - build_rebalance_sheet
- build_regime_trim_guidance - build_regime_trim_guidance
- build_routing_execution_log - build_routing_execution_log
- build_sector_trend_analysis
- build_shadow_promotion - build_shadow_promotion
- build_value_preservation_scorer - build_value_preservation_scorer
- build_velocity - build_velocity
@@ -226,6 +228,30 @@ dag:
artifact_policy: "keep" artifact_policy: "keep"
note: "MISSING_FORMULA_BRIDGE_V1 — 10개 공식 커버리지 앵커 등록 (harness auditor PY_FILES)" 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: build_routing_execution_log:
id: build_routing_execution_log id: build_routing_execution_log
command: ["python", "tools/build_routing_execution_log_v1.py"] command: ["python", "tools/build_routing_execution_log_v1.py"]
+156 -1
View File
@@ -1,5 +1,5 @@
// gas_lib.gs - Common utilities & static features // 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 // 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 // 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); .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 변환 헬퍼 ─────────────────────────────────────────────── // ── Sheets → JSON 변환 헬퍼 ───────────────────────────────────────────────
function parseCompactFlag_(value) { function parseCompactFlag_(value) {
const raw = String(value ?? "").trim().toLowerCase(); const raw = String(value ?? "").trim().toLowerCase();
@@ -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
+361
View File
@@ -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
+7
View File
@@ -2,6 +2,7 @@ import json
import os import os
import requests import requests
import time import time
import subprocess
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
@@ -93,6 +94,12 @@ def main():
print("\nDownload failed. Please check Drive API scopes.") print("\nDownload failed. Please check Drive API scopes.")
else: else:
print("\nGAS execution failed. Process aborted.") 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: except Exception as e:
print(f"Error: {e}") print(f"Error: {e}")
@@ -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())
+33
View File
@@ -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())
+105
View File
@@ -8,6 +8,7 @@ import shutil
import json import json
import argparse import argparse
import subprocess import subprocess
import urllib.request
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
@@ -54,7 +55,12 @@ BUNDLE_MAP: dict[str, list[str]] = {
} }
SCRIPT_ID = "1xfeBAeeknmnBtSvrIqWXO_2hc3ByeriLUOSuOOB4YxLLHhN3zdnL7tVh" SCRIPT_ID = "1xfeBAeeknmnBtSvrIqWXO_2hc3ByeriLUOSuOOB4YxLLHhN3zdnL7tVh"
PROJECT_ID = "1072944905499"
DEPLOYMENT_ID = "AKfycbzq1XM53XafyCNYurnF9TAQHT3FHBDsBd36rCbCoWSmJD3SaZ1BHCPDYZYhclG9qD5Y" 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: def get_now_kst() -> str:
@@ -113,6 +119,7 @@ def build_deploy(dry_run: bool = False) -> bool:
if not dry_run: if not dry_run:
clasp_cfg = { clasp_cfg = {
"scriptId": SCRIPT_ID, "scriptId": SCRIPT_ID,
"projectId": PROJECT_ID,
"rootDir": str(DEPLOY_DIR.relative_to(ROOT)).replace("\\", "/"), "rootDir": str(DEPLOY_DIR.relative_to(ROOT)).replace("\\", "/"),
} }
(ROOT / ".clasp.json").write_text( (ROOT / ".clasp.json").write_text(
@@ -166,10 +173,96 @@ def clasp_deploy() -> bool:
return False 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: def main() -> None:
parser = argparse.ArgumentParser(description="GAS auto-deploy") parser = argparse.ArgumentParser(description="GAS auto-deploy")
parser.add_argument("--dry-run", action="store_true", help="List files without writing") 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("--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() args = parser.parse_args()
ok = build_deploy(dry_run=args.dry_run) 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") print("[deploy_gas] Some source files missing -- check warnings above")
raise SystemExit(1) 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: if args.dry_run or args.skip_push:
print("[deploy_gas] dry-run/skip-push -- push skipped") 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 return
if not clasp_push(): if not clasp_push():
@@ -187,6 +286,12 @@ def main() -> None:
if not clasp_deploy(): if not clasp_deploy():
raise SystemExit(1) 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") print("[deploy_gas] Done. To run_all: python tools/automate_routine.py")
+3
View File
@@ -15,6 +15,9 @@ REPORT_SECTION_ORDER = [
"single_conclusion", "single_conclusion",
"immediate_execution_playbook", "immediate_execution_playbook",
"market_context_learning_note", "market_context_learning_note",
"portfolio_performance_summary",
"sector_trend_analysis_v1",
"etf_representative_monitor_v1",
# PHASE-2: quality + readiness scores # PHASE-2: quality + readiness scores
"investment_quality_headline", "investment_quality_headline",
"operational_truth_score", "operational_truth_score",
+321 -1
View File
@@ -7,17 +7,25 @@ from __future__ import annotations
import argparse import argparse
import json import json
import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
ROOT = Path(__file__).resolve().parents[1] 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 = [ SECTION_ORDER = [
"exec_safety_declaration", "final_judgment_table", "final_execution_decision", "exec_safety_declaration", "final_judgment_table", "final_execution_decision",
"concise_hts_input_sheet", "watch_breakout_gate", "concise_hts_input_sheet", "watch_breakout_gate",
"single_conclusion", "immediate_execution_playbook", "market_context_learning_note", "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", "execution_readiness_matrix", "pass_100_criteria",
"today_decision_summary_card", "routing_serving_trace", "today_decision_summary_card", "routing_serving_trace",
"export_gate_diagnosis", "QEH_AUDIT_BLOCK", "export_gate_diagnosis", "QEH_AUDIT_BLOCK",
@@ -48,6 +56,10 @@ SECTION_TITLES = {
"single_conclusion": "단일 결론", "single_conclusion": "단일 결론",
"immediate_execution_playbook": "즉시 실행 플레이북", "immediate_execution_playbook": "즉시 실행 플레이북",
"market_context_learning_note": "시장 컨텍스트 학습 노트", "market_context_learning_note": "시장 컨텍스트 학습 노트",
"portfolio_performance_summary": "포트폴리오 성과 요약",
"portfolio_sector_exposure_summary": "포트폴리오 섹터 노출",
"sector_trend_analysis_v1": "섹터 동향 분석",
"etf_representative_monitor_v1": "ETF 대표 종목 모니터",
"investment_quality_headline": "투자 품질 헤드라인", "investment_quality_headline": "투자 품질 헤드라인",
"operational_truth_score": "운영 진실성 점수", "operational_truth_score": "운영 진실성 점수",
"execution_readiness_matrix": "실행 준비도 매트릭스", "execution_readiness_matrix": "실행 준비도 매트릭스",
@@ -142,6 +154,34 @@ def _first_keys(items: list, n: int = 6) -> list[str]:
return [] 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 렌더러 ──────────────────────────────────────────────────────────── # ── PHASE-0 렌더러 ────────────────────────────────────────────────────────────
def _exec_safety_declaration(hctx: dict, se: list) -> str: 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) 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 렌더러 ──────────────────────────────────────────────────────────── # ── PHASE-2 렌더러 ────────────────────────────────────────────────────────────
def _investment_quality_headline(hctx: dict, se: list) -> str: def _investment_quality_headline(hctx: dict, se: list) -> str:
@@ -834,6 +1151,8 @@ def main() -> int:
"single_conclusion": lambda: _single_conclusion(hctx, se), "single_conclusion": lambda: _single_conclusion(hctx, se),
"immediate_execution_playbook": lambda: _immediate_execution_playbook(hctx, se), "immediate_execution_playbook": lambda: _immediate_execution_playbook(hctx, se),
"market_context_learning_note": lambda: _market_context_learning_note(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), "investment_quality_headline": lambda: _investment_quality_headline(hctx, se),
"operational_truth_score": lambda: _operational_truth_score(hctx, se), "operational_truth_score": lambda: _operational_truth_score(hctx, se),
"execution_readiness_matrix": lambda: _execution_readiness_matrix(hctx, packet, 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), "routing_serving_trace": lambda: _routing_serving_trace(hctx, se),
"export_gate_diagnosis": lambda: _export_gate_diagnosis(hctx, se), "export_gate_diagnosis": lambda: _export_gate_diagnosis(hctx, se),
"QEH_AUDIT_BLOCK": lambda: _qeh_audit_block(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), "fundamental_quality_gate_v1": lambda: _fundamental_quality_gate_v1(hctx, se),
"horizon_allocation_lock_v1": lambda: _horizon_allocation_lock_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), "smart_money_liquidity_gate_v1": lambda: _smart_money_liquidity_gate_v1(hctx, se),
+658
View File
@@ -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()
+111
View File
@@ -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_GATE_RESULT_JSON = ROOT / "Temp" / "engine_harness_gate_result.json"
DEFAULT_RULE_LIFECYCLE_JSON = ROOT / "Temp" / "rule_lifecycle_policy.json" DEFAULT_RULE_LIFECYCLE_JSON = ROOT / "Temp" / "rule_lifecycle_policy.json"
DEFAULT_STRATEGY_HARNESS_JSON = ROOT / "Temp" / "strategy_harness_v2.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: def _ensure_utf8_stdio() -> None:
@@ -64,6 +65,7 @@ def main() -> int:
result_json_path = Path(args.result_json_path) result_json_path = Path(args.result_json_path)
rule_lifecycle_json_path = Path(args.rule_lifecycle_json_path) rule_lifecycle_json_path = Path(args.rule_lifecycle_json_path)
strategy_harness_json_path = Path(args.strategy_harness_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(): if not json_path.is_absolute():
json_path = ROOT / json_path json_path = ROOT / json_path
if not report_path.is_absolute(): if not report_path.is_absolute():
@@ -138,6 +140,16 @@ def main() -> int:
], ],
["REPORT RENDERED OK", "PREDICTION_IMPROVEMENT_HARNESS_EXPORTED"], ["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_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_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"]), ("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: if not check87_ok:
failed = True 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) # CHECK_88: effective_coverage_pct=100.0 (GAS+Python)
cov_path = ROOT / "Temp" / "harness_coverage_audit.json" cov_path = ROOT / "Temp" / "harness_coverage_audit.json"
cov_data = _load_json(cov_path) cov_data = _load_json(cov_path)
@@ -18,6 +18,9 @@ REPORT_SECTION_ORDER = [
"exec_safety_declaration", "final_judgment_table", "final_execution_decision", "exec_safety_declaration", "final_judgment_table", "final_execution_decision",
"concise_hts_input_sheet", "watch_breakout_gate", "concise_hts_input_sheet", "watch_breakout_gate",
"single_conclusion", "immediate_execution_playbook", "market_context_learning_note", "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", "investment_quality_headline", "operational_truth_score",
"execution_readiness_matrix", "pass_100_criteria", "execution_readiness_matrix", "pass_100_criteria",
"today_decision_summary_card", "routing_serving_trace", "today_decision_summary_card", "routing_serving_trace",