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:
+156
-1
@@ -1,5 +1,5 @@
|
||||
// gas_lib.gs - Common utilities & static features
|
||||
// Last Updated: 2026-06-14 17:23:33 KST
|
||||
// Last Updated: 2026-06-14 20:48:30 KST
|
||||
// Math/KRX utils, sheet I/O, sector flow, Web API, static runners
|
||||
// GAS global scope: functions in gas_data_feed.gs / gas_data_collect.gs callable directly
|
||||
//
|
||||
@@ -2081,6 +2081,161 @@ function doGet(e) {
|
||||
.setMimeType(ContentService.MimeType.JSON);
|
||||
}
|
||||
|
||||
function doPost(e) {
|
||||
const payload = parseJsonPostBody_(e);
|
||||
const action = String(payload.action || payload.view || "").trim().toLowerCase();
|
||||
try {
|
||||
if (action === "sync_sector_insights") {
|
||||
const result = syncSectorInsightSheets_(payload);
|
||||
return ContentService
|
||||
.createTextOutput(JSON.stringify(result, null, 2))
|
||||
.setMimeType(ContentService.MimeType.JSON);
|
||||
}
|
||||
return ContentService
|
||||
.createTextOutput(JSON.stringify({
|
||||
status: "ERROR",
|
||||
message: `unsupported action: ${action || "missing"}`,
|
||||
}, null, 2))
|
||||
.setMimeType(ContentService.MimeType.JSON);
|
||||
} catch (err) {
|
||||
return ContentService
|
||||
.createTextOutput(JSON.stringify({
|
||||
status: "ERROR",
|
||||
message: String(err && err.message ? err.message : err),
|
||||
}, null, 2))
|
||||
.setMimeType(ContentService.MimeType.JSON);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonPostBody_(e) {
|
||||
try {
|
||||
const raw = String(e?.postData?.contents ?? "").trim();
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (err) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function rowFromObject_(headers, obj) {
|
||||
return headers.map(function(h) {
|
||||
const v = obj && Object.prototype.hasOwnProperty.call(obj, h) ? obj[h] : "";
|
||||
if (v === null || v === undefined) return "";
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
function writeSummarySheet_(sheetName, rows) {
|
||||
const headers = ["section", "key", "value"];
|
||||
const tableRows = (rows || []).map(function(r) {
|
||||
return [r.section || "", r.key || "", r.value || ""];
|
||||
});
|
||||
writeToSheet(sheetName, headers, tableRows);
|
||||
return tableRows.length;
|
||||
}
|
||||
|
||||
function writeSectorTrendAnalysisSheet_(analysis) {
|
||||
if (!analysis || typeof analysis !== "object") return 0;
|
||||
const summary = analysis.summary || {};
|
||||
const concentration = analysis.concentration || {};
|
||||
const detailHeaders = [
|
||||
"sector", "proxy_ticker", "proxy_name", "proxy_type", "etf_code",
|
||||
"etf_execution_use", "etf_liquidity_score", "etf_liquidity_status", "etf_nav_risk",
|
||||
"proxy_confidence", "rank", "rank_delta_w1", "rank_delta_w2", "sector_score",
|
||||
"score_delta", "sector_ret5d", "sector_ret20d", "etf_return_5d", "etf_return_20d",
|
||||
"sector_etf_ret_gap_5d", "sector_etf_ret_gap_20d", "smart_money_5d_krw_raw",
|
||||
"smart_money_20d_krw_raw", "smart_money_direction", "liquidity_direction",
|
||||
"flow_alignment_state", "momentum_state", "concentration_weight_pct"
|
||||
];
|
||||
const detailRows = Array.isArray(analysis.rows)
|
||||
? analysis.rows.map(function(r) { return rowFromObject_(detailHeaders, r); })
|
||||
: [];
|
||||
writeSummarySheet_("sector_trend_summary", [
|
||||
{ section: "summary", key: "formula_id", value: analysis.formula_id || "" },
|
||||
{ section: "summary", key: "gate", value: analysis.gate || "" },
|
||||
{ section: "summary", key: "latest_snapshot_date", value: analysis.latest_snapshot_date || "" },
|
||||
{ section: "summary", key: "previous_snapshot_date", value: analysis.previous_snapshot_date || "" },
|
||||
{ section: "summary", key: "sector_count", value: analysis.sector_count || 0 },
|
||||
{ section: "summary", key: "trend_posture", value: summary.trend_posture || "" },
|
||||
{ section: "summary", key: "rising_count", value: summary.rising_count || 0 },
|
||||
{ section: "summary", key: "fading_count", value: summary.fading_count || 0 },
|
||||
{ section: "summary", key: "stable_count", value: summary.stable_count || 0 },
|
||||
{ section: "summary", key: "etf_proxy_count", value: summary.etf_proxy_count || 0 },
|
||||
{ section: "summary", key: "smart_money_inflow_count", value: summary.smart_money_inflow_count || 0 },
|
||||
{ section: "summary", key: "smart_money_outflow_count", value: summary.smart_money_outflow_count || 0 },
|
||||
{ section: "concentration", key: "top_sector", value: concentration.top_sector || "" },
|
||||
{ section: "concentration", key: "top_sector_weight_pct", value: concentration.top_sector_weight_pct || 0 },
|
||||
{ section: "concentration", key: "top2_weight_pct", value: concentration.top2_weight_pct || 0 },
|
||||
{ section: "concentration", key: "concentration_gate", value: concentration.concentration_gate || "" },
|
||||
]);
|
||||
writeToSheet("sector_trend_analysis", detailHeaders, detailRows);
|
||||
const timelineHeaders = [
|
||||
"snapshot_date", "sector_count", "avg_sector_score", "top_sector", "top_sector_score",
|
||||
"positive_breadth_count", "liquidity_warn_count", "net_smart_money_5d_krw"
|
||||
];
|
||||
const timelineRows = Array.isArray(analysis.timeline)
|
||||
? analysis.timeline.map(function(r) { return rowFromObject_(timelineHeaders, r); })
|
||||
: [];
|
||||
writeToSheet("sector_trend_timeline", timelineHeaders, timelineRows);
|
||||
return detailRows.length;
|
||||
}
|
||||
|
||||
function writeEtfRepresentativeMonitorSheet_(monitor) {
|
||||
if (!monitor || typeof monitor !== "object") return 0;
|
||||
const summary = monitor.summary || {};
|
||||
const detailHeaders = [
|
||||
"sector", "etf_proxy_ticker", "etf_proxy_name", "etf_proxy_type", "sector_rank",
|
||||
"sector_score", "sector_smart_money_5d_krw", "sector_ret20d", "representative_count",
|
||||
"representative_ticker", "representative_name", "representative_basis",
|
||||
"representative_basis_detail", "constituent_weight", "basket_quality_state",
|
||||
"basket_coverage_pct", "basket_state", "basket_buy_review_count",
|
||||
"basket_track_count", "basket_watch_count", "basket_caution_count",
|
||||
"basket_aligned_count", "basket_missing_count", "basket_real_count",
|
||||
"selection_source", "selection_score", "monitor_reason", "representatives_json"
|
||||
];
|
||||
const detailRows = Array.isArray(monitor.rows)
|
||||
? monitor.rows.map(function(r) {
|
||||
const repJson = Array.isArray(r.representatives) ? JSON.stringify(r.representatives) : "";
|
||||
const base = Object.assign({}, r, { representatives_json: repJson });
|
||||
return rowFromObject_(detailHeaders, base);
|
||||
})
|
||||
: [];
|
||||
writeSummarySheet_("etf_representative_summary", [
|
||||
{ section: "summary", key: "formula_id", value: monitor.formula_id || "" },
|
||||
{ section: "summary", key: "gate", value: monitor.gate || "" },
|
||||
{ section: "summary", key: "etf_sector_count", value: monitor.etf_sector_count || 0 },
|
||||
{ section: "summary", key: "tracked_count", value: monitor.tracked_count || 0 },
|
||||
{ section: "summary", key: "buy_review_count", value: summary.buy_review_count || 0 },
|
||||
{ section: "summary", key: "track_count", value: summary.track_count || 0 },
|
||||
{ section: "summary", key: "watch_count", value: summary.watch_count || 0 },
|
||||
{ section: "summary", key: "caution_count", value: summary.caution_count || 0 },
|
||||
{ section: "summary", key: "aligned_count", value: summary.aligned_count || 0 },
|
||||
{ section: "summary", key: "weighted_basis_count", value: summary.weighted_basis_count || 0 },
|
||||
{ section: "summary", key: "fallback_basis_count", value: summary.fallback_basis_count || 0 },
|
||||
{ section: "summary", key: "complete_basket_count", value: summary.complete_basket_count || 0 },
|
||||
{ section: "summary", key: "partial_basket_count", value: summary.partial_basket_count || 0 },
|
||||
{ section: "summary", key: "basket_missing_total", value: summary.basket_missing_total || 0 },
|
||||
]);
|
||||
writeToSheet("etf_representative_monitor", detailHeaders, detailRows);
|
||||
return detailRows.length;
|
||||
}
|
||||
|
||||
function syncSectorInsightSheets_(payload) {
|
||||
const trend = payload.sector_trend_analysis || payload.sectorTrendAnalysis || null;
|
||||
const etf = payload.etf_representative_monitor || payload.etfRepresentativeMonitor || null;
|
||||
const written = {};
|
||||
if (trend) written.sector_trend_analysis = writeSectorTrendAnalysisSheet_(trend);
|
||||
if (etf) written.etf_representative_monitor = writeEtfRepresentativeMonitorSheet_(etf);
|
||||
return {
|
||||
status: "OK",
|
||||
action: "sync_sector_insights",
|
||||
written,
|
||||
generated_at: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss") + " KST",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Sheets → JSON 변환 헬퍼 ───────────────────────────────────────────────
|
||||
function parseCompactFlag_(value) {
|
||||
const raw = String(value ?? "").trim().toLowerCase();
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user