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:
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"scriptId": "1xfeBAeeknmnBtSvrIqWXO_2hc3ByeriLUOSuOOB4YxLLHhN3zdnL7tVh",
|
"scriptId": "1xfeBAeeknmnBtSvrIqWXO_2hc3ByeriLUOSuOOB4YxLLHhN3zdnL7tVh",
|
||||||
|
"projectId": "1072944905499",
|
||||||
"rootDir": "Temp/gas_deploy"
|
"rootDir": "Temp/gas_deploy"
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user