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:
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 전 행 공란",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user