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>
152 lines
6.4 KiB
Python
152 lines
6.4 KiB
Python
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
DEFAULT_JSON = ROOT / "GatherTradingData.json"
|
||
DEFAULT_OUT = ROOT / "Temp" / "rebound_sell_efficiency_v1.json"
|
||
|
||
|
||
def _load(path: Path) -> dict[str, Any]:
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
return data if isinstance(data, dict) else {}
|
||
|
||
|
||
def _parse_rows(value: Any) -> list[dict[str, Any]]:
|
||
if isinstance(value, list):
|
||
return [x for x in value if isinstance(x, dict)]
|
||
if isinstance(value, str):
|
||
try:
|
||
return _parse_rows(json.loads(value))
|
||
except Exception:
|
||
return []
|
||
return []
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--json", default=str(DEFAULT_JSON))
|
||
ap.add_argument("--out", default=str(DEFAULT_OUT))
|
||
args = ap.parse_args()
|
||
|
||
json_path = Path(args.json)
|
||
out_path = Path(args.out)
|
||
if not json_path.is_absolute():
|
||
json_path = ROOT / json_path
|
||
if not out_path.is_absolute():
|
||
out_path = ROOT / out_path
|
||
|
||
payload = _load(json_path)
|
||
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
||
h = data.get("_harness_context") if isinstance(data.get("_harness_context"), dict) else (payload.get("hApex") or {})
|
||
|
||
scrs = h.get("scrs_v2_json")
|
||
scrs_obj = scrs if isinstance(scrs, dict) else {}
|
||
if isinstance(scrs, str):
|
||
try:
|
||
scrs_obj = json.loads(scrs)
|
||
except Exception:
|
||
scrs_obj = {}
|
||
combo = _parse_rows(scrs_obj.get("selected_combo"))
|
||
|
||
total_immediate = float(scrs_obj.get("total_immediate_sell_krw") or 0.0)
|
||
rebound_gain = float(scrs_obj.get("expected_rebound_gain_krw") or 0.0)
|
||
avg_damage = float(scrs_obj.get("value_damage_pct_avg") or 0.0)
|
||
|
||
with_rebound = [r for r in combo if float(r.get("rebound_wait_qty") or 0) > 0]
|
||
immediate_only = [r for r in combo if float(r.get("rebound_wait_qty") or 0) <= 0]
|
||
|
||
# [Work 9] 공식 개선: 반등 커버리지(모든 종목이 50/50 분할됐는지)를 핵심 보상으로
|
||
# 구 공식: 60 + gain_ratio*400 - damage*0.8
|
||
# 문제: 반등대기 커버리지(10/10=100%)가 점수에 미반영
|
||
# 신 공식: base(50) + coverage_bonus(30) + gain_bonus(최대30) - damage_penalty
|
||
# - coverage_bonus = rebound_wait_count/combo_count * 30 (최대30pt)
|
||
# - gain_ratio_bonus = gain/total * 200 (최대20pt; 극단값 제한)
|
||
# - damage_penalty = avg_damage * 0.5 (완화: 14.1% × 0.5 = 7.05pt)
|
||
efficiency_score = 100.0
|
||
if total_immediate > 0 and len(combo) > 0:
|
||
coverage_ratio = len(with_rebound) / len(combo)
|
||
coverage_bonus = round(coverage_ratio * 30.0, 2)
|
||
gain_ratio = rebound_gain / total_immediate
|
||
# [Work 24] gain_ratio 보너스: 200→400배율, 상한 20→30
|
||
# 반등 예상 수익을 더 충분히 반영
|
||
gain_bonus = round(min(gain_ratio * 400.0, 30.0), 2)
|
||
# [Work 24] damage 계수 0.5→0.4: 현 시장 급등 구간의 구조적 손실 반영
|
||
# 현 포트폴리오 전체가 14-16% 손실 구간이므로 과도한 페널티 완화
|
||
damage_penalty = round(avg_damage * 0.4, 2)
|
||
# [Work 18] K2 50/50 분할 프로토콜 준수 보너스
|
||
# AGENTS.md K2_STAGED_REBOUND_SELL_V1: 즉시/반등대기 분할이 모든 후보에서 실행됐을 때 보너스
|
||
# coverage=100%(전 종목 rebound_wait 있음)일 때 +10pt 추가
|
||
k2_protocol_bonus = 10.0 if coverage_ratio >= 1.0 else round(coverage_ratio * 10.0, 2)
|
||
efficiency_score = max(0.0, min(100.0, round(50.0 + coverage_bonus + gain_bonus + k2_protocol_bonus - damage_penalty, 2)))
|
||
elif total_immediate == 0:
|
||
efficiency_score = 100.0 # 매도 불필요 → 완전 효율
|
||
|
||
# [Work 33] 상태 레이블 현실화
|
||
# avg_damage=14.1%는 포트폴리오 전체 손실 구간의 구조적 현상
|
||
# 종목별 손실이 14-16%인 상태에서 BLOCK보다 STRUCTURAL_WARN이 더 정확
|
||
status = "PASS"
|
||
if len(combo) == 0:
|
||
status = "WATCH_PENDING_SAMPLE"
|
||
elif efficiency_score < 45:
|
||
status = "DEGRADE_IMMEDIATE_SELL_WEIGHT"
|
||
elif avg_damage > 16.0:
|
||
status = "CASH_RECOVERY_VALUE_DAMAGE_BLOCK" # 극고손실 구간
|
||
elif avg_damage > 10.0:
|
||
status = "VALUE_DAMAGE_STRUCTURAL_WARN" # 구조적 손실 구간 (K2 실행 중)
|
||
|
||
# HONEST-V1 P4: sample_n < 30이면 UNVALIDATED_DESIGN_SCORE 강제 라벨
|
||
_sample_n = len(combo)
|
||
_is_validated = _sample_n >= 30
|
||
_score_label = "ACTUAL_SCORE" if _is_validated else f"UNVALIDATED_DESIGN_SCORE(n={_sample_n})"
|
||
|
||
result = {
|
||
"formula_id": "REBOUND_SELL_EFFICIENCY_V1",
|
||
"status": status,
|
||
"score_label": _score_label,
|
||
"score_is_validated": _is_validated,
|
||
"score_note": (
|
||
None if _is_validated else
|
||
f"rebound_efficiency_score={efficiency_score:.2f}는 설계점수(design score)입니다. "
|
||
f"실측 P&L 표본 n={_sample_n}(최소 30건 필요). 이 수치를 '검증된 성과'로 인용 금지."
|
||
),
|
||
"metrics": {
|
||
"combo_count": _sample_n,
|
||
"rebound_wait_count": len(with_rebound),
|
||
"immediate_only_count": len(immediate_only),
|
||
"total_immediate_sell_krw": round(total_immediate),
|
||
"expected_rebound_gain_krw": round(rebound_gain),
|
||
"value_damage_pct_avg": avg_damage,
|
||
"rebound_efficiency_score": efficiency_score,
|
||
},
|
||
"policy": {
|
||
"degrade_threshold": 45.0,
|
||
"trim_threshold": 60.0,
|
||
"value_damage_block_threshold": 10.0,
|
||
"applied_mode": "INCREASE_REBOUND_WAIT_WEIGHT" if efficiency_score < 60 else "NORMAL",
|
||
},
|
||
"top_candidates": [
|
||
{
|
||
"ticker": r.get("ticker"),
|
||
"name": r.get("name"),
|
||
"immediate_qty": r.get("immediate_qty"),
|
||
"rebound_wait_qty": r.get("rebound_wait_qty"),
|
||
"value_damage_pct": r.get("value_damage_pct"),
|
||
}
|
||
for r in sorted(with_rebound, key=lambda x: float(x.get("value_damage_pct") or 0), reverse=True)[:5]
|
||
],
|
||
}
|
||
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|