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>
243 lines
8.3 KiB
Python
243 lines
8.3 KiB
Python
"""MARKET_SHARE_SIGNAL_V2 — 시장점유율 프록시 시그널 산출기.
|
|
|
|
실제 매출 기반 점유율 데이터가 없는 환경에서 3중 프록시를 사용한다:
|
|
1. AvgTradeValue_20D_M — 20일 평균 거래대금(억 기준) : 유동성/시장 영향력
|
|
2. Frg_20D + Inst_20D — 외인/기관 20일 누적 순매수 : 수급 강도
|
|
3. Ret20D — 20일 수익률 : 상대 모멘텀
|
|
|
|
비-ETF 유니버스 내 백분위를 산출하여 GAINING/STABLE/LOSING을 결정한다.
|
|
|
|
백분위 계산:
|
|
상위 33% → GAINING
|
|
중간 34% → STABLE
|
|
하위 33% → LOSING
|
|
|
|
ETF, 데이터 미수집 → NO_PEER_DATA
|
|
|
|
confidence: 항상 LOW (proxy 기반)
|
|
proxy_basis: "trade_volume_20d+flow+momentum"
|
|
|
|
참고: Revenue/시장점유율 실데이터 수집 후 HIGH confidence로 업그레이드 예정.
|
|
"""
|
|
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" / "market_share_signal_v2.json"
|
|
|
|
_PERCENTILE_GAINING = 67.0 # 상위 33% (≥ 67번째 백분위)
|
|
_PERCENTILE_LOSING = 33.0 # 하위 33% (< 33번째 백분위)
|
|
|
|
|
|
def _load(path: Path) -> dict[str, Any]:
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
d = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
return d if isinstance(d, dict) else {}
|
|
|
|
|
|
def _rows(v: Any) -> list[dict[str, Any]]:
|
|
if isinstance(v, list):
|
|
return [x for x in v if isinstance(x, dict)]
|
|
return []
|
|
|
|
|
|
def _f(v: Any, default: float | None = None) -> float | None:
|
|
if v is None or v == "" or v == "N/A":
|
|
return default
|
|
try:
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
import re as _re
|
|
|
|
# ETF 브랜드명 패턴 (이름 기반 1차 판별)
|
|
_ETF_NAME_RE = _re.compile(
|
|
r"\b(KODEX|TIGER|KINDEX|ARIRANG|HANARO|KOSEF|TREX|SOL|FOCUS|ACE|TIMEFOLIO|PLUS)\b",
|
|
_re.IGNORECASE,
|
|
)
|
|
# 6자리 중 5번째 자리 이후에 알파벳이 있는 ETF 티커 패턴 (예: 0117V0)
|
|
_ETF_TICKER_RE = _re.compile(r"^\d{4}[A-Z]\d")
|
|
|
|
|
|
def _is_etf(r: dict[str, Any]) -> bool:
|
|
"""ETF 여부 판별.
|
|
|
|
판별 순서:
|
|
1. Name에 ETF 브랜드명 포함 → ETF
|
|
2. Ticker가 ETF 형식(영문자 포함 6자리) → ETF
|
|
3. EPS / Forward_PE / PBR 모두 없을 때 → 재무 데이터 부재(ETF)
|
|
단, 위 1·2가 모두 false면 비-ETF로 처리 (재무 데이터 미수집 주식 보호)
|
|
"""
|
|
name = str(r.get("Name") or r.get("name") or "")
|
|
ticker = str(r.get("Ticker") or r.get("ticker") or "")
|
|
if _ETF_NAME_RE.search(name):
|
|
return True
|
|
if _ETF_TICKER_RE.match(ticker):
|
|
return True
|
|
# 재무 데이터가 있으면 비-ETF로 간주; 없어도 이름/티커 기반 판별이 false면 비-ETF 취급
|
|
return False
|
|
|
|
|
|
def _composite_score(r: dict[str, Any]) -> float | None:
|
|
"""거래대금 + 수급 + 모멘텀 합산 점수 (정규화된 상대값)."""
|
|
tv = _f(r.get("AvgTradeValue_20D_M"))
|
|
frg = _f(r.get("Frg_20D"))
|
|
inst = _f(r.get("Inst_20D"))
|
|
ret20 = _f(r.get("Ret20D"))
|
|
|
|
if tv is None:
|
|
return None # 점수 산출 불가
|
|
|
|
# 각 요소 점수 (가중치)
|
|
# 거래대금: 50% — 유동성/영향력의 핵심 지표
|
|
tv_score = tv # 억 단위, 이후 백분위 계산에서 정규화
|
|
|
|
# 수급: 30% — 외인+기관 합산 순매수
|
|
flow_score = 0.0
|
|
if frg is not None:
|
|
flow_score += frg * 0.5
|
|
if inst is not None:
|
|
flow_score += inst * 0.5
|
|
|
|
# 모멘텀: 20% — 20일 수익률
|
|
mom_score = ret20 if ret20 is not None else 0.0
|
|
|
|
# 가중 합산을 위해 각 요소를 거래대금 단위로 스케일링
|
|
# 거래대금이 가장 큰 절대값이므로 기준으로 사용
|
|
composite = tv_score + (flow_score / 1e6 if flow_score != 0 else 0.0) + (mom_score * tv_score * 0.01)
|
|
return composite
|
|
|
|
|
|
def _percentile_rank(value: float, sorted_values: list[float]) -> float:
|
|
"""sorted_values 내에서 value의 백분위 계산 (0~100)."""
|
|
if not sorted_values:
|
|
return 50.0
|
|
n = len(sorted_values)
|
|
rank = sum(1 for v in sorted_values if v <= value)
|
|
return rank / n * 100.0
|
|
|
|
|
|
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) if Path(args.json).is_absolute() else ROOT / args.json
|
|
out_path = Path(args.out) if Path(args.out).is_absolute() else ROOT / args.out
|
|
|
|
payload = _load(json_path)
|
|
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
|
|
|
# data_feed 우선, universe 폴백
|
|
df_list = _rows(data.get("data_feed")) or _rows(data.get("universe"))
|
|
|
|
tickers_seen: set[str] = set()
|
|
all_rows: list[dict[str, Any]] = []
|
|
|
|
for r in df_list:
|
|
ticker = str(r.get("Ticker") or r.get("ticker") or "")
|
|
if not ticker or ticker in tickers_seen:
|
|
continue
|
|
tickers_seen.add(ticker)
|
|
all_rows.append(r)
|
|
|
|
# 비-ETF 유니버스만 백분위 산출
|
|
non_etf_rows = [r for r in all_rows if not _is_etf(r)]
|
|
non_etf_scores: dict[str, float] = {}
|
|
|
|
for r in non_etf_rows:
|
|
ticker = str(r.get("Ticker") or r.get("ticker") or "")
|
|
score = _composite_score(r)
|
|
if score is not None:
|
|
non_etf_scores[ticker] = score
|
|
|
|
sorted_scores = sorted(non_etf_scores.values())
|
|
|
|
rows: list[dict[str, Any]] = []
|
|
label_counts: dict[str, int] = {}
|
|
|
|
for r in all_rows:
|
|
ticker = str(r.get("Ticker") or r.get("ticker") or "")
|
|
name = str(r.get("Name") or r.get("name") or "")
|
|
|
|
if _is_etf(r):
|
|
state = "NO_PEER_DATA"
|
|
conf = "N/A"
|
|
proxy_basis = "etf_excluded"
|
|
pct_rank: float | None = None
|
|
composite: float | None = None
|
|
elif ticker not in non_etf_scores:
|
|
state = "NO_PEER_DATA"
|
|
conf = "N/A"
|
|
proxy_basis = "trade_value_missing"
|
|
pct_rank = None
|
|
composite = None
|
|
else:
|
|
composite = non_etf_scores[ticker]
|
|
pct_rank = _percentile_rank(composite, sorted_scores)
|
|
if pct_rank >= _PERCENTILE_GAINING:
|
|
state = "GAINING"
|
|
elif pct_rank < _PERCENTILE_LOSING:
|
|
state = "LOSING"
|
|
else:
|
|
state = "STABLE"
|
|
conf = "LOW"
|
|
proxy_basis = "trade_volume_20d+flow+momentum"
|
|
|
|
entry = {
|
|
"ticker": ticker,
|
|
"name": name,
|
|
"market_share_state": state,
|
|
"confidence": conf,
|
|
"proxy_basis": proxy_basis,
|
|
"percentile_rank": round(pct_rank, 1) if pct_rank is not None else None,
|
|
"composite_score": round(composite, 2) if composite is not None else None,
|
|
"is_etf": _is_etf(r),
|
|
"formula_id": "MARKET_SHARE_SIGNAL_V2",
|
|
}
|
|
rows.append(entry)
|
|
label_counts[state] = label_counts.get(state, 0) + 1
|
|
|
|
# 게이트: 비-ETF 중 GAINING/STABLE/LOSING이 고루 분포해야 신뢰성 있음
|
|
non_etf_labeled = [r for r in rows if not r["is_etf"] and r["market_share_state"] != "NO_PEER_DATA"]
|
|
unique_states = {r["market_share_state"] for r in non_etf_labeled}
|
|
gate = "PASS" if len(unique_states) >= 2 else ("CAUTION" if non_etf_labeled else "FAIL")
|
|
|
|
out = {
|
|
"formula_id": "MARKET_SHARE_SIGNAL_V2",
|
|
"gate": gate,
|
|
"proxy_method": "trade_volume_20d+frg_inst_flow+ret20d_momentum",
|
|
"confidence": "LOW",
|
|
"non_etf_scored_count": len(non_etf_scores),
|
|
"unique_states": sorted(unique_states),
|
|
"label_counts": label_counts,
|
|
"row_count": len(rows),
|
|
"rows": rows,
|
|
}
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
out_path.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
status = "MARKET_SHARE_SIGNAL_V2_OK" if gate != "FAIL" else "MARKET_SHARE_SIGNAL_V2_FAIL"
|
|
print(
|
|
f"MARKET_SHARE_SIGNAL_V2 gate={gate} rows={len(rows)} "
|
|
f"non_etf_scored={len(non_etf_scores)} unique_states={sorted(unique_states)} "
|
|
f"labels={label_counts}"
|
|
)
|
|
print(status)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|