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>
160 lines
6.3 KiB
Python
160 lines
6.3 KiB
Python
"""build_fundamental_raw_evidence_v3.py — FUNDAMENTAL_RAW_EVIDENCE_V3
|
|
|
|
P0-011: 펀더멘털 실측화.
|
|
ROE/OPM/OCF/FCF 누락을 DATA_MISSING으로 명시하고, 필드 커버리지를 기반으로
|
|
confidence_cap을 자동 하향한다. LONG 판단은 커버리지 < 임계치이면 CANDIDATE_ONLY로 강등한다.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_RAW = ROOT / "Temp" / "fundamental_raw_v2.json"
|
|
DEFAULT_FINAL_JDG = ROOT / "Temp" / "final_judgment_gate_v1.json"
|
|
DEFAULT_OUT = ROOT / "Temp" / "fundamental_raw_evidence_v3.json"
|
|
|
|
# 필수 펀더멘털 필드 (P0-011 요구사항)
|
|
REQUIRED_FIELDS = ["roe_pct", "opm_pct", "ocf_krw", "fcf_krw"]
|
|
COVERAGE_THRESHOLD = 0.95 # 95% 이상이어야 LONG 판단 허용
|
|
LONG_HORIZONS = {"LONG", "POSITION", "MOMENTUM"} # horizon 값 중 장기 분류
|
|
|
|
|
|
def _load(path: Path) -> dict[str, Any]:
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
obj = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
return obj if isinstance(obj, dict) else {}
|
|
|
|
|
|
def _field_presence(row: dict[str, Any], field: str) -> bool:
|
|
"""필드 값이 실제 데이터(None/빈값 아님)인지 확인."""
|
|
v = row.get(field)
|
|
return v is not None and str(v).strip() not in ("", "None", "DATA_MISSING", "N/A")
|
|
|
|
|
|
def _coverage(row: dict[str, Any], fields: list[str]) -> float:
|
|
present = sum(1 for f in fields if _field_presence(row, f))
|
|
return present / len(fields) if fields else 0.0
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--raw", default=str(DEFAULT_RAW))
|
|
ap.add_argument("--fj", default=str(DEFAULT_FINAL_JDG))
|
|
ap.add_argument("--out", default=str(DEFAULT_OUT))
|
|
args = ap.parse_args()
|
|
|
|
raw_path = Path(args.raw) if Path(args.raw).is_absolute() else ROOT / args.raw
|
|
raw = _load(raw_path)
|
|
fj = _load(Path(args.fj) if Path(args.fj).is_absolute() else ROOT / args.fj)
|
|
|
|
# data_feed의 OCF_B/FCF_B를 보완 소스로 활용
|
|
gtd = _load(ROOT / "GatherTradingData.json")
|
|
df_list = (gtd.get("data") or {}).get("data_feed") or []
|
|
if not isinstance(df_list, list):
|
|
df_list = []
|
|
df_by_ticker: dict[str, dict[str, Any]] = {str(r.get("Ticker") or ""): r for r in df_list}
|
|
|
|
raw_rows = raw.get("rows", [])
|
|
non_etf = [r for r in raw_rows if not r.get("is_etf")]
|
|
|
|
# verdict/horizon lookup from final judgment
|
|
horizon_by_ticker: dict[str, str] = {}
|
|
for row in fj.get("rows", []) if isinstance(fj.get("rows"), list) else []:
|
|
t = str(row.get("ticker") or "")
|
|
h = str(row.get("best_horizon") or row.get("horizon") or "")
|
|
if t:
|
|
horizon_by_ticker[t] = h
|
|
|
|
evidence_rows = []
|
|
total_field_slots = 0
|
|
filled_field_slots = 0
|
|
|
|
for row in non_etf:
|
|
ticker = str(row.get("ticker") or "")
|
|
df_row = df_by_ticker.get(ticker, {})
|
|
field_status: dict[str, str] = {}
|
|
|
|
# OCF/FCF는 raw_v2의 ocf_krw/fcf_krw 우선, 없으면 data_feed의 OCF_B/FCF_B 사용
|
|
if not _field_presence(row, "ocf_krw") and _field_presence(df_row, "OCF_B"):
|
|
row = dict(row); row["ocf_krw"] = df_row["OCF_B"]
|
|
if not _field_presence(row, "fcf_krw") and _field_presence(df_row, "FCF_B"):
|
|
row = dict(row); row["fcf_krw"] = df_row["FCF_B"]
|
|
|
|
for field in REQUIRED_FIELDS:
|
|
if _field_presence(row, field):
|
|
field_status[field] = str(row[field])
|
|
filled_field_slots += 1
|
|
else:
|
|
field_status[field] = "DATA_MISSING"
|
|
total_field_slots += 1
|
|
|
|
field_coverage = _coverage(row, REQUIRED_FIELDS)
|
|
horizon = horizon_by_ticker.get(ticker, "UNKNOWN")
|
|
is_long_horizon = any(lh in horizon.upper() for lh in LONG_HORIZONS)
|
|
long_buy_downgraded = is_long_horizon and field_coverage < COVERAGE_THRESHOLD
|
|
|
|
evidence_rows.append({
|
|
"ticker": ticker,
|
|
"name": row.get("name", ""),
|
|
"source": row.get("source", ""),
|
|
"as_of_date": row.get("as_of_date", ""),
|
|
"field_coverage_pct": round(field_coverage * 100, 2),
|
|
"horizon": horizon,
|
|
"is_long_horizon": is_long_horizon,
|
|
"long_buy_downgraded_to_candidate_only": long_buy_downgraded,
|
|
"downgrade_reason": f"fundamental_coverage={field_coverage*100:.0f}% < {COVERAGE_THRESHOLD*100:.0f}%" if long_buy_downgraded else None,
|
|
"fields": field_status,
|
|
"source_path": str(raw_path.relative_to(ROOT)),
|
|
"formula_id": "FUNDAMENTAL_RAW_EVIDENCE_V3",
|
|
})
|
|
|
|
overall_coverage = (filled_field_slots / total_field_slots * 100.0) if total_field_slots > 0 else 0.0
|
|
roe_opm_ocf_fcf_missing_count = sum(
|
|
1 for r in evidence_rows
|
|
for field in REQUIRED_FIELDS
|
|
if r["fields"].get(field) == "DATA_MISSING"
|
|
)
|
|
long_buy_with_missing = [r for r in evidence_rows if r["long_buy_downgraded_to_candidate_only"]]
|
|
|
|
# gate 판정
|
|
if overall_coverage >= 95.0 and len(long_buy_with_missing) == 0:
|
|
gate = "PASS"
|
|
elif overall_coverage >= 50.0:
|
|
gate = "CAUTION"
|
|
else:
|
|
gate = "FAIL"
|
|
|
|
result = {
|
|
"formula_id": "FUNDAMENTAL_RAW_EVIDENCE_V3",
|
|
"gate": gate,
|
|
"fundamental_source_field_coverage_pct": round(overall_coverage, 2),
|
|
"roe_opm_ocf_fcf_missing_count": roe_opm_ocf_fcf_missing_count,
|
|
"long_horizon_buy_with_missing_fundamental_count": len(long_buy_with_missing),
|
|
"long_buy_downgraded_tickers": [r["ticker"] for r in long_buy_with_missing],
|
|
"coverage_threshold_pct": COVERAGE_THRESHOLD * 100,
|
|
"non_etf_ticker_count": len(non_etf),
|
|
"rows": evidence_rows,
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"source_path": "Temp/fundamental_raw_evidence_v3.json",
|
|
}
|
|
|
|
out_path = Path(args.out) if Path(args.out).is_absolute() else ROOT / args.out
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
summary = {k: v for k, v in result.items() if k != "rows"}
|
|
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|