Skip to content

Commit 5e5b378

Browse files
committed
review 1-4 현금흐름 구현 — CF 3구간 + FCF + 이익의 현금 뒷받침
- cashflow.py: calcCashFlowOverview (영업/투자/재무CF + FCF + CF 패턴 시계열) - cashflow.py: calcCashQuality (영업CF/순이익, 영업CF 마진 시계열) - cashflow.py: calcCashFlowFlags (영업CF 적자, FCF 적자, 위기형 패턴, 3년 연속 감소) - builders.py: cashFlowOverviewBlock, cashQualityBlock, cashFlowFlagsBlock - templates.py: "현금흐름" 템플릿 (partId 1-4) + helper/aiGuide - registry.py: 현금흐름 블록 등록 - DEV.md: 1-4 현금흐름 구현 완료 반영 (ROIC → 현금흐름으로 변경) - Part 1 "자본의 여정" 4개 섹션 완성: 수익구조 → 자금조달 → 자산구조 → 현금흐름
1 parent 3001762 commit 5e5b378

5 files changed

Lines changed: 363 additions & 6 deletions

File tree

src/dartlab/analysis/DEV.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ gather/는 데이터 수집 엔진이지 분석 엔진이 아니다. 현재 위
133133
│ ├─ 1-1. 이 회사는 무엇으로 돈을 버는가 (수익 구조)
134134
│ ├─ 1-2. 돈을 어디서 조달하는가 (자금 구조)
135135
│ ├─ 1-3. 조달한 돈으로 뭘 준비했는가 (자산 구조)
136-
│ ├─ 1-4. 투자 대비 얼마를 버는가 (ROIC — 자본의 여정 완결)
136+
│ ├─ 1-4. 실제로 현금은 어떻게 흘렀는가 (현금흐름)
137137
│ ├─ 1-5. 경쟁에서 이길 수 있는가 (경쟁 우위)
138138
│ ├─ 1-6. 산업은 어떤 국면인가 (산업 사이클)
139139
│ └─ 1-7. 경영진은 신뢰할 수 있는가 (지배구조/ESG)
@@ -185,7 +185,7 @@ gather/는 데이터 수집 엔진이지 분석 엔진이 아니다. 현재 위
185185

186186
| Part | 영역 | 상태 | 비고 |
187187
|------|------|------|------|
188-
| 1. 사업 | strategy/ | **1-1, 1-2, 1-3 구현 완료** | 수익구조(9 calc) + 자금구조 + 자산구조 + ESG + Supply |
188+
| 1. 사업 | strategy/ | **1-1~1-4 구현 완료** | 수익구조 + 자금구조 + 자산구조 + 현금흐름 + ESG + Supply |
189189
| 2. 검증 | accounting/ | Watch + Signal만 | 이익의 질, Red Flags 없음 |
190190
| 3. 재무 | financial/ | **완성도 높음** | 10영역 등급 + 부실 예측 + 종합 리포트 |
191191
| 4. 비교 | comparative/ | 구현됨 | Peer/Sector/Rank/Event |
@@ -200,7 +200,7 @@ gather/는 데이터 수집 엔진이지 분석 엔진이 아니다. 현재 위
200200
| 1-1 | 수익 구조 | **구현 완료** | strategy/revenue.py |
201201
| 1-2 | 자금 구조 | **구현 완료** | strategy/capital.py |
202202
| 1-3 | 자산 구조 | **구현 완료** | strategy/asset.py |
203-
| 1-4 | ROIC | 설계 완료, 미구현 | |
203+
| 1-4 | 현금흐름 | **구현 완료** | strategy/cashflow.py |
204204
| 3-1 | 이익 구조 | 빌더 있음, 미등록 | strategy/_income_builders.py |
205205
| 2-1 | 이익의 질 | 빌더 있음, 미등록 | strategy/_income_builders.py |
206206

@@ -312,7 +312,7 @@ c.review("수익구조") # 특정 항목만
312312
- 사용자 제안: "재무제표 볼 때 자금조달 구조에서 시작 → 뭘 준비했고 → 돈을 어떻게 버는지"
313313
- 조사 결과: McKinsey ROIC + Penman Reformulation + DuPont 역순 읽기와 일치
314314
- 주류(Palepu-Healy, CFA, Buffett)는 사업 이해에서 시작 → Part 1 "사업을 먼저 본다" 유지
315-
- 1-1(수익 구조) 뒤에 1-2(자금) → 1-3(자산) → 1-4(ROIC) "자본의 여정" 흐름 삽입
315+
- 1-1(수익 구조) 뒤에 1-2(자금) → 1-3(자산) → 1-4(현금흐름) "자본의 여정" 흐름 삽입
316316
- Part 1이 5항목 → 7항목으로 확장
317317

318318
---
@@ -339,7 +339,7 @@ c.review("수익구조") # 특정 항목만
339339
| 1-1 수익 구조 | strategy/ | **상세: `strategy/_01_revenueStructure.md`** |
340340
| 1-2 자금 구조 | strategy/ | 자본 구조(부채/자본 비중), 조달원 분석 |
341341
| 1-3 자산 구조 | strategy/ | 투하자본 구성, 유무형자산 비중, CAPEX 패턴 |
342-
| 1-4 ROIC | strategy/ | 투하자본수익률, McKinsey/Penman 기반 자본효율 |
342+
| 1-4 현금흐름 | strategy/ | CF 3구간, FCF, 이익의 현금 뒷받침, CF 패턴 |
343343
| 1-5 경쟁 우위 | strategy/ | 경쟁 포지셔닝, 진입장벽, 마진 지속성 |
344344
| 1-6 산업 사이클 | macro/ | gather/fred 활용, 산업 단계 판별 |
345345
| 1-7 지배구조/ESG | strategy/ | 현재 수준 유지 (esg/ 이미 있음) |
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
"""1-4 현금흐름 — 계산만 담당.
2+
3+
CF 3구간(영업/투자/재무) + FCF + 이익의 현금 뒷받침 + CF 패턴.
4+
블록 조립은 review/builders.py가 한다.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
_MAX_YEARS = 5
10+
11+
12+
# ── 유틸 ──
13+
14+
15+
def _toDict(selectResult) -> tuple[dict[str, dict], list[str]] | None:
16+
"""SelectResult -> ({계정명: {period: val}}, periodCols)."""
17+
from dartlab.analysis.strategy._helpers import toDict
18+
19+
return toDict(selectResult)
20+
21+
22+
def _annualCols(periods: list[str], maxYears: int = _MAX_YEARS) -> list[str]:
23+
"""연도 컬럼만 추출 (Q4 fallback)."""
24+
cols = sorted([c for c in periods if "Q" not in c], reverse=True)
25+
if cols:
26+
return cols[:maxYears]
27+
return sorted([c for c in periods if c.endswith("Q4")], reverse=True)[:maxYears]
28+
29+
30+
def _get(row: dict, col: str) -> float:
31+
"""dict에서 안전하게 값 꺼내기 (None -> 0)."""
32+
v = row.get(col) if row else None
33+
return v if v is not None else 0
34+
35+
36+
# ── CF 패턴 분류 ──
37+
38+
39+
def _classifyCfPattern(ocf: float, icf: float, fcf: float) -> str | None:
40+
"""영업/투자/재무 CF 부호 조합으로 패턴 분류."""
41+
42+
def _s(v: float) -> str:
43+
if v > 0:
44+
return "+"
45+
if v < 0:
46+
return "-"
47+
return "0"
48+
49+
patterns = {
50+
("+", "-", "-"): "성숙형 — 영업으로 벌어 투자하고 부채 상환",
51+
("+", "-", "+"): "확장형 — 영업 + 외부 조달로 적극 투자",
52+
("+", "+", "-"): "구조조정형 — 자산 매각하며 부채 상환",
53+
("-", "-", "+"): "위기형 — 영업 적자를 외부 차입으로 메움",
54+
("-", "+", "+"): "축소형 — 자산 매각 + 차입으로 영업 적자 보전",
55+
("-", "+", "-"): "전환형 — 자산 매각으로 부채 상환, 영업 회복 필요",
56+
}
57+
return patterns.get((_s(ocf), _s(icf), _s(fcf)))
58+
59+
60+
# ── 메인: CF 3구간 + FCF ──
61+
62+
63+
def calcCashFlowOverview(company) -> dict | None:
64+
"""영업CF/투자CF/재무CF + FCF 시계열.
65+
66+
반환::
67+
68+
{
69+
"history": [
70+
{
71+
"period": str,
72+
"ocf": float, "icf": float, "fcf_financing": float,
73+
"capex": float, "fcf": float,
74+
"pattern": str | None,
75+
},
76+
...
77+
],
78+
}
79+
"""
80+
cfAccounts = [
81+
"영업활동현금흐름",
82+
"투자활동현금흐름",
83+
"재무활동으로인한현금흐름",
84+
"유형자산의취득",
85+
"무형자산의취득",
86+
]
87+
result = company.select("CF", cfAccounts)
88+
parsed = _toDict(result)
89+
if parsed is None or "영업활동현금흐름" not in parsed[0]:
90+
return None
91+
92+
data, allPeriods = parsed
93+
ocfRow = data["영업활동현금흐름"]
94+
icfRow = data.get("투자활동현금흐름", {})
95+
finRow = data.get("재무활동으로인한현금흐름", {})
96+
capexRow = data.get("유형자산의취득", {})
97+
intCapexRow = data.get("무형자산의취득", {})
98+
99+
yCols = _annualCols(allPeriods, _MAX_YEARS)
100+
if not yCols:
101+
return None
102+
103+
history = []
104+
for col in yCols:
105+
ocf = _get(ocfRow, col)
106+
icf = _get(icfRow, col)
107+
fin = _get(finRow, col)
108+
# CAPEX: CF에서 음수로 나옴 -> abs
109+
capex = abs(_get(capexRow, col)) + abs(_get(intCapexRow, col))
110+
fcf = ocf - capex
111+
112+
entry = {
113+
"period": col,
114+
"ocf": ocf,
115+
"icf": icf,
116+
"fcfFinancing": fin,
117+
"capex": capex,
118+
"fcf": fcf,
119+
"pattern": _classifyCfPattern(ocf, icf, fin),
120+
}
121+
history.append(entry)
122+
123+
if not history:
124+
return None
125+
return {"history": history}
126+
127+
128+
# ── 이익의 현금 뒷받침 ──
129+
130+
131+
def calcCashQuality(company) -> dict | None:
132+
"""영업CF/순이익, 영업CF/매출 — 이익이 현금으로 뒷받침되는가.
133+
134+
반환::
135+
136+
{
137+
"history": [
138+
{
139+
"period": str,
140+
"ocf": float, "netIncome": float, "revenue": float,
141+
"ocfToNi": float | None,
142+
"ocfMargin": float | None,
143+
},
144+
...
145+
],
146+
}
147+
"""
148+
cfResult = company.select("CF", ["영업활동현금흐름"])
149+
isResult = company.select("IS", ["당기순이익", "매출액"])
150+
151+
cfParsed = _toDict(cfResult)
152+
isParsed = _toDict(isResult)
153+
if cfParsed is None or isParsed is None:
154+
return None
155+
156+
cfData, cfPeriods = cfParsed
157+
isData, _ = isParsed
158+
159+
ocfRow = cfData.get("영업활동현금흐름", {})
160+
niRow = isData.get("당기순이익", {})
161+
revRow = isData.get("매출액", {})
162+
163+
yCols = _annualCols(cfPeriods, _MAX_YEARS)
164+
if not yCols:
165+
return None
166+
167+
history = []
168+
for col in yCols:
169+
ocf = _get(ocfRow, col)
170+
ni = _get(niRow, col)
171+
rev = _get(revRow, col)
172+
173+
ocfToNi = ocf / ni * 100 if ni != 0 else None
174+
ocfMargin = ocf / rev * 100 if rev > 0 else None
175+
176+
history.append(
177+
{
178+
"period": col,
179+
"ocf": ocf,
180+
"netIncome": ni,
181+
"revenue": rev,
182+
"ocfToNi": ocfToNi,
183+
"ocfMargin": ocfMargin,
184+
}
185+
)
186+
187+
if not history:
188+
return None
189+
return {"history": history}
190+
191+
192+
# ── CF 플래그 ──
193+
194+
195+
def calcCashFlowFlags(company) -> list[str]:
196+
"""현금흐름 경고 신호."""
197+
flags = []
198+
199+
overview = calcCashFlowOverview(company)
200+
if overview and overview["history"]:
201+
h0 = overview["history"][0]
202+
203+
# 영업CF 적자
204+
if h0["ocf"] < 0:
205+
flags.append("영업CF 적자 — 본업에서 현금이 나오지 않음")
206+
207+
# FCF 적자
208+
if h0["fcf"] < 0 and h0["ocf"] > 0:
209+
flags.append("FCF 적자 — 영업CF보다 투자가 큼")
210+
211+
# 위기형/축소형 패턴
212+
pat = h0.get("pattern", "")
213+
if pat and ("위기형" in pat or "축소형" in pat):
214+
flags.append(f"CF 패턴: {pat}")
215+
216+
# 영업CF 3년 연속 감소
217+
hist = overview["history"]
218+
if len(hist) >= 3:
219+
ocfs = [h["ocf"] for h in hist[:3]]
220+
if ocfs[0] < ocfs[1] < ocfs[2]:
221+
flags.append("영업CF 3년 연속 감소")
222+
223+
quality = calcCashQuality(company)
224+
if quality and quality["history"]:
225+
q0 = quality["history"][0]
226+
227+
# 영업CF/순이익 < 40% (이익 대비 현금 부족)
228+
ratio = q0.get("ocfToNi")
229+
if ratio is not None and 0 < ratio < 40:
230+
flags.append(f"영업CF/순이익 {ratio:.0f}% — 이익의 현금 뒷받침 부족")
231+
232+
# 영업CF 마진 < 0
233+
margin = q0.get("ocfMargin")
234+
if margin is not None and margin < 0:
235+
flags.append(f"영업CF 마진 {margin:.1f}% — 매출 대비 현금 유출")
236+
237+
return flags

src/dartlab/review/builders.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,3 +803,87 @@ def assetFlagsBlock(flags: list[str]) -> list:
803803
if not flags:
804804
return []
805805
return [FlagBlock(flags, kind="warning")]
806+
807+
808+
# ── 1-4 현금흐름 빌더 ──
809+
810+
811+
def cashFlowOverviewBlock(data: dict) -> list:
812+
"""calcCashFlowOverview 결과 → CF 3구간 + FCF 시계열 테이블."""
813+
if not data:
814+
return []
815+
history = data.get("history", [])
816+
if not history:
817+
return []
818+
819+
blocks: list = []
820+
blocks.append(
821+
HeadingBlock(
822+
"현금흐름 3구간",
823+
level=2,
824+
helper="영업CF(+)/투자CF(-)/재무CF(-) = 건전한 패턴",
825+
)
826+
)
827+
828+
rows = ["영업CF", "투자CF", "재무CF", "CAPEX", "FCF"]
829+
cols = {"": rows}
830+
for h in history:
831+
cols[h["period"]] = [
832+
_fmtAmtShort(h["ocf"]),
833+
_fmtAmtShort(h["icf"]),
834+
_fmtAmtShort(h["fcfFinancing"]),
835+
_fmtAmtShort(h["capex"]),
836+
_fmtAmtShort(h["fcf"]),
837+
]
838+
blocks.append(TableBlock("현금흐름 추이", pl.DataFrame(cols)))
839+
840+
# CF 패턴 시계열
841+
patternRows = ["CF 패턴"]
842+
patternCols = {"": patternRows}
843+
for h in history:
844+
pat = h.get("pattern")
845+
label = pat.split(" — ")[0] if pat else "-"
846+
patternCols[h["period"]] = [label]
847+
blocks.append(TableBlock("CF 패턴 추이", pl.DataFrame(patternCols)))
848+
849+
return blocks
850+
851+
852+
def cashQualityBlock(data: dict) -> list:
853+
"""calcCashQuality 결과 → 영업CF/순이익, 영업CF 마진 시계열."""
854+
if not data:
855+
return []
856+
history = data.get("history", [])
857+
if not history:
858+
return []
859+
860+
blocks: list = []
861+
blocks.append(
862+
HeadingBlock(
863+
"이익의 현금 뒷받침",
864+
level=2,
865+
helper="영업CF/순이익 > 100%이면 이익이 현금으로 회수됨",
866+
)
867+
)
868+
869+
rows = ["영업CF", "당기순이익", "영업CF/순이익", "영업CF 마진"]
870+
cols = {"": rows}
871+
for h in history:
872+
ratio = h.get("ocfToNi")
873+
margin = h.get("ocfMargin")
874+
cols[h["period"]] = [
875+
_fmtAmtShort(h["ocf"]),
876+
_fmtAmtShort(h["netIncome"]),
877+
f"{ratio:.0f}%" if ratio is not None else "-",
878+
f"{margin:.1f}%" if margin is not None else "-",
879+
]
880+
blocks.append(TableBlock("현금 품질 추이", pl.DataFrame(cols)))
881+
882+
return blocks
883+
884+
885+
def cashFlowFlagsBlock(flags: list[str]) -> list:
886+
"""calcCashFlowFlags 결과 → FlagBlock."""
887+
if not flags:
888+
return []
889+
return [FlagBlock(flags, kind="warning")]

0 commit comments

Comments
 (0)