Skip to content

Commit 9e168b3

Browse files
authored
Merge pull request #437 from easygap/feat/deposit-twr
feat: 적립 입금 + 시간가중수익률(TWR) — 소액 적립 트랙 선행 구현
2 parents 761950b + 50b962c commit 9e168b3

10 files changed

Lines changed: 755 additions & 16 deletions

‎core/basket_evaluation.py‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,31 @@ def build_daily_report_extras(
250250
return extras
251251

252252

253+
def time_weighted_capital(
254+
initial_capital: float,
255+
flows: list,
256+
operation_start: Any,
257+
today: Any,
258+
) -> float:
259+
"""기간 시간가중 평균 자본 (Modified Dietz 분모) — 비용 드래그 연환산의 공정한 분모.
260+
261+
flows: [(occurred_at, amount)...]. 각 유입은 '남은 기간 비율'만큼만 자본에 기여한다 —
262+
말기 입금이 전 기간 분모를 부풀려 비용 게이트를 느슨하게 만드는 것을 막는다
263+
(예: 59일차 3M 입금이 60일치 비용의 분모가 되면 안 된다).
264+
"""
265+
def _d(v):
266+
return v.date() if hasattr(v, "date") and callable(getattr(v, "date")) else v
267+
268+
start_d, end_d = _d(operation_start), _d(today)
269+
period_days = max(1, (end_d - start_d).days)
270+
weighted = float(initial_capital)
271+
for occurred_at, amount in flows or []:
272+
remain = (end_d - _d(occurred_at)).days
273+
frac = min(1.0, max(0.0, remain / period_days))
274+
weighted += float(amount) * frac
275+
return max(weighted, 1.0)
276+
277+
253278
def decompose_return_gap(
254279
nav_return_pct: float | None,
255280
design_return_pct: float | None,
@@ -418,9 +443,23 @@ def _d(v):
418443
)
419444
)
420445

446+
# 적립식 계정(외부 현금 흐름 존재): 총평가/초기자본은 입금을 수익으로 오표기하므로
447+
# 스냅샷의 TWR 누적수익률을 쓰고, 비용 분모는 시간가중 평균 자본(Modified Dietz)으로
448+
# 바꾼다 — 말기 입금이 전 기간 비용의 분모를 부풀려 게이트를 느슨하게 만들지 않게.
449+
# 흐름 0건이면 아래 두 값은 기존과 완전히 동일하다(하위 호환).
450+
from database.repositories import get_cash_flows, has_cash_flows
451+
account_has_flows = has_cash_flows(account_key=basket_key)
452+
if account_has_flows:
453+
initial_capital = time_weighted_capital(
454+
initial_capital, get_cash_flows(account_key=basket_key), operation_start, today,
455+
)
456+
421457
nav_return_pct = None
422458
if snaps:
423-
nav_return_pct = (float(snaps[-1].total_value) / initial_capital - 1.0) * 100
459+
if account_has_flows:
460+
nav_return_pct = float(snaps[-1].cumulative_return or 0.0)
461+
else:
462+
nav_return_pct = (float(snaps[-1].total_value) / initial_capital - 1.0) * 100
424463

425464
# NAV는 마지막 스냅샷 시점의 값이다 — 벤치마크·설계 조회도 같은 종료일로 맞춰야
426465
# 세 값을 비교할 때 하루치 시장 변동이 실행 격차로 오귀속되지 않는다(적대적 리뷰 medium).

‎core/notifier.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,14 @@ def send_daily_report(self, report: dict) -> None:
314314
{"name": "📋 보유 종목", "value": f"{report.get('position_count', 0)}개", "inline": True},
315315
{"name": "🔄 당일 매매", "value": f"{report.get('total_trades', 0)}건", "inline": True},
316316
]
317+
# 적립식 계정: 누적 원금(초기+입금)을 평가액과 분리 표기(있을 때만) —
318+
# '내가 넣은 돈 대비 얼마'가 한눈에 보이게.
319+
if report.get("principal") is not None:
320+
fields.insert(1, {
321+
"name": "💳 누적 원금",
322+
"value": f"{report.get('principal', 0):,.0f}원",
323+
"inline": True,
324+
})
317325
# 리포트 v2 부가 필드(있을 때만) — 시장/설계/일정 대비 판단용.
318326
# 값은 core.basket_evaluation.build_daily_report_extras가 만든 문자열이다.
319327
for key, label, inline in (

‎core/portfolio_manager.py‎

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,26 @@
1414
save_portfolio_snapshot,
1515
get_latest_peak_value,
1616
get_strategy_performance_summary,
17+
get_cash_flow_total,
18+
get_cash_flow_total_between,
19+
get_max_cumulative_return,
20+
get_latest_snapshot_summary,
21+
has_cash_flows,
1722
)
1823

1924

25+
def twr_period_return(v_prev: float, v_now: float, flow: float = 0.0) -> float:
26+
"""구간 시간가중수익률(소수). 외부 현금 흐름(flow, +입금)은 구간 시작에 유입으로 간주.
27+
28+
r = v_now / (v_prev + flow) - 1 — 입금은 수익이 아니므로 분모에 더해 중화한다.
29+
분모가 0 이하이면 판정 불가로 0을 반환한다(신규 계정 초기 상태 등).
30+
"""
31+
base = float(v_prev) + float(flow)
32+
if base <= 0:
33+
return 0.0
34+
return float(v_now) / base - 1.0
35+
36+
2037
class LiveBrokerBalanceUnavailable(RuntimeError):
2138
"""live 주문 판단에 필요한 KIS 잔고를 확인하지 못한 상태."""
2239

@@ -102,9 +119,12 @@ def _get_db_financials(self, invested: float, current_value: float, mode: str) -
102119
mode=mode,
103120
account_key=self.account_key if self.account_key else None,
104121
)
105-
cash = self.initial_capital + cash_summary["cash_delta"]
122+
# 외부 현금 흐름(입금/출금)은 현금에 더하되 손익에서는 제외한다 —
123+
# 입금은 수익이 아니다(적립식 지원, docs/POCKET_TRACK_PLAN.md §4).
124+
deposits = get_cash_flow_total(account_key=self.account_key)
125+
cash = self.initial_capital + deposits + cash_summary["cash_delta"]
106126
total_value = cash + current_value
107-
realized_pnl = cash + invested - self.initial_capital
127+
realized_pnl = cash + invested - self.initial_capital - deposits
108128
unrealized_pnl = current_value - invested
109129
return {
110130
"cash": cash,
@@ -154,6 +174,8 @@ def get_portfolio_summary(self, current_prices: dict = None) -> dict:
154174
broker_balance_error = str(e)
155175
logger.warning("KIS 잔고 조회 실패 — DB 기준으로 대체: {}", e)
156176

177+
deposits_total = get_cash_flow_total(account_key=self.account_key)
178+
157179
if cash is None or total_value is None:
158180
financials = self._get_db_financials(
159181
invested,
@@ -166,13 +188,47 @@ def get_portfolio_summary(self, current_prices: dict = None) -> dict:
166188
unrealized_pnl = financials["unrealized_pnl"]
167189
else:
168190
unrealized_pnl = current_value - invested
169-
realized_pnl = total_value - self.initial_capital - unrealized_pnl
191+
realized_pnl = total_value - self.initial_capital - deposits_total - unrealized_pnl
192+
193+
# 분기는 순합이 아니라 '흐름 존재 여부'로 — 순합 0(+100/-100)이어도 구간
194+
# 수익률은 이미 흐름의 영향을 받았으므로 TWR 경로를 유지해야 한다.
195+
account_has_flows = deposits_total != 0 or has_cash_flows(self.account_key)
170196

171-
total_return = ((total_value / self.initial_capital) - 1) * 100 if self.initial_capital > 0 else 0
197+
if not account_has_flows:
198+
# 무입금 계정: 기존 산식 그대로 (하위 호환 — 결과 불변)
199+
total_return = ((total_value / self.initial_capital) - 1) * 100 if self.initial_capital > 0 else 0
172200

173-
if total_value > self._peak_value:
174-
self._peak_value = total_value
175-
mdd = ((self._peak_value - total_value) / self._peak_value) * 100 if self._peak_value > 0 else 0
201+
if total_value > self._peak_value:
202+
self._peak_value = total_value
203+
mdd = ((self._peak_value - total_value) / self._peak_value) * 100 if self._peak_value > 0 else 0
204+
else:
205+
# 적립식 계정: 시간가중수익률(TWR) — 입금은 수익이 아니다.
206+
# 직전 스냅샷과 이번 측정 사이 유입(flow)을 분모에 더해 중화하고,
207+
# 누적은 직전 스냅샷의 누적수익률에 구간 수익률을 연결한다.
208+
from datetime import datetime as _dt
209+
prev = get_latest_snapshot_summary(account_key=self.account_key)
210+
if prev is None:
211+
# 첫 측정: 초기자본이 첫 유입, 그간의 입금 전액이 구간 유입
212+
r = twr_period_return(self.initial_capital, total_value, deposits_total)
213+
total_return = r * 100
214+
else:
215+
# 경계는 실제 측정 시각(created_at) — date(자정 귀속)를 쓰면 스냅샷
216+
# 이전의 같은 날 입금이 이중 산입된다.
217+
boundary = prev.get("created_at") or prev.get("date")
218+
flow_since = get_cash_flow_total_between(
219+
self.account_key, boundary, _dt.now(),
220+
)
221+
r = twr_period_return(prev["total_value"], total_value, flow_since)
222+
total_return = ((1 + prev["cumulative_return"] / 100) * (1 + r) - 1) * 100
223+
224+
# MDD도 TWR 지수 기준 — 원화 피크로 재면 입금이 낙폭을 가짜 회복시킨다.
225+
index_now = 1 + total_return / 100
226+
hist_max = get_max_cumulative_return(account_key=self.account_key)
227+
peak_index = max(1.0, index_now, 1 + (hist_max or 0.0) / 100)
228+
mdd = ((peak_index - index_now) / peak_index) * 100 if peak_index > 0 else 0
229+
# 원화 피크(peak_value 컬럼)는 스냅샷 연속성 위해 기존대로 계속 기록
230+
if total_value > self._peak_value:
231+
self._peak_value = total_value
176232

177233
return {
178234
"total_value": round(total_value, 0),
@@ -184,6 +240,8 @@ def get_portfolio_summary(self, current_prices: dict = None) -> dict:
184240
"position_count": len(positions),
185241
"realized_pnl": round(realized_pnl, 0),
186242
"unrealized_pnl": round(unrealized_pnl, 0),
243+
"deposits_total": round(deposits_total, 0),
244+
"principal": round(self.initial_capital + deposits_total, 0),
187245
"positions": position_details,
188246
"broker_balance_ok": broker_balance_ok,
189247
"broker_balance_source": broker_balance_source,

‎database/models.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,29 @@ def __repr__(self):
223223
return f"<FailedOrder({self.symbol} {self.action} {self.quantity}주 @{self.price}, status={self.status})>"
224224

225225

226+
class CashFlow(Base):
227+
"""
228+
외부 현금 흐름(입금/출금) 테이블 — 적립식 운영 지원.
229+
230+
수익률 계산에서 입금은 수익이 아니다: 시간가중수익률(TWR) 계산이 이 기록으로
231+
입금 구간을 분리한다(docs/POCKET_TRACK_PLAN.md §4). paper는
232+
tools/record_deposit.py로 기록하고, live도 실제 입금 후 같은 도구로 기록해야
233+
TWR이 맞는다(증권사 잔고는 현금만 알지, 언제 얼마가 외부에서 왔는지는 모른다).
234+
"""
235+
__tablename__ = "cash_flows"
236+
237+
id = Column(Integer, primary_key=True, autoincrement=True)
238+
account_key = Column(String(64), default="", nullable=False, index=True)
239+
amount = Column(Float, nullable=False) # +입금 / -출금 (원)
240+
occurred_at = Column(DateTime, nullable=False, index=True) # 발생 시각(귀속 기준)
241+
note = Column(String(200), default="") # 메모 (예: 7월 적립)
242+
mode = Column(String(20), default="paper")
243+
created_at = Column(DateTime, default=datetime.now)
244+
245+
def __repr__(self):
246+
return f"<CashFlow({self.account_key}, {self.amount:+,.0f}, {self.occurred_at})>"
247+
248+
226249
class OperationEvent(Base):
227250
"""
228251
운영 이벤트 로그 테이블.

0 commit comments

Comments
 (0)