Skip to content

Commit 8860da0

Browse files
committed
feat: Articulation Check + OCF 분해 + 영업외 분해 추가
Phase 1-3 + Phase 2 재무제표 직접 읽기: 3. calcArticulationCheck (crossStatement.py) BS-CF 정합성: PPE/현금/자본 변동 검증. 오차가 크면 연결범위/환율 변동. 4. calcOcfDecomposition (cashflow.py) 영업CF = 순이익 + 감가상각 + 운전자본변동 + 기타. 삼성전자: NI 19.6조 + 감가상각 21.5조 + 운전자본 -8.4조 = OCF 28.8조 5. calcNonOperatingBreakdown (earningsQuality.py) 영업외 = 순금융 + 지분법 + 기타. 삼성전자 2024: 금융수익 6조 ≈ 영업이익 6.5조
1 parent f87d9bd commit 8860da0

3 files changed

Lines changed: 269 additions & 0 deletions

File tree

src/dartlab/analysis/financial/cashflow.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,92 @@ def calcCashFlowFlags(company, *, basePeriod: str | None = None) -> list[str]:
248248
flags.append(f"영업CF 마진 {margin:.1f}% — 매출 대비 현금 유출")
249249

250250
return flags
251+
252+
253+
# ── 영업CF 내부 분해 (BS 변동 기반) ──
254+
255+
256+
def calcOcfDecomposition(company, *, basePeriod: str | None = None) -> dict | None:
257+
"""영업CF를 구성요소로 분해 — 현금흐름의 원천을 파악.
258+
259+
대부분 기업이 CF에 개별 조정항목을 안 쓰므로 BS 변동으로 간접 추정.
260+
261+
OCF ≈ 순이익 + 비현금비용(감가상각 추정) + 운전자본 변동
262+
운전자본 변동 = -(delta_AR) - (delta_Inv) + (delta_AP)
263+
264+
반환::
265+
266+
{
267+
"history": [
268+
{"period": str, "ni": float, "ocf": float,
269+
"depEstimate": float, "wcEffect": float,
270+
"arChange": float, "invChange": float, "apChange": float,
271+
"residual": float},
272+
...
273+
],
274+
}
275+
"""
276+
isResult = company.select("IS", ["당기순이익"])
277+
cfResult = company.select("CF", ["영업활동현금흐름"])
278+
bsResult = company.select(
279+
"BS",
280+
["매출채권및기타채권", "재고자산", "매입채무", "유형자산"],
281+
)
282+
283+
isParsed = _toDict(isResult)
284+
cfParsed = _toDict(cfResult)
285+
bsParsed = _toDict(bsResult)
286+
if isParsed is None or cfParsed is None or bsParsed is None:
287+
return None
288+
289+
isData, _ = isParsed
290+
cfData, cfPeriods = cfParsed
291+
bsData, _ = bsParsed
292+
293+
niRow = isData.get("당기순이익", {})
294+
ocfRow = cfData.get("영업활동현금흐름", {})
295+
arRow = bsData.get("매출채권및기타채권", {})
296+
invRow = bsData.get("재고자산", {})
297+
apRow = bsData.get("매입채무", {})
298+
ppeRow = bsData.get("유형자산", {})
299+
300+
from dartlab.analysis.financial._helpers import annualColsFromPeriods
301+
302+
yCols = annualColsFromPeriods(cfPeriods, basePeriod, 9)
303+
if len(yCols) < 2:
304+
return None
305+
306+
history = []
307+
for i in range(len(yCols) - 1):
308+
col = yCols[i]
309+
prevCol = yCols[i + 1]
310+
311+
ni = _get(niRow, col)
312+
ocf = _get(ocfRow, col)
313+
ppe = _get(ppeRow, col)
314+
315+
# 감가상각 추정 (유형자산/10)
316+
depEst = ppe / 10 if ppe > 0 else 0
317+
318+
# 운전자본 변동 (BS delta)
319+
arChange = _get(arRow, col) - _get(arRow, prevCol) # 증가=현금유출
320+
invChange = _get(invRow, col) - _get(invRow, prevCol)
321+
apChange = _get(apRow, col) - _get(apRow, prevCol) # 증가=현금유입
322+
wcEffect = -arChange - invChange + apChange
323+
324+
# 잔차 (설명 안 되는 부분: 영업외, 세금, 기타 조정)
325+
residual = ocf - ni - depEst - wcEffect
326+
327+
history.append({
328+
"period": col,
329+
"ni": ni,
330+
"ocf": ocf,
331+
"depEstimate": round(depEst),
332+
"wcEffect": round(wcEffect),
333+
"arChange": round(arChange),
334+
"invChange": round(invChange),
335+
"apChange": round(apChange),
336+
"residual": round(residual),
337+
})
338+
339+
return {"history": history} if history else None

src/dartlab/analysis/financial/crossStatement.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,109 @@ def calcCrossStatementFlags(company, *, basePeriod: str | None = None) -> list[s
330330
flags.append(f"종합 이상점수 {h0['score']:.0f} — 재무제표 신뢰성 주의")
331331

332332
return flags
333+
334+
335+
# ── BS-CF Articulation Check ──
336+
337+
338+
def calcArticulationCheck(company, *, basePeriod: str | None = None) -> dict | None:
339+
"""BS-CF 정합성 검증 — 재무제표 3표가 수학적으로 연결되는지.
340+
341+
3가지 정합성:
342+
1. PPE 정합: delta_PPE ≈ CAPEX - 감가상각 - 처분
343+
2. 현금 정합: delta_Cash ≈ OCF + ICF + FCF
344+
3. 자본 정합: delta_Equity ≈ NI - 배당 + OCI + 신주발행
345+
346+
오차가 크면 연결범위 변동, 환율 효과, 재분류 가능성.
347+
348+
반환::
349+
350+
{
351+
"history": [
352+
{"period": str, "ppeError": float, "cashError": float,
353+
"equityError": float, "maxErrorPct": float},
354+
...
355+
],
356+
}
357+
358+
학술근거: Articulation of Financial Statements (FASB/IASB).
359+
"""
360+
bsResult = company.select(
361+
"BS",
362+
["유형자산", "현금및현금성자산", "자본총계"],
363+
)
364+
cfResult = company.select(
365+
"CF",
366+
["영업활동현금흐름", "투자활동현금흐름", "재무활동현금흐름",
367+
"유형자산의취득", "유형자산의처분"],
368+
)
369+
isResult = company.select("IS", ["당기순이익"])
370+
371+
bsParsed = _toDict(bsResult)
372+
cfParsed = _toDict(cfResult)
373+
isParsed = _toDict(isResult)
374+
if bsParsed is None or cfParsed is None or isParsed is None:
375+
return None
376+
377+
bsData, bsPeriods = bsParsed
378+
cfData, _ = cfParsed
379+
isData, _ = isParsed
380+
381+
ppeRow = bsData.get("유형자산", {})
382+
cashRow = bsData.get("현금및현금성자산", {})
383+
eqRow = bsData.get("자본총계", {})
384+
ocfRow = cfData.get("영업활동현금흐름", {})
385+
icfRow = cfData.get("투자활동현금흐름", {})
386+
fcfRow = cfData.get("재무활동현금흐름", {})
387+
capexRow = cfData.get("유형자산의취득", {})
388+
dispRow = cfData.get("유형자산의처분", {})
389+
niRow = isData.get("당기순이익", {})
390+
391+
yCols = _annualColsFromPeriods(bsPeriods, basePeriod, _MAX_YEARS + 1)
392+
if len(yCols) < 2:
393+
return None
394+
395+
history = []
396+
for i in range(len(yCols) - 1):
397+
col = yCols[i]
398+
prevCol = yCols[i + 1]
399+
400+
# 1. PPE 정합
401+
ppeCur = _get(ppeRow, col)
402+
ppePrev = _get(ppeRow, prevCol)
403+
capex = abs(_get(capexRow, col))
404+
disp = abs(_get(dispRow, col))
405+
# 감가상각은 추정 (유형자산/10)
406+
depEst = ppePrev / 10 if ppePrev > 0 else 0
407+
ppeExpected = ppePrev + capex - depEst - disp
408+
ppeActual = ppeCur
409+
ppeError = abs(ppeActual - ppeExpected) / ppePrev * 100 if ppePrev > 0 else None
410+
411+
# 2. 현금 정합
412+
cashCur = _get(cashRow, col)
413+
cashPrev = _get(cashRow, prevCol)
414+
ocf = _get(ocfRow, col)
415+
icf = _get(icfRow, col)
416+
fcf = _get(fcfRow, col)
417+
cashExpected = cashPrev + ocf + icf + fcf
418+
cashError = abs(cashCur - cashExpected) / abs(cashPrev) * 100 if cashPrev != 0 else None
419+
420+
# 3. 자본 정합
421+
eqCur = _get(eqRow, col)
422+
eqPrev = _get(eqRow, prevCol)
423+
ni = _get(niRow, col)
424+
eqExpected = eqPrev + ni # 배당/OCI 미포함이므로 대략적
425+
eqError = abs(eqCur - eqExpected) / abs(eqPrev) * 100 if eqPrev != 0 else None
426+
427+
errors = [e for e in [ppeError, cashError, eqError] if e is not None]
428+
maxErr = max(errors) if errors else None
429+
430+
history.append({
431+
"period": col,
432+
"ppeError": round(ppeError, 1) if ppeError is not None else None,
433+
"cashError": round(cashError, 1) if cashError is not None else None,
434+
"equityError": round(eqError, 1) if eqError is not None else None,
435+
"maxErrorPct": round(maxErr, 1) if maxErr is not None else None,
436+
})
437+
438+
return {"history": history} if history else None

src/dartlab/analysis/financial/earningsQuality.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,3 +460,77 @@ def calcRichardsonAccrual(company, *, basePeriod: str | None = None) -> dict | N
460460
})
461461

462462
return {"history": history} if history else None
463+
464+
465+
# ── 영업외손익 분해 ──
466+
467+
468+
def calcNonOperatingBreakdown(company, *, basePeriod: str | None = None) -> dict | None:
469+
"""영업외손익 항목별 분해 — 영업이익과 세전이익 사이의 갭.
470+
471+
금융이익/비용, 지분법손익, 기타수익/비용을 개별 추적.
472+
영업외가 영업이익의 30% 이상이면 영업만으로 기업 판단 불가.
473+
474+
반환::
475+
476+
{
477+
"history": [
478+
{"period": str, "opIncome": float, "finIncome": float,
479+
"finCost": float, "netFinance": float, "associateIncome": float,
480+
"otherIncome": float, "otherExpense": float,
481+
"nonOpTotal": float, "nonOpRatio": float},
482+
...
483+
],
484+
}
485+
"""
486+
isResult = company.select(
487+
"IS",
488+
["영업이익", "금융이익", "금융비용", "지분법관련손익",
489+
"기타수익", "기타비용", "법인세차감전순이익"],
490+
)
491+
492+
isParsed = _toDict(isResult)
493+
if isParsed is None:
494+
return None
495+
496+
isData, isPeriods = isParsed
497+
opRow = isData.get("영업이익", {})
498+
finIncRow = isData.get("금융이익", {})
499+
finCostRow = isData.get("금융비용", {})
500+
assocRow = isData.get("지분법관련손익", {})
501+
otherIncRow = isData.get("기타수익", {})
502+
otherExpRow = isData.get("기타비용", {})
503+
ptRow = isData.get("법인세차감전순이익", {})
504+
505+
yCols = _annualColsFromPeriods(isPeriods, _MAX_YEARS, basePeriod=basePeriod)
506+
if not yCols:
507+
return None
508+
509+
history = []
510+
for col in yCols:
511+
op = _get(opRow, col)
512+
finInc = _get(finIncRow, col)
513+
finCost = _get(finCostRow, col)
514+
assoc = _get(assocRow, col)
515+
otherInc = _get(otherIncRow, col)
516+
otherExp = _get(otherExpRow, col)
517+
pt = _get(ptRow, col)
518+
519+
netFinance = finInc - finCost
520+
nonOpTotal = pt - op if op != 0 else None
521+
nonOpRatio = round(abs(nonOpTotal) / abs(op) * 100, 1) if op != 0 and nonOpTotal is not None else None
522+
523+
history.append({
524+
"period": col,
525+
"opIncome": op,
526+
"finIncome": finInc,
527+
"finCost": finCost,
528+
"netFinance": netFinance,
529+
"associateIncome": assoc,
530+
"otherIncome": otherInc,
531+
"otherExpense": otherExp,
532+
"nonOpTotal": nonOpTotal,
533+
"nonOpRatio": nonOpRatio,
534+
})
535+
536+
return {"history": history} if history else None

0 commit comments

Comments
 (0)