WBS-7.3 F12/F13: distribution_risk 두 공식 역할 분리 확정(KEEP_BOTH)

GAS calcDistributionRiskRow_의 "THIN_ADAPTER: delegated to Python" 주석이
틀린 주석이었음을 발견 — GAS(DISTRIBUTION_RISK_SCORE_V1, 점수식 BUY 차단
게이트)와 Python calc_distribution_detector_per_ticker(DISTRIBUTION_SELL_DETECTOR_V1,
6신호 카운트, PRE_DISTRIBUTION_EARLY_WARNING 정밀도 보완)는 이미 spec에
서로 다른 고유 formula_id로 등록된 독립 공식이었다. "GAS가 Python의 중복"
이라는 ledger 전제가 거짓이었을 뿐, 코드는 원래부터 올바르게 분리돼 있었다.

사용자 결정(둘 다 유지, 역할 분리)에 따라:
- GAS 소스의 잘못된 주석 정정(gdf_03_portfolio_gates.gs) + 번들 재생성
- 양쪽 formula_registry에 상호 related_formula 참조 추가(향후 혼동 방지)
- governance/gas_logic_migration_ledger_v1.yaml: migration_action을
  DELETE_DISTRIBUTION_RISK_GAS → KEEP_BOTH_SEPARATE_ROLES로 변경, DONE
This commit is contained in:
2026-06-22 02:29:50 +09:00
parent 2af3681fb9
commit 6d4ee39e04
10 changed files with 485 additions and 21 deletions
+76 -4
View File
@@ -99,12 +99,59 @@ def _find_first_value(payload: Any, keys: tuple[str, ...]) -> Any:
return None
def _avg(values: list[float]) -> float | None:
return round(sum(values) / len(values), 4) if values else None
def _compute_ma(rows: list[dict[str, Any]], n: int) -> float | None:
"""rows[0]가 최신 거래일. 최근 n거래일 종가 단순이동평균."""
closes = [r["close"] for r in rows[:n] if r.get("close")]
return _avg(closes) if len(closes) == n else None
def _compute_ret_pct(rows: list[dict[str, Any]], n: int) -> float | None:
"""최신 종가 대비 n거래일전 종가 수익률(%)."""
closes = [r["close"] for r in rows if r.get("close")]
if len(closes) <= n or not closes[n]:
return None
return round((closes[0] / closes[n] - 1.0) * 100.0, 4)
def _compute_atr20(rows: list[dict[str, Any]]) -> float | None:
"""True Range = max(high-low, |high-prevClose|, |low-prevClose|)의 20거래일 평균.
rows[0]가 최신이므로 rows[i]의 전일종가는 rows[i+1]['close']."""
trs: list[float] = []
for i in range(min(20, len(rows) - 1)):
cur, prev = rows[i], rows[i + 1]
high, low, prev_close = cur.get("high"), cur.get("low"), prev.get("close")
if high is None or low is None or prev_close is None:
continue
trs.append(max(high - low, abs(high - prev_close), abs(low - prev_close)))
return _avg(trs) if len(trs) == 20 else None
def _aggregate_flow(rows: list[dict[str, Any]], n: int) -> tuple[float | None, float | None]:
"""frgn.naver rows(최신순)의 최근 n거래일 외국인/기관 순매수 합계(주식수)."""
window = rows[:n]
if len(window) < n:
return None, None
frg = sum(r.get("frgn_net") or 0 for r in window)
inst = sum(r.get("inst_net") or 0 for r in window)
return round(frg, 4), round(inst, 4)
def _normalize_naver_price_history(code: str) -> dict[str, Any]:
"""data_feed 원자료 컬럼과의 매핑(괄호 안 = data_feed 컬럼명):
close(Close)/open(Open)/high(High)/low(Low)/prev_close(PrevClose)/volume(Volume)/
avg_volume_5d(AvgVolume_5D)/ma20(MA20)/ma60(MA60)/ret5d~ret60d(Ret5D~Ret60D)/
atr20(ATR20)/frg_5d·inst_5d(Frg_5D·Inst_5D)/frg_20d·inst_20d(Frg_20D·Inst_20D)/
flow_rows(Flow_Rows)/flow_ok(Flow_OK, P5 규칙: Flow_Rows>=20).
"""
if naver_session is None or fetch_price_history is None:
return {"status": "DISABLED"}
try:
session = naver_session()
price = fetch_price_history(session, code)
# MA60/Ret60D 계산에 60거래일 종가가 필요 — 10행/페이지이므로 7페이지(70행) 수집.
price = fetch_price_history(session, code, pages=7)
result: dict[str, Any] = {"status": price.get("status", "UNKNOWN"), "source_url": price.get("source_url")}
rows = price.get("rows") or []
if rows:
@@ -113,13 +160,29 @@ def _normalize_naver_price_history(code: str) -> dict[str, Any]:
result["high"] = rows[0].get("high")
result["low"] = rows[0].get("low")
result["volume"] = rows[0].get("volume")
if len(rows) > 1:
result["prev_close"] = rows[1].get("close")
result["avg_volume_5d"] = _avg([r["volume"] for r in rows[:5] if r.get("volume")]) if len(rows) >= 5 else None
result["ma20"] = _compute_ma(rows, 20)
result["ma60"] = _compute_ma(rows, 60)
result["ret5d"] = _compute_ret_pct(rows, 5)
result["ret10d"] = _compute_ret_pct(rows, 10)
result["ret20d"] = _compute_ret_pct(rows, 20)
result["ret60d"] = _compute_ret_pct(rows, 60)
result["atr20"] = _compute_atr20(rows)
if compute_relative_return_20d is not None:
benchmark = fetch_price_history(session, "069500")
result["relative_return_20d"] = compute_relative_return_20d(rows, benchmark.get("rows", []))
if compute_volume_ratio_5d is not None:
result["volume_ratio_5d"] = compute_volume_ratio_5d(rows)
if fetch_foreign_institution_flow is not None:
result["foreign_institution_flow"] = fetch_foreign_institution_flow(session, code)
flow = fetch_foreign_institution_flow(session, code)
result["foreign_institution_flow"] = flow
flow_rows = flow.get("rows") or []
result["flow_rows"] = len(flow_rows)
result["flow_ok"] = len(flow_rows) >= 20 # P5: Flow_Rows < 20 → no A-grade/즉시매수
result["frg_5d"], result["inst_5d"] = _aggregate_flow(flow_rows, 5)
result["frg_20d"], result["inst_20d"] = _aggregate_flow(flow_rows, 20)
return result
except Exception as exc: # noqa: BLE001 - fallback source must not break the batch
return {"status": "ERROR", "error": str(exc)}
@@ -222,8 +285,17 @@ def _collect_one(row: dict[str, Any], *, kis_account: str, include_naver: bool,
naver = _normalize_naver_price_history(ticker)
provenance["naver"] = naver
if naver.get("status") in {"OK", "DATA_MISSING"}:
normalized.setdefault("relative_return_20d", naver.get("relative_return_20d"))
normalized.setdefault("volume_ratio_5d", naver.get("volume_ratio_5d"))
# KIS가 이미 채운 필드(close/open/high/low/volume 등)는 setdefault로 보존하고,
# Naver만 제공하는 파생 필드(이동평균/수익률/ATR/수급 5D·20D)는 그대로 채운다.
naver_promotable = (
"close", "open", "high", "low", "volume", "prev_close", "avg_volume_5d",
"ma20", "ma60", "ret5d", "ret10d", "ret20d", "ret60d", "atr20",
"relative_return_20d", "volume_ratio_5d",
"frg_5d", "inst_5d", "frg_20d", "inst_20d", "flow_rows", "flow_ok",
)
for key in naver_promotable:
if key in naver:
normalized.setdefault(key, naver.get(key))
normalized.setdefault("naver_price_status", naver.get("status"))
provenance["source_priority"].append("naver_finance")