Skip to content

Commit 644791f

Browse files
committed
feat: Penman RNOA/FLEV 분해 + Richardson 3계층 발생액 추가
Phase 1 재무제표 극한 활용: 1. calcPenmanDecomposition (profitability.py) ROCE = RNOA + FLEV × SPREAD. ROE가 영업력인지 레버리지인지 분리. 삼성전자: RNOA 5.41%, FLEV -0.08(순현금), LevEffect -1.54%p 2. calcRichardsonAccrual (earningsQuality.py) BS 변동 기반 3계층 분해: WCACC(운전자본) + LTOACC(비유동) + FINACC(금융) LTOACC 비중 > 50%이면 이익 신뢰도 낮음 ops/analysis.md에 9개 방법론 갭 매트릭스 + 구현 로드맵 문서화
1 parent bca03df commit 644791f

3 files changed

Lines changed: 356 additions & 8 deletions

File tree

ops/analysis.md

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,23 +78,129 @@ dartlab은 투자자 관점을 채택 — "이 회사가 뭘로 돈을 버는가
7878
- fallback이 가치 없으면 제거 (None 반환이 나음)
7979
- 금융업(은행/증권/보험) IS/BS 구조 미지원 — 장기 과제
8080

81+
## 재무제표 극한 활용 (갭 매트릭스)
82+
83+
9개 학술/실무 방법론 조사 결과. 현재 구현 수준과 빠진 것.
84+
85+
### 구현 수준 매트릭스
86+
87+
| 방법론 | 구현율 | 핵심 갭 |
88+
|--------|--------|---------|
89+
| Penman Reformulated FS | 60% | RNOA, FLEV/SPREAD 분해 없음 |
90+
| Richardson 발생액 3계층 | 30% | BS 기반 WCACC/LTOACC/FINACC 분리 없음 |
91+
| Mohanram G-Score | 0% | 성장주 전용 스코어 부재 |
92+
| DuPont 확장 | 50% | RNOA 기반 분해, 업종 조정 없음 |
93+
| CF 품질 분석 | 40% | Core OCF 조정, Maintenance CAPEX 분리 없음 |
94+
| BS 자산 재분류 | 70% | NFO, 이연법인세 분류, 초과현금 분리 없음 |
95+
| SCE 활용 | 20% | OCI 분해, Dirty Surplus 없음 |
96+
| 세그먼트 심화 | 50% | 부문별 마진/ROIC, SOTP 밸류에이션 없음 |
97+
| 3표 교차 검증 | 50% | BS-CF 연결, Articulation Check 없음 |
98+
99+
### 재무제표 직접 읽기 (비율 아닌 원본 활용) — 빠진 것
100+
101+
| 영역 | 빠진 분석 | 데이터 위치 |
102+
|------|----------|-----------|
103+
| IS | 판관비 하위 분해 (인건비/광고/R&D/임차료) | sections 주석 |
104+
| IS | 영업외손익 분해 (이자/환차/지분법/처분) | IS finance_income/cost, 지분법손익 |
105+
| IS | 매출원가 분해 (원재료/노무/경비) | sections 제조원가명세서 |
106+
| CF | 영업CF 내부 분해 (비현금+운전자본 항목별) | CF 개별 조정항목 |
107+
| CF | 투자CF 상세 (금융자산/관계기업 개별) | CF |
108+
| BS | 부채 상세 (선수금/충당부채/리스부채 개별) | BS |
109+
| BS | 자본 항목 분해 (자본금/잉여금/OCI 개별) | BS + SCE |
110+
| 전체 | 계정별 CAGR 비교 (절대값 장기 추세) | IS/BS/CF 전체 |
111+
| 전체 | BS-CF 정합성 (PPE/현금/자본 Articulation) | BS + CF |
112+
113+
### 구현 로드맵
114+
115+
**Phase 1 — 즉시** (데이터 있음, 나누기만 추가):
116+
1. Penman RNOA + FLEV/SPREAD → `profitability.py`
117+
2. Richardson 3계층 발생액 → `earningsQuality.py`
118+
3. BS-CF Articulation Check → `crossStatement.py`
119+
120+
**Phase 2 — 재무제표 직접 읽기** (select()로 접근 가능):
121+
4. 영업CF 내부 분해 → `cashflow.py`
122+
5. 영업외손익 분해 → `earningsQuality.py`
123+
6. 부채 상세 분해 → `stability.py`
124+
7. 절대값 CAGR 비교 → `growthAnalysis.py`
125+
126+
**Phase 3 — 데이터 확장** (sections 파싱 연동 필요):
127+
8. 판관비/매출원가 하위 분해
128+
9. SCE 기반 OCI 분해
129+
10. 부문별 영업이익/마진
130+
11. Mohanram G-Score
131+
132+
### 학술 근거
133+
134+
- Penman: Nissim & Penman (2001), Penman FSA&SV 5e
135+
- Richardson: Richardson et al. (2005) — Accrual Reliability, Earnings Persistence
136+
- Mohanram: Mohanram (2005) — G-Score for Growth Stocks
137+
- Soliman: Soliman (2008) — Industry-Adjusted DuPont
138+
- CF 품질: Mulford & Comiskey (2005) — Creative Cash Flow Reporting
139+
81140
## forecast (예측)
82141

83-
### 예측신호 6축
142+
### 매출 방향 예측엔진 (calcRevenueDirection)
143+
144+
**방법론**: 모멘텀(전분기 방향 유지) + 영업이익률 확인 + OLS 확인
145+
146+
```
147+
1. 기본: 전분기 YoY 매출 방향을 그대로 유지 (72.1%)
148+
2. 확인1: 영업이익률 > 0이면 신뢰도 상승 (76.1%) — API 불필요
149+
3. 확인2: OLS 외생변수와 일치하면 추가 상승 (77.7%)
150+
4. 2연속 같은 방향이면 74.7%
151+
```
152+
153+
**검증 수치** (walk-forward, 과적합 불가):
154+
155+
| 조건 | 정확도 | 관측치 | 커버리지 |
156+
|------|--------|--------|---------|
157+
| 모멘텀 단독 | 72.1% | 4825건 | 100% |
158+
| 2연속 모멘텀 | 74.7% | 360건 | 69% |
159+
| 모멘텀+영업이익률 일치 | 76.1% | 3660건 | 76% |
160+
| 모멘텀+OLS 일치 | 77.7% | 355건 | 68% |
161+
162+
**신뢰도 체계**:
163+
- `very_high`: 3개 확인(마진+OLS+2연속) 모두 일치
164+
- `high`: 2개 확인
165+
- `medium`: 1개 이하
166+
167+
**학술 근거**:
168+
- M4/M5 Competition: 단순 방법 > 복잡한 ML (100,000 시계열)
169+
- Sloan 1996: 이익 지속성 → 모멘텀의 이론적 기반
170+
- PEAD: 실적 방향 지속 효과
171+
172+
**시도했지만 효과 없던 것**:
173+
- Logistic Regression (+0.8%p) — 모델 구조 변경 무의미
174+
- 한국 PPI 13개 추가 — 하락 (가격 < 생산량)
175+
- 11신호 다수결 앙상블 (61%) — static 신호 = 상수 바이어스
176+
- 후보 풀 확대 — 과적합 변수 선택
177+
- GDP — **영구 제외** (기업 매출의 직접 외생변수가 아님)
178+
179+
### 외생변수 6축 (OLS 확인용)
84180

85181
|| 지표 예시 |
86182
|------|------|
87183
| 원자재 가격 | 구리, 알루미늄, 유가, 금속PPI, 밀, 면화 |
88-
| 산업생산 | 반도체, 자동차, 화학, 식품, INDPRO |
89-
| 실물수요 | 자동차판매, 내구재, 화물운송, 설비가동률 |
184+
| 산업생산 | 반도체, 자동차, 화학, 식품, INDPRO, 배터리PPI |
185+
| 실물수요 | 자동차판매, 내구재, 화물운송, 설비가동률, BSI |
90186
| 금융조건 | 금리, 하이일드 스프레드, 회사채 |
91-
| 내수경기 | IPI, 서비스업, BSI, 아파트가격 |
187+
| 내수경기 | IPI, 서비스업, BSI 내수/수출, 아파트가격 |
92188
| 환율 | 원/달러, 원/엔, 원/위안 |
93189

94-
- 143개 업종 매핑, 95.5% 커버리지
95-
- 방향 정확도: 평균 66%, lag=1분기 선행 시 49%가 70%+
96-
- **GDP는 영구 제외** — 기업 매출의 직접 외생변수가 아님
97-
- 천장(62~66%)은 모델 구조로 못 뚫음 — 변수 차원 변경 필요
190+
- 143개 업종 매핑, 95.5% 커버리지, `exogenousAxes.py`
191+
- 적응형 변수 선택: 매핑 후보 + 범용 후보에서 상관도 상위 3개
192+
- `productIndex.parquet`: 2444종목 공시 제품 텍스트 (0.3MB)
193+
- OLS 단독은 62~66% 천장 — "확인자" 역할만 (독립 예측자 아님)
194+
195+
### 추가 탐색 결과
196+
197+
| 방향 | 결과 | 상태 |
198+
|------|------|------|
199+
| 횡단면 ML (Chen 2022) | 모멘텀+마진이 이미 76% → ML 추가 이득 불분명 | 장기 검토 |
200+
| 관세청 수출입 HS코드 | API 키 필요 | 보류 |
201+
| 공시 tone (키워드) | preview 200자 한계 | 보류 |
202+
| Google Trends | 한국 점유율 낮음 | 제외 |
203+
| 피어 선행 | Frankel 2025: spillover 약함 | 제외 |
98204

99205
### valuation (가치평가)
100206

src/dartlab/analysis/financial/earningsQuality.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,103 @@ def calcEarningsQualityFlags(company, *, basePeriod: str | None = None) -> list[
360360
flags.append(f"Beneish M-Score {ms:.2f} — 임계값 초과, 이익 조작 가능성")
361361

362362
return flags
363+
364+
365+
# ── Richardson 3계층 발생액 분해 ──
366+
367+
368+
def calcRichardsonAccrual(company, *, basePeriod: str | None = None) -> dict | None:
369+
"""Richardson et al. (2005) 3계층 발생액 분해.
370+
371+
BS 변동 기반으로 발생액을 운전자본/비유동영업/금융으로 분리.
372+
신뢰도가 낮은 LTOACC가 클수록 이익 지속성이 낮다.
373+
374+
WCACC = (delta_CA - delta_Cash) - (delta_CL - delta_STD) 신뢰도 높음
375+
LTOACC = delta_NCOA - delta_NCOL 신뢰도 낮음
376+
FINACC = delta_STI + delta_LTI - delta_LTD - delta_PSTK 중간
377+
378+
반환::
379+
380+
{
381+
"history": [
382+
{"period": str, "wcacc": float, "ltoacc": float, "finacc": float,
383+
"totalAccrual": float, "reliabilityScore": str},
384+
...
385+
],
386+
}
387+
388+
학술근거: Richardson, Sloan, Soliman, Tuna (2005).
389+
"""
390+
bsResult = company.select(
391+
"BS",
392+
[
393+
"유동자산", "비유동자산", "유동부채", "비유동부채",
394+
"현금및현금성자산", "단기차입금", "장기차입금", "사채",
395+
"자산총계",
396+
],
397+
)
398+
399+
bsParsed = _toDict(bsResult)
400+
if bsParsed is None:
401+
return None
402+
403+
bsData, bsPeriods = bsParsed
404+
caRow = bsData.get("유동자산", {})
405+
ncaRow = bsData.get("비유동자산", {})
406+
clRow = bsData.get("유동부채", {})
407+
nclRow = bsData.get("비유동부채", {})
408+
cashRow = bsData.get("현금및현금성자산", {})
409+
stRow = bsData.get("단기차입금", {})
410+
ltRow = bsData.get("장기차입금", {})
411+
bondRow = bsData.get("사채", {})
412+
taRow = bsData.get("자산총계", {})
413+
414+
yCols = _annualColsFromPeriods(bsPeriods, _MAX_YEARS + 1, basePeriod=basePeriod)
415+
if len(yCols) < 2:
416+
return None
417+
418+
history = []
419+
for i in range(len(yCols) - 1):
420+
col = yCols[i]
421+
prevCol = yCols[i + 1]
422+
423+
# 델타 계산
424+
dCA = _get(caRow, col) - _get(caRow, prevCol)
425+
dCash = _get(cashRow, col) - _get(cashRow, prevCol)
426+
dCL = _get(clRow, col) - _get(clRow, prevCol)
427+
dSTD = _get(stRow, col) - _get(stRow, prevCol)
428+
dNCA = _get(ncaRow, col) - _get(ncaRow, prevCol)
429+
dNCL = _get(nclRow, col) - _get(nclRow, prevCol)
430+
dLTD = (_get(ltRow, col) + _get(bondRow, col)) - (_get(ltRow, prevCol) + _get(bondRow, prevCol))
431+
432+
# 3계층 분해
433+
wcacc = (dCA - dCash) - (dCL - dSTD)
434+
ltoacc = dNCA - dNCL
435+
finacc = -dCash + dSTD + dLTD # 금융자산 증가 - 금융부채 증가의 역
436+
437+
totalAccrual = wcacc + ltoacc + finacc
438+
avgTA = (_get(taRow, col) + _get(taRow, prevCol)) / 2
439+
440+
# 정규화 (총자산 평균 대비)
441+
wcaccNorm = round(wcacc / avgTA * 100, 2) if avgTA > 0 else None
442+
ltoaccNorm = round(ltoacc / avgTA * 100, 2) if avgTA > 0 else None
443+
finaccNorm = round(finacc / avgTA * 100, 2) if avgTA > 0 else None
444+
totalNorm = round(totalAccrual / avgTA * 100, 2) if avgTA > 0 else None
445+
446+
# 신뢰도 판단: LTOACC 비중이 50% 이상이면 낮음
447+
if totalAccrual != 0 and avgTA > 0:
448+
ltoShare = abs(ltoacc) / (abs(wcacc) + abs(ltoacc) + abs(finacc)) if (abs(wcacc) + abs(ltoacc) + abs(finacc)) > 0 else 0
449+
reliability = "low" if ltoShare > 0.5 else "high" if ltoShare < 0.2 else "medium"
450+
else:
451+
reliability = None
452+
453+
history.append({
454+
"period": col,
455+
"wcacc": wcaccNorm,
456+
"ltoacc": ltoaccNorm,
457+
"finacc": finaccNorm,
458+
"totalAccrual": totalNorm,
459+
"reliabilityScore": reliability,
460+
})
461+
462+
return {"history": history} if history else None

src/dartlab/analysis/financial/profitability.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,145 @@ def calcProfitabilityFlags(company, *, basePeriod: str | None = None) -> list[st
323323
flags.append(f"진성 고수익 (ROE {roe:.1f}%, 낮은 레버리지)")
324324

325325
return flags
326+
327+
328+
# ── Penman RNOA + FLEV/SPREAD 분해 ──
329+
330+
331+
def _get(row: dict, col: str) -> float:
332+
v = row.get(col)
333+
return v if v is not None else 0
334+
335+
336+
def calcPenmanDecomposition(company, *, basePeriod: str | None = None) -> dict | None:
337+
"""Penman 분해 -- ROE가 영업력인지 레버리지인지 분리.
338+
339+
ROCE = RNOA + FLEV × SPREAD
340+
RNOA = NOPAT / NOA (순영업자산수익률)
341+
FLEV = NFO / Equity (금융레버리지)
342+
NBC = 순금융비용 / NFO (순차입비용률)
343+
SPREAD = RNOA - NBC (초과수익률)
344+
leverageEffect = FLEV × SPREAD (레버리지 효과)
345+
346+
반환::
347+
348+
{
349+
"history": [
350+
{"period": str, "rnoa": float, "flev": float, "nbc": float,
351+
"spread": float, "leverageEffect": float, "roce": float},
352+
...
353+
],
354+
}
355+
356+
Guide::
357+
358+
RNOA와 ROE를 비교하면 레버리지 효과를 알 수 있다.
359+
RNOA > NBC이면 차입이 주주에게 유리 (양의 SPREAD).
360+
RNOA < NBC이면 차입이 가치를 파괴.
361+
362+
SeeAlso::
363+
364+
- calcReturnTrend: 기존 5요소 DuPont
365+
- calcRoicTimeline: ROIC 시계열
366+
367+
학술근거: Nissim & Penman (2001), Penman FSA&SV 5e.
368+
"""
369+
isResult = company.select(
370+
"IS", ["영업이익", "법인세비용", "법인세차감전순이익", "금융이익", "금융비용"]
371+
)
372+
bsResult = company.select(
373+
"BS",
374+
[
375+
"자산총계", "자본총계",
376+
"매출채권및기타채권", "재고자산", "유형자산", "무형자산",
377+
"매입채무", "선수금", "계약부채",
378+
"단기차입금", "장기차입금", "사채",
379+
"현금및현금성자산",
380+
],
381+
)
382+
383+
isParsed = toDict(isResult)
384+
bsParsed = toDict(bsResult)
385+
if isParsed is None or bsParsed is None:
386+
return None
387+
388+
isData, isPeriods = isParsed
389+
bsData, _ = bsParsed
390+
391+
opRow = isData.get("영업이익", {})
392+
taxRow = isData.get("법인세비용", {})
393+
ptRow = isData.get("법인세차감전순이익", {})
394+
finIncRow = isData.get("금융이익", {})
395+
finCostRow = isData.get("금융비용", {})
396+
397+
eqRow = bsData.get("자본총계", {})
398+
recRow = bsData.get("매출채권및기타채권", {})
399+
invRow = bsData.get("재고자산", {})
400+
ppeRow = bsData.get("유형자산", {})
401+
intRow = bsData.get("무형자산", {})
402+
apRow = bsData.get("매입채무", {})
403+
advRow = bsData.get("선수금", {})
404+
contRow = bsData.get("계약부채", {})
405+
stRow = bsData.get("단기차입금", {})
406+
ltRow = bsData.get("장기차입금", {})
407+
bondRow = bsData.get("사채", {})
408+
cashRow = bsData.get("현금및현금성자산", {})
409+
410+
yCols = _annualColsFromPeriods(isPeriods, maxYears=_MAX_YEARS, basePeriod=basePeriod)
411+
if len(yCols) < 2:
412+
return None
413+
414+
history = []
415+
for col in yCols:
416+
# NOPAT = 영업이익 × (1 - 유효세율)
417+
opIncome = _get(opRow, col)
418+
taxExpense = abs(_get(taxRow, col))
419+
ptIncome = abs(_get(ptRow, col))
420+
effectiveTaxRate = taxExpense / ptIncome if ptIncome > 0 else 0.25
421+
effectiveTaxRate = min(effectiveTaxRate, 0.5)
422+
nopat = opIncome * (1 - effectiveTaxRate) if opIncome != 0 else None
423+
424+
# NOA = 영업자산 - 영업부채
425+
opAssets = _get(recRow, col) + _get(invRow, col) + _get(ppeRow, col) + _get(intRow, col)
426+
opLiab = _get(apRow, col) + _get(advRow, col) + _get(contRow, col)
427+
noa = opAssets - opLiab if opAssets > 0 else None
428+
429+
# NFO = 금융부채 - 금융자산(현금)
430+
finDebt = _get(stRow, col) + _get(ltRow, col) + _get(bondRow, col)
431+
cash = _get(cashRow, col)
432+
nfo = finDebt - cash
433+
434+
# 순금융비용
435+
finInc = _get(finIncRow, col)
436+
finCost = _get(finCostRow, col)
437+
netFinCost = finCost - finInc # 양수 = 순비용
438+
439+
equity = _get(eqRow, col)
440+
441+
# RNOA
442+
rnoa = round(nopat / noa * 100, 2) if nopat is not None and noa and noa > 0 else None
443+
# FLEV
444+
flev = round(nfo / equity, 2) if equity > 0 else None
445+
# NBC
446+
nbc = round(netFinCost * (1 - effectiveTaxRate) / abs(nfo) * 100, 2) if nfo != 0 else None
447+
# SPREAD
448+
spread = round(rnoa - nbc, 2) if rnoa is not None and nbc is not None else None
449+
# Leverage Effect
450+
levEffect = round(flev * spread, 2) if flev is not None and spread is not None else None
451+
# ROCE (검증: ≈ RNOA + leverageEffect)
452+
roce = round(rnoa + levEffect, 2) if rnoa is not None and levEffect is not None else None
453+
454+
history.append({
455+
"period": col,
456+
"rnoa": rnoa,
457+
"flev": flev,
458+
"nbc": nbc,
459+
"spread": spread,
460+
"leverageEffect": levEffect,
461+
"roce": roce,
462+
})
463+
464+
if not history:
465+
return None
466+
467+
return {"history": history}

0 commit comments

Comments
 (0)