ee3e799de1
주요 변경: - 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>
208 lines
7.5 KiB
Python
208 lines
7.5 KiB
Python
"""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()
|