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