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:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user