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+
2037class 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 ,
0 commit comments