fix: CI venv hash-cache + sector exposure renderer + auditor registration

- ci.yml: venv 해시 기반 캐싱 적용 (validate_specs.py md5 기준), requirements.txt 불필요 스텝 제거
- harness_coverage_auditor.py: sector_trend_analysis.py, etf_representative_monitor.py PY_FILES 등록
- render_operational_report.py: _portfolio_sector_exposure_summary 개선 — account_snapshot 실데이터 집계 + Top5 섹터별 상위 보유 종목 상세 테이블 + _display() 누락값 표시
- update_workbook_sector_insights.py: row-2 헤더 처리 + sector_holdings 상세 추적 + _display() 누락값 표시
- operational_report_contract.py: portfolio_sector_exposure_summary REPORT_SECTION_ORDER 등록
- validate_report_section_completeness_v1.py: 동일 섹션 추가
- build_architecture_boundaries_v2.py: sparkline/idx/basket-delta UI 프리미티브 whitelist 추가
- runtime/refactor_baseline_v1.yaml: 엔트로피 베이스라인 갱신 (1692 files, gate=PASS)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 21:16:32 +09:00
parent fe52aef56e
commit 0823d1b5a8
8 changed files with 230 additions and 45 deletions
+121 -15
View File
@@ -304,6 +304,9 @@ def _market_context_learning_note(hctx: dict, se: list) -> str:
def _portfolio_performance_summary(data_root: dict, hctx: dict, se: list) -> str:
def _display(v: Any) -> Any:
return v if v not in (None, "") else "DATA_MISSING — 하네스 업데이트 필요"
data = data_root.get("data", {}) if isinstance(data_root.get("data"), dict) else {}
daily = _sj(data.get("daily_history", []))
monthly = _sj(data.get("monthly_history", []))
@@ -340,14 +343,14 @@ def _portfolio_performance_summary(data_root: dict, hctx: dict, se: list) -> str
monthly_return_series.append(row.get("Actual_Return_Pct", row.get("actual_return_pct", "")))
rows = [
("최신 일간 자산", latest_daily.get("Total_Asset_KRW", latest_daily.get("total_asset_krw", ""))),
("최신 일간 MDD(%)", latest_daily.get("MDD_Pct", latest_daily.get("mdd_pct", ""))),
("최신 월간 자산", latest_month.get("Total_Asset", latest_month.get("total_asset", ""))),
("최신 월간 실현 수익률(%)", latest_month.get("Actual_Return_Pct", latest_month.get("actual_return_pct", ""))),
("최신 월간 MoM 수익률(%)", latest_month.get("MoM_Return_Pct", latest_month.get("mom_return_pct", ""))),
("최신 월간 YTD 수익률(%)", latest_month.get("YTD_Return_Pct", latest_month.get("ytd_return_pct", ""))),
("최신 스냅샷 시각", latest_capture or hctx.get("captured_at", "")),
("최신 보유 수", len(latest_holdings)),
("최신 일간 자산", _display(latest_daily.get("Total_Asset_KRW", latest_daily.get("total_asset_krw", "")))),
("최신 일간 MDD(%)", _display(latest_daily.get("MDD_Pct", latest_daily.get("mdd_pct", "")))),
("최신 월간 자산", _display(latest_month.get("Total_Asset", latest_month.get("total_asset", "")))),
("최신 월간 실현 수익률(%)", _display(latest_month.get("Actual_Return_Pct", latest_month.get("actual_return_pct", "")))),
("최신 월간 MoM 수익률(%)", _display(latest_month.get("MoM_Return_Pct", latest_month.get("mom_return_pct", "")))),
("최신 월간 YTD 수익률(%)", _display(latest_month.get("YTD_Return_Pct", latest_month.get("ytd_return_pct", "")))),
("최신 스냅샷 시각", _display(latest_capture or hctx.get("captured_at", ""))),
("최신 보유 수", _display(len(latest_holdings))),
]
md = "## 포트폴리오 성과 요약\n\n" + _kv(rows)
md += "\n\n**일간 자산 추이** \n" + _sparkline(asset_series)
@@ -362,15 +365,118 @@ def _portfolio_performance_summary(data_root: dict, hctx: dict, se: list) -> str
def _portfolio_sector_exposure_summary(data_root: dict, hctx: dict, se: list) -> str:
raw = hctx.get("sector_concentration_json", [])
sectors = _sj(raw) if isinstance(raw, str) else raw
if not isinstance(sectors, list) or not sectors:
data = data_root.get("data", {}) if isinstance(data_root.get("data"), dict) else {}
account = _sj(data.get("account_snapshot", []))
universe = _sj(data.get("universe", []))
if not isinstance(account, list):
account = []
if not isinstance(universe, list):
universe = []
sector_map: dict[str, str] = {}
for row in universe:
if not isinstance(row, dict):
continue
ticker = str(row.get("Ticker", "") or "").zfill(6)
sector = str(row.get("Sector", "") or "").strip()
if ticker and sector:
sector_map[ticker] = sector
latest_capture = ""
for row in account:
if not isinstance(row, dict):
continue
cap = str(row.get("captured_at", "") or "")
if cap and cap >= latest_capture:
latest_capture = cap
latest_rows = [r for r in account if isinstance(r, dict) and str(r.get("captured_at", "") or "") == latest_capture]
if not latest_rows:
return "## 포트폴리오 섹터 노출\n\n_섹터 노출 데이터 없음_"
conc_gate = str(hctx.get("sector_concentration_gate") or "")
exposure: dict[str, dict[str, float]] = {}
holdings_by_sector: dict[str, list[dict[str, Any]]] = {}
total_mv = 0.0
for row in latest_rows:
ticker = str(row.get("ticker", "") or "").zfill(6)
sector = sector_map.get(ticker, "미분류")
mv = _num(row.get("market_value", 0))
pl = _num(row.get("profit_loss", 0))
cost = _num(row.get("total_cost", 0))
total_mv += mv
bucket = exposure.setdefault(sector, {"market_value": 0.0, "profit_loss": 0.0, "cost": 0.0, "count": 0.0})
bucket["market_value"] += mv
bucket["profit_loss"] += pl
bucket["cost"] += cost
bucket["count"] += 1
holdings_by_sector.setdefault(sector, []).append({
"ticker": ticker,
"name": row.get("name", ""),
"market_value": mv,
"profit_loss": pl,
"return_pct": row.get("return_pct", ""),
})
total_mv = total_mv or 1.0
sector_rows = []
for sector, vals in sorted(exposure.items(), key=lambda kv: kv[1]["market_value"], reverse=True):
pct = vals["market_value"] / total_mv * 100.0
ret_pct = (vals["profit_loss"] / vals["cost"] * 100.0) if vals["cost"] else 0.0
sector_rows.append({
"sector": sector,
"holding_count": int(vals["count"]),
"market_value": round(vals["market_value"], 2),
"weight_pct": round(pct, 2),
"profit_loss": round(vals["profit_loss"], 2),
"return_pct": round(ret_pct, 2),
})
top_sector = sector_rows[0]["sector"] if sector_rows else ""
top_sector_weight = sector_rows[0]["weight_pct"] if sector_rows else 0
top3_weight = sum(r["weight_pct"] for r in sector_rows[:3]) if sector_rows else 0
weights_line = _sparkline([r["weight_pct"] for r in sector_rows[:10]])
md = "## 포트폴리오 섹터 노출\n\n"
md += _kv([("섹터 집중 게이트", conc_gate)])
md += "\n\n"
md += _tbl(sectors, ["sector", "weight_pct", "gate"], max_rows=20)
md += _kv([
("최신 스냅샷", latest_capture),
("섹터 수", len(sector_rows)),
("최대 섹터", top_sector),
("Top1 비중(%)", top_sector_weight),
("Top3 비중(%)", top3_weight),
("총 시장가치", round(total_mv, 2)),
("섹터 집중도 그래프", weights_line),
("섹터 집중 게이트", hctx.get("sector_concentration_gate", "")),
])
md += "\n\n**섹터 요약**\n\n"
md += _tbl(sector_rows, ["sector", "holding_count", "market_value", "weight_pct", "profit_loss", "return_pct"], max_rows=20)
detail_rows: list[dict[str, Any]] = []
for sector in [r["sector"] for r in sector_rows[:5]]:
sector_total = exposure.get(sector, {}).get("market_value", 0.0) or 1.0
holdings = sorted(holdings_by_sector.get(sector, []), key=lambda item: _num(item.get("market_value", 0)), reverse=True)[:3]
for rank, holding in enumerate(holdings, start=1):
mv = _num(holding.get("market_value", 0))
detail_rows.append({
"sector": sector if rank == 1 else "",
"rank_in_sector": rank,
"ticker": holding.get("ticker", ""),
"name": holding.get("name", ""),
"market_value": round(mv, 2),
"sector_weight_pct": round(mv / sector_total * 100.0, 2),
"portfolio_weight_pct": round(mv / total_mv * 100.0, 2),
"return_pct": holding.get("return_pct", ""),
})
if detail_rows:
md += "\n\n**섹터별 상위 보유 기여도**\n\n"
md += _tbl(detail_rows, [
"sector", "rank_in_sector", "ticker", "name", "market_value",
"sector_weight_pct", "portfolio_weight_pct", "return_pct",
], max_rows=20)
md += "\n\n**해석 메모**\n\n"
md += (
"- 섹터 비중은 시장가치 기준이며, 상위 섹터의 비중과 상위 보유 종목이 실제 노출을 만든다.\n"
"- 같은 섹터 안에서도 상위 3종목이 노출 대부분을 설명하는지 확인해야 한다.\n"
"- ETF 프록시와 직접 보유 종목이 엇갈리면, 섹터 베타와 개별 종목 리스크를 분리해서 봐야 한다.\n"
)
return md