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,207 @@
|
||||
"""build_time_stop_forecast_v1.py — P1-1: TIME_STOP 사전 발동 예측
|
||||
|
||||
60일 TIME_STOP 기준에 가까운 종목을 사전에 탐지하여 운영자가
|
||||
7월 발동 전에 포지션 검토를 할 수 있도록 예측 보고서를 생성한다.
|
||||
|
||||
formula_id: BUILD_TIME_STOP_FORECAST_V1
|
||||
contract_ref: spec/exit/stop_loss.yaml (TIME_STOP 60일 기준)
|
||||
spec/54_temporal_data_integrity.yaml
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
DEFAULT_HARNESS = ROOT / "Temp" / "computed_harness_v1.json"
|
||||
DEFAULT_DATA = ROOT / "GatherTradingData.json"
|
||||
OUTPUT_PATH = ROOT / "Temp" / "time_stop_forecast_v1.json"
|
||||
|
||||
TIME_STOP_HOLD_DAYS = 60 # 60일 기준 (spec/exit/stop_loss.yaml)
|
||||
FORECAST_WINDOW_DAYS = 30 # 앞으로 30일 내 발동 예상 종목 탐지
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
return {"_missing": True, "_path": str(path)}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
return {"_error": str(e), "_path": str(path)}
|
||||
|
||||
|
||||
def _parse_date(s: str | None) -> date | None:
|
||||
if not s:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _forecast_from_harness(harness: dict, today: date) -> list[dict]:
|
||||
"""Parse relative_stop_signal_json for TIME_STOP candidates."""
|
||||
forecasts = []
|
||||
signals = harness.get("relative_stop_signal_json") or []
|
||||
if not isinstance(signals, list):
|
||||
return forecasts
|
||||
|
||||
for sig in signals:
|
||||
if not isinstance(sig, dict):
|
||||
continue
|
||||
details = sig.get("details") or {}
|
||||
hold_days = details.get("hold_days")
|
||||
excess = details.get("excess_ret20d")
|
||||
entry_date_str = details.get("entry_date")
|
||||
|
||||
if hold_days is None:
|
||||
continue
|
||||
|
||||
hold_days = int(hold_days)
|
||||
days_to_trigger = TIME_STOP_HOLD_DAYS - hold_days
|
||||
|
||||
if days_to_trigger <= FORECAST_WINDOW_DAYS:
|
||||
trigger_date = (
|
||||
(_parse_date(entry_date_str) + timedelta(days=TIME_STOP_HOLD_DAYS))
|
||||
if entry_date_str else None
|
||||
)
|
||||
forecasts.append({
|
||||
"ticker": sig.get("ticker"),
|
||||
"name": sig.get("name"),
|
||||
"current_hold_days": hold_days,
|
||||
"days_to_trigger": max(0, days_to_trigger),
|
||||
"projected_trigger_date": str(trigger_date) if trigger_date else "UNKNOWN",
|
||||
"excess_ret20d": excess,
|
||||
"current_signal": sig.get("signal"),
|
||||
"time_stop_condition": excess is not None and float(excess) < 0,
|
||||
"action_required": days_to_trigger <= 7,
|
||||
"note": (
|
||||
"TIME_STOP 발동 임박 (7일 이내)" if days_to_trigger <= 7
|
||||
else f"TIME_STOP {days_to_trigger}일 후 예상"
|
||||
),
|
||||
})
|
||||
|
||||
return sorted(forecasts, key=lambda x: x["days_to_trigger"])
|
||||
|
||||
|
||||
def _forecast_from_data(data_json: dict, today: date) -> list[dict]:
|
||||
"""Fallback: parse account_snapshot rows for hold_days."""
|
||||
forecasts = []
|
||||
rows = data_json.get("account_snapshot") or []
|
||||
if isinstance(rows, dict):
|
||||
rows = rows.get("rows") or []
|
||||
if not isinstance(rows, list):
|
||||
return forecasts
|
||||
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
entry_date_str = row.get("entry_date") or row.get("EntryDate")
|
||||
entry_date = _parse_date(entry_date_str)
|
||||
if not entry_date:
|
||||
continue
|
||||
|
||||
hold_days = (today - entry_date).days
|
||||
days_to_trigger = TIME_STOP_HOLD_DAYS - hold_days
|
||||
|
||||
if 0 <= days_to_trigger <= FORECAST_WINDOW_DAYS:
|
||||
trigger_date = entry_date + timedelta(days=TIME_STOP_HOLD_DAYS)
|
||||
forecasts.append({
|
||||
"ticker": row.get("ticker") or row.get("code"),
|
||||
"name": row.get("name") or row.get("종목명"),
|
||||
"current_hold_days": hold_days,
|
||||
"days_to_trigger": days_to_trigger,
|
||||
"projected_trigger_date": str(trigger_date),
|
||||
"excess_ret20d": None,
|
||||
"current_signal": "DATA_MISSING",
|
||||
"time_stop_condition": None,
|
||||
"action_required": days_to_trigger <= 7,
|
||||
"note": (
|
||||
"TIME_STOP 발동 임박 (7일 이내)" if days_to_trigger <= 7
|
||||
else f"TIME_STOP {days_to_trigger}일 후 예상"
|
||||
),
|
||||
})
|
||||
|
||||
return sorted(forecasts, key=lambda x: x["days_to_trigger"])
|
||||
|
||||
|
||||
def run(harness_path: Path, data_path: Path) -> dict:
|
||||
today = date.today()
|
||||
harness = _load_json(harness_path)
|
||||
data = _load_json(data_path)
|
||||
|
||||
forecasts = []
|
||||
if not harness.get("_missing"):
|
||||
forecasts = _forecast_from_harness(harness, today)
|
||||
|
||||
if not forecasts and not data.get("_missing"):
|
||||
forecasts = _forecast_from_data(data, today)
|
||||
|
||||
imminent = [f for f in forecasts if f["days_to_trigger"] <= 7]
|
||||
upcoming = [f for f in forecasts if 7 < f["days_to_trigger"] <= 30]
|
||||
triggered = [f for f in forecasts if f.get("current_signal") == "TIME_STOP"]
|
||||
|
||||
gate = "PASS"
|
||||
if imminent:
|
||||
gate = "WARN"
|
||||
if triggered:
|
||||
gate = "TRIGGERED"
|
||||
|
||||
result = {
|
||||
"gate": gate,
|
||||
"as_of_date": str(today),
|
||||
"time_stop_hold_days_threshold": TIME_STOP_HOLD_DAYS,
|
||||
"forecast_window_days": FORECAST_WINDOW_DAYS,
|
||||
"imminent_count": len(imminent),
|
||||
"upcoming_count": len(upcoming),
|
||||
"triggered_count": len(triggered),
|
||||
"imminent_tickers": imminent,
|
||||
"upcoming_tickers": upcoming,
|
||||
"triggered_tickers": triggered,
|
||||
"summary": (
|
||||
f"TODAY={today}: "
|
||||
f"{len(triggered)}건 발동중, "
|
||||
f"{len(imminent)}건 7일내 임박, "
|
||||
f"{len(upcoming)}건 30일내 예정"
|
||||
),
|
||||
"action_note": (
|
||||
"임박 종목 포지션 재검토 필요 — excess_ret20d 추이 모니터링 권장"
|
||||
if (imminent or triggered) else
|
||||
"현재 30일내 발동 예상 종목 없음"
|
||||
),
|
||||
"contract": "spec/54_temporal_data_integrity.yaml",
|
||||
}
|
||||
|
||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUTPUT_PATH.write_text(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="TIME_STOP Forecast Builder")
|
||||
parser.add_argument("--harness", default=str(DEFAULT_HARNESS))
|
||||
parser.add_argument("--data", default=str(DEFAULT_DATA))
|
||||
args = parser.parse_args()
|
||||
|
||||
result = run(Path(args.harness), Path(args.data))
|
||||
gate = result.get("gate", "PASS")
|
||||
print(f"[BUILD_TIME_STOP_FORECAST_V1] gate={gate}")
|
||||
print(f" {result.get('summary')}")
|
||||
if result.get("imminent_tickers"):
|
||||
print(" 임박 종목:")
|
||||
for t in result["imminent_tickers"]:
|
||||
print(f" {t['ticker']} {t.get('name','')} — {t['days_to_trigger']}일후 ({t['projected_trigger_date']}) excess={t.get('excess_ret20d')}")
|
||||
if result.get("triggered_tickers"):
|
||||
print(" 발동중:")
|
||||
for t in result["triggered_tickers"]:
|
||||
print(f" {t['ticker']} {t.get('name','')} — hold={t['current_hold_days']}일")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user