섹터 리포트와 대표종목 모니터 고도화

This commit is contained in:
2026-06-15 02:30:02 +09:00
parent 82ca4ddbfd
commit 2439980730
5 changed files with 192 additions and 25 deletions
+48 -13
View File
@@ -13,6 +13,29 @@ ETF_NAME_HINTS = (
"SOL", "TIMEFOLIO", "WOORI", "PLUS", "NPLUS", "TREX", "FOCUS", "KIWOOM", "SOL", "TIMEFOLIO", "WOORI", "PLUS", "NPLUS", "TREX", "FOCUS", "KIWOOM",
) )
ROBOTICS_FALLBACK_PROXY = {
"Sector": "로보틱스",
"Proxy_Ticker": "0190C0",
"Proxy_Name": "RISE 현대차고정피지컬AI",
"Proxy_Type": "ETF",
"Sector_Rank": 12,
"SmartMoney_5D_KRW": 0.0,
"Sector_Ret20D": 0.0,
}
ROBOTICS_FALLBACK_UNIVERSE = [
{"Sector": "로보틱스", "Proxy_Ticker": "0190C0", "Proxy_Name": "RISE 현대차고정피지컬AI", "Proxy_Type": "ETF", "Constituent_Code": "005380", "Constituent_Name": "현대차", "Weight": 0.2402, "Is_ETF": False},
{"Sector": "로보틱스", "Proxy_Ticker": "0190C0", "Proxy_Name": "RISE 현대차고정피지컬AI", "Proxy_Type": "ETF", "Constituent_Code": "012330", "Constituent_Name": "현대모비스", "Weight": 0.1588, "Is_ETF": False},
{"Sector": "로보틱스", "Proxy_Ticker": "0190C0", "Proxy_Name": "RISE 현대차고정피지컬AI", "Proxy_Type": "ETF", "Constituent_Code": "011070", "Constituent_Name": "LG이노텍", "Weight": 0.1450, "Is_ETF": False},
{"Sector": "로보틱스", "Proxy_Ticker": "0190C0", "Proxy_Name": "RISE 현대차고정피지컬AI", "Proxy_Type": "ETF", "Constituent_Code": "000270", "Constituent_Name": "기아", "Weight": 0.1234, "Is_ETF": False},
{"Sector": "로보틱스", "Proxy_Ticker": "0190C0", "Proxy_Name": "RISE 현대차고정피지컬AI", "Proxy_Type": "ETF", "Constituent_Code": "307950", "Constituent_Name": "현대오토에버", "Weight": 0.0899, "Is_ETF": False},
{"Sector": "로보틱스", "Proxy_Ticker": "0190C0", "Proxy_Name": "RISE 현대차고정피지컬AI", "Proxy_Type": "ETF", "Constituent_Code": "277810", "Constituent_Name": "레인보우로보틱스", "Weight": 0.0673, "Is_ETF": False},
{"Sector": "로보틱스", "Proxy_Ticker": "0190C0", "Proxy_Name": "RISE 현대차고정피지컬AI", "Proxy_Type": "ETF", "Constituent_Code": "064400", "Constituent_Name": "LG씨엔에스", "Weight": 0.0519, "Is_ETF": False},
{"Sector": "로보틱스", "Proxy_Ticker": "0190C0", "Proxy_Name": "RISE 현대차고정피지컬AI", "Proxy_Type": "ETF", "Constituent_Code": "454910", "Constituent_Name": "두산로보틱스", "Weight": 0.0367, "Is_ETF": False},
{"Sector": "로보틱스", "Proxy_Ticker": "0190C0", "Proxy_Name": "RISE 현대차고정피지컬AI", "Proxy_Type": "ETF", "Constituent_Code": "108490", "Constituent_Name": "로보티즈", "Weight": 0.0240, "Is_ETF": False},
{"Sector": "로보틱스", "Proxy_Ticker": "0190C0", "Proxy_Name": "RISE 현대차고정피지컬AI", "Proxy_Type": "ETF", "Constituent_Code": "058610", "Constituent_Name": "에스피지", "Weight": 0.0173, "Is_ETF": False},
]
def _parse_jsonish(value: Any) -> Any: def _parse_jsonish(value: Any) -> Any:
if isinstance(value, (dict, list)): if isinstance(value, (dict, list)):
@@ -174,6 +197,8 @@ def build_etf_representative_monitor(payload: dict[str, Any]) -> dict[str, Any]:
continue continue
if _txt(row.get("Proxy_Type")).upper() == "ETF": if _txt(row.get("Proxy_Type")).upper() == "ETF":
etf_sectors[sector] = row etf_sectors[sector] = row
if "로보틱스" not in etf_sectors:
etf_sectors["로보틱스"] = ROBOTICS_FALLBACK_PROXY
sector_candidates: dict[str, list[dict[str, Any]]] = defaultdict(list) sector_candidates: dict[str, list[dict[str, Any]]] = defaultdict(list)
core_by_ticker: dict[str, dict[str, Any]] = {} core_by_ticker: dict[str, dict[str, Any]] = {}
@@ -201,9 +226,12 @@ def build_etf_representative_monitor(payload: dict[str, Any]) -> dict[str, Any]:
if _txt(row.get("Status"), "OK").upper() not in {"OK", "ACTIVE", "LIVE"}: if _txt(row.get("Status"), "OK").upper() not in {"OK", "ACTIVE", "LIVE"}:
continue continue
universe_candidates[sector].append(row) universe_candidates[sector].append(row)
if "로보틱스" not in universe_candidates:
universe_candidates["로보틱스"] = ROBOTICS_FALLBACK_UNIVERSE.copy()
rows: list[dict[str, Any]] = [] 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)))): 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)))):
target_rep_count = 5 if sector == "로보틱스" else 3
fallback_rows = sorted( fallback_rows = sorted(
sector_candidates.get(sector, []), sector_candidates.get(sector, []),
key=lambda r: ( key=lambda r: (
@@ -213,31 +241,36 @@ def build_etf_representative_monitor(payload: dict[str, Any]) -> dict[str, Any]:
-_num(r.get("Ret10D"), 0.0), -_num(r.get("Ret10D"), 0.0),
), ),
) )
# ETF 대표주는 구성비 내림차순을 1차 기준으로 고정한다.
# live score는 동일 비중/동일 구성일 때만 보조 판단으로 사용한다.
universe_rows = sorted( universe_rows = sorted(
universe_candidates.get(sector, []), universe_candidates.get(sector, []),
key=lambda r: _constituent_priority_score( key=lambda r: (
r, -_num(r.get("Weight"), 0.0),
core_by_ticker.get(_txt(r.get("Constituent_Code"))) _constituent_priority_score(
or next((x for x in fallback_rows if _txt(x.get("Ticker")) == _txt(r.get("Constituent_Code"))), None), 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]] = [] 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_specs: list[tuple[str, dict[str, Any]]] = [("ETF_CONSTITUENT_WEIGHT", row) for row in universe_rows[:target_rep_count]]
selected_tickers = {_txt(row.get("Constituent_Code")) for row in universe_rows[:3]} selected_tickers = {_txt(row.get("Constituent_Code")) for row in universe_rows[:target_rep_count]}
if len(selected_specs) < 3: if len(selected_specs) < target_rep_count:
for row in fallback_rows: for row in fallback_rows:
ticker = _txt(row.get("Ticker")) ticker = _txt(row.get("Ticker"))
if not ticker or ticker in selected_tickers: if not ticker or ticker in selected_tickers:
continue continue
selected_specs.append(("SECTOR_LIQUIDITY_FALLBACK", row)) selected_specs.append(("SECTOR_LIQUIDITY_FALLBACK", row))
selected_tickers.add(ticker) selected_tickers.add(ticker)
if len(selected_specs) >= 3: if len(selected_specs) >= target_rep_count:
break break
if not selected_specs: if not selected_specs:
selected_specs = [("SECTOR_LIQUIDITY_FALLBACK", row) for row in fallback_rows[:3]] selected_specs = [("SECTOR_LIQUIDITY_FALLBACK", row) for row in fallback_rows[:target_rep_count]]
rep_source = "ETF_CONSTITUENT_WEIGHT" if universe_rows else "SECTOR_LIQUIDITY_FALLBACK" rep_source = "ETF_CONSTITUENT_WEIGHT" if universe_rows else "SECTOR_LIQUIDITY_FALLBACK"
rep_basis_detail = "ETF_WEIGHT_PRIMARY" rep_basis_detail = "ETF_WEIGHT_PRIMARY"
if universe_rows and len(universe_rows) < 3 and len(selected_specs) >= 3: if universe_rows and len(universe_rows) < target_rep_count and len(selected_specs) >= target_rep_count:
rep_basis_detail = "ETF_WEIGHT_PRIMARY_PLUS_SECTOR_TOPUP" rep_basis_detail = "ETF_WEIGHT_PRIMARY_PLUS_SECTOR_TOPUP"
if not universe_rows: if not universe_rows:
rep_basis_detail = "SECTOR_LIQUIDITY_FALLBACK" rep_basis_detail = "SECTOR_LIQUIDITY_FALLBACK"
@@ -283,7 +316,7 @@ def build_etf_representative_monitor(payload: dict[str, Any]) -> dict[str, Any]:
_txt(spec.get("Constituent_Code")), _txt(spec.get("Constituent_Code")),
_txt(spec.get("Constituent_Name")), _txt(spec.get("Constituent_Name")),
)) ))
if len(basket_items) < 3: if len(basket_items) < target_rep_count:
used_tickers = {item["ticker"] for item in basket_items} used_tickers = {item["ticker"] for item in basket_items}
for rep in fallback_rows: for rep in fallback_rows:
ticker = _txt(rep.get("Ticker")) ticker = _txt(rep.get("Ticker"))
@@ -291,7 +324,7 @@ def build_etf_representative_monitor(payload: dict[str, Any]) -> dict[str, Any]:
continue continue
basket_items.append(_build_rep_item(rep, {"Weight": ""}, proxy, "SECTOR_LIQUIDITY_FALLBACK")) basket_items.append(_build_rep_item(rep, {"Weight": ""}, proxy, "SECTOR_LIQUIDITY_FALLBACK"))
used_tickers.add(ticker) used_tickers.add(ticker)
if len(basket_items) >= 3: if len(basket_items) >= target_rep_count:
break break
if not basket_items: if not basket_items:
continue continue
@@ -313,6 +346,7 @@ def build_etf_representative_monitor(payload: dict[str, Any]) -> dict[str, Any]:
"etf_proxy_ticker": _txt(proxy.get("Proxy_Ticker")), "etf_proxy_ticker": _txt(proxy.get("Proxy_Ticker")),
"etf_proxy_name": _txt(proxy.get("Proxy_Name")), "etf_proxy_name": _txt(proxy.get("Proxy_Name")),
"etf_proxy_type": _txt(proxy.get("Proxy_Type")), "etf_proxy_type": _txt(proxy.get("Proxy_Type")),
"universe_source": _txt(proxy.get("Universe_Source"), "DEFAULT_TEMPLATE"),
"sector_rank": proxy.get("Sector_Rank", ""), "sector_rank": proxy.get("Sector_Rank", ""),
"sector_score": proxy.get("Sector_Score", ""), "sector_score": proxy.get("Sector_Score", ""),
"sector_smart_money_5d_krw": proxy.get("SmartMoney_5D_KRW", ""), "sector_smart_money_5d_krw": proxy.get("SmartMoney_5D_KRW", ""),
@@ -348,7 +382,7 @@ def build_etf_representative_monitor(payload: dict[str, Any]) -> dict[str, Any]:
"basket_quality_state": basket_quality_state, "basket_quality_state": basket_quality_state,
"representatives": basket_items, "representatives": basket_items,
"monitor_reason": ( "monitor_reason": (
"ETF 구성비중 상위 3종목이 같은 방향으로 정렬" f"ETF 구성비중 상위 {target_rep_count}종목이 같은 방향으로 정렬"
if basket_state == "BUY_REVIEW" if basket_state == "BUY_REVIEW"
else "대표 종목 바스켓 추세 확인 중" if basket_state == "TRACK" else "대표 종목 바스켓 추세 확인 중" if basket_state == "TRACK"
else "유동성/추세 보수 모니터링" else "유동성/추세 보수 모니터링"
@@ -390,6 +424,7 @@ def build_etf_representative_monitor(payload: dict[str, Any]) -> dict[str, Any]:
"sector_flow_rows": len(sector_flow), "sector_flow_rows": len(sector_flow),
"core_satellite_rows": len(core_satellite), "core_satellite_rows": len(core_satellite),
"sector_universe_rows": len(sector_universe), "sector_universe_rows": len(sector_universe),
"template_source_count": sum(1 for r in rows if str(r.get("universe_source") or "").upper() == "DEFAULT_TEMPLATE"),
}, },
} }
return result return result
@@ -187,6 +187,7 @@ def build_sector_trend_analysis(payload: dict[str, Any]) -> dict[str, Any]:
proxy_ticker = _txt(row.get("Proxy_Ticker")) proxy_ticker = _txt(row.get("Proxy_Ticker"))
proxy_name = _txt(row.get("Proxy_Name")) proxy_name = _txt(row.get("Proxy_Name"))
proxy_type = _txt(row.get("Proxy_Type"), "UNKNOWN") proxy_type = _txt(row.get("Proxy_Type"), "UNKNOWN")
universe_source = _txt(row.get("Universe_Source"), "DEFAULT_TEMPLATE")
etf_code = _txt(row.get("ETF_Code"), proxy_ticker) etf_code = _txt(row.get("ETF_Code"), proxy_ticker)
etf_execution_use = _txt(row.get("ETF_Execution_Use")) etf_execution_use = _txt(row.get("ETF_Execution_Use"))
etf_liquidity_status = _txt(row.get("ETF_Liquidity_Status"), "UNKNOWN") etf_liquidity_status = _txt(row.get("ETF_Liquidity_Status"), "UNKNOWN")
@@ -224,6 +225,7 @@ def build_sector_trend_analysis(payload: dict[str, Any]) -> dict[str, Any]:
"proxy_ticker": proxy_ticker, "proxy_ticker": proxy_ticker,
"proxy_name": proxy_name, "proxy_name": proxy_name,
"proxy_type": proxy_type, "proxy_type": proxy_type,
"universe_source": universe_source,
"etf_code": etf_code, "etf_code": etf_code,
"etf_execution_use": etf_execution_use, "etf_execution_use": etf_execution_use,
"etf_liquidity_score": etf_liquidity_score, "etf_liquidity_score": etf_liquidity_score,
@@ -356,6 +358,7 @@ def build_sector_trend_analysis(payload: dict[str, Any]) -> dict[str, Any]:
"sector_rotation_momentum_rows": len(rotation_rows), "sector_rotation_momentum_rows": len(rotation_rows),
"sector_concentration_rows": len(concentration_rows), "sector_concentration_rows": len(concentration_rows),
"proxy_coverage_pct": round((etf_proxy_count / len(rows)) * 100.0, 2) if rows else 0.0, "proxy_coverage_pct": round((etf_proxy_count / len(rows)) * 100.0, 2) if rows else 0.0,
"template_source_count": sum(1 for r in rows if str(r.get("universe_source") or "").upper() == "DEFAULT_TEMPLATE"),
}, },
} }
return result return result
+60 -5
View File
@@ -17,6 +17,7 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from src.quant_engine.etf_representative_monitor import build_etf_representative_monitor from src.quant_engine.etf_representative_monitor import build_etf_representative_monitor
from src.quant_engine.sector_universe_refresh import build_sector_universe_refresh_audit
from src.quant_engine.sector_trend_analysis import build_sector_trend_analysis from src.quant_engine.sector_trend_analysis import build_sector_trend_analysis
SECTION_ORDER = [ SECTION_ORDER = [
@@ -25,6 +26,7 @@ SECTION_ORDER = [
"single_conclusion", "immediate_execution_playbook", "market_context_learning_note", "single_conclusion", "immediate_execution_playbook", "market_context_learning_note",
"portfolio_performance_summary", "portfolio_performance_summary",
"portfolio_sector_exposure_summary", "portfolio_sector_exposure_summary",
"sector_universe_refresh_audit_v1",
"sector_trend_analysis_v1", "etf_representative_monitor_v1", "investment_quality_headline", "operational_truth_score", "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",
@@ -59,6 +61,7 @@ SECTION_TITLES = {
"market_context_learning_note": "시장 컨텍스트 학습 노트", "market_context_learning_note": "시장 컨텍스트 학습 노트",
"portfolio_performance_summary": "포트폴리오 성과 요약", "portfolio_performance_summary": "포트폴리오 성과 요약",
"portfolio_sector_exposure_summary": "포트폴리오 섹터 노출", "portfolio_sector_exposure_summary": "포트폴리오 섹터 노출",
"sector_universe_refresh_audit_v1": "섹터 월간 갱신 감사",
"sector_trend_analysis_v1": "섹터 동향 분석", "sector_trend_analysis_v1": "섹터 동향 분석",
"etf_representative_monitor_v1": "ETF 대표 종목 모니터", "etf_representative_monitor_v1": "ETF 대표 종목 모니터",
"investment_quality_headline": "투자 품질 헤드라인", "investment_quality_headline": "투자 품질 헤드라인",
@@ -670,7 +673,7 @@ def _sector_trend_analysis_v1(data_root: dict, hctx: dict, se: list) -> str:
rows_data = result.get("rows") if isinstance(result.get("rows"), list) else [] rows_data = result.get("rows") if isinstance(result.get("rows"), list) else []
if rows_data: if rows_data:
md += "\n\n**섹터 상세 트렌드**\n\n" + _tbl(rows_data, [ md += "\n\n**섹터 상세 트렌드**\n\n" + _tbl(rows_data, [
"sector", "proxy_ticker", "proxy_name", "proxy_type", "etf_execution_use", "sector", "proxy_ticker", "proxy_name", "proxy_type", "universe_source", "etf_execution_use",
"etf_liquidity_status", "etf_nav_risk", "proxy_confidence", "rank", "etf_liquidity_status", "etf_nav_risk", "proxy_confidence", "rank",
"rank_delta_w1", "rank_delta_w2", "sector_score", "score_delta", "rank_delta_w1", "rank_delta_w2", "sector_score", "score_delta",
"sector_ret5d", "sector_ret20d", "etf_return_5d", "etf_return_20d", "sector_ret5d", "sector_ret20d", "etf_return_5d", "etf_return_20d",
@@ -756,10 +759,55 @@ def _sector_trend_analysis_v1(data_root: dict, hctx: dict, se: list) -> str:
"- 섹터 수급은 ETF 프록시와 직접 스마트머니를 분리해서 보여주고, 둘이 어긋날 때 경고를 강화해야 합니다.\n" "- 섹터 수급은 ETF 프록시와 직접 스마트머니를 분리해서 보여주고, 둘이 어긋날 때 경고를 강화해야 합니다.\n"
"- 현재 시계열은 스코어와 스마트머니 중심이므로, 다음 단계에서는 5D/20D 수익률 변화를 동일한 스파크라인 패널에 추가하는 것이 좋습니다.\n" "- 현재 시계열은 스코어와 스마트머니 중심이므로, 다음 단계에서는 5D/20D 수익률 변화를 동일한 스파크라인 패널에 추가하는 것이 좋습니다.\n"
"- 포트폴리오 자금 패널은 목표 달성율, 드로우다운, 베타, 알파 신뢰도를 함께 묶어 보여줘야 실제 투자 판단과 연결됩니다.\n" "- 포트폴리오 자금 패널은 목표 달성율, 드로우다운, 베타, 알파 신뢰도를 함께 묶어 보여줘야 실제 투자 판단과 연결됩니다.\n"
"- 다음 세분화 후보는 `바이오/제약`과 `방산/우주`처럼 현재 섹터를 더 세밀하게 나누는 방향입니다.\n"
) )
return md return md
def _sector_universe_refresh_audit_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_universe_refresh_audit(payload)
if not isinstance(result, dict) or not result:
return _err(se, "sector_universe_refresh_audit_v1", "sector universe refresh audit unavailable")
summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
rows = [
("갱신 게이트", result.get("gate", "")),
("섹터 수", summary.get("sector_count", "")),
("Naver 소스 섹터 수", summary.get("naver_source_count", "")),
("레이아웃 변경 수", summary.get("layout_changed_count", "")),
("SHEET_INPUT 섹터 수", summary.get("sheet_input_count", "")),
("DEFAULT_TEMPLATE 섹터 수", summary.get("template_count", "")),
("갱신 최신일", summary.get("newest_source_asof", "")),
("갱신 최저일", summary.get("oldest_source_asof", "")),
("CURRENT", summary.get("current_count", "")),
("DUE", summary.get("due_count", "")),
("OVERDUE", summary.get("overdue_count", "")),
("MISSING_URL", summary.get("missing_source_url_count", "")),
("STALE", summary.get("stale_sector_count", "")),
]
md = _kv(rows)
md += "\n\n**갱신 분리 메모**\n\n"
md += (
"- `NAVER_ETF_PAGE`는 월간 갱신된 구성종목이고, `SHEET_INPUT`은 수동 입력/보강분이다.\n"
"- `DEFAULT_TEMPLATE`는 자동 갱신이 아직 안 된 템플릿이므로, 월간 게이트에서 별도 실패로 본다.\n"
"- `Source_URL`와 `Source_AsOf`가 함께 있어야 provenance가 완성된다.\n"
"- 이 데이터는 AJAX/XHR 호출이 아니라 서버 렌더링 HTML 테이블이다. 따라서 잘못된 API 호출을 가정하지 말고, `main.naver`와 `coinfo.naver?target=cu_more`를 HTML 우선으로 읽는다.\n"
"- Naver 홈페이지 리뉴얼이나 DOM 변경이 생기면, JS는 보조 탐지용으로만 보고 실제값은 추정하지 않는다. 테이블이 없으면 실패를 그대로 남겨 추정값을 쓰지 않는다.\n"
"- `NAVER_ETF_PAGE_FAIL_LAYOUT_CHANGED`는 레이아웃 변경 전용 실패로 분리하고, 일반 실패(`NAVER_ETF_PAGE_FAIL`)와 구분해 읽는다.\n"
"- 금융 섹터는 `은행 / 증권 / 지주회사`로 분리해 `sector_universe`를 구성하고, `sector_flow`는 현재 JSON 브리지를 통해 carryover 분리본을 표시한다. GAS `runDataFeed`를 다시 돌리면 native 분리본으로 다시 물린다.\n"
"- 이 분리는 월 1회 갱신 하네스의 대상이며, 섹터별 대표 ETF 구성비 증빙은 `Source_URL`과 `Source_AsOf`가 유효해야만 인정한다.\n"
)
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", "source_kind", "transport_mode",
"source_url", "source_asof", "age_days", "constituent_count",
"stock_count", "etf_count", "weight_sum", "status", "refresh_reason",
], max_rows=20)
return md
def _etf_representative_monitor_v1(data_root: dict, hctx: dict, se: list) -> str: 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 {} 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} payload = {"data": inner_data, "data_root": data_root, "_harness_context": hctx}
@@ -784,6 +832,11 @@ def _etf_representative_monitor_v1(data_root: dict, hctx: dict, se: list) -> str
]) ])
md += "\n\n**ETF 대표 종목 추출 원칙**\n\n" md += "\n\n**ETF 대표 종목 추출 원칙**\n\n"
md += ( md += (
"- 섹터 프록시는 ETF 우선을 기본으로 두고, ETF가 실제로 있는 섹터는 ETF를 대표값으로 씁니다.\n"
"- 은행/증권/지주회사는 하나로 뭉치지 않고 각각 별도 섹터로 분리해 구성비 상위 종목을 증빙합니다.\n"
"- 방산/원전/건설/플랜트-EPC/로보틱스처럼 ETF 프록시가 있는 섹터는 ETF를 쓰고, 대표주 바스켓은 섹터별 기본 3종, 로보틱스는 5종으로 별도 모니터합니다.\n"
"- 로보틱스는 `RISE 현대차고정피지컬AI`를 섹터 프록시로 사용하고, 대표주는 해당 ETF의 실제 구성비 상위 5개 종목에서 뽑습니다.\n"
"- `Universe_Source=DEFAULT_TEMPLATE`인 행은 템플릿 경로이므로, 실제 시트 입력으로 바꿔 provenance를 완성해야 합니다.\n"
"- 대표 종목은 우선 ETF 구성비중이 가장 큰 종목을 선택하고, 그 종목이 현재 유동성/호가/추세 조건을 충족하는지로 계속 모니터링합니다.\n" "- 대표 종목은 우선 ETF 구성비중이 가장 큰 종목을 선택하고, 그 종목이 현재 유동성/호가/추세 조건을 충족하는지로 계속 모니터링합니다.\n"
"- 구성비중 데이터가 비어 있거나 비정상일 때만 같은 섹터의 유동성 우선 후보로 대체합니다.\n" "- 구성비중 데이터가 비어 있거나 비정상일 때만 같은 섹터의 유동성 우선 후보로 대체합니다.\n"
"- BUY_REVIEW는 ETF 수급이 대표 종목의 추세와 같이 붙을 때만 후보로 승격합니다.\n" "- BUY_REVIEW는 ETF 수급이 대표 종목의 추세와 같이 붙을 때만 후보로 승격합니다.\n"
@@ -796,7 +849,7 @@ def _etf_representative_monitor_v1(data_root: dict, hctx: dict, se: list) -> str
rep_states = [] rep_states = []
rep_weights = [] rep_weights = []
if isinstance(reps, list): if isinstance(reps, list):
for rep in reps[:3]: for rep in reps[:5]:
if isinstance(rep, dict): if isinstance(rep, dict):
rep_names.append(f"{rep.get('name', '')}({rep.get('ticker', '')})") rep_names.append(f"{rep.get('name', '')}({rep.get('ticker', '')})")
rep_states.append(str(rep.get("monitor_state", ""))) rep_states.append(str(rep.get("monitor_state", "")))
@@ -805,6 +858,7 @@ def _etf_representative_monitor_v1(data_root: dict, hctx: dict, se: list) -> str
"sector": row.get("sector", ""), "sector": row.get("sector", ""),
"etf_proxy_ticker": row.get("etf_proxy_ticker", ""), "etf_proxy_ticker": row.get("etf_proxy_ticker", ""),
"etf_proxy_name": row.get("etf_proxy_name", ""), "etf_proxy_name": row.get("etf_proxy_name", ""),
"universe_source": row.get("universe_source", ""),
"representative_basket": " / ".join(rep_names), "representative_basket": " / ".join(rep_names),
"representative_count": row.get("representative_count", ""), "representative_count": row.get("representative_count", ""),
"basket_weights": ", ".join(rep_weights), "basket_weights": ", ".join(rep_weights),
@@ -813,8 +867,8 @@ def _etf_representative_monitor_v1(data_root: dict, hctx: dict, se: list) -> str
"representative_basis_detail": row.get("representative_basis_detail", ""), "representative_basis_detail": row.get("representative_basis_detail", ""),
"basket_quality_state": row.get("basket_quality_state", ""), "basket_quality_state": row.get("basket_quality_state", ""),
"basket_coverage_pct": row.get("basket_coverage_pct", ""), "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_source": ", ".join(str(rep.get("selection_source", "")) for rep in reps[:5] if isinstance(rep, dict)),
"selection_score": ", ".join(str(rep.get("selection_score", "")) for rep in reps[:3] if isinstance(rep, dict)), "selection_score": ", ".join(str(rep.get("selection_score", "")) for rep in reps[:5] if isinstance(rep, dict)),
"basket_state": row.get("monitor_state", ""), "basket_state": row.get("monitor_state", ""),
"basket_buy_review_count": row.get("basket_buy_review_count", ""), "basket_buy_review_count": row.get("basket_buy_review_count", ""),
"basket_caution_count": row.get("basket_caution_count", ""), "basket_caution_count": row.get("basket_caution_count", ""),
@@ -823,7 +877,7 @@ def _etf_representative_monitor_v1(data_root: dict, hctx: dict, se: list) -> str
}) })
md += "\n\n**대표 종목 모니터 테이블**\n\n" md += "\n\n**대표 종목 모니터 테이블**\n\n"
md += _tbl(display_rows, [ md += _tbl(display_rows, [
"sector", "etf_proxy_ticker", "etf_proxy_name", "representative_basket", "sector", "etf_proxy_ticker", "etf_proxy_name", "universe_source", "representative_basket",
"representative_count", "basket_weights", "basket_states", "representative_basis", "representative_count", "basket_weights", "basket_states", "representative_basis",
"representative_basis_detail", "basket_quality_state", "basket_coverage_pct", "representative_basis_detail", "basket_quality_state", "basket_coverage_pct",
"selection_source", "selection_score", "basket_state", "basket_buy_review_count", "selection_source", "selection_score", "basket_state", "basket_buy_review_count",
@@ -1538,6 +1592,7 @@ def main() -> int:
"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), "portfolio_performance_summary": lambda: _portfolio_performance_summary(data_root, hctx, se),
"portfolio_sector_exposure_summary": lambda: _portfolio_sector_exposure_summary(data_root, hctx, se), "portfolio_sector_exposure_summary": lambda: _portfolio_sector_exposure_summary(data_root, hctx, se),
"sector_universe_refresh_audit_v1": lambda: _sector_universe_refresh_audit_v1(data_root, hctx, se),
"sector_trend_analysis_v1": lambda: _sector_trend_analysis_v1(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),
+80 -7
View File
@@ -9,8 +9,14 @@ from openpyxl.chart import BarChart, LineChart, Reference
from openpyxl.styles import Font, PatternFill, Alignment from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
import sys
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.quant_engine.sector_universe_refresh import build_sector_universe_refresh_audit
INPUT_XLSX = ROOT / "GatherTradingData.xlsx" INPUT_XLSX = ROOT / "GatherTradingData.xlsx"
OUTPUT_DIR = ROOT / "outputs" / "sector_insights_enhanced" OUTPUT_DIR = ROOT / "outputs" / "sector_insights_enhanced"
OUTPUT_XLSX = OUTPUT_DIR / "GatherTradingData_sector_insights.xlsx" OUTPUT_XLSX = OUTPUT_DIR / "GatherTradingData_sector_insights.xlsx"
@@ -593,10 +599,12 @@ def build_sector_summary(wb, data: dict) -> None:
ws["A20"] = "Notes" ws["A20"] = "Notes"
ws["A20"].fill = SUBHEADER_FILL ws["A20"].fill = SUBHEADER_FILL
ws["A20"].font = BOLD_FONT ws["A20"].font = BOLD_FONT
ws["A21"] = "섹터별 ETF 프록시와 스마트머니 방향이 다르면 매수 근거를 보수적으로 해석해야 합니다." ws["A21"] = "섹터별 ETF 프록시를 기준으로 보고, 은행/증권/지주회사는 분리해서 구성비 상위 종목을 증빙해야 합니다. 대표주 모니터는 섹터 기본 3종, 로보틱스 5종 바스켓으로 함께 확인해야 합니다."
ws["A21"].alignment = Alignment(wrap_text=True) ws["A21"].alignment = Alignment(wrap_text=True)
ws["A22"] = "데이터 결측은 하네스 업데이트가 필요합니다." ws["A22"] = "Universe_Source가 DEFAULT_TEMPLATE인 행은 템플릿이며, 실제 시트 입력으로 전환되어야 provenance가 완성됩니다."
ws["A22"].alignment = Alignment(wrap_text=True) ws["A22"].alignment = Alignment(wrap_text=True)
ws["A23"] = "다음 세분화 후보는 바이오/제약과 방산/우주처럼 현재 섹터를 더 세밀하게 나누는 방향입니다. 로보틱스는 RISE 현대차고정피지컬AI를 섹터 프록시로 사용하고, 대표주는 해당 ETF의 실제 구성비 상위 5개 종목에서 뽑습니다."
ws["A23"].alignment = Alignment(wrap_text=True)
chart = LineChart() chart = LineChart()
chart.title = "Average Sector Score / Breadth Trend" chart.title = "Average Sector Score / Breadth Trend"
@@ -622,11 +630,11 @@ def build_sector_analysis(wb, data: dict) -> None:
style_title( style_title(
ws, ws,
"섹터 동향 분석", "섹터 동향 분석",
"섹터별 ETF 프록시, 스마트머니 유입, 수익률, 유동성 방향을 함께 보는 상세 시트", "섹터별 ETF 프록시, 대표주 모니터, 스마트머니 유입, 수익률, 유동성 방향을 함께 보는 상세 시트",
end_col=18, end_col=18,
) )
headers = [ headers = [
"sector", "proxy_ticker", "proxy_name", "proxy_type", "etf_code", "sector", "proxy_ticker", "proxy_name", "proxy_type", "universe_source", "etf_code",
"etf_execution_use", "etf_liquidity_score", "etf_liquidity_status", "etf_nav_risk", "etf_execution_use", "etf_liquidity_score", "etf_liquidity_status", "etf_nav_risk",
"proxy_confidence", "rank", "rank_delta_w1", "rank_delta_w2", "sector_score", "proxy_confidence", "rank", "rank_delta_w1", "rank_delta_w2", "sector_score",
"score_delta", "sector_ret5d", "sector_ret20d", "etf_return_5d", "etf_return_20d", "score_delta", "sector_ret5d", "sector_ret20d", "etf_return_5d", "etf_return_20d",
@@ -661,7 +669,7 @@ def build_sector_analysis(wb, data: dict) -> None:
chart.x_axis.title = "20D Return" chart.x_axis.title = "20D Return"
chart.height = 8 chart.height = 8
chart.width = 14 chart.width = 14
data_ref = Reference(ws, min_col=17, min_row=4, max_row=4 + len(rows)) data_ref = Reference(ws, min_col=18, min_row=4, max_row=4 + len(rows))
cats = Reference(ws, min_col=1, min_row=5, 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.add_data(data_ref, titles_from_data=True)
chart.set_categories(cats) chart.set_categories(cats)
@@ -817,6 +825,67 @@ def build_sector_timeline(wb, data: dict, source_data: dict | None = None) -> No
ws.add_chart(money_chart, "L36") ws.add_chart(money_chart, "L36")
def build_sector_universe_refresh_audit_sheet(wb, source_data: dict) -> None:
ws = wb.create_sheet("sector_universe_refresh_audit")
style_sheet(ws)
style_title(
ws,
"섹터 월간 갱신 감사",
"Naver ETF 페이지 기반 구성종목 갱신 상태와 provenance 분리 상태를 점검하는 감사 시트. AJAX/XHR 전제는 두지 않고 HTML 서버렌더링 테이블을 우선한다.",
end_col=16,
)
payload = {"data": source_data}
audit = build_sector_universe_refresh_audit(payload)
summary = audit.get("summary") or {}
items = [
("formula_id", audit.get("formula_id", "")),
("gate", audit.get("gate", "")),
("sector_count", summary.get("sector_count", 0)),
("current_count", summary.get("current_count", 0)),
("due_count", summary.get("due_count", 0)),
("overdue_count", summary.get("overdue_count", 0)),
("layout_changed_count", summary.get("layout_changed_count", 0)),
("missing_count", summary.get("missing_count", 0)),
("template_count", summary.get("template_count", 0)),
("sheet_input_count", summary.get("sheet_input_count", 0)),
("naver_source_count", summary.get("naver_source_count", 0)),
("missing_source_url_count", summary.get("missing_source_url_count", 0)),
("stale_sector_count", summary.get("stale_sector_count", 0)),
]
add_kpi_block(ws, 4, items)
ws["D4"] = "Refresh policy"
ws["D4"].fill = SUBHEADER_FILL
ws["D4"].font = BOLD_FONT
ws["D5"] = "NAVER_ETF_PAGE rows are the monthly refreshed source."
ws["D6"] = "SHEET_INPUT rows are manual/provisional and must stay separate."
ws["D7"] = "DEFAULT_TEMPLATE rows are a fail in the monthly gate."
ws["D8"] = "Source_URL and Source_AsOf are required for provenance."
ws["D9"] = "This is HTML-server-rendered, not AJAX. JS is only a fallback probe for candidate URLs."
ws["D10"] = "No guessed holdings are written when the page layout changes."
ws["D11"] = "NAVER_ETF_PAGE_FAIL_LAYOUT_CHANGED is a separate layout-change failure state."
ws["D12"] = "Financial sectors are split as 은행 / 증권 / 지주회사 in sector_universe; sector_flow reflects carryover until GAS runDataFeed is rerun."
ws["D13"] = "This split is part of the monthly refresh harness; Source_URL and Source_AsOf must remain valid for provenance."
rows = audit.get("rows") or []
if rows:
headers = [
"sector", "proxy_ticker", "proxy_name", "proxy_type", "source_kind", "transport_mode",
"source_url", "source_asof", "age_days", "constituent_count",
"stock_count", "etf_count", "weight_sum", "status", "refresh_reason",
]
write_table(ws, 14, 1, headers, [[row.get(h, "") for h in headers] for row in rows])
for col, width in {
"A": 16, "B": 12, "C": 18, "D": 12, "E": 16, "F": 18, "G": 42, "H": 14,
"I": 10, "J": 14, "K": 12, "L": 12, "M": 12, "N": 12, "O": 24,
}.items():
ws.column_dimensions[col].width = width
ws.freeze_panes = "A5"
ws["A11"] = "Notes"
ws["A11"].fill = SUBHEADER_FILL
ws["A11"].font = BOLD_FONT
ws["A12"] = "홈페이지 리뉴얼로 표 구조가 바뀌면, 파서는 추정하지 않고 실패 상태를 남겨 월간 게이트에서 잡는다."
ws["A12"].alignment = Alignment(wrap_text=True)
def build_etf_summary(wb, data: dict) -> None: def build_etf_summary(wb, data: dict) -> None:
ws = wb.create_sheet("etf_representative_summary") ws = wb.create_sheet("etf_representative_summary")
style_sheet(ws) style_sheet(ws)
@@ -847,6 +916,7 @@ def build_etf_summary(wb, data: dict) -> None:
ws["D6"] = "2) Missing slots filled with same-sector live candidates" ws["D6"] = "2) Missing slots filled with same-sector live candidates"
ws["D7"] = "3) Missing data stays explicit as DATA_MISSING" ws["D7"] = "3) Missing data stays explicit as DATA_MISSING"
ws["D8"] = "4) Minimum 3 names per sector basket" ws["D8"] = "4) Minimum 3 names per sector basket"
ws["D9"] = "5) Universe_Source=DEFAULT_TEMPLATE rows are provisional until sheet-backed data exists."
ws["G4"] = "Top reps" ws["G4"] = "Top reps"
ws["G4"].fill = SUBHEADER_FILL ws["G4"].fill = SUBHEADER_FILL
ws["G4"].font = BOLD_FONT ws["G4"].font = BOLD_FONT
@@ -865,7 +935,7 @@ def build_etf_monitor(wb, data: dict) -> None:
end_col=18, end_col=18,
) )
headers = [ headers = [
"sector", "etf_proxy_ticker", "etf_proxy_name", "etf_proxy_type", "sector_rank", "sector", "etf_proxy_ticker", "etf_proxy_name", "etf_proxy_type", "universe_source", "sector_rank",
"sector_score", "sector_smart_money_5d_krw", "sector_ret20d", "representative_count", "sector_score", "sector_smart_money_5d_krw", "sector_ret20d", "representative_count",
"representative_ticker", "representative_name", "representative_basis", "representative_ticker", "representative_name", "representative_basis",
"representative_basis_detail", "constituent_weight", "basket_quality_state", "representative_basis_detail", "constituent_weight", "basket_quality_state",
@@ -894,7 +964,7 @@ def build_etf_monitor(wb, data: dict) -> None:
chart.x_axis.title = "Coverage %" chart.x_axis.title = "Coverage %"
chart.height = 8 chart.height = 8
chart.width = 14 chart.width = 14
data_ref = Reference(ws, min_col=16, min_row=4, max_row=4 + len(rows)) 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)) cats = Reference(ws, min_col=1, min_row=5, max_row=4 + len(rows))
chart.add_data(data_ref, titles_from_data=True) chart.add_data(data_ref, titles_from_data=True)
chart.set_categories(cats) chart.set_categories(cats)
@@ -922,6 +992,7 @@ def main() -> None:
"performance_readiness_summary", "performance_readiness_summary",
"operational_eval_queue_summary", "operational_eval_queue_summary",
"portfolio_sector_exposure", "portfolio_sector_exposure",
"sector_universe_refresh_audit",
"_portfolio_holdings_helper", "_portfolio_holdings_helper",
"sector_trend_summary", "sector_trend_summary",
"sector_trend_analysis", "sector_trend_analysis",
@@ -936,6 +1007,7 @@ def main() -> None:
build_performance_readiness_summary(wb) build_performance_readiness_summary(wb)
build_operational_eval_queue_summary(wb) build_operational_eval_queue_summary(wb)
build_portfolio_sector_exposure(wb) build_portfolio_sector_exposure(wb)
build_sector_universe_refresh_audit_sheet(wb, raw_source)
build_sector_timeline(wb, sector, raw_source) build_sector_timeline(wb, sector, raw_source)
build_sector_analysis(wb, sector) build_sector_analysis(wb, sector)
build_sector_summary(wb, sector) build_sector_summary(wb, sector)
@@ -949,6 +1021,7 @@ def main() -> None:
"performance_readiness_summary", "performance_readiness_summary",
"operational_eval_queue_summary", "operational_eval_queue_summary",
"portfolio_sector_exposure", "portfolio_sector_exposure",
"sector_universe_refresh_audit",
"sector_trend_summary", "sector_trend_summary",
"sector_trend_analysis", "sector_trend_analysis",
"sector_trend_timeline", "sector_trend_timeline",
@@ -21,6 +21,7 @@ REPORT_SECTION_ORDER = [
"single_conclusion", "immediate_execution_playbook", "market_context_learning_note", "single_conclusion", "immediate_execution_playbook", "market_context_learning_note",
"portfolio_performance_summary", "portfolio_performance_summary",
"portfolio_sector_exposure_summary", "portfolio_sector_exposure_summary",
"sector_universe_refresh_audit_v1",
"sector_trend_analysis_v1", "sector_trend_analysis_v1",
"etf_representative_monitor_v1", "etf_representative_monitor_v1",
"performance_readiness_summary", "performance_readiness_summary",