krx-quant-core 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. krx_quant_core/__init__.py +8 -0
  2. krx_quant_core/backtest/__init__.py +36 -0
  3. krx_quant_core/backtest/crosssectional.py +299 -0
  4. krx_quant_core/backtest/fills.py +62 -0
  5. krx_quant_core/backtest/ledger.py +262 -0
  6. krx_quant_core/backtest/orderbook.py +82 -0
  7. krx_quant_core/backtest/panels.py +53 -0
  8. krx_quant_core/costs/__init__.py +30 -0
  9. krx_quant_core/costs/model.py +205 -0
  10. krx_quant_core/costs/tax.py +85 -0
  11. krx_quant_core/execution/__init__.py +27 -0
  12. krx_quant_core/execution/guards.py +272 -0
  13. krx_quant_core/execution/kiwoom_spec.py +149 -0
  14. krx_quant_core/execution/orders.py +134 -0
  15. krx_quant_core/market/__init__.py +64 -0
  16. krx_quant_core/market/calendar.py +170 -0
  17. krx_quant_core/market/codes.py +73 -0
  18. krx_quant_core/market/limits.py +71 -0
  19. krx_quant_core/market/session.py +86 -0
  20. krx_quant_core/market/ticks.py +108 -0
  21. krx_quant_core/py.typed +0 -0
  22. krx_quant_core/risk/__init__.py +45 -0
  23. krx_quant_core/risk/dart_db.py +89 -0
  24. krx_quant_core/risk/disclosure.py +396 -0
  25. krx_quant_core/risk/killswitch.py +186 -0
  26. krx_quant_core/runtime/__init__.py +5 -0
  27. krx_quant_core/runtime/host.py +46 -0
  28. krx_quant_core/stats/__init__.py +93 -0
  29. krx_quant_core/stats/cv.py +296 -0
  30. krx_quant_core/stats/fragility.py +121 -0
  31. krx_quant_core/stats/metrics.py +251 -0
  32. krx_quant_core/stats/sharpe.py +184 -0
  33. krx_quant_core/stats/trials.py +99 -0
  34. krx_quant_core-0.1.1.dist-info/METADATA +149 -0
  35. krx_quant_core-0.1.1.dist-info/RECORD +37 -0
  36. krx_quant_core-0.1.1.dist-info/WHEEL +4 -0
  37. krx_quant_core-0.1.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,8 @@
1
+ """krx-quant-core — 한국 주식 퀀트 공통 코어.
2
+
3
+ scalp-it(스캘핑)·daytrade-it(데이트레이딩)·swing-it(스윙 리서치)이 공유하는
4
+ 시장 규칙·비용·주문 가드·리스크·체결 시뮬레이션·검증 통계를 한곳에 둔다.
5
+ 전략 로직은 여기에 두지 않는다.
6
+ """
7
+
8
+ __version__ = "0.1.1"
@@ -0,0 +1,36 @@
1
+ """백테스트 부품 — 호가 충격·지정가 체결 판정·트레이드 원장/성과지표·횡단면 시뮬레이션.
2
+
3
+ 엔진(이벤트 루프)은 여기 없다. 루프는 소비자마다 다르지만(틱 스캘핑·일봉 데이트레이딩·
4
+ 월간 리밸런스), 그 루프가 부르는 "체결됐나/얼마에/순손익이 얼마고 성과가 어떤가" 는
5
+ 세 레포가 같은 답을 내야 한다.
6
+ """
7
+
8
+ from .crosssectional import rank_ic, rank_tilt_backtest, staggered_tranche_backtest
9
+ from .fills import EPS, FILL_BASES, FillBasis, limit_buy_filled, limit_sell_filled
10
+ from .ledger import PerformanceMetrics, Trade, TradeResult, apply_costs, performance_metrics
11
+ from .orderbook import Level, liquidity_size_multiplier, roundtrip_bp, sweep_vwap
12
+ from .panels import adv_panel, forward_returns, lookup_panel, panel_pivot
13
+
14
+ __all__ = [
15
+ "EPS",
16
+ "FILL_BASES",
17
+ "FillBasis",
18
+ "Level",
19
+ "PerformanceMetrics",
20
+ "Trade",
21
+ "TradeResult",
22
+ "adv_panel",
23
+ "apply_costs",
24
+ "forward_returns",
25
+ "limit_buy_filled",
26
+ "limit_sell_filled",
27
+ "liquidity_size_multiplier",
28
+ "lookup_panel",
29
+ "panel_pivot",
30
+ "performance_metrics",
31
+ "rank_ic",
32
+ "rank_tilt_backtest",
33
+ "roundtrip_bp",
34
+ "staggered_tranche_backtest",
35
+ "sweep_vwap",
36
+ ]
@@ -0,0 +1,299 @@
1
+ """횡단면 순위 틸트 시뮬레이션 — 정기 리밸런스 + 측정 회전율 비용.
2
+
3
+ swing-it ``engine/sim_crosssectional.py`` 를 **수치 동일**로 옮겼다(루프·기본값 그대로).
4
+ 원래 PEAD 전략에서 뽑아낸 회계(``t+1`` 진입, ADV 하한, 회전율 비용, 대차 비용, 롱온리
5
+ 초과수익)라, 다른 횡단면 실험이 그걸 다시 유도하지 않고 재사용하게 하려는 것이다.
6
+
7
+ 모든 입력은 ``code × date`` numpy 패널이고 ``dates`` 는 열 라벨 목록이다(반환 ``periods``
8
+ 프레임의 ``date`` 열에 그대로 실린다). DataFrame → 패널 변환은 :mod:`.panels` 참고.
9
+
10
+ 기본값(``start_index=130``·``adv_floor`` 등)은 swing-it 연구 수치에 고정돼 있다 —
11
+ 바꾸면 swing-it 의 발표 수치가 조용히 달라지므로 그대로 둔다.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ from ..stats.metrics import newey_west_t, summarize_periods
20
+
21
+ __all__ = ["rank_ic", "rank_tilt_backtest", "staggered_tranche_backtest"]
22
+
23
+
24
+ def _trailing_adv(V: np.ndarray, nD: int, adv_window: int, like: np.ndarray) -> np.ndarray:
25
+ adv = np.full_like(like, np.nan)
26
+ for j in range(adv_window, nD):
27
+ adv[:, j] = np.nanmean(V[:, j - adv_window:j], axis=1)
28
+ return adv
29
+
30
+
31
+ def rank_tilt_backtest(
32
+ close: np.ndarray,
33
+ trade_value: np.ndarray,
34
+ signal: np.ndarray,
35
+ dates: list,
36
+ *,
37
+ horizon: int = 40,
38
+ adv_floor: float = 5000.0,
39
+ adv_window: int = 20,
40
+ cost_one_way: float = 0.0023,
41
+ min_names: int = 30,
42
+ start_index: int = 130,
43
+ fresh_days: int = 0,
44
+ long_only: bool = False,
45
+ borrow_cost_annual: float = 0.0,
46
+ top_n: int = 0,
47
+ age: np.ndarray | None = None,
48
+ ) -> tuple[pd.DataFrame, dict]:
49
+ """순위 가중 달러중립(또는 롱온리 초과수익) 북, 측정 비용 차감.
50
+
51
+ ``horizon`` 거래일마다 리밸런스한다. ``t`` 시점 신호로 ``t+1`` 종가 진입(같은 종가
52
+ look-ahead 방지), ``t+1+horizon`` 종가 청산. ADV 는 ``t`` 직전 ``adv_window`` 일
53
+ 평균(당일 제외).
54
+
55
+ Args:
56
+ close: ``code × date`` 가격 패널(상류에서 abs).
57
+ trade_value: ``code × date`` 거래대금 패널(``adv_floor`` 와 같은 단위).
58
+ signal: ``code × date`` 신호 패널.
59
+ dates: 패널 열에 맞춘 날짜 라벨.
60
+ long_only: 참이면 상위 틸트(또는 ``top_n`` 동일가중) 롱북의 **적격 유니버스 대비
61
+ 초과수익**. 공매도가 막힌 경우의 구현 가능한 형태.
62
+ borrow_cost_annual: 달러중립일 때 숏 총액에 물리는 연 대차비용.
63
+ age: ``code × date`` 공시 경과일 패널; ``fresh_days>0`` 일 때만 본다.
64
+ ``None`` → 전부 NaN(신선도 게이트 없음).
65
+
66
+ Returns:
67
+ ``(periods, summary)`` — periods 열은 ``date, gross, turnover, net``,
68
+ summary 는 :func:`~krx_quant_core.stats.metrics.summarize_periods` 결과.
69
+ """
70
+ C = close
71
+ V = trade_value
72
+ yoy = signal
73
+ if age is None:
74
+ age = np.full_like(yoy, np.nan)
75
+ nD = len(dates)
76
+
77
+ adv = _trailing_adv(V, nD, adv_window, C)
78
+
79
+ def fwd(t: int, h: int) -> np.ndarray:
80
+ return C[:, t + h] / C[:, t] - 1.0 if t + h < nD else np.full(C.shape[0], np.nan)
81
+
82
+ rows: list[dict] = []
83
+ prev_w = np.zeros(C.shape[0])
84
+ t = start_index
85
+ while t < nD - horizon - 1:
86
+ sig = yoy[:, t].copy()
87
+ if fresh_days > 0:
88
+ sig = np.where(age[:, t] <= fresh_days, sig, np.nan)
89
+ ok = np.isfinite(sig)
90
+ ret = fwd(t + 1, horizon) # t+1 진입 — 같은 종가 look-ahead 방지
91
+ ok &= np.isfinite(ret)
92
+ if adv_floor > 0:
93
+ ok &= adv[:, t] >= adv_floor
94
+ if ok.sum() < min_names:
95
+ t += horizon
96
+ continue
97
+ idx = np.where(ok)[0]
98
+ pct = pd.Series(sig[ok]).rank(pct=True).to_numpy()
99
+ w = np.zeros(C.shape[0])
100
+ if long_only:
101
+ if top_n > 0:
102
+ # 집중 동일가중 top-N: 적고 큰 비대칭 베팅(낮은 승률·높은 손익비).
103
+ sel = idx[np.argsort(-sig[ok])[:top_n]]
104
+ w[sel] = 1.0 / len(sel)
105
+ else:
106
+ lw = np.clip(pct - 0.5, 0, None) # 북 전체 순위 틸트
107
+ w[idx] = lw / lw.sum() if lw.sum() > 0 else 0.0
108
+ bench = float(np.nanmean(ret[idx]))
109
+ gross = float(np.nansum(w * np.nan_to_num(ret))) - bench
110
+ short_gross = 0.0
111
+ else:
112
+ w[idx] = (pct - 0.5) / np.abs(pct - 0.5).sum() # 달러중립, 총노출=1
113
+ gross = float(np.nansum(w * np.nan_to_num(ret)))
114
+ short_gross = float(np.abs(w[w < 0]).sum()) # 중립북이면 ~0.5
115
+ turnover = float(np.abs(w - prev_w).sum())
116
+ borrow = borrow_cost_annual * (horizon / 252.0) * short_gross
117
+ rows.append({"date": dates[t], "gross": gross, "turnover": turnover,
118
+ "net": gross - turnover * cost_one_way - borrow})
119
+ prev_w = w
120
+ t += horizon
121
+
122
+ periods = pd.DataFrame(rows)
123
+ return periods, summarize_periods(periods, horizon)
124
+
125
+
126
+ def staggered_tranche_backtest(
127
+ close: np.ndarray,
128
+ trade_value: np.ndarray,
129
+ signal: np.ndarray,
130
+ dates: list,
131
+ *,
132
+ horizon: int = 60,
133
+ step: int = 20,
134
+ top_n: int = 40,
135
+ adv_floor: float = 20000.0,
136
+ adv_window: int = 20,
137
+ start_index: int = 130,
138
+ min_names: int = 20,
139
+ cap_array: np.ndarray | None = None,
140
+ cap_rank: tuple[int, int] | None = None,
141
+ delisting_exit: bool = False,
142
+ ) -> tuple[pd.DataFrame, dict]:
143
+ """분할 진입 롱온리 초과수익 — ``horizon // step`` 개 트랜치를 겹쳐 보유.
144
+
145
+ ``step`` 일마다 새 트랜치를 top-N 으로 짜고, 지난 트랜치들(``t - k·step``)의 북을 함께
146
+ 든다. 각 구간 수익은 트랜치별 북 평균 − 적격 유니버스 평균(벤치)의 평균이고, 회전율은
147
+ ``1/n_tranches`` 로 고정 보고한다(이 함수는 비용을 떼지 않는다 — ``net == gross``).
148
+
149
+ Args:
150
+ cap_array: ``code × date`` 시가총액 패널 또는 ``None``.
151
+ cap_rank: ``(lo, hi)`` 시총 순위 구간으로 유니버스 제한(``cap_array`` 와 함께).
152
+ delisting_exit: 보유 중 상장폐지된 종목의 손실을 반영할지.
153
+
154
+ 기본 ``False`` 는 기존 동작이다: 폐지 후 가격이 NaN 이 되고 ``np.nanmean`` 이
155
+ 그 종목을 **조용히 빼버린다** — 북과 벤치마크 양쪽을 낙관 쪽으로 왜곡한다.
156
+ ``True`` 면 마지막 관측 종가(정리매매 종료가)로 청산한 것으로 본다. 한국 시장은
157
+ 폐지 전 정리매매(보통 7거래일)가 있어 임의 상수(-30% 등)보다 데이터에 충실하다.
158
+ 다만 정리매매 자체를 못 판 경우는 반영되지 않으므로 이 역시 낙관 쪽 하한이다.
159
+
160
+ Returns:
161
+ ``(periods, summary)`` — periods 열은 ``date, gross, turnover, net, book, bench,
162
+ n_universe``. book/bench 를 함께 남기는 건 초과수익이 전략 개선인지 벤치 악화인지
163
+ 가르기 위해서다(생존편향 같은 유니버스 변경에서 결론을 뒤집는다).
164
+ """
165
+ C = close
166
+ V = trade_value
167
+ sig_m = signal
168
+ nD = len(dates)
169
+ adv = _trailing_adv(V, nD, adv_window, C)
170
+ n_tranches = max(1, horizon // step)
171
+ capm = cap_array
172
+
173
+ # 폐지 청산가: 각 시점까지의 마지막 관측 종가(행 방향 forward-fill). 꺼져 있으면 안 만든다.
174
+ C_ff = pd.DataFrame(C).ffill(axis=1).to_numpy(float) if delisting_exit else None
175
+
176
+ # eligible/book 은 t 의 순수함수라 메모이즈해도 숫자가 안 바뀐다. 스태거링이 지난
177
+ # 리밸런스일을 다시 부르므로, 캐시가 없으면 같은 날 전유니버스 스캔이 트랜치 수만큼 반복된다.
178
+ _elig_cache: dict[int, np.ndarray] = {}
179
+ _book_cache: dict[int, np.ndarray | None] = {}
180
+
181
+ def eligible(t: int) -> np.ndarray:
182
+ cached = _elig_cache.get(t)
183
+ if cached is not None:
184
+ return cached
185
+ ok = np.isfinite(sig_m[:, t]) & (adv[:, t] >= adv_floor)
186
+ if capm is not None and cap_rank is not None:
187
+ liq = np.where(ok & np.isfinite(capm[:, t]))[0]
188
+ order = liq[np.argsort(-capm[liq, t])] # 시총 내림차순
189
+ tier = order[cap_rank[0]:cap_rank[1]]
190
+ mask = np.zeros(C.shape[0], bool)
191
+ mask[tier] = True
192
+ ok = ok & mask
193
+ _elig_cache[t] = ok
194
+ return ok
195
+
196
+ def book(t: int) -> np.ndarray | None:
197
+ if t in _book_cache:
198
+ return _book_cache[t]
199
+ ok = eligible(t)
200
+ if ok.sum() < min_names:
201
+ _book_cache[t] = None
202
+ return None
203
+ idx = np.where(ok)[0]
204
+ out = idx[np.argsort(-sig_m[idx, t])[:top_n]]
205
+ _book_cache[t] = out
206
+ return out
207
+
208
+ rows: list[dict] = []
209
+ for t in range(start_index, nD - step - 1, step):
210
+ uni = np.where(eligible(t))[0]
211
+ if uni.size < min_names:
212
+ continue
213
+ ret = C[:, t + step] / C[:, t] - 1.0
214
+ if C_ff is not None:
215
+ # 진입 시점엔 가격이 있었는데 청산 시점에 없는 종목 = 보유 중 상장폐지.
216
+ gone = np.isfinite(C[:, t]) & ~np.isfinite(C[:, t + step])
217
+ ret[gone] = C_ff[gone, t + step] / C[gone, t] - 1.0
218
+ bench = float(np.nanmean(ret[uni]))
219
+ tranche_excess, tranche_book = [], []
220
+ for k in range(n_tranches):
221
+ b = book(t - k * step)
222
+ if b is not None:
223
+ book_ret = float(np.nanmean(ret[b]))
224
+ tranche_book.append(book_ret)
225
+ tranche_excess.append(book_ret - bench)
226
+ if tranche_excess:
227
+ rows.append({"date": dates[t], "gross": float(np.mean(tranche_excess)),
228
+ "turnover": 1.0 / n_tranches, "net": float(np.mean(tranche_excess)),
229
+ "book": float(np.mean(tranche_book)), "bench": bench,
230
+ "n_universe": int(uni.size)})
231
+ periods = pd.DataFrame(rows)
232
+ return periods, summarize_periods(periods, step)
233
+
234
+
235
+ def rank_ic(
236
+ close: np.ndarray,
237
+ trade_value: np.ndarray,
238
+ signal: np.ndarray,
239
+ dates: list,
240
+ *,
241
+ horizon: int = 40,
242
+ adv_floor: float = 5000.0,
243
+ adv_window: int = 20,
244
+ start_index: int = 130,
245
+ fresh_days: int = 0,
246
+ n_regimes: int = 4,
247
+ age: np.ndarray | None = None,
248
+ ) -> dict:
249
+ """신호 vs 선행수익의 일별 횡단면 순위 IC + Newey-West t + 국면별 분해.
250
+
251
+ 적격 종목이 20개 미만인 날은 건너뛴다. HAC lag = ``horizon`` (겹치는 선행수익 보정).
252
+ ``dates`` 라벨의 앞 7자(``YYYY-MM``)가 국면 start/end 로 실린다.
253
+
254
+ Returns:
255
+ ``{"ic_mean", "ic_nw_t", "n_days", "frac_positive", "regimes"}``.
256
+ """
257
+ C = close
258
+ V = trade_value
259
+ yoy = signal
260
+ if age is None:
261
+ age = np.full_like(yoy, np.nan)
262
+ nD = len(dates)
263
+ adv = _trailing_adv(V, nD, adv_window, C)
264
+
265
+ ics: list[float] = []
266
+ ic_dates: list[str] = []
267
+ for t in range(start_index, nD - horizon - 1):
268
+ sig = yoy[:, t].copy()
269
+ if fresh_days > 0:
270
+ sig = np.where(age[:, t] <= fresh_days, sig, np.nan)
271
+ ok = np.isfinite(sig)
272
+ if adv_floor > 0:
273
+ ok &= adv[:, t] >= adv_floor
274
+ ret = C[:, t + 1 + horizon] / C[:, t + 1] - 1.0
275
+ ok &= np.isfinite(ret)
276
+ if ok.sum() < 20:
277
+ continue
278
+ a = pd.Series(sig[ok]).rank().to_numpy()
279
+ b = pd.Series(ret[ok]).rank().to_numpy()
280
+ if a.std() > 0 and b.std() > 0:
281
+ ics.append(float(np.corrcoef(a, b)[0, 1]))
282
+ ic_dates.append(dates[t])
283
+
284
+ ic = np.array(ics)
285
+ mean_ic, nw_t = newey_west_t(ic, horizon)
286
+ regimes: list[dict] = []
287
+ if len(ic) >= n_regimes:
288
+ b = len(ic) // n_regimes
289
+ for k in range(n_regimes):
290
+ s0 = k * b
291
+ s1 = (k + 1) * b if k < n_regimes - 1 else len(ic)
292
+ m, tt = newey_west_t(ic[s0:s1], horizon)
293
+ regimes.append({"start": ic_dates[s0][:7], "end": ic_dates[s1 - 1][:7],
294
+ "ic_mean": m, "nw_t": tt})
295
+ return {
296
+ "ic_mean": mean_ic, "ic_nw_t": nw_t, "n_days": len(ic),
297
+ "frac_positive": float((ic > 0).mean()) if len(ic) else float("nan"),
298
+ "regimes": regimes,
299
+ }
@@ -0,0 +1,62 @@
1
+ """지정가 체결 판정 — ``touch``(낙관) vs ``through``(보수) 두 가정.
2
+
3
+ scalp-it 은 같은 판정을 두 곳에 들고 있다: 가상 포지션 회계
4
+ (``realtime/risk_guard.py`` ``RiskGuard._advance``)와 dry-run 주문 실행기
5
+ (``realtime/order_executor.py`` ``_is_buy_filled``·``_check_sell_fill``). 두 곳의 기준이
6
+ 갈리면 같은 틱열에서 회계와 실행기가 서로 다른 체결을 기록한다(2026-08-25 사고).
7
+ 그래서 판정식을 순수 함수 하나로 올린다.
8
+
9
+ 틱 체결가만 보고 내 지정가가 채워졌는지 추정한다 — 호가 큐 위치는 모른다:
10
+
11
+ - **touch(낙관)**: 체결가가 내 지정가에 **닿기만** 하면 체결. 큐 맨 앞이라는 가정.
12
+ 매수 ``trade <= limit``, 매도 ``trade >= limit``.
13
+ - **through(보수, 기본)**: 체결가가 지정가를 **관통**해야 체결. 그 가격의 큐가 다
14
+ 비워져야 내 주문까지 온다는 가정. 매수 ``trade < limit``, 매도 ``trade > limit``.
15
+ scalp-it 감사가 "through 를 실전 기대로" 요구해 기본값이다.
16
+
17
+ 부동소수 비교는 scalp-it 과 같은 ``1e-9`` 여유를 둔다(through 는 더 엄격한 쪽으로,
18
+ touch 는 더 너그러운 쪽으로).
19
+
20
+ 원본과 한 가지 다르다: 알 수 없는 ``basis`` 문자열. 실행기는 조용히 through 로,
21
+ 회계는 조용히 touch 로 읽어 **서로 반대**였다. 여기서는 ``ValueError`` 를 낸다 — 두
22
+ 원본 모두 CLI 에서 ``choices=("through","touch")`` 로 막혀 있어 유효 입력에서는 동일하다.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Literal
28
+
29
+ __all__ = ["EPS", "FILL_BASES", "FillBasis", "limit_buy_filled", "limit_sell_filled"]
30
+
31
+ FillBasis = Literal["through", "touch"]
32
+ FILL_BASES: tuple[str, ...] = ("through", "touch")
33
+
34
+ #: scalp-it ``risk_guard._EPS`` / ``order_executor._EPS`` 와 같은 값.
35
+ EPS = 1e-9
36
+
37
+
38
+ def _check(basis: str) -> None:
39
+ if basis not in FILL_BASES:
40
+ raise ValueError(f"fill basis 는 {FILL_BASES} 중 하나여야 한다: {basis!r}")
41
+
42
+
43
+ def limit_buy_filled(limit: float, trade_price: float, basis: FillBasis = "through") -> bool:
44
+ """매수 지정가 ``limit`` 이 체결가 ``trade_price`` 틱으로 채워졌다고 볼 수 있나.
45
+
46
+ through: ``trade_price < limit - EPS`` · touch: ``trade_price <= limit + EPS``.
47
+ """
48
+ _check(basis)
49
+ if basis == "through":
50
+ return trade_price < limit - EPS
51
+ return trade_price <= limit + EPS
52
+
53
+
54
+ def limit_sell_filled(limit: float, trade_price: float, basis: FillBasis = "through") -> bool:
55
+ """매도 지정가 ``limit`` 이 채워졌다고 볼 수 있나 — 매수와 대칭(가격이 **위로** 가야 붙는다).
56
+
57
+ through: ``trade_price > limit + EPS`` · touch: ``trade_price >= limit - EPS``.
58
+ """
59
+ _check(basis)
60
+ if basis == "through":
61
+ return trade_price > limit + EPS
62
+ return trade_price >= limit - EPS
@@ -0,0 +1,262 @@
1
+ """트레이드 원장 — 체결 한 건(``Trade``), 비용 차감 순손익, 성과지표.
2
+
3
+ ``performance_metrics`` 는 daytrade-it ``application/handlers/backtest.py``
4
+ ``BacktestHandler._calculate_metrics`` 의 산식을 **그대로** 옮겼다. 반환형
5
+ :class:`PerformanceMetrics` 의 필드·반올림 자릿수·``Decimal`` 타입도 daytrade 의
6
+ ``BacktestMetrics`` 와 같다 — 대시보드·MCP 도구가 그 모양을 이미 읽고 있어서, 산식을
7
+ 옮기면서 모양을 바꾸면 소비자 쪽 비교 테스트가 무의미해진다.
8
+
9
+ 그 산식의 관례(고치지 않고 보존한 것 — 바꾸면 기존 수치가 조용히 바뀐다):
10
+
11
+ - 연환산 수익률은 **달력일** 기준 ``(end-start).days / 365.25`` 년.
12
+ - Sharpe/Sortino 는 equity curve 의 일간 수익률로 ``mean·252 / (stdev·√252)``,
13
+ **무위험수익률 차감 없음**, 표본표준편차(``statistics.stdev``, ddof=1).
14
+ - Sortino 의 하방편차는 음수 수익률들의 **표본표준편차**(0 기준 semideviation 이 아님).
15
+ 음수 수익률이 2개 미만이면 전체 표준편차로 대체.
16
+ - 손실 트레이드가 없으면 profit_factor 는 ``999`` 로 캡.
17
+ - 최대낙폭은 equity curve 각 점의 ``drawdown``(양수 비율) 최대값이고, 금액은
18
+ ``max_dd × initial_capital`` (고점 자본이 아니라 초기자본 기준).
19
+ - 무승부(pnl == 0) 는 승도 패도 아니지만 ``total_trades`` 와 승률 분모에는 들어간다.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import statistics
25
+ from collections.abc import Callable, Iterable, Mapping, Sequence
26
+ from dataclasses import dataclass
27
+ from datetime import datetime
28
+ from decimal import Decimal
29
+ from typing import Any, Literal
30
+
31
+ __all__ = [
32
+ "PerformanceMetrics",
33
+ "Trade",
34
+ "TradeResult",
35
+ "apply_costs",
36
+ "performance_metrics",
37
+ ]
38
+
39
+ Side = Literal["long", "short"]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Trade:
44
+ """체결된 트레이드 한 건. ``exit_ts``/``exit_price`` 가 None 이면 미청산.
45
+
46
+ ``side`` 는 포지션 방향(``"long"``·``"short"``)이다 — 주문 방향(BUY/SELL)이 아니다.
47
+ ``market`` 은 비용 함수가 KOSPI/KOSDAQ 세율을 고를 수 있게 실어 나른다.
48
+ """
49
+
50
+ code: str
51
+ side: Side
52
+ entry_ts: datetime
53
+ entry_price: float
54
+ exit_ts: datetime | None
55
+ exit_price: float | None
56
+ qty: float
57
+ market: str = "KOSPI"
58
+
59
+ @property
60
+ def closed(self) -> bool:
61
+ return self.exit_ts is not None and self.exit_price is not None
62
+
63
+ @property
64
+ def gross_pnl(self) -> float:
65
+ """비용 전 손익(원). 미청산이면 0."""
66
+ if not self.closed:
67
+ return 0.0
68
+ sign = 1.0 if self.side == "long" else -1.0
69
+ return (float(self.exit_price) - float(self.entry_price)) * float(self.qty) * sign
70
+
71
+ @property
72
+ def gross_return(self) -> float:
73
+ """비용 전 가격 수익률. 미청산이거나 진입가 0 이면 0."""
74
+ if not self.closed or not self.entry_price:
75
+ return 0.0
76
+ sign = 1.0 if self.side == "long" else -1.0
77
+ return (float(self.exit_price) - float(self.entry_price)) / float(self.entry_price) * sign
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class TradeResult:
82
+ """비용을 뗀 트레이드. ``pnl`` = ``gross_pnl - cost`` (원)."""
83
+
84
+ trade: Trade
85
+ gross_pnl: float
86
+ cost: float
87
+ pnl: float
88
+ pnl_pct: float
89
+
90
+ @property
91
+ def entry_ts(self) -> datetime:
92
+ return self.trade.entry_ts
93
+
94
+ @property
95
+ def exit_ts(self) -> datetime | None:
96
+ return self.trade.exit_ts
97
+
98
+
99
+ def apply_costs(trades: Iterable[Trade], cost_fn: Callable[[Trade], float]) -> list[TradeResult]:
100
+ """트레이드마다 ``cost_fn(trade)`` (왕복 총비용, 원 — 수수료·세금·슬리피지)을 떼 순손익을 낸다.
101
+
102
+ 비용 모델은 여기서 정하지 않는다(``krx_quant_core.costs`` 나 소비자 몫). 미청산
103
+ 트레이드는 비용을 부르지 않고 손익 0 으로 둔다 — 아직 안 판 것에 매도세를 물리면
104
+ 안 된다. ``pnl_pct`` 는 daytrade 관례대로 **비용 전** 가격 수익률이다.
105
+ """
106
+ out: list[TradeResult] = []
107
+ for t in trades:
108
+ if not t.closed:
109
+ out.append(TradeResult(t, 0.0, 0.0, 0.0, 0.0))
110
+ continue
111
+ gross = t.gross_pnl
112
+ cost = float(cost_fn(t))
113
+ out.append(TradeResult(t, gross, cost, gross - cost, t.gross_return))
114
+ return out
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class PerformanceMetrics:
119
+ """daytrade-it ``BacktestMetrics`` 와 같은 필드·타입."""
120
+
121
+ total_return: Decimal
122
+ total_return_pct: Decimal
123
+ annualized_return: Decimal
124
+ sharpe_ratio: Decimal
125
+ sortino_ratio: Decimal
126
+ max_drawdown: Decimal
127
+ max_drawdown_pct: Decimal
128
+ win_rate: Decimal
129
+ profit_factor: Decimal
130
+ total_trades: int
131
+ winning_trades: int
132
+ losing_trades: int
133
+ avg_win: Decimal
134
+ avg_loss: Decimal
135
+ avg_trade_duration_hours: Decimal
136
+ best_trade: Decimal
137
+ worst_trade: Decimal
138
+
139
+
140
+ def _dec(x: Any) -> Decimal:
141
+ # daytrade 는 pnl 을 이미 Decimal(str(round(x, 2))) 로 들고 온다. float 가 들어와도
142
+ # str 경유로 바꿔 Decimal 합산을 유지한다 — float 합으로 바꾸면 반올림 경계가 흔들린다.
143
+ return x if isinstance(x, Decimal) else Decimal(str(x))
144
+
145
+
146
+ def _ts(t: Any, which: str) -> datetime | None:
147
+ # core 는 entry_ts/exit_ts, daytrade BacktestTrade 는 entry_date/exit_date 다.
148
+ v = getattr(t, f"{which}_ts", None)
149
+ if v is None:
150
+ v = getattr(t, f"{which}_date", None)
151
+ return v
152
+
153
+
154
+ def performance_metrics(
155
+ trades: Sequence[Any],
156
+ equity_curve: Sequence[Mapping[str, Any]],
157
+ initial_capital: Decimal | float,
158
+ *,
159
+ start_date: datetime,
160
+ end_date: datetime,
161
+ ) -> PerformanceMetrics:
162
+ """트레이드 목록·equity curve → 성과지표 (daytrade ``_calculate_metrics`` 와 수치 동일).
163
+
164
+ Args:
165
+ trades: ``pnl`` (순손익)과 ``entry_ts``/``exit_ts`` (또는 daytrade 의
166
+ ``entry_date``/``exit_date``) 속성을 가진 객체들. :class:`TradeResult` 가 맞다.
167
+ 청산 시각이 None 인 트레이드는 집계에서 빠진다.
168
+ equity_curve: ``{"equity": float, "drawdown": float}`` 점들(시간순).
169
+ initial_capital: 초기자본.
170
+ start_date, end_date: 연환산 기간(달력일).
171
+ """
172
+ if not trades:
173
+ zero = Decimal("0")
174
+ return PerformanceMetrics(
175
+ total_return=zero, total_return_pct=zero, annualized_return=zero,
176
+ sharpe_ratio=zero, sortino_ratio=zero, max_drawdown=zero,
177
+ max_drawdown_pct=zero, win_rate=zero, profit_factor=zero,
178
+ total_trades=0, winning_trades=0, losing_trades=0,
179
+ avg_win=zero, avg_loss=zero, avg_trade_duration_hours=zero,
180
+ best_trade=zero, worst_trade=zero,
181
+ )
182
+ initial_capital = _dec(initial_capital)
183
+
184
+ closed = [t for t in trades if _ts(t, "exit") is not None]
185
+ pnl_of = {id(t): _dec(t.pnl) for t in closed}
186
+ winning = [t for t in closed if pnl_of[id(t)] > 0]
187
+ losing = [t for t in closed if pnl_of[id(t)] < 0]
188
+
189
+ total_pnl = sum(pnl_of[id(t)] for t in closed)
190
+ total_return_pct = total_pnl / initial_capital
191
+
192
+ days = (end_date - start_date).days
193
+ years = days / 365.25
194
+
195
+ if years > 0 and total_return_pct > -1:
196
+ annualized = (1 + float(total_return_pct)) ** (1 / years) - 1
197
+ else:
198
+ annualized = 0
199
+
200
+ win_rate = len(winning) / len(closed) if closed else 0
201
+
202
+ gross_profit = sum(pnl_of[id(t)] for t in winning)
203
+ gross_loss = abs(sum(pnl_of[id(t)] for t in losing))
204
+ profit_factor = gross_profit / gross_loss if gross_loss > 0 else Decimal("inf")
205
+
206
+ avg_win = gross_profit / len(winning) if winning else Decimal("0")
207
+ avg_loss = gross_loss / len(losing) if losing else Decimal("0")
208
+
209
+ max_dd = max(e["drawdown"] for e in equity_curve) if equity_curve else 0
210
+ max_dd_value = max_dd * float(initial_capital)
211
+
212
+ durations = []
213
+ for t in closed:
214
+ entry, exit_ = _ts(t, "entry"), _ts(t, "exit")
215
+ if entry and exit_:
216
+ durations.append((exit_ - entry).total_seconds() / 3600)
217
+ avg_duration = sum(durations) / len(durations) if durations else 0
218
+
219
+ pnls = [float(pnl_of[id(t)]) for t in closed]
220
+ best = max(pnls) if pnls else 0
221
+ worst = min(pnls) if pnls else 0
222
+
223
+ sharpe: float = 0
224
+ sortino: float = 0
225
+ if len(equity_curve) > 1:
226
+ daily_returns = []
227
+ for i in range(1, len(equity_curve)):
228
+ prev = equity_curve[i - 1]["equity"]
229
+ curr = equity_curve[i]["equity"]
230
+ if prev > 0:
231
+ daily_returns.append((curr - prev) / prev)
232
+ if daily_returns:
233
+ avg_return = statistics.mean(daily_returns)
234
+ std_return = statistics.stdev(daily_returns) if len(daily_returns) > 1 else 1
235
+ sharpe = (avg_return * 252) / (std_return * (252**0.5)) if std_return > 0 else 0
236
+ negative = [r for r in daily_returns if r < 0]
237
+ downside_std = statistics.stdev(negative) if len(negative) > 1 else std_return
238
+ sortino = (avg_return * 252) / (downside_std * (252**0.5)) if downside_std > 0 else 0
239
+
240
+ return PerformanceMetrics(
241
+ total_return=total_pnl,
242
+ total_return_pct=Decimal(str(round(float(total_return_pct), 4))),
243
+ annualized_return=Decimal(str(round(annualized, 4))),
244
+ sharpe_ratio=Decimal(str(round(sharpe, 2))),
245
+ sortino_ratio=Decimal(str(round(sortino, 2))),
246
+ max_drawdown=Decimal(str(round(max_dd_value, 2))),
247
+ max_drawdown_pct=Decimal(str(round(max_dd, 4))),
248
+ win_rate=Decimal(str(round(win_rate, 4))),
249
+ profit_factor=(
250
+ Decimal(str(round(float(profit_factor), 2)))
251
+ if profit_factor != Decimal("inf")
252
+ else Decimal("999")
253
+ ),
254
+ total_trades=len(closed),
255
+ winning_trades=len(winning),
256
+ losing_trades=len(losing),
257
+ avg_win=Decimal(str(round(float(avg_win), 2))),
258
+ avg_loss=Decimal(str(round(float(avg_loss), 2))),
259
+ avg_trade_duration_hours=Decimal(str(round(avg_duration, 1))),
260
+ best_trade=Decimal(str(round(best, 2))),
261
+ worst_trade=Decimal(str(round(worst, 2))),
262
+ )