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