feat: DAG T+20 추적 인프라 + 섹터 추세 시계열 개선

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 21:31:22 +09:00
parent f5b202fb5e
commit 70675a5a92
4 changed files with 157 additions and 4 deletions
+47 -2
View File
@@ -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"]
+20
View File
@@ -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", {}))
+89 -2
View File
@@ -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)
+1
View File
@@ -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}")