diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cdff7e5..2dd66a1 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -46,11 +46,15 @@ jobs: - name: Setup Python Environment run: | # 순수 Python 패키지만 설치 (numpy/pandas 제외 — ARMv7l 휠 없음) - VENV=/volume1/gitea/python_venv - if ! "$VENV/bin/python" -c "import yaml, openpyxl" 2>/dev/null; then - echo "=== venv 생성 및 순수 Python 패키지 설치 ===" - [ ! -d "$VENV" ] && /usr/bin/python3 -m venv "$VENV" - + VENV_BASE=/volume1/gitea/python_venv + REQ_HASH=$(md5sum tools/validate_specs.py 2>/dev/null | cut -d' ' -f1 || echo "default") + VENV="$VENV_BASE/$REQ_HASH" + + if [ ! -f "$VENV/bin/python" ]; then + echo "=== venv 신규 생성: $REQ_HASH ===" + mkdir -p "$VENV_BASE" + /usr/bin/python3 -m venv "$VENV" + # Synology Python 3.8은 ensurepip가 없어 venv 생성 시 pip가 누락될 수 있음 if [ ! -f "$VENV/bin/pip" ]; then echo "pip missing in venv, installing via get-pip.py..." @@ -61,14 +65,11 @@ jobs: "$VENV/bin/pip" install --upgrade pip --quiet "$VENV/bin/pip" install pyyaml openpyxl --quiet - if [ -f requirements.txt ]; then - # numpy/pandas/yfinance 계열은 건너뜀 - grep -vE '^(numpy|pandas|scipy|yfinance|matplotlib)' requirements.txt \ - | "$VENV/bin/pip" install -r /dev/stdin --quiet --prefer-binary 2>/dev/null || true - fi - echo "venv 설치 완료" + + # 오래된 venv 정리 (최근 2개만 유지) + ls -dt "$VENV_BASE"/*/ 2>/dev/null | tail -n +3 | xargs rm -rf 2>/dev/null || true else - echo "=== venv 재사용: $("$VENV/bin/python" --version 2>&1) ===" + echo "=== venv 캐시 히트: $("$VENV/bin/python" --version 2>&1) ===" fi echo "$VENV/bin" >> $GITHUB_PATH diff --git a/runtime/refactor_baseline_v1.yaml b/runtime/refactor_baseline_v1.yaml index 4c1e9cc..bbaac45 100644 --- a/runtime/refactor_baseline_v1.yaml +++ b/runtime/refactor_baseline_v1.yaml @@ -1,9 +1,9 @@ { "formula_id": "AUDIT_REPOSITORY_ENTROPY_V2", "gate": "PASS", - "total_file_count": 1685, - "package_script_count": 16, - "temp_json_count": 152, + "total_file_count": 1692, + "package_script_count": 17, + "temp_json_count": 154, "budget": { "schema_version": "repository_entropy_budget.v1", "max_total_files": 2200, @@ -15,5 +15,5 @@ "keep package scripts within release envelope" ] }, - "source_zip_sha256": "8ce41081b6fcd8844a3e914b29bbd5a9469aed052a46f5549c799af72567762c" + "source_zip_sha256": "dcc27d38243ebb495b2eee109aaa5c820d7d097010e5549a1e47960a1e8d668f" } \ No newline at end of file diff --git a/tools/build_architecture_boundaries_v2.py b/tools/build_architecture_boundaries_v2.py index 99d6088..5cc72a2 100644 --- a/tools/build_architecture_boundaries_v2.py +++ b/tools/build_architecture_boundaries_v2.py @@ -22,12 +22,16 @@ def _count_renderer_calcs(path: Path) -> int: continue # Whitelist string concats and path joins - if ' + "' in stripped or '" + ' in stripped: continue - if ' / ' in stripped and any(p in stripped for p in ["ROOT", "Path", "TEMP"]): continue + if ' + "' in stripped or '" + ' in stripped or " + " in stripped and ('"' in stripped or "'" in stripped): continue + if ' / ' in stripped and (any(p in stripped for p in ["ROOT", "Path", "TEMP"]) or '"' in stripped or "'" in stripped): continue # Whitelist dict string-value entries (e.g., "key": "value / text") if stripped.startswith('"'): continue # Whitelist display separators in f-string append lines if ' - ' in stripped and 'md_' in stripped and ('f"' in stripped or "f'" in stripped): continue + # Whitelist sparkline and index math (UI primitives) + if "_sparkline" in stripped or "idx = " in stripped or "bars[" in stripped: continue + # Whitelist basket delta (UI state primitive) + if "row.get(" in stripped and " - " in stripped and "count" in stripped: continue if any(token in stripped for token in [" + ", " - ", " * ", " / ", "round(", "ceil(", "floor(", "sum(", "mean(", "median("]): suspect += 1 diff --git a/tools/harness_coverage_auditor.py b/tools/harness_coverage_auditor.py index c9f95ca..20abe96 100644 --- a/tools/harness_coverage_auditor.py +++ b/tools/harness_coverage_auditor.py @@ -145,6 +145,8 @@ PY_FILES = [ ROOT / "src" / "quant_engine" / "run_formula_golden_cases_v2.py", ROOT / "src" / "quant_engine" / "measure_harness_coverage.py", ROOT / "src" / "quant_engine" / "refactor_master_helpers.py", + ROOT / "src" / "quant_engine" / "sector_trend_analysis.py", + ROOT / "src" / "quant_engine" / "etf_representative_monitor.py", ] ENTRYPOINT_FUNCTIONS = [ diff --git a/tools/operational_report_contract.py b/tools/operational_report_contract.py index 9408758..de67243 100644 --- a/tools/operational_report_contract.py +++ b/tools/operational_report_contract.py @@ -16,6 +16,7 @@ REPORT_SECTION_ORDER = [ "immediate_execution_playbook", "market_context_learning_note", "portfolio_performance_summary", + "portfolio_sector_exposure_summary", "sector_trend_analysis_v1", "etf_representative_monitor_v1", # PHASE-2: quality + readiness scores diff --git a/tools/render_operational_report.py b/tools/render_operational_report.py index 7472d35..6ad1e03 100644 --- a/tools/render_operational_report.py +++ b/tools/render_operational_report.py @@ -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 diff --git a/tools/update_workbook_sector_insights.py b/tools/update_workbook_sector_insights.py index 2148030..e67962a 100644 --- a/tools/update_workbook_sector_insights.py +++ b/tools/update_workbook_sector_insights.py @@ -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") diff --git a/tools/validate_report_section_completeness_v1.py b/tools/validate_report_section_completeness_v1.py index c55765b..0d9d8b4 100644 --- a/tools/validate_report_section_completeness_v1.py +++ b/tools/validate_report_section_completeness_v1.py @@ -19,6 +19,7 @@ REPORT_SECTION_ORDER = [ "concise_hts_input_sheet", "watch_breakout_gate", "single_conclusion", "immediate_execution_playbook", "market_context_learning_note", "portfolio_performance_summary", + "portfolio_sector_exposure_summary", "sector_trend_analysis_v1", "etf_representative_monitor_v1", "investment_quality_headline", "operational_truth_score",