Files
QuantEngineByItz/src/quant_engine/prepare_upload_zip.py
T
kjh2064 9b1ef4a100 Merge WBS-7 완료: GAS→Python 마이그레이션 + 보완고도화
## 주요 변경사항

###  완료된 11개 항목

- WBS-7.1: 캘리브레이션 실증 전환 도구
- WBS-7.2: T+5 지표 단일 진실원천 통일
- WBS-7.3: GAS→Python 공식 마이그레이션 재검토 + F05/F10 포팅 
- WBS-7.4: Deprecated 별칭·시트 정리
- WBS-7.5: 임시 하드코딩 폴백 비례화
- WBS-7.6: 슬리피지 실측 보정 스캐폴딩
- WBS-7.7: E2E 통합 테스트 구축
- WBS-7.8: ETF NAV/공매도 자동화 검토 및 운영절차 명문화
- WBS-7.9: snapshot_admin Synology POC 기본 보안 게이트
- WBS-7.10: 어드민 페이지 Tabler 그리드 조회
- WBS-7.11: spec-코드 동기화 게이트

### F05/F10 포팅 (이번 세션)

**F05 (calc_exit_sell_action)**
- 7단계 우선순위 계층 구현
- JavaScript Number.isFinite() 의미론 보장 via safe_float()
- 가격 폴백 체인 (tp2 → tp1 → close)
- 17개 parity 테스트 PASS

**F10 (run_route_flow)**
- 5개 게이트 순차 필터링
- Stop_Breach → Relative_Stop → Intraday_Lock → Heat_Gate → Mean_Reversion
- 17개 parity 테스트 PASS

### 📊 테스트 상태

**Parity 테스트**: 64/64 PASS
- F02/F04/F06 (price_basis): 8개
- F05 (execution_decision): 17개
- F07 (score_thresholds): 9개
- F10 (routing_decision): 17개
- F11 (classify_order_type): 13개

### 🎯 최종 상태

Phase 1~6 모두 완료, Phase 7 보완·고도화 DONE → 엔진 전체 경화 완료.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

# Conflicts:
#	GatherTradingData.json
#	governance/gas_logic_migration_ledger_v1.yaml
2026-06-22 23:22:32 +09:00

388 lines
15 KiB
Python

from __future__ import annotations
import argparse
import json
import zipfile
from functools import lru_cache
from datetime import datetime, timezone
from pathlib import Path
import yaml
from tools.orchestration_harness_v1 import run_plan
from src.quant_engine.pipeline_runtime_anomaly_lib_v1 import finalize_runtime_profile, runtime_profile_from_steps
ROOT = Path(__file__).resolve().parents[2]
DEFAULT_OUTPUT = ROOT.parent / f"{ROOT.name}.zip"
DEFAULT_PROFILE = ROOT / "Temp" / "pipeline_runtime_profile_v1.json"
DEFAULT_GATE_JSON = ROOT / "Temp" / "engine_harness_gate_result.json"
DEFAULT_HARDENING_V2 = ROOT / "Temp" / "strategy_hardening_harness_v2.json"
DEFAULT_DQ_LOCK_V2 = ROOT / "Temp" / "data_integrity_100_lock_v2.json"
RUNTIME_PROFILE = ROOT / "Temp" / "pipeline_runtime_profile_v1.json"
UPLOAD_KEEP_FILES = {
"AGENTS.md",
"README.md",
"REPORT_GUIDE.md",
"package.json",
"RetirementAssetPortfolio.yaml",
"RetirementAssetPortfolioReportTemplate.yaml",
"GatherTradingData.json",
"gas_data_feed.gs",
"gas_data_collect.gs",
"gas_lib.gs",
"gas_harness_rows.gs",
"gas_report.gs",
"gas_event_calendar.gs",
"gas_apex_alpha_watch.gs",
"gas_apex_runtime_core.gs"
}
UPLOAD_KEEP_DIRS = {
"artifacts",
"docs",
"dist",
"examples",
"governance",
"prompts",
"runtime",
"schemas",
"spec",
"src",
"suggest",
"tests",
"tools",
"Temp",
}
ALWAYS_EXCLUDE_DIRS = {
".git",
".claude",
"node_modules",
"__pycache__",
".pytest_cache",
"tmp",
}
ALWAYS_EXCLUDE_SUFFIXES = {
".pyc",
".pyo",
}
LEGACY_SEED_FILES = {
"sector_targets.json",
}
TEMP_EXCLUDED_FILES = {
"build_bundle_cache_v1.json",
}
TEMP_KEEP_FILES = {
"final_decision_packet_active.json",
"final_decision_packet_v4.json",
"operational_report.json",
"operational_report.md",
"release_dag_run_v1.json",
"pipeline_runtime_profile_v1.json",
"engine_harness_gate_result.json",
"strategy_hardening_harness_v2.json",
"data_integrity_100_lock_v2.json",
"number_provenance_ledger_v4.json",
"final_context_for_llm_v4.yaml",
"live_replay_separation_v2.json",
"formula_runtime_registry_v1.json",
"canonical_artifact_resolver_v1.json",
"final_execution_decision_v2.json",
"prediction_accuracy_harness_v2.json",
"validate_prediction_accuracy_harness_v2.json",
# Data Analysis & Verification Reports
"horizon_rebalance_plan_v1.json",
"factor_lifecycle_completeness_v1.json",
"factor_shadow_eligibility_v1.json",
"algorithm_guidance_proof_v1.json",
"strategy_routing_audit_v1.json",
}
TEMP_NOISE_FILES = {
"canonical_artifact_resolver_v1.json",
"final_execution_decision_v2.json",
"rebalance_cadence_gate_v1.json",
"single_truth_ledger_v2.json",
"smart_cash_recovery_v7.json",
"smart_cash_recovery_v9.json",
"state_vector_constructor_v1.json",
"transition_set_enumerator_v1.json",
"walk_forward_bootstrap_v1.json",
"weekly_legacy_transfer_plan_v1.json",
"prediction_accuracy_harness_v2.json",
}
UPLOAD_KEEP_DIRS_UPLOAD = {
"artifacts",
"docs",
"governance",
"runtime",
"spec",
"Temp",
}
@lru_cache(maxsize=1)
def _active_manifest_refs() -> set[str]:
manifest_path = ROOT / "runtime" / "active_artifact_manifest.yaml"
if not manifest_path.exists():
return set()
try:
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {}
except Exception:
return set()
refs: set[str] = set()
if isinstance(manifest, dict):
canonical = manifest.get("canonical_source")
if isinstance(canonical, str) and canonical.strip():
refs.add(Path(canonical).as_posix())
aliases = manifest.get("active_aliases")
if isinstance(aliases, dict):
for val in aliases.values():
if isinstance(val, str) and val.strip():
refs.add(Path(val).as_posix())
rows = manifest.get("manifest_rows")
if isinstance(rows, list):
for row in rows:
if isinstance(row, dict):
active_artifact = row.get("active_artifact")
if isinstance(active_artifact, str) and active_artifact.strip():
refs.add(Path(active_artifact).as_posix())
return refs
def _load_json(path: Path) -> dict:
if not path.exists():
return {}
try:
obj = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
return obj if isinstance(obj, dict) else {}
def _is_fresh(path: Path, max_minutes: int) -> bool:
if not path.exists():
return False
age_seconds = (datetime.now(timezone.utc).timestamp() - path.stat().st_mtime)
return age_seconds <= max_minutes * 60
def _quick_gate_ready(max_minutes: int = 60) -> tuple[bool, list[str]]:
missing: list[str] = []
required = [DEFAULT_GATE_JSON, DEFAULT_HARDENING_V2, DEFAULT_DQ_LOCK_V2]
for p in required:
if not p.exists():
missing.append(f"MISSING:{p}")
elif not _is_fresh(p, max_minutes):
missing.append(f"STALE:{p}")
gate = _load_json(DEFAULT_GATE_JSON)
if gate.get("status") != "OK":
missing.append("ENGINE_GATE_NOT_OK")
failed_checks = gate.get("failed_checks")
if not isinstance(failed_checks, list) or len(failed_checks) != 0:
missing.append("ENGINE_GATE_FAILED_CHECKS_NOT_EMPTY")
return len(missing) == 0, missing
def should_include(path: Path, mode: str, include_xlsx: bool, include_backups: bool) -> bool:
rel = path.relative_to(ROOT)
parts = rel.parts
if any(part in ALWAYS_EXCLUDE_DIRS for part in parts):
return False
if path.suffix in ALWAYS_EXCLUDE_SUFFIXES:
return False
if path.name == DEFAULT_OUTPUT.name:
return False
if mode == "upload" and path.name in TEMP_NOISE_FILES:
return False
if mode == "upload" and parts[0] != "Temp" and rel.as_posix() in _active_manifest_refs():
return True
if parts[0] == "Temp":
if path.name in TEMP_EXCLUDED_FILES:
return False
if mode != "full" and path.name not in TEMP_KEEP_FILES:
return False
if path.name in LEGACY_SEED_FILES:
return False
if not include_backups and ".bak" in path.name:
return False
if not include_xlsx and path.suffix.lower() in {".xlsm", ".xls"}:
return False
if mode == "full":
return True
top = parts[0]
if len(parts) == 1:
return path.name in UPLOAD_KEEP_FILES
# Strictly exclude code directories (src, tools, tests, dist) in upload mode to limit LLM context
if top not in UPLOAD_KEEP_DIRS_UPLOAD:
return False
if top == "tools" and path.name.endswith(".bak"):
return False
return True
def build_zip(output: Path, mode: str, include_xlsx: bool, include_backups: bool) -> int:
output = output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
output.unlink()
files = [
path
for path in ROOT.rglob("*")
if path.is_file() and should_include(path, mode, include_xlsx, include_backups)
]
files.sort(key=lambda p: str(p.relative_to(ROOT)).lower())
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
for path in files:
arcname = Path(ROOT.name) / path.relative_to(ROOT)
zf.write(path, arcname.as_posix())
size_kb = output.stat().st_size / 1024
print(f"ZIP OK: {output} files={len(files)} size={size_kb:.1f}KB mode={mode}")
return len(files)
def main() -> int:
parser = argparse.ArgumentParser(description="Convert market xlsx to JSON and zip this folder for upload.")
parser.add_argument("--mode", choices=["upload", "full"], default="upload", help="upload excludes heavy/noisy files; full includes the current folder except cache/temp.")
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="Zip output path. Default is parent/<folder>.zip.")
parser.add_argument("--include-xlsx", action="store_true", help="Include xlsx/xlsm/xls files in the zip.")
parser.add_argument("--include-backups", action="store_true", help="Include .bak files in the zip.")
parser.add_argument("--skip-convert", action="store_true", help="Do not regenerate GatherTradingData.json.")
parser.add_argument("--skip-validate", action="store_true", help="Do not run validation before zipping.")
parser.add_argument("--validation-mode", choices=["release", "quick", "package-only"], default="release", help="Validation depth for packaging.")
parser.add_argument("--profile", action="store_true", help="Write runtime profile JSON.")
args = parser.parse_args()
prof_steps: list[dict[str, object]] = []
skipped_steps: list[str] = []
gate_status = "UNKNOWN"
if args.skip_validate and args.validation_mode == "release":
print(f"P0-01 ENFORCEMENT: skip_validate=True ignored because validation_mode=release")
args.skip_validate = False
runtime_context = {
"harness_name": "prepare-upload-zip",
"mode": args.mode,
"validation_mode": args.validation_mode,
"include_xlsx": args.include_xlsx,
"include_backups": args.include_backups,
"skip_validate": args.skip_validate,
}
if args.skip_validate:
plan = []
if not args.skip_convert:
plan.append({"name": "prepare", "command": ["tools/convert_xlsx_to_json.py"]})
plan.append({
"name": "zip",
"depends_on": ["prepare"] if not args.skip_convert else [],
"callable": lambda: build_zip(args.output, args.mode, args.include_xlsx, args.include_backups),
})
prof_steps = run_plan(plan, label="prepare-upload-zip:skip-validate")
gate_status = "SKIPPED"
else:
if args.validation_mode == "release":
plan = []
if not args.skip_convert:
plan.append({"name": "prepare", "command": ["tools/convert_xlsx_to_json.py"]})
plan.extend([
{"name": "release_full", "command": ["tools/run_release_dag_v3.py", "--mode", "full"], "depends_on": ["prepare"] if not args.skip_convert else []},
{
"name": "zip",
"depends_on": ["release_full"],
"callable": lambda: build_zip(args.output, args.mode, args.include_xlsx, args.include_backups),
},
])
prof_steps = run_plan(plan, label="prepare-upload-zip:release")
gate_status = "OK"
elif args.validation_mode == "quick":
ready, reasons = _quick_gate_ready(max_minutes=60)
if ready:
skipped_steps.append("release-gate-reused-recent-artifacts")
gate_status = "OK"
plan = []
if not args.skip_convert:
plan.append({"name": "prepare", "command": ["tools/convert_xlsx_to_json.py"]})
plan.extend([
{
"name": "build_bundle",
"command": ["tools/build_bundle.py"],
},
{
"name": "zip",
"depends_on": ["build_bundle", "prepare"] if not args.skip_convert else ["build_bundle"],
"callable": lambda: build_zip(args.output, args.mode, args.include_xlsx, args.include_backups),
},
])
prof_steps = run_plan(plan, label="prepare-upload-zip:quick-reused")
else:
print("QUICK_MODE_FALLBACK_RELEASE_GATE:", ";".join(reasons))
plan = []
if not args.skip_convert:
plan.append({"name": "prepare", "command": ["tools/convert_xlsx_to_json.py"]})
plan.extend([
{"name": "release_gate", "command": ["tools/run_release_dag_v3.py", "--mode", "release"], "depends_on": ["prepare"] if not args.skip_convert else []},
{"name": "build_bundle", "command": ["tools/build_bundle.py"]},
{
"name": "zip",
"depends_on": ["release_gate", "build_bundle"],
"callable": lambda: build_zip(args.output, args.mode, args.include_xlsx, args.include_backups),
},
])
prof_steps = run_plan(plan, label="prepare-upload-zip:quick-fallback")
gate_status = "OK"
else: # package-only
ready, reasons = _quick_gate_ready(max_minutes=60 * 24)
if not ready:
raise SystemExit("PACKAGE_ONLY_BLOCKED: " + ";".join(reasons))
skipped_steps.append("all-validation-reused-existing-gate")
gate_status = "SKIPPED"
plan = []
if not args.skip_convert:
plan.append({"name": "prepare", "command": ["tools/convert_xlsx_to_json.py"]})
plan.extend([
{
"name": "build_bundle",
"command": ["tools/build_bundle.py"],
},
{
"name": "zip",
"depends_on": ["build_bundle", "prepare"] if not args.skip_convert else ["build_bundle"],
"callable": lambda: build_zip(args.output, args.mode, args.include_xlsx, args.include_backups),
},
])
prof_steps = run_plan(plan, label="prepare-upload-zip:package-only")
total = round(sum(float(s.get("elapsed_sec") or 0.0) for s in prof_steps), 3)
payload = runtime_profile_from_steps(
harness_name="prepare-upload-zip",
mode=args.validation_mode,
steps=prof_steps,
runtime_context=runtime_context,
file_count=len([
path for path in ROOT.rglob("*")
if path.is_file() and should_include(path, args.mode, args.include_xlsx, args.include_backups)
]),
elapsed_sec_total=total,
skipped_duplicate_steps=skipped_steps,
gate_status=gate_status,
)
payload["allowed_use"] = "production_investment_decisions" if args.validation_mode in {"release", "quick"} else "packaging_only"
payload["validation_mode"] = args.validation_mode
min_samples = 1 if args.validation_mode == "package-only" else 5
analysis = finalize_runtime_profile(profile_path=RUNTIME_PROFILE, payload=payload, min_samples=min_samples)
if analysis.get("status") == "ALERT":
print("RUNTIME_ANOMALY:", ";".join(analysis.get("anomaly_reason_codes") or []))
return 0
if __name__ == "__main__":
raise SystemExit(main())