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

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
+60 -5
View File
@@ -17,6 +17,7 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.quant_engine.etf_representative_monitor import build_etf_representative_monitor
from src.quant_engine.sector_universe_refresh import build_sector_universe_refresh_audit
from src.quant_engine.sector_trend_analysis import build_sector_trend_analysis
SECTION_ORDER = [
@@ -25,6 +26,7 @@ SECTION_ORDER = [
"single_conclusion", "immediate_execution_playbook", "market_context_learning_note",
"portfolio_performance_summary",
"portfolio_sector_exposure_summary",
"sector_universe_refresh_audit_v1",
"sector_trend_analysis_v1", "etf_representative_monitor_v1", "investment_quality_headline", "operational_truth_score",
"execution_readiness_matrix", "pass_100_criteria",
"today_decision_summary_card", "routing_serving_trace",
@@ -59,6 +61,7 @@ SECTION_TITLES = {
"market_context_learning_note": "시장 컨텍스트 학습 노트",
"portfolio_performance_summary": "포트폴리오 성과 요약",
"portfolio_sector_exposure_summary": "포트폴리오 섹터 노출",
"sector_universe_refresh_audit_v1": "섹터 월간 갱신 감사",
"sector_trend_analysis_v1": "섹터 동향 분석",
"etf_representative_monitor_v1": "ETF 대표 종목 모니터",
"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 []
if 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",
"rank_delta_w1", "rank_delta_w2", "sector_score", "score_delta",
"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"
"- 현재 시계열은 스코어와 스마트머니 중심이므로, 다음 단계에서는 5D/20D 수익률 변화를 동일한 스파크라인 패널에 추가하는 것이 좋습니다.\n"
"- 포트폴리오 자금 패널은 목표 달성율, 드로우다운, 베타, 알파 신뢰도를 함께 묶어 보여줘야 실제 투자 판단과 연결됩니다.\n"
"- 다음 세분화 후보는 `바이오/제약`과 `방산/우주`처럼 현재 섹터를 더 세밀하게 나누는 방향입니다.\n"
)
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:
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}
@@ -784,6 +832,11 @@ def _etf_representative_monitor_v1(data_root: dict, hctx: dict, se: list) -> str
])
md += "\n\n**ETF 대표 종목 추출 원칙**\n\n"
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"
"- 구성비중 데이터가 비어 있거나 비정상일 때만 같은 섹터의 유동성 우선 후보로 대체합니다.\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_weights = []
if isinstance(reps, list):
for rep in reps[:3]:
for rep in reps[:5]:
if isinstance(rep, dict):
rep_names.append(f"{rep.get('name', '')}({rep.get('ticker', '')})")
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", ""),
"etf_proxy_ticker": row.get("etf_proxy_ticker", ""),
"etf_proxy_name": row.get("etf_proxy_name", ""),
"universe_source": row.get("universe_source", ""),
"representative_basket": " / ".join(rep_names),
"representative_count": row.get("representative_count", ""),
"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", ""),
"basket_quality_state": row.get("basket_quality_state", ""),
"basket_coverage_pct": row.get("basket_coverage_pct", ""),
"selection_source": ", ".join(str(rep.get("selection_source", "")) for rep in reps[:3] if isinstance(rep, dict)),
"selection_score": ", ".join(str(rep.get("selection_score", "")) for rep in reps[:3] if isinstance(rep, dict)),
"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[:5] if isinstance(rep, dict)),
"basket_state": row.get("monitor_state", ""),
"basket_buy_review_count": row.get("basket_buy_review_count", ""),
"basket_caution_count": row.get("basket_caution_count", ""),
@@ -823,7 +877,7 @@ def _etf_representative_monitor_v1(data_root: dict, hctx: dict, se: list) -> str
})
md += "\n\n**대표 종목 모니터 테이블**\n\n"
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_basis_detail", "basket_quality_state", "basket_coverage_pct",
"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),
"portfolio_performance_summary": lambda: _portfolio_performance_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),
"investment_quality_headline": lambda: _investment_quality_headline(hctx, se),
"operational_truth_score": lambda: _operational_truth_score(hctx, se),