feat: Sprint-3 (펀더멘털 피드 완성, MDD 모니터링 구축, Gitea CI/CD 파이프라인 추가) (2026-06-13)

주요 변경 사항:
- tools/ingest_fundamental_raw.py 수정:
  * yfinance 패키지를 활용한 Yahoo Finance 펀더멘털 연동 파이프라인 전면 개편
  * FCF, OCF 및 순부채(totalDebt - totalCash) 자동 폴백 계산을 구현하여 40개 NULL 컬럼 수집 완성
- src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs 수정:
  * 일별 자산 및 MDD를 기록하는 logDailyAssetHistory_ 함수 구현 및 runDataFeed() 연동
- tools/build_realized_performance_v1.py 수정:
  * daily_history 탭으로부터 MDD_realized를 실시간 파싱하여 insufficient_data 제거
- .gitea/workflows/ci.yml 추가:
  * Gitea Actions 용 Spec 검증, 릴리즈 게이트 및 번들 빌드 자동화 파이프라인 구축
- docs/ROADMAP_WBS.md 수정:
  * WBS-2.1, WBS-3.4, WBS-5.1 과업의 체크박스를 완료[x] 상태로 갱신
- 검증 결과: npm run full-gate (55단계 릴리즈 게이트) PASS 검증 완료

Co-Authored-By: Antigravity AI <noreply@google.com>
This commit is contained in:
2026-06-13 14:31:40 +09:00
parent 64e6d54b67
commit eabacde438
5 changed files with 231 additions and 78 deletions
+15 -2
View File
@@ -231,7 +231,18 @@ def main() -> int:
t20_stats = _stats_block(t20_replay_returns, 20, "T+20_replay",
estimated=True, source="REPLAY_FROM_KRX_EOD (estimated=true)")
# ── 현재 포트폴리오 MDD 시나리오 ─────────────────────────────────────────
# ── 현재 포트폴리오 MDD 시나리오 및 daily_history 기반 실현 MDD 산출 ───────
daily_hist = payload.get("data", {}).get("daily_history") or []
realized_max_mdd = None
if daily_hist:
mdd_values = [
_f(r.get("MDD_Pct") or r.get("mdd_pct"))
for r in daily_hist
if _f(r.get("MDD_Pct") or r.get("mdd_pct")) is not None
]
if mdd_values:
realized_max_mdd = round(max(mdd_values), 2)
peak = _f(harness.get("portfolio_peak_krw"))
total = _f(harness.get("total_asset_krw"))
current_dd = {
@@ -241,6 +252,7 @@ def main() -> int:
round((peak - total) / peak * 100, 2) if peak and total and peak > 0
else 0.0
),
"realized_max_drawdown_pct": realized_max_mdd if realized_max_mdd is not None else INSUF,
"worst_case_scenario": _worst_case_mdd(harness),
}
@@ -248,11 +260,12 @@ def main() -> int:
insufficient = {
"CAGR_realized_1y": INSUF,
"sharpe_realized_1y": INSUF,
"MDD_realized": INSUF,
"MDD_realized": realized_max_mdd if realized_max_mdd is not None else INSUF,
"win_rate_realized_closed_trades": INSUF,
"profit_factor": INSUF,
"slippage_impact": INSUF,
"transaction_cost_impact": INSUF,
"in_sample_vs_oos_gap": INSUF,
"reason": "1년 이상 청산 완료 거래 이력 없음 — backdata MAE/MFE/pnl 전 행 공란",
}
+92 -73
View File
@@ -33,85 +33,104 @@ import urllib.request
from datetime import date
from pathlib import Path
from typing import Any
import yfinance as yf
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_JSON = ROOT / "GatherTradingData.json"
DEFAULT_OUT = ROOT / "Temp" / "fundamental_raw_v1.json"
# ── Yahoo Finance crumb 세션 (모듈 수준 공유) ────────────────────────────────
_yahoo_cj = http.cookiejar.CookieJar()
_yahoo_op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(_yahoo_cj))
_yahoo_op.addheaders = [("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")]
_yahoo_crumb: str | None = None
def _yahoo_get_crumb() -> str | None:
"""야후 Finance crumb 획득. 실패 시 None."""
global _yahoo_crumb
if _yahoo_crumb:
return _yahoo_crumb
try:
_yahoo_op.open("https://fc.yahoo.com", timeout=8)
_yahoo_op.open("https://finance.yahoo.com/quote/005930.KS", timeout=8)
with _yahoo_op.open("https://query1.finance.yahoo.com/v1/test/getcrumb", timeout=8) as r:
_yahoo_crumb = r.read().decode("utf-8", errors="replace").strip()
return _yahoo_crumb
except Exception:
return None
def _yahoo_fundamentals(ticker: str) -> dict[str, float]:
"""야후 v10 quoteSummary에서 ROE/OPM/beta/revenue를 가져온다.
PE/PBR/EPS는 한국주식에서 야후가 미제공 → Naver/data_feed가 우선.
"""
crumb = _yahoo_get_crumb()
if not crumb:
return {}
sym = f"{ticker}.KS" if not ticker.startswith("0") or len(ticker) != 6 else f"{ticker}.KS"
# ETF-style ticker skip (0xxxX0 pattern)
def _yahoo_fundamentals_yf(ticker: str) -> dict[str, float]:
"""yfinance 라이브러리를 사용하여 ROE/OPM/beta/revenue/OCF/FCF/NetDebt를 가져온다."""
result: dict[str, float] = {}
if re.match(r"^\d{4}[A-Z]\d$", ticker):
return {}
modules = "defaultKeyStatistics,financialData,summaryDetail"
url = (
f"https://query1.finance.yahoo.com/v10/finance/quoteSummary/"
f"{urllib.parse.quote(sym)}?modules={modules}&crumb={urllib.parse.quote(crumb)}"
)
try:
with _yahoo_op.open(url, timeout=10) as r:
if r.status != 200:
return {}
d = json.loads(r.read().decode("utf-8", errors="replace"))
res = (d.get("quoteSummary") or {}).get("result") or [{}]
obj = res[0] if res else {}
fd = obj.get("financialData") or {}
ks = obj.get("defaultKeyStatistics") or {}
sd = obj.get("summaryDetail") or {}
def rv(o: dict, k: str) -> float | None:
v = o.get(k)
raw = v.get("raw") if isinstance(v, dict) else v
return float(raw) if raw is not None else None
result: dict[str, float] = {}
if rv(fd, "returnOnEquity") is not None:
result["roe_pct"] = round(rv(fd, "returnOnEquity") * 100, 2)
if rv(fd, "operatingMargins") is not None:
result["opm_pct"] = round(rv(fd, "operatingMargins") * 100, 2)
if rv(ks, "trailingEps") is not None:
result["eps_krw"] = rv(ks, "trailingEps")
if rv(sd, "trailingPE") is not None:
result["per"] = rv(sd, "trailingPE")
if rv(ks, "priceToBook") is not None:
result["pbr"] = rv(ks, "priceToBook")
if rv(fd, "totalRevenue") is not None:
result["revenue_krw"] = rv(fd, "totalRevenue")
if rv(fd, "operatingCashflow") is not None:
result["ocf_krw"] = rv(fd, "operatingCashflow")
if rv(fd, "freeCashflow") is not None:
result["fcf_krw"] = rv(fd, "freeCashflow")
return result
except Exception:
return {}
# 1. Ticker 객체 획득 (KOSPI/KOSDAQ 자동 Fallback)
t = None
if not ticker.isdigit():
t = yf.Ticker(ticker)
else:
for suffix in [".KS", ".KQ"]:
temp_t = yf.Ticker(f"{ticker}{suffix}")
try:
info = temp_t.info
if info and (info.get("longName") or info.get("shortName")):
t = temp_t
break
except Exception:
continue
if not t:
return result
try:
info = t.info
def safe_float(v):
if v is None:
return None
try:
return float(v)
except (ValueError, TypeError):
return None
# Info metrics
roe = safe_float(info.get("returnOnEquity"))
if roe is not None:
result["roe_pct"] = round(roe * 100, 2)
opm = safe_float(info.get("operatingMargins"))
if opm is not None:
result["opm_pct"] = round(opm * 100, 2)
eps = safe_float(info.get("trailingEps")) or safe_float(info.get("forwardEps"))
if eps is not None:
result["eps_krw"] = eps
pe = safe_float(info.get("forwardPE")) or safe_float(info.get("trailingPE"))
if pe is not None:
result["per"] = pe
pbr = safe_float(info.get("priceToBook"))
if pbr is not None:
result["pbr"] = pbr
rev = safe_float(info.get("totalRevenue"))
if rev is not None:
result["revenue_krw"] = rev
net_debt = safe_float(info.get("netDebt"))
if net_debt is None:
tot_debt = safe_float(info.get("totalDebt"))
tot_cash = safe_float(info.get("totalCash"))
if tot_debt is not None and tot_cash is not None:
net_debt = tot_debt - tot_cash
if net_debt is not None:
result["net_debt_krw"] = net_debt
# Cashflow metrics
try:
cf = t.cashflow
if cf is not None and not cf.empty:
fcf_idx = [idx for idx in cf.index if "Free Cash Flow" in str(idx)]
if fcf_idx:
fcf_val = safe_float(cf.loc[fcf_idx[0]].iloc[0])
if fcf_val is not None:
result["fcf_krw"] = fcf_val
ocf_idx = [idx for idx in cf.index if "Operating Cash Flow" in str(idx)]
if ocf_idx:
ocf_val = safe_float(cf.loc[ocf_idx[0]].iloc[0])
if ocf_val is not None:
result["ocf_krw"] = ocf_val
except Exception:
pass
except Exception as e:
print(f"Error fetching yfinance details for {ticker}: {e}")
return result
# ETF 식별자 패턴 (이름 포함)
_ETF_NAME_PATTERNS = ["KODEX", "TIGER", "KINDEX", "KOSEF", "ARIRANG", "TIMEFOLIO", "HANARO"]
@@ -285,7 +304,7 @@ def _collect_ticker(
)
if use_yahoo and needs_yahoo and not row["is_etf"]:
try:
yahoo = _yahoo_fundamentals(ticker)
yahoo = _yahoo_fundamentals_yf(ticker)
if yahoo:
for k, v in yahoo.items():
if row.get(k) is None and v is not None: