Merge pull request 'feat: Sprint-3 (펀더멘털 피드 완성, MDD 모니터링 구축, Gitea CI/CD 파이프라인 추가)' (#11) from feature/wbs-sprint-3 into main

Reviewed-on: http://192.168.123.100:8418/KimJaeHyun/myfinance/pulls/11
This commit is contained in:
2026-06-13 14:35:25 +09:00
5 changed files with 231 additions and 78 deletions
+48
View File
@@ -0,0 +1,48 @@
name: Quant Engine CI/CD Pipeline
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
validate-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install Python Dependencies
run: |
python -m pip install --upgrade pip
pip install yfinance pandas openpyxl pyyaml
- name: Install Node Dependencies
run: npm install
- name: Validate Specs
run: python tools/validate_specs.py
- name: Validate Formula Registry
run: python tools/validate_formula_registry.py
- name: Validate Golden Case Coverage
run: python tools/validate_golden_coverage_100.py
- name: Run Full Integration Gate
run: python tools/run_release_dag_v3.py --mode release --strict
- name: Build Operational Bundle
run: python tools/build_bundle.py
+3 -3
View File
@@ -598,10 +598,10 @@ CI 게이트:
### Sprint-3 (4주): 펀더멘털 + 성과 기반 구축 ### Sprint-3 (4주): 펀더멘털 + 성과 기반 구축
``` ```
[ ] WBS-2.1: DART 재무데이터 수집 파이프라인 구현 [x] WBS-2.1: DART 재무데이터 수집 파이프라인 구현
[ ] WBS-3.4: MDD 일별 기록 테이블 생성 시작 [x] WBS-3.4: MDD 일별 기록 테이블 생성 시작
[ ] WBS-4.1: T+20 레저 첫 30건 달성 (2026-07-15) [ ] WBS-4.1: T+20 레저 첫 30건 달성 (2026-07-15)
[ ] WBS-5.1: Gitea CI/CD 기본 파이프라인 구축 [x] WBS-5.1: Gitea CI/CD 기본 파이프라인 구축
``` ```
--- ---
@@ -2556,9 +2556,82 @@ function runDataFeed() {
// F4: account_snapshot trailing stop 일괄 갱신 // F4: account_snapshot trailing stop 일괄 갱신
applyTrailingStopUpdates_(); applyTrailingStopUpdates_();
// [WBS-3.4] 일별 자산 총액 및 고점, MDD를 기록
logDailyAssetHistory_(totalAssetKrw_, today);
// 개별 실행에서는 기존 연쇄를 유지하고, run_all() 모드에서는 상위 오케스트레이터가 다음 단계를 수행한다. // 개별 실행에서는 기존 연쇄를 유지하고, run_all() 모드에서는 상위 오케스트레이터가 다음 단계를 수행한다.
if (!isRunAllOrchestrated_()) { if (!isRunAllOrchestrated_()) {
runSectorFlow(); runSectorFlow();
} }
} }
/**
* [WBS-3.4] 일별 자산 총액 및 고점, MDD를 기록한다.
*/
function logDailyAssetHistory_(totalAsset, todayStr) {
try {
if (!Number.isFinite(totalAsset) || totalAsset <= 0) {
Logger.log("[MDD_GUARD] totalAsset이 유효하지 않아 일별 기록을 건너뜁니다.");
return;
}
// daily_history 시트 획득 또는 생성
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName("daily_history");
if (!sheet) {
sheet = ss.insertSheet("daily_history");
// 헤더 작성
sheet.appendRow(["Date", "Total_Asset_KRW", "Peak_Asset_KRW", "MDD_Pct"]);
Logger.log("[MDD_GUARD] daily_history 시트를 신규 생성하고 헤더를 작성했습니다.");
}
// 기존 데이터 읽기
var data = sheet.getDataRange().getValues();
// 오늘 날짜가 이미 존재하는지 체크 (중복 기록 방지)
var todayIndex = -1;
for (var i = 1; i < data.length; i++) {
var dateVal = data[i][0];
var dateStr = "";
if (dateVal instanceof Date) {
dateStr = Utilities.formatDate(dateVal, "Asia/Seoul", "yyyy-MM-dd");
} else {
dateStr = String(dateVal).trim();
}
if (dateStr === todayStr) {
todayIndex = i + 1; // 1-based index
break;
}
}
// 역사적 고점(Peak) 계산
var peakAsset = totalAsset;
for (var i = 1; i < data.length; i++) {
if (i + 1 === todayIndex) continue;
var assetVal = parseFloat(data[i][1]);
if (Number.isFinite(assetVal) && assetVal > peakAsset) {
peakAsset = assetVal;
}
}
// MDD 계산
var mddPct = 0.0;
if (peakAsset > 0) {
mddPct = parseFloat(((peakAsset - totalAsset) / peakAsset * 100).toFixed(2));
}
if (todayIndex > 0) {
// 이미 오늘 날짜가 있으면 해당 행 업데이트
sheet.getRange(todayIndex, 1, 1, 4).setValues([[todayStr, totalAsset, peakAsset, mddPct]]);
Logger.log("[MDD_GUARD] 오늘(" + todayStr + ") 자산 기록을 업데이트했습니다: Asset=" + totalAsset + ", Peak=" + peakAsset + ", MDD=" + mddPct + "%");
} else {
// 없으면 새 행 추가
sheet.appendRow([todayStr, totalAsset, peakAsset, mddPct]);
Logger.log("[MDD_GUARD] 오늘(" + todayStr + ") 자산 기록을 추가했습니다: Asset=" + totalAsset + ", Peak=" + peakAsset + ", MDD=" + mddPct + "%");
}
} catch(e) {
Logger.log("[MDD_GUARD] daily_history 기록 실패: " + e.message);
}
}
+15 -2
View File
@@ -231,7 +231,18 @@ def main() -> int:
t20_stats = _stats_block(t20_replay_returns, 20, "T+20_replay", t20_stats = _stats_block(t20_replay_returns, 20, "T+20_replay",
estimated=True, source="REPLAY_FROM_KRX_EOD (estimated=true)") 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")) peak = _f(harness.get("portfolio_peak_krw"))
total = _f(harness.get("total_asset_krw")) total = _f(harness.get("total_asset_krw"))
current_dd = { current_dd = {
@@ -241,6 +252,7 @@ def main() -> int:
round((peak - total) / peak * 100, 2) if peak and total and peak > 0 round((peak - total) / peak * 100, 2) if peak and total and peak > 0
else 0.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), "worst_case_scenario": _worst_case_mdd(harness),
} }
@@ -248,11 +260,12 @@ def main() -> int:
insufficient = { insufficient = {
"CAGR_realized_1y": INSUF, "CAGR_realized_1y": INSUF,
"sharpe_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, "win_rate_realized_closed_trades": INSUF,
"profit_factor": INSUF, "profit_factor": INSUF,
"slippage_impact": INSUF, "slippage_impact": INSUF,
"transaction_cost_impact": INSUF, "transaction_cost_impact": INSUF,
"in_sample_vs_oos_gap": INSUF, "in_sample_vs_oos_gap": INSUF,
"reason": "1년 이상 청산 완료 거래 이력 없음 — backdata MAE/MFE/pnl 전 행 공란", "reason": "1년 이상 청산 완료 거래 이력 없음 — backdata MAE/MFE/pnl 전 행 공란",
} }
+92 -73
View File
@@ -33,85 +33,104 @@ import urllib.request
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import yfinance as yf
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
DEFAULT_JSON = ROOT / "GatherTradingData.json" DEFAULT_JSON = ROOT / "GatherTradingData.json"
DEFAULT_OUT = ROOT / "Temp" / "fundamental_raw_v1.json" DEFAULT_OUT = ROOT / "Temp" / "fundamental_raw_v1.json"
# ── Yahoo Finance crumb 세션 (모듈 수준 공유) ──────────────────────────────── def _yahoo_fundamentals_yf(ticker: str) -> dict[str, float]:
_yahoo_cj = http.cookiejar.CookieJar() """yfinance 라이브러리를 사용하여 ROE/OPM/beta/revenue/OCF/FCF/NetDebt를 가져온다."""
_yahoo_op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(_yahoo_cj)) result: dict[str, float] = {}
_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)
if re.match(r"^\d{4}[A-Z]\d$", ticker): 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 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 식별자 패턴 (이름 포함)
_ETF_NAME_PATTERNS = ["KODEX", "TIGER", "KINDEX", "KOSEF", "ARIRANG", "TIMEFOLIO", "HANARO"] _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"]: if use_yahoo and needs_yahoo and not row["is_etf"]:
try: try:
yahoo = _yahoo_fundamentals(ticker) yahoo = _yahoo_fundamentals_yf(ticker)
if yahoo: if yahoo:
for k, v in yahoo.items(): for k, v in yahoo.items():
if row.get(k) is None and v is not None: if row.get(k) is None and v is not None: