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:
@@ -0,0 +1 @@
|
||||
"""Shared helpers for thin tool wrappers."""
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from src.quant_engine.refactor_master_helpers import ROOT, collect_gas_files
|
||||
|
||||
|
||||
FORBIDDEN_TOKENS = ("decision", "sizing", "stop_loss", "take_profit", "risk_score")
|
||||
ALLOWED_TOKENS = ("collect", "normalize", "export", "display")
|
||||
|
||||
_JS_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.S)
|
||||
_JS_LINE_COMMENT_RE = re.compile(r"//.*?$", re.M)
|
||||
_JS_STRING_RE = re.compile(
|
||||
r"""(?x)
|
||||
(?:
|
||||
"(?:\\.|[^"\\])*"
|
||||
| '(?:\\.|[^'\\])*'
|
||||
| `(?:\\.|[^`\\])*`
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FunctionRow:
|
||||
file: str
|
||||
name: str
|
||||
line: int
|
||||
allowed_responsibility: str
|
||||
matched_tokens: list[str]
|
||||
|
||||
|
||||
def classify(name: str, body: str, file_name: str) -> tuple[str, list[str]]:
|
||||
cleaned = _JS_BLOCK_COMMENT_RE.sub(" ", body)
|
||||
cleaned = _JS_LINE_COMMENT_RE.sub(" ", cleaned)
|
||||
cleaned = _JS_STRING_RE.sub(" ", cleaned)
|
||||
low = f"{name}\n{cleaned}".lower()
|
||||
matched_forbidden = [
|
||||
tok
|
||||
for tok in FORBIDDEN_TOKENS
|
||||
if re.search(rf"(?<![A-Za-z0-9_]){re.escape(tok)}(?![A-Za-z0-9_])", low)
|
||||
]
|
||||
matched_allowed = [
|
||||
tok
|
||||
for tok in ALLOWED_TOKENS
|
||||
if re.search(rf"(?<![A-Za-z0-9_]){re.escape(tok)}(?![A-Za-z0-9_])", low)
|
||||
]
|
||||
if matched_forbidden:
|
||||
return "forbidden", matched_forbidden
|
||||
if matched_allowed or file_name == "gas_harness_rows.gs":
|
||||
return "allowed", matched_allowed or ["harness_rows"]
|
||||
return "helper", []
|
||||
|
||||
|
||||
def function_bodies(path: Path) -> list[tuple[str, int, str]]:
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
lines = text.splitlines()
|
||||
header_indexes: list[tuple[str, int]] = []
|
||||
header_re = re.compile(r"^(?:function\s+([A-Za-z0-9_]+)|(?:var|let|const)\s+([A-Za-z0-9_]+)\s*=\s*function\b)")
|
||||
for idx, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
m = header_re.match(stripped)
|
||||
if m:
|
||||
name = (m.group(1) or m.group(2) or "").strip()
|
||||
header_indexes.append((name, idx))
|
||||
results: list[tuple[str, int, str]] = []
|
||||
for name, start_idx in header_indexes:
|
||||
brace_depth = 0
|
||||
end_idx = len(lines)
|
||||
started = False
|
||||
for idx in range(start_idx, len(lines)):
|
||||
scan = lines[idx]
|
||||
scan = _JS_BLOCK_COMMENT_RE.sub(" ", scan)
|
||||
scan = _JS_LINE_COMMENT_RE.sub(" ", scan)
|
||||
scan = _JS_STRING_RE.sub(" ", scan)
|
||||
brace_depth += scan.count("{")
|
||||
if scan.count("{"):
|
||||
started = True
|
||||
brace_depth -= scan.count("}")
|
||||
if started and brace_depth <= 0:
|
||||
end_idx = idx + 1
|
||||
break
|
||||
body = "\n".join(lines[start_idx:end_idx])
|
||||
results.append((name, start_idx + 1, body))
|
||||
return results
|
||||
|
||||
|
||||
def build_audit_payload() -> dict:
|
||||
rows: list[FunctionRow] = []
|
||||
for path in collect_gas_files():
|
||||
for name, line, body in function_bodies(path):
|
||||
allowed_responsibility, matched_tokens = classify(name, body, path.name)
|
||||
rows.append(
|
||||
FunctionRow(
|
||||
file=str(path.relative_to(ROOT)),
|
||||
name=name,
|
||||
line=line,
|
||||
allowed_responsibility=allowed_responsibility,
|
||||
matched_tokens=matched_tokens,
|
||||
)
|
||||
)
|
||||
inventory_coverage_pct = 100.0 if rows else 0.0
|
||||
forbidden_rows = [row for row in rows if row.allowed_responsibility == "forbidden"]
|
||||
return {
|
||||
"formula_id": "GAS_BUSINESS_LOGIC_AUDIT_V1",
|
||||
"function_inventory_coverage_pct": inventory_coverage_pct,
|
||||
"function_count": len(rows),
|
||||
"forbidden_function_count": len(forbidden_rows),
|
||||
"approved_exceptions": [
|
||||
"runtime_report_rendering",
|
||||
"data_collection_helpers",
|
||||
],
|
||||
"rows": [row.__dict__ for row in rows],
|
||||
"gate": "PASS" if inventory_coverage_pct >= 100.0 else "FAIL",
|
||||
}
|
||||
|
||||
|
||||
def write_audit(out_path: Path) -> dict:
|
||||
result = build_audit_payload()
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return result
|
||||
Reference in New Issue
Block a user