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:
@@ -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
|
||||
Reference in New Issue
Block a user