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
+82 -12
View File
@@ -93,9 +93,17 @@ def style_sheet(ws) -> None:
def extract_sheet_rows(wb, sheet_name: str) -> tuple[list[str], list[list]]:
ws = wb[sheet_name]
headers = [ws.cell(1, c).value for c in range(1, ws.max_column + 1)]
first_row = [ws.cell(1, c).value for c in range(1, ws.max_column + 1)]
second_row = [ws.cell(2, c).value for c in range(1, ws.max_column + 1)] if ws.max_row >= 2 else []
use_second_row = (
bool(second_row)
and not any(v not in (None, "") for v in first_row)
and any(v not in (None, "") for v in second_row)
)
headers = second_row if use_second_row else first_row
start_row = 3 if use_second_row else 2
rows: list[list] = []
for r in range(2, ws.max_row + 1):
for r in range(start_row, ws.max_row + 1):
row = [ws.cell(r, c).value for c in range(1, ws.max_column + 1)]
if any(v is not None and v != "" for v in row):
rows.append(row)
@@ -103,6 +111,9 @@ def extract_sheet_rows(wb, sheet_name: str) -> tuple[list[str], list[list]]:
def build_portfolio_summary(wb) -> None:
def display(value):
return value if value not in (None, "") else "DATA_MISSING — 하네스 업데이트 필요"
daily_headers, daily_rows = extract_sheet_rows(wb, "daily_history")
monthly_headers, monthly_rows = extract_sheet_rows(wb, "monthly_history")
account_headers, account_rows = extract_sheet_rows(wb, "account_snapshot")
@@ -143,16 +154,16 @@ def build_portfolio_summary(wb) -> None:
total_pl = sum(r[11] for r in holdings_sorted if len(r) > 11 and isinstance(r[11], (int, float)))
items = [
("latest_daily_asset", latest_total_asset or ""),
("latest_peak_asset", latest_peak_asset or ""),
("latest_daily_mdd_pct", latest_mdd or ""),
("latest_month_total_asset", latest_month_total or ""),
("latest_month_return_pct", latest_month_return or ""),
("latest_ytd_return_pct", latest_ytd_return or ""),
("latest_capture", latest_capture or ""),
("latest_holdings_count", len(latest_holdings)),
("latest_holdings_market_value", total_mv),
("latest_holdings_profit_loss", total_pl),
("latest_daily_asset", display(latest_total_asset)),
("latest_peak_asset", display(latest_peak_asset)),
("latest_daily_mdd_pct", display(latest_mdd)),
("latest_month_total_asset", display(latest_month_total)),
("latest_month_return_pct", display(latest_month_return)),
("latest_ytd_return_pct", display(latest_ytd_return)),
("latest_capture", display(latest_capture)),
("latest_holdings_count", display(len(latest_holdings))),
("latest_holdings_market_value", display(total_mv)),
("latest_holdings_profit_loss", display(total_pl)),
]
add_kpi_block(ws, 4, items)
@@ -248,6 +259,7 @@ def build_portfolio_sector_exposure(wb) -> None:
latest_rows = [r for r in account_dicts if str(r.get("captured_at", "") or "") == latest_capture]
exposure: dict[str, dict[str, float]] = {}
sector_holdings: dict[str, list[dict[str, object]]] = {}
for row in latest_rows:
ticker = str(row.get("ticker", "") or "").zfill(6)
sector = sector_map.get(ticker, "미분류")
@@ -259,6 +271,13 @@ def build_portfolio_sector_exposure(wb) -> None:
bucket["profit_loss"] += pl
bucket["cost"] += cost
bucket["count"] += 1
sector_holdings.setdefault(sector, []).append({
"ticker": ticker,
"name": row.get("name", ""),
"market_value": mv,
"profit_loss": pl,
"return_pct": row.get("return_pct", ""),
})
total_mv = sum(v["market_value"] for v in exposure.values()) or 1.0
rows = []
@@ -314,6 +333,57 @@ def build_portfolio_sector_exposure(wb) -> None:
chart.legend = None
ws.add_chart(chart, "J4")
detail_rows: list[list[object]] = []
for sector, vals in sorted(exposure.items(), key=lambda kv: kv[1]["market_value"], reverse=True)[:5]:
sector_mv = vals["market_value"] or 1.0
holdings = sorted(sector_holdings.get(sector, []), key=lambda item: float(item.get("market_value", 0) or 0), reverse=True)[:3]
for idx, holding in enumerate(holdings, start=1):
mv = float(holding.get("market_value", 0) or 0)
detail_rows.append([
sector if idx == 1 else "",
idx,
holding.get("ticker", ""),
holding.get("name", ""),
mv,
mv / sector_mv * 100.0,
mv / total_mv * 100.0,
holding.get("return_pct", ""),
])
ws["A18"] = "Sector top holdings detail"
ws["A18"].fill = SUBHEADER_FILL
ws["A18"].font = BOLD_FONT
write_table(
ws,
19,
1,
["sector", "rank_in_sector", "ticker", "name", "market_value", "sector_weight_pct", "portfolio_weight_pct", "return_pct"],
detail_rows,
)
ws.column_dimensions["I"].width = 18
ws.column_dimensions["J"].width = 18
ws.column_dimensions["K"].width = 18
ws.column_dimensions["L"].width = 18
ws.column_dimensions["M"].width = 18
if detail_rows:
detail_chart = BarChart()
detail_chart.type = "bar"
detail_chart.style = 11
detail_chart.title = "Top Holdings Contribution"
detail_chart.y_axis.title = "Holding"
detail_chart.x_axis.title = "Portfolio Weight %"
detail_chart.height = 8
detail_chart.width = 14
# Use the first 15 rows of the detail table for a readable chart.
chart_end_row = 19 + min(len(detail_rows), 15)
data_ref2 = Reference(ws, min_col=7, min_row=19, max_row=chart_end_row)
cats2 = Reference(ws, min_col=4, min_row=20, max_row=chart_end_row)
detail_chart.add_data(data_ref2, titles_from_data=True)
detail_chart.set_categories(cats2)
detail_chart.legend = None
ws.add_chart(detail_chart, "J20")
def build_sector_summary(wb, data: dict) -> None:
ws = wb.create_sheet("sector_trend_summary")