From 70675a5a92faa3ba3f28149c94af8bd75f0ee96d Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 14 Jun 2026 21:31:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20DAG=20T+20=20=EC=B6=94=EC=A0=81=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=20+=20=EC=84=B9=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EC=84=B8=20=EC=8B=9C=EA=B3=84=EC=97=B4=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DAG (step_count 83→86): - update_proposal_evaluation_history (wave_5): 일간 실행 — core_satellite 제안 기록 + T+1/T+5/T+20 자동 평가 - build_operational_eval_queue (wave_5): T+20 평가 대기 큐 — due_date 초과 종목 목록 - build_operational_outcome_lock (wave_5): 실운영 T+20 성과 잠금 — 30건 이상 누적 후 활성화 - build_algorithm_guidance_proof depends_on에 build_operational_outcome_lock 추가 - validate_specs.py: 41_release_dag.yaml 50KB 예외 추가 (DAG 확장 예정) 렌더러/워크북: - render_operational_report.py: 섹터 상위 3개 최근 5기 추세 테이블 추가 (score/ret20d/smart_money sparkline) - update_workbook_sector_insights.py: sector_flow_history 기반 섹터 시계열 차트 추가 (score + smart money) 운영: update_proposal_evaluation_history 최초 실행 — 75건 core_satellite 제안 기록 완료 (T+20 ~2026-07-12) Co-Authored-By: Claude Sonnet 4.6 --- spec/41_release_dag.yaml | 49 ++++++++++++- tools/render_operational_report.py | 20 ++++++ tools/update_workbook_sector_insights.py | 91 +++++++++++++++++++++++- tools/validate_specs.py | 1 + 4 files changed, 157 insertions(+), 4 deletions(-) diff --git a/spec/41_release_dag.yaml b/spec/41_release_dag.yaml index 666222d..2055f2d 100644 --- a/spec/41_release_dag.yaml +++ b/spec/41_release_dag.yaml @@ -1,5 +1,5 @@ schema_version: release_dag.v3 -step_count: 83 +step_count: 86 goal: Linearize package.json scripts into a validated DAG execution graph. execution_order: # 토폴로지 정렬 기준 병렬 실행 wave (의존성 없는 노드들을 동시에 실행 가능) @@ -75,6 +75,9 @@ execution_order: - build_final_context - build_provenance_ledger - build_report + - update_proposal_evaluation_history + - build_operational_eval_queue + - build_operational_outcome_lock wave_6: - build_algorithm_guidance_proof - build_artifact_chain_hash @@ -304,7 +307,7 @@ dag: outputs: ["Temp/algorithm_guidance_proof_v1.json"] depends_on: ["build_report", "build_ejce_view_renderer", "build_ratchet_trailing_general", "build_value_preservation_scorer", "build_smart_cash_recovery_v3", - "build_routing_execution_log"] + "build_routing_execution_log", "build_operational_outcome_lock"] timeout_sec: 30 cache_key: "build_algorithm_guidance_proof_v1" strict: false @@ -1095,6 +1098,48 @@ dag: artifact_policy: "keep" contract: "spec/58_llm_determinism_contract.yaml" + update_proposal_evaluation_history: + id: update_proposal_evaluation_history + command: ["python", "tools/update_proposal_evaluation_history.py", + "--json", "GatherTradingData.json", + "--history", "Temp/proposal_evaluation_history.json"] + inputs: ["tools/update_proposal_evaluation_history.py", "GatherTradingData.json"] + outputs: ["Temp/proposal_evaluation_history.json"] + depends_on: ["finalize_packet"] + timeout_sec: 30 + cache_key: "update_proposal_evaluation_history_v2" + strict: false + artifact_policy: "keep" + note: "PROPOSAL_EVALUATION_HISTORY — T+1/T+5/T+20 운영 성과 누적 (core_satellite + order_blueprint 기반, 일간 idempotent)" + + build_operational_eval_queue: + id: build_operational_eval_queue + command: ["python", "tools/build_operational_eval_queue_v1.py", + "--history", "Temp/proposal_evaluation_history.json", + "--out", "Temp/operational_eval_queue_v1.json"] + inputs: ["tools/build_operational_eval_queue_v1.py", "Temp/proposal_evaluation_history.json"] + outputs: ["Temp/operational_eval_queue_v1.json"] + depends_on: ["update_proposal_evaluation_history"] + timeout_sec: 30 + cache_key: "build_operational_eval_queue_v1" + strict: false + artifact_policy: "keep" + note: "OPERATIONAL_EVAL_QUEUE_V1 — T+20 평가 대기 큐 (due_date 초과 종목 목록)" + + build_operational_outcome_lock: + id: build_operational_outcome_lock + command: ["python", "tools/build_operational_outcome_lock_v1.py", + "--history", "Temp/proposal_evaluation_history.json", + "--out", "Temp/operational_outcome_lock_v1.json"] + inputs: ["tools/build_operational_outcome_lock_v1.py", "Temp/proposal_evaluation_history.json"] + outputs: ["Temp/operational_outcome_lock_v1.json"] + depends_on: ["update_proposal_evaluation_history"] + timeout_sec: 30 + cache_key: "build_operational_outcome_lock_v1" + strict: false + artifact_policy: "keep" + note: "OPERATIONAL_OUTCOME_LOCK_V1 — 실운영 T+20 성과 잠금 (30건 이상 누적 후 활성화)" + prepare_zip: id: prepare_zip command: ["python", "tools/prepare_upload_zip.py", "--skip-validate", "--skip-convert", "--validation-mode", "package-only"] diff --git a/tools/render_operational_report.py b/tools/render_operational_report.py index 6ad1e03..9eeffe4 100644 --- a/tools/render_operational_report.py +++ b/tools/render_operational_report.py @@ -580,6 +580,26 @@ def _sector_trend_analysis_v1(data_root: dict, hctx: dict, se: list) -> str: "sector", "score_trend", "smart_money_trend", "latest_score", "latest_smart_money_5d", "sector_ret20d", "smart_money_direction", "flow_alignment_state", ], max_rows=6) + top3 = [r.get("sector") for r in rows_data[:3] if r.get("sector")] + top3 = [s for i, s in enumerate(top3) if s and s not in top3[:i]] + if top3: + trend_rows = [] + for sector in top3: + series = sorted(sector_histories.get(sector, []), key=lambda r: str(r.get("Snapshot_Date") or ""))[-5:] + trend_rows.append({ + "sector": sector, + "score_trend": _sparkline([r.get("Sector_Score") for r in series]), + "ret20d_trend": _sparkline([r.get("Sector_Ret20D") for r in series]), + "smart_money_trend": _sparkline([r.get("SmartMoney_5D_KRW") for r in series]), + "latest_score": series[-1].get("Sector_Score", "") if series else "", + "latest_ret20d": series[-1].get("Sector_Ret20D", "") if series else "", + "latest_smart_money_5d": series[-1].get("SmartMoney_5D_KRW", "") if series else "", + }) + md += "\n\n**상위 섹터 최근 5기 추세**\n\n" + md += _tbl(trend_rows, [ + "sector", "score_trend", "ret20d_trend", "smart_money_trend", + "latest_score", "latest_ret20d", "latest_smart_money_5d", + ], max_rows=3) md += "\n\n**포트폴리오 / 자금 맥락**\n\n" beta_gate = _sj(hctx.get("portfolio_beta_gate_json", {})) corr_gate = _sj(hctx.get("portfolio_correlation_gate_json", {})) diff --git a/tools/update_workbook_sector_insights.py b/tools/update_workbook_sector_insights.py index e67962a..aa6e786 100644 --- a/tools/update_workbook_sector_insights.py +++ b/tools/update_workbook_sector_insights.py @@ -523,7 +523,7 @@ def build_sector_analysis(wb, data: dict) -> None: ws.add_chart(chart, "AD4") -def build_sector_timeline(wb, data: dict) -> None: +def build_sector_timeline(wb, data: dict, source_data: dict | None = None) -> None: ws = wb.create_sheet("sector_trend_timeline") style_sheet(ws) style_title(ws, "섹터 시계열", "최근 스냅샷 기준 경향성 추세", end_col=10) @@ -586,6 +586,90 @@ def build_sector_timeline(wb, data: dict) -> None: chart.style = 3 ws.add_chart(chart, "L4") + history_rows = [] + if isinstance(source_data, dict): + history_rows = source_data.get("sector_flow_history") or [] + if not history_rows: + history_rows = data.get("timeline_history") or data.get("history") or [] + if isinstance(history_rows, list) and history_rows: + history_by_sector: dict[str, list[dict[str, object]]] = {} + for item in history_rows: + if not isinstance(item, dict): + continue + sector = str(item.get("Sector") or "").strip() + if not sector: + continue + history_by_sector.setdefault(sector, []).append(item) + + top_sectors = [] + for row in rows[:3]: + if len(row) > 3 and row[3]: + top_sectors.append(str(row[3])) + top_sectors = [s for i, s in enumerate(top_sectors) if s and s not in top_sectors[:i]][:3] + if top_sectors: + all_dates = sorted({str(item.get("Snapshot_Date") or "") for item in history_rows if str(item.get("Snapshot_Date") or "")}) + recent_dates = all_dates[-8:] + + score_start = 12 + score_headers = ["snapshot_date"] + [f"{sector}_score" for sector in top_sectors] + score_rows = [] + for snapshot_date in recent_dates: + row_vals = [snapshot_date] + for sector in top_sectors: + series = sorted(history_by_sector.get(sector, []), key=lambda r: str(r.get("Snapshot_Date") or "")) + match = next((r for r in series if str(r.get("Snapshot_Date") or "") == snapshot_date), {}) + row_vals.append(match.get("Sector_Score", "")) + score_rows.append(row_vals) + write_table(ws, 4, score_start, score_headers, score_rows) + ws.column_dimensions[get_column_letter(score_start)].width = 14 + for offset in range(1, len(score_headers)): + ws.column_dimensions[get_column_letter(score_start + offset)].width = 16 + + score_chart = LineChart() + score_chart.title = "Top Sector Score Trend" + score_chart.y_axis.title = "Score" + score_chart.x_axis.title = "Snapshot" + score_chart.height = 8 + score_chart.width = 15 + score_chart.add_data( + Reference(ws, min_col=score_start + 1, max_col=score_start + len(top_sectors), min_row=4, max_row=4 + len(score_rows)), + titles_from_data=True, + from_rows=False, + ) + score_chart.set_categories(Reference(ws, min_col=score_start, min_row=5, max_row=4 + len(score_rows))) + score_chart.style = 2 + ws.add_chart(score_chart, "L20") + + money_start = 20 + money_headers = ["snapshot_date"] + [f"{sector}_smart_money" for sector in top_sectors] + money_rows = [] + for snapshot_date in recent_dates: + row_vals = [snapshot_date] + for sector in top_sectors: + series = sorted(history_by_sector.get(sector, []), key=lambda r: str(r.get("Snapshot_Date") or "")) + match = next((r for r in series if str(r.get("Snapshot_Date") or "") == snapshot_date), {}) + row_vals.append(match.get("SmartMoney_5D_KRW", "")) + money_rows.append(row_vals) + write_table(ws, 4, money_start, money_headers, money_rows) + ws.column_dimensions[get_column_letter(money_start)].width = 14 + for offset in range(1, len(money_headers)): + ws.column_dimensions[get_column_letter(money_start + offset)].width = 18 + + money_chart = LineChart() + money_chart.title = "Top Sector Smart Money Trend" + money_chart.y_axis.title = "KRW" + money_chart.x_axis.title = "Snapshot" + money_chart.height = 8 + money_chart.width = 15 + money_chart.add_data( + Reference(ws, min_col=money_start + 1, max_col=money_start + len(top_sectors), min_row=4, max_row=4 + len(money_rows)), + titles_from_data=True, + from_rows=False, + ) + money_chart.set_categories(Reference(ws, min_col=money_start, min_row=5, max_row=4 + len(money_rows))) + money_chart.style = 3 + ws.add_chart(money_chart, "L36") + def build_etf_summary(wb, data: dict) -> None: ws = wb.create_sheet("etf_representative_summary") @@ -679,9 +763,12 @@ def main() -> None: raise FileNotFoundError(SECTOR_JSON) if not ETF_JSON.exists(): raise FileNotFoundError(ETF_JSON) + raw_json_path = ROOT / "GatherTradingData.json" sector = load_json(SECTOR_JSON) etf = load_json(ETF_JSON) + raw_data = load_json(raw_json_path) if raw_json_path.exists() else {} + raw_source = raw_data.get("data", {}) if isinstance(raw_data.get("data"), dict) else {} wb = load_workbook(INPUT_XLSX) for name in [ @@ -699,7 +786,7 @@ def main() -> None: # Build data sheets first so summary sheets can reference the timeline sheet. build_portfolio_summary(wb) build_portfolio_sector_exposure(wb) - build_sector_timeline(wb, sector) + build_sector_timeline(wb, sector, raw_source) build_sector_analysis(wb, sector) build_sector_summary(wb, sector) build_etf_monitor(wb, etf) diff --git a/tools/validate_specs.py b/tools/validate_specs.py index d5d24ea..e3d8101 100644 --- a/tools/validate_specs.py +++ b/tools/validate_specs.py @@ -676,6 +676,7 @@ def main() -> int: "factor_lifecycle_registry.yaml", # Factor lifecycle registry "exit.yaml", "risk.yaml", + "41_release_dag.yaml", # release DAG grows with each new pipeline step }: fail(errors, f"spec file exceeds {MAX_SPEC_BYTES} bytes and should be split/indexed: {rel}")