feat: 리밸런싱 엔진 V1 + GAS 버그 수정 (2026-06-13)

주요 변경:
- tools/build_rebalance_engine_v1.py: REBALANCE_ENGINE_V1 신규
  * account_snapshot 직접 합산(_build_snap_position_map) → 소수주 분리 행 병합
  * 레짐 소스 macro.REGIME_PRELIM 최우선 (GAS 와 동일)
- src/gas_adapter_parts/gdf_06_rebalance.gs: runRebalanceSheet_() 신규
  * Logger.log / getSpreadsheet_() 로 run_all 연동 수정
- src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs
  * _mergePositionRecord_(): 소수주 중복 행 합산 신규
  * parseInt → parseFloat (qty, availQty)
- src/gas_adapter_parts/gdf_01_price_metrics.gs
  * 미보유 종목 SELL_READY → WATCH_EXIT_SIGNAL
- spec/41_release_dag.yaml: build_rebalance_sheet 노드 추가 (step_count 63)
- spec/51_formula_lifecycle_registry.yaml: REBALANCE_ENGINE_V1 등록

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 13:20:14 +09:00
commit ee3e799de1
1474 changed files with 176087 additions and 0 deletions
@@ -0,0 +1,207 @@
from __future__ import annotations
import os
import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from math import fsum
from pathlib import Path
from typing import Any, Callable
ROOT = Path(__file__).resolve().parents[2]
def utf8_env() -> dict[str, str]:
env = os.environ.copy()
env.setdefault("PYTHONIOENCODING", "utf-8")
env.setdefault("PYTHONUTF8", "1")
return env
def _run_command(command: list[str], cwd: Path | None = None) -> dict[str, Any]:
started = datetime.now(timezone.utc)
resolved = list(command)
if os.name == "nt" and resolved and resolved[0].lower() == "npm":
resolved[0] = "npm.cmd"
subprocess.run(resolved, cwd=cwd or ROOT, check=True, env=utf8_env())
finished = datetime.now(timezone.utc)
return {
"kind": "command",
"command": command,
"started_at": started.isoformat(),
"finished_at": finished.isoformat(),
"elapsed_sec": round((finished - started).total_seconds(), 3),
"status": "OK",
}
def _run_callable(func: Callable[[], Any]) -> dict[str, Any]:
started = datetime.now(timezone.utc)
payload = func()
finished = datetime.now(timezone.utc)
return {
"kind": "callable",
"started_at": started.isoformat(),
"finished_at": finished.isoformat(),
"elapsed_sec": round((finished - started).total_seconds(), 3),
"status": "OK",
"payload": payload,
}
def _parse_iso8601(value: str) -> datetime:
text = str(value or "")
if text.endswith("Z"):
text = text[:-1] + "+00:00"
return datetime.fromisoformat(text)
def summarize_plan(results: list[dict[str, Any]]) -> dict[str, Any]:
if not results:
return {
"step_count": 0,
"command_step_count": 0,
"callable_step_count": 0,
"critical_path_sec": 0.0,
"wall_clock_sec": 0.0,
"parallel_width_max": 0,
"step_names": [],
}
by_name = {str(item.get("name")): item for item in results if item.get("name")}
memo: dict[str, float] = {}
def critical_path_for(name: str) -> float:
if name in memo:
return memo[name]
item = by_name[name]
own = float(item.get("elapsed_sec") or 0.0)
deps = [str(dep) for dep in (item.get("depends_on") or []) if str(dep) in by_name]
if deps:
own += max(critical_path_for(dep) for dep in deps)
memo[name] = own
return own
starts = []
ends = []
points: list[tuple[datetime, int]] = []
command_step_count = 0
callable_step_count = 0
for item in results:
started = _parse_iso8601(str(item.get("started_at")))
finished = _parse_iso8601(str(item.get("finished_at")))
starts.append(started)
ends.append(finished)
points.append((started, 1))
points.append((finished, -1))
if item.get("kind") == "command":
command_step_count += 1
elif item.get("kind") == "callable":
callable_step_count += 1
points.sort(key=lambda pair: (pair[0], -pair[1]))
active = 0
parallel_width_max = 0
for _, delta in points:
active += delta
if active > parallel_width_max:
parallel_width_max = active
step_elapsed_sum = fsum(float(item.get("elapsed_sec") or 0.0) for item in results)
wall_clock_sec = round((max(ends) - min(starts)).total_seconds(), 3)
critical_path_sec = round(max(critical_path_for(name) for name in by_name), 3)
return {
"step_count": len(results),
"command_step_count": command_step_count,
"callable_step_count": callable_step_count,
"step_elapsed_sum_sec": round(step_elapsed_sum, 3),
"critical_path_sec": critical_path_sec,
"wall_clock_sec": wall_clock_sec,
"parallel_width_max": parallel_width_max,
"step_names": [str(item.get("name")) for item in results if item.get("name")],
}
def run_plan(
steps: list[dict[str, Any]],
*,
label: str = "orchestration",
cwd: Path | None = None,
) -> list[dict[str, Any]]:
"""
Execute a dependency plan.
Each step accepts:
- name: unique string
- depends_on: list[str]
- command: list[str] for subprocess execution, or
- callable: zero-arg callable for in-process execution
"""
if not steps:
return []
step_map: dict[str, dict[str, Any]] = {}
dependents: dict[str, set[str]] = {}
remaining: set[str] = set()
for index, step in enumerate(steps):
name = str(step.get("name") or "").strip()
if not name:
raise ValueError(f"{label}: step missing name at index {index}")
if name in step_map:
raise ValueError(f"{label}: duplicate step name: {name}")
step_map[name] = dict(step)
step_map[name]["_index"] = index
remaining.add(name)
dependents.setdefault(name, set())
for name, step in step_map.items():
deps = step.get("depends_on") or []
if not isinstance(deps, list):
raise ValueError(f"{label}: depends_on must be a list for {name}")
step["_depends_on"] = {str(dep) for dep in deps}
for dep in step["_depends_on"]:
if dep not in step_map:
raise ValueError(f"{label}: unknown dependency {dep} for {name}")
dependents.setdefault(dep, set()).add(name)
completed: set[str] = set()
results: dict[str, dict[str, Any]] = {}
while remaining:
ready = sorted(
[name for name in remaining if step_map[name]["_depends_on"].issubset(completed)],
key=lambda n: step_map[n]["_index"],
)
if not ready:
blocked = sorted(remaining)
raise RuntimeError(f"{label}: cyclic or blocked dependencies: {blocked}")
with ThreadPoolExecutor(max_workers=len(ready)) as executor:
future_map = {}
for name in ready:
step = step_map[name]
if "command" in step and step["command"] is not None:
future = executor.submit(_run_command, list(step["command"]), cwd)
elif "callable" in step and step["callable"] is not None:
future = executor.submit(_run_callable, step["callable"])
else:
raise ValueError(f"{label}: step {name} missing command/callable")
future_map[future] = name
for future in as_completed(future_map):
name = future_map[future]
result = future.result()
result["name"] = name
result["depends_on"] = list(step_map[name]["_depends_on"])
result["index"] = step_map[name]["_index"]
results[name] = result
completed.add(name)
remaining.remove(name)
for child in dependents.get(name, set()):
if child in step_map:
step_map[child]["_depends_on"].discard(name)
ordered = sorted(results.values(), key=lambda item: item["index"])
for item in ordered:
item.pop("index", None)
return ordered