openbacktesting 0.2.0__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 (350) hide show
  1. openbacktesting/__init__.py +85 -0
  2. openbacktesting/aggregation.py +8 -0
  3. openbacktesting/broker/__init__.py +54 -0
  4. openbacktesting/broker/commission.py +57 -0
  5. openbacktesting/broker/core.py +1122 -0
  6. openbacktesting/broker/execution.py +62 -0
  7. openbacktesting/broker/instrument.py +106 -0
  8. openbacktesting/broker/ledger.py +60 -0
  9. openbacktesting/broker/models.py +236 -0
  10. openbacktesting/broker/observations.py +99 -0
  11. openbacktesting/broker/requests.py +192 -0
  12. openbacktesting/broker/sizing.py +76 -0
  13. openbacktesting/colors.py +175 -0
  14. openbacktesting/data/__init__.py +20 -0
  15. openbacktesting/data/aggregation.py +39 -0
  16. openbacktesting/data/context.py +30 -0
  17. openbacktesting/data/core.py +218 -0
  18. openbacktesting/data/generators.py +54 -0
  19. openbacktesting/data/strategy_data.py +103 -0
  20. openbacktesting/data/timeframe.py +69 -0
  21. openbacktesting/engine.py +34 -0
  22. openbacktesting/helpers.py +77 -0
  23. openbacktesting/indicator_api.py +10 -0
  24. openbacktesting/indicator_methods.py +123 -0
  25. openbacktesting/indicator_types.py +19 -0
  26. openbacktesting/indicators/__init__.py +7 -0
  27. openbacktesting/indicators/api.py +97 -0
  28. openbacktesting/indicators/calculus.py +75 -0
  29. openbacktesting/indicators/candles/__init__.py +0 -0
  30. openbacktesting/indicators/candles/cdl_doji.py +17 -0
  31. openbacktesting/indicators/candles/cdl_engulfing.py +14 -0
  32. openbacktesting/indicators/candles/cdl_eveningstar.py +15 -0
  33. openbacktesting/indicators/candles/cdl_hammer.py +14 -0
  34. openbacktesting/indicators/candles/cdl_harami.py +14 -0
  35. openbacktesting/indicators/candles/cdl_inside.py +14 -0
  36. openbacktesting/indicators/candles/cdl_marubozu.py +14 -0
  37. openbacktesting/indicators/candles/cdl_morningstar.py +15 -0
  38. openbacktesting/indicators/candles/cdl_shootingstar.py +14 -0
  39. openbacktesting/indicators/candles/cdl_z.py +28 -0
  40. openbacktesting/indicators/candles/ha.py +19 -0
  41. openbacktesting/indicators/catalog.json +8348 -0
  42. openbacktesting/indicators/common.py +94 -0
  43. openbacktesting/indicators/cycles/__init__.py +0 -0
  44. openbacktesting/indicators/cycles/dsp.py +10 -0
  45. openbacktesting/indicators/cycles/ebsw.py +10 -0
  46. openbacktesting/indicators/cycles/ht_dcperiod.py +10 -0
  47. openbacktesting/indicators/cycles/ht_dcphase.py +10 -0
  48. openbacktesting/indicators/cycles/ht_phasor.py +17 -0
  49. openbacktesting/indicators/cycles/ht_sine.py +17 -0
  50. openbacktesting/indicators/cycles/ht_trendmode.py +10 -0
  51. openbacktesting/indicators/cycles/msw.py +19 -0
  52. openbacktesting/indicators/methods.py +5762 -0
  53. openbacktesting/indicators/momentum/__init__.py +0 -0
  54. openbacktesting/indicators/momentum/ac.py +9 -0
  55. openbacktesting/indicators/momentum/ao.py +10 -0
  56. openbacktesting/indicators/momentum/apo.py +10 -0
  57. openbacktesting/indicators/momentum/bias.py +10 -0
  58. openbacktesting/indicators/momentum/bop.py +10 -0
  59. openbacktesting/indicators/momentum/brar.py +26 -0
  60. openbacktesting/indicators/momentum/cci.py +10 -0
  61. openbacktesting/indicators/momentum/cfo.py +10 -0
  62. openbacktesting/indicators/momentum/cg.py +10 -0
  63. openbacktesting/indicators/momentum/cmo.py +10 -0
  64. openbacktesting/indicators/momentum/coppock.py +10 -0
  65. openbacktesting/indicators/momentum/crsi.py +28 -0
  66. openbacktesting/indicators/momentum/cti.py +10 -0
  67. openbacktesting/indicators/momentum/dm.py +17 -0
  68. openbacktesting/indicators/momentum/er.py +10 -0
  69. openbacktesting/indicators/momentum/eri.py +17 -0
  70. openbacktesting/indicators/momentum/fisher.py +17 -0
  71. openbacktesting/indicators/momentum/fosc.py +10 -0
  72. openbacktesting/indicators/momentum/gator.py +13 -0
  73. openbacktesting/indicators/momentum/ibs.py +7 -0
  74. openbacktesting/indicators/momentum/inertia.py +22 -0
  75. openbacktesting/indicators/momentum/kdj.py +18 -0
  76. openbacktesting/indicators/momentum/kst.py +30 -0
  77. openbacktesting/indicators/momentum/lrsi.py +10 -0
  78. openbacktesting/indicators/momentum/macd.py +27 -0
  79. openbacktesting/indicators/momentum/macdext.py +27 -0
  80. openbacktesting/indicators/momentum/macdfix.py +18 -0
  81. openbacktesting/indicators/momentum/mom.py +10 -0
  82. openbacktesting/indicators/momentum/pgo.py +10 -0
  83. openbacktesting/indicators/momentum/pmo.py +13 -0
  84. openbacktesting/indicators/momentum/po.py +10 -0
  85. openbacktesting/indicators/momentum/ppo.py +20 -0
  86. openbacktesting/indicators/momentum/psl.py +12 -0
  87. openbacktesting/indicators/momentum/pvo.py +20 -0
  88. openbacktesting/indicators/momentum/qqe.py +31 -0
  89. openbacktesting/indicators/momentum/rci.py +20 -0
  90. openbacktesting/indicators/momentum/rmi.py +7 -0
  91. openbacktesting/indicators/momentum/roc.py +10 -0
  92. openbacktesting/indicators/momentum/rocp.py +10 -0
  93. openbacktesting/indicators/momentum/rocr.py +10 -0
  94. openbacktesting/indicators/momentum/rocr100.py +10 -0
  95. openbacktesting/indicators/momentum/rsi.py +10 -0
  96. openbacktesting/indicators/momentum/rsx.py +10 -0
  97. openbacktesting/indicators/momentum/rvgi.py +26 -0
  98. openbacktesting/indicators/momentum/slope.py +17 -0
  99. openbacktesting/indicators/momentum/smc_sweep.py +18 -0
  100. openbacktesting/indicators/momentum/smi.py +21 -0
  101. openbacktesting/indicators/momentum/smi_stochastic.py +15 -0
  102. openbacktesting/indicators/momentum/squeeze.py +47 -0
  103. openbacktesting/indicators/momentum/squeeze_pro.py +53 -0
  104. openbacktesting/indicators/momentum/stc.py +20 -0
  105. openbacktesting/indicators/momentum/stoch.py +19 -0
  106. openbacktesting/indicators/momentum/stochf.py +19 -0
  107. openbacktesting/indicators/momentum/stochrsi.py +19 -0
  108. openbacktesting/indicators/momentum/td_seq.py +17 -0
  109. openbacktesting/indicators/momentum/trix.py +19 -0
  110. openbacktesting/indicators/momentum/trixh.py +20 -0
  111. openbacktesting/indicators/momentum/tsi.py +26 -0
  112. openbacktesting/indicators/momentum/uo.py +22 -0
  113. openbacktesting/indicators/momentum/vwmacd.py +20 -0
  114. openbacktesting/indicators/momentum/wavetrend.py +15 -0
  115. openbacktesting/indicators/momentum/willr.py +10 -0
  116. openbacktesting/indicators/overlap/__init__.py +0 -0
  117. openbacktesting/indicators/overlap/alligator.py +13 -0
  118. openbacktesting/indicators/overlap/alma.py +33 -0
  119. openbacktesting/indicators/overlap/anchored_vwap.py +50 -0
  120. openbacktesting/indicators/overlap/anchored_vwap_bands.py +13 -0
  121. openbacktesting/indicators/overlap/avgprice.py +10 -0
  122. openbacktesting/indicators/overlap/dema.py +10 -0
  123. openbacktesting/indicators/overlap/ema.py +12 -0
  124. openbacktesting/indicators/overlap/frama.py +29 -0
  125. openbacktesting/indicators/overlap/fwma.py +10 -0
  126. openbacktesting/indicators/overlap/hilo.py +26 -0
  127. openbacktesting/indicators/overlap/hl2.py +10 -0
  128. openbacktesting/indicators/overlap/hlc3.py +10 -0
  129. openbacktesting/indicators/overlap/hma.py +10 -0
  130. openbacktesting/indicators/overlap/ht_trendline.py +10 -0
  131. openbacktesting/indicators/overlap/hwma.py +10 -0
  132. openbacktesting/indicators/overlap/ichimoku.py +44 -0
  133. openbacktesting/indicators/overlap/jma.py +10 -0
  134. openbacktesting/indicators/overlap/kama.py +10 -0
  135. openbacktesting/indicators/overlap/linreg.py +15 -0
  136. openbacktesting/indicators/overlap/linregangle.py +10 -0
  137. openbacktesting/indicators/overlap/linregintercept.py +10 -0
  138. openbacktesting/indicators/overlap/linregslope.py +10 -0
  139. openbacktesting/indicators/overlap/ma.py +10 -0
  140. openbacktesting/indicators/overlap/ma_envelopes.py +16 -0
  141. openbacktesting/indicators/overlap/mama.py +17 -0
  142. openbacktesting/indicators/overlap/mavp.py +17 -0
  143. openbacktesting/indicators/overlap/mcgd.py +10 -0
  144. openbacktesting/indicators/overlap/medprice.py +10 -0
  145. openbacktesting/indicators/overlap/midpoint.py +10 -0
  146. openbacktesting/indicators/overlap/midprice.py +10 -0
  147. openbacktesting/indicators/overlap/mmar.py +21 -0
  148. openbacktesting/indicators/overlap/ohlc4.py +10 -0
  149. openbacktesting/indicators/overlap/pwma.py +10 -0
  150. openbacktesting/indicators/overlap/rainbow.py +25 -0
  151. openbacktesting/indicators/overlap/rma.py +10 -0
  152. openbacktesting/indicators/overlap/sinwma.py +10 -0
  153. openbacktesting/indicators/overlap/sma.py +14 -0
  154. openbacktesting/indicators/overlap/ssf.py +10 -0
  155. openbacktesting/indicators/overlap/supertrend.py +21 -0
  156. openbacktesting/indicators/overlap/swma.py +10 -0
  157. openbacktesting/indicators/overlap/t3.py +10 -0
  158. openbacktesting/indicators/overlap/tema.py +10 -0
  159. openbacktesting/indicators/overlap/trima.py +10 -0
  160. openbacktesting/indicators/overlap/tsf.py +10 -0
  161. openbacktesting/indicators/overlap/typprice.py +10 -0
  162. openbacktesting/indicators/overlap/vidya.py +10 -0
  163. openbacktesting/indicators/overlap/vwap.py +10 -0
  164. openbacktesting/indicators/overlap/vwma.py +10 -0
  165. openbacktesting/indicators/overlap/wcp.py +10 -0
  166. openbacktesting/indicators/overlap/wma.py +10 -0
  167. openbacktesting/indicators/overlap/zlma.py +10 -0
  168. openbacktesting/indicators/performance/__init__.py +0 -0
  169. openbacktesting/indicators/performance/drawdown.py +18 -0
  170. openbacktesting/indicators/performance/log_return.py +10 -0
  171. openbacktesting/indicators/performance/percent_return.py +10 -0
  172. openbacktesting/indicators/predicates.py +88 -0
  173. openbacktesting/indicators/registry.py +571 -0
  174. openbacktesting/indicators/runtime.py +290 -0
  175. openbacktesting/indicators/statistics/__init__.py +0 -0
  176. openbacktesting/indicators/statistics/beta.py +10 -0
  177. openbacktesting/indicators/statistics/correl.py +10 -0
  178. openbacktesting/indicators/statistics/entropy.py +10 -0
  179. openbacktesting/indicators/statistics/kurtosis.py +10 -0
  180. openbacktesting/indicators/statistics/mad.py +10 -0
  181. openbacktesting/indicators/statistics/md.py +10 -0
  182. openbacktesting/indicators/statistics/median.py +10 -0
  183. openbacktesting/indicators/statistics/mode.py +48 -0
  184. openbacktesting/indicators/statistics/quantile.py +10 -0
  185. openbacktesting/indicators/statistics/skew.py +10 -0
  186. openbacktesting/indicators/statistics/stderr.py +10 -0
  187. openbacktesting/indicators/statistics/stdev.py +10 -0
  188. openbacktesting/indicators/statistics/variance.py +10 -0
  189. openbacktesting/indicators/statistics/zscore.py +10 -0
  190. openbacktesting/indicators/trend/__init__.py +0 -0
  191. openbacktesting/indicators/trend/adx.py +28 -0
  192. openbacktesting/indicators/trend/adxr.py +28 -0
  193. openbacktesting/indicators/trend/amat.py +19 -0
  194. openbacktesting/indicators/trend/aroon.py +18 -0
  195. openbacktesting/indicators/trend/atr_stop.py +33 -0
  196. openbacktesting/indicators/trend/chop.py +20 -0
  197. openbacktesting/indicators/trend/cksp.py +17 -0
  198. openbacktesting/indicators/trend/cpr.py +57 -0
  199. openbacktesting/indicators/trend/decay.py +10 -0
  200. openbacktesting/indicators/trend/decreasing.py +18 -0
  201. openbacktesting/indicators/trend/dpo.py +12 -0
  202. openbacktesting/indicators/trend/dx.py +19 -0
  203. openbacktesting/indicators/trend/edecay.py +10 -0
  204. openbacktesting/indicators/trend/increasing.py +18 -0
  205. openbacktesting/indicators/trend/long_run.py +10 -0
  206. openbacktesting/indicators/trend/minus_dm.py +10 -0
  207. openbacktesting/indicators/trend/pivot_points.py +89 -0
  208. openbacktesting/indicators/trend/pivothigh.py +23 -0
  209. openbacktesting/indicators/trend/pivotlow.py +23 -0
  210. openbacktesting/indicators/trend/plus_dm.py +10 -0
  211. openbacktesting/indicators/trend/pmax.py +18 -0
  212. openbacktesting/indicators/trend/psar.py +19 -0
  213. openbacktesting/indicators/trend/qstick.py +10 -0
  214. openbacktesting/indicators/trend/sarext.py +35 -0
  215. openbacktesting/indicators/trend/short_run.py +10 -0
  216. openbacktesting/indicators/trend/tsignals.py +26 -0
  217. openbacktesting/indicators/trend/ttm_trend.py +16 -0
  218. openbacktesting/indicators/trend/vhf.py +10 -0
  219. openbacktesting/indicators/trend/volatility_stop.py +33 -0
  220. openbacktesting/indicators/trend/vortex.py +17 -0
  221. openbacktesting/indicators/trend/xsignals.py +29 -0
  222. openbacktesting/indicators/types.py +344 -0
  223. openbacktesting/indicators/utility/__init__.py +0 -0
  224. openbacktesting/indicators/utility/barssince.py +21 -0
  225. openbacktesting/indicators/utility/change.py +7 -0
  226. openbacktesting/indicators/utility/cross.py +12 -0
  227. openbacktesting/indicators/utility/crossover.py +11 -0
  228. openbacktesting/indicators/utility/crossunder.py +11 -0
  229. openbacktesting/indicators/utility/cum.py +7 -0
  230. openbacktesting/indicators/utility/falling.py +10 -0
  231. openbacktesting/indicators/utility/highest.py +7 -0
  232. openbacktesting/indicators/utility/highestbars.py +29 -0
  233. openbacktesting/indicators/utility/lowest.py +7 -0
  234. openbacktesting/indicators/utility/lowestbars.py +29 -0
  235. openbacktesting/indicators/utility/percentile_linear_interpolation.py +12 -0
  236. openbacktesting/indicators/utility/percentile_nearest_rank.py +16 -0
  237. openbacktesting/indicators/utility/percentrank.py +7 -0
  238. openbacktesting/indicators/utility/range.py +8 -0
  239. openbacktesting/indicators/utility/rising.py +10 -0
  240. openbacktesting/indicators/utility/rolling_count.py +12 -0
  241. openbacktesting/indicators/utility/rolling_sum.py +7 -0
  242. openbacktesting/indicators/utility/running_max.py +7 -0
  243. openbacktesting/indicators/utility/running_min.py +7 -0
  244. openbacktesting/indicators/utility/shift.py +7 -0
  245. openbacktesting/indicators/utility/valuewhen.py +25 -0
  246. openbacktesting/indicators/volatility/__init__.py +0 -0
  247. openbacktesting/indicators/volatility/aberration.py +21 -0
  248. openbacktesting/indicators/volatility/accbands.py +20 -0
  249. openbacktesting/indicators/volatility/adr.py +7 -0
  250. openbacktesting/indicators/volatility/atr.py +12 -0
  251. openbacktesting/indicators/volatility/avolume.py +10 -0
  252. openbacktesting/indicators/volatility/bbands.py +20 -0
  253. openbacktesting/indicators/volatility/ce.py +25 -0
  254. openbacktesting/indicators/volatility/cvi.py +10 -0
  255. openbacktesting/indicators/volatility/donchian.py +20 -0
  256. openbacktesting/indicators/volatility/hvol.py +10 -0
  257. openbacktesting/indicators/volatility/hwc.py +27 -0
  258. openbacktesting/indicators/volatility/kc.py +27 -0
  259. openbacktesting/indicators/volatility/massi.py +10 -0
  260. openbacktesting/indicators/volatility/natr.py +19 -0
  261. openbacktesting/indicators/volatility/pdist.py +10 -0
  262. openbacktesting/indicators/volatility/rvi.py +21 -0
  263. openbacktesting/indicators/volatility/starc.py +11 -0
  264. openbacktesting/indicators/volatility/thermo.py +28 -0
  265. openbacktesting/indicators/volatility/true_range.py +31 -0
  266. openbacktesting/indicators/volatility/ui.py +10 -0
  267. openbacktesting/indicators/volume/__init__.py +0 -0
  268. openbacktesting/indicators/volume/ad.py +10 -0
  269. openbacktesting/indicators/volume/adosc.py +19 -0
  270. openbacktesting/indicators/volume/aobv.py +31 -0
  271. openbacktesting/indicators/volume/cmf.py +12 -0
  272. openbacktesting/indicators/volume/efi.py +12 -0
  273. openbacktesting/indicators/volume/emv.py +10 -0
  274. openbacktesting/indicators/volume/eom.py +19 -0
  275. openbacktesting/indicators/volume/iii.py +9 -0
  276. openbacktesting/indicators/volume/kvo.py +28 -0
  277. openbacktesting/indicators/volume/marketfi.py +10 -0
  278. openbacktesting/indicators/volume/mfi.py +12 -0
  279. openbacktesting/indicators/volume/nvi.py +26 -0
  280. openbacktesting/indicators/volume/obv.py +10 -0
  281. openbacktesting/indicators/volume/pvi.py +26 -0
  282. openbacktesting/indicators/volume/pvol.py +10 -0
  283. openbacktesting/indicators/volume/pvr.py +10 -0
  284. openbacktesting/indicators/volume/pvt.py +10 -0
  285. openbacktesting/indicators/volume/rvol.py +8 -0
  286. openbacktesting/indicators/volume/vfi.py +18 -0
  287. openbacktesting/indicators/volume/vosc.py +10 -0
  288. openbacktesting/indicators/volume/wad.py +10 -0
  289. openbacktesting/indicators/volume/wvad.py +9 -0
  290. openbacktesting/inputs.py +51 -0
  291. openbacktesting/lib.py +31 -0
  292. openbacktesting/logs.py +129 -0
  293. openbacktesting/optimization.py +8 -0
  294. openbacktesting/optimizer/__init__.py +25 -0
  295. openbacktesting/optimizer/_semantics.py +155 -0
  296. openbacktesting/optimizer/identity.py +45 -0
  297. openbacktesting/optimizer/models.py +92 -0
  298. openbacktesting/optimizer/plan.py +129 -0
  299. openbacktesting/optimizer/portfolio-result.schema.json +4984 -0
  300. openbacktesting/optimizer/query.py +257 -0
  301. openbacktesting/optimizer/runner.py +98 -0
  302. openbacktesting/optimizer/storage.py +261 -0
  303. openbacktesting/optimizer/validation.py +64 -0
  304. openbacktesting/parallel.py +9 -0
  305. openbacktesting/plotting/__init__.py +23 -0
  306. openbacktesting/plotting/collector.py +509 -0
  307. openbacktesting/plotting/encoding.py +102 -0
  308. openbacktesting/plotting/types.py +92 -0
  309. openbacktesting/portfolio/__init__.py +15 -0
  310. openbacktesting/portfolio/analysis.py +245 -0
  311. openbacktesting/portfolio/builder.py +381 -0
  312. openbacktesting/portfolio/catalog.json +1404 -0
  313. openbacktesting/portfolio/helpers.py +92 -0
  314. openbacktesting/portfolio/metrics.py +186 -0
  315. openbacktesting/portfolio/models.py +63 -0
  316. openbacktesting/portfolio/periods.py +164 -0
  317. openbacktesting/portfolio/rates.py +14 -0
  318. openbacktesting/portfolio/snapshot.py +111 -0
  319. openbacktesting/portfolio/stats.py +272 -0
  320. openbacktesting/portfolio/trades.py +136 -0
  321. openbacktesting/portfolio/trading.py +215 -0
  322. openbacktesting/py.typed +0 -0
  323. openbacktesting/runtime.py +330 -0
  324. openbacktesting/simulation/__init__.py +17 -0
  325. openbacktesting/simulation/collection.py +13 -0
  326. openbacktesting/simulation/config.py +241 -0
  327. openbacktesting/simulation/core.py +528 -0
  328. openbacktesting/simulation/optimization.py +326 -0
  329. openbacktesting/simulation/parallel.py +145 -0
  330. openbacktesting/simulation/variants.py +107 -0
  331. openbacktesting/strategy/__init__.py +19 -0
  332. openbacktesting/strategy/bar_state.py +14 -0
  333. openbacktesting/strategy/base.py +810 -0
  334. openbacktesting/strategy/declaration.py +90 -0
  335. openbacktesting/strategy/helpers.py +198 -0
  336. openbacktesting/strategy/inputs.py +460 -0
  337. openbacktesting/strategy/sizing.py +80 -0
  338. openbacktesting/strategy/templates.py +160 -0
  339. openbacktesting/strategy_data.py +10 -0
  340. openbacktesting/test/BTCUSD.csv +157 -0
  341. openbacktesting/test/EURUSD.csv +5001 -0
  342. openbacktesting/test/GOOG.csv +2149 -0
  343. openbacktesting/test/__init__.py +29 -0
  344. openbacktesting/test/__main__.py +11 -0
  345. openbacktesting/test/_test.py +1312 -0
  346. openbacktesting/util.py +31 -0
  347. openbacktesting-0.2.0.dist-info/METADATA +151 -0
  348. openbacktesting-0.2.0.dist-info/RECORD +350 -0
  349. openbacktesting-0.2.0.dist-info/WHEEL +4 -0
  350. openbacktesting-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1122 @@
1
+ """Single-asset trade scheduling: requests, execution facts, lots, and protection groups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from decimal import Decimal
7
+ from math import copysign
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from openbacktesting.broker.commission import Commission, normalize_commission
13
+ from openbacktesting.broker.execution import Slippage, execution_price, match_price
14
+ from openbacktesting.broker.instrument import InstrumentSpec
15
+ from openbacktesting.broker.ledger import TradingLedger
16
+ from openbacktesting.broker.models import (
17
+ ClosedTradeStats,
18
+ Fill,
19
+ NormalizedOrderRequest,
20
+ OrderEvent,
21
+ OrderSizingSnapshot,
22
+ OrderSnapshot,
23
+ OrderStatus,
24
+ PositionSnapshot,
25
+ TradeAllocation,
26
+ finite,
27
+ freeze_tag,
28
+ record,
29
+ )
30
+ from openbacktesting.broker.requests import OrderRequest, _normalize_conditions
31
+ from openbacktesting.broker.sizing import resolve_size
32
+ from openbacktesting.helpers import _set_legacy_module
33
+
34
+ __pdoc__ = {"Order.__init__": False, "Trade.__init__": False, "Position.__init__": False}
35
+
36
+
37
+ class _OutOfMoneyError(Exception):
38
+ pass
39
+
40
+
41
+ class Order:
42
+ """Stable order handle; request and snapshot are immutable values, and cancel is repeatable."""
43
+
44
+ def __init__(self, broker, request, *, group_id=None, kind="entry", eligible=None, sizing=None):
45
+ self._broker = broker
46
+ self._request_value = request
47
+ self._sizing_value = sizing
48
+ self._order_id = broker._id("order")
49
+ self.group_id = group_id
50
+ self.kind = kind
51
+ self.ordinal = broker._counts["order"]
52
+ self.created_bar = broker._i
53
+ self.eligible_bar = broker._i + 1 if eligible is None else eligible
54
+ self._snapshot = OrderSnapshot(
55
+ self.order_id, OrderStatus.SUBMITTED, 0, request.qty, None, None, False
56
+ )
57
+ self.active_stop = request.stop
58
+ self.trail = None
59
+ self.target = None
60
+
61
+ @property
62
+ def order_id(self):
63
+ """Stable, read-only order identity within each run."""
64
+ return self._order_id
65
+
66
+ @property
67
+ def request(self) -> NormalizedOrderRequest:
68
+ """The original normalized request cannot be replaced."""
69
+ return self._request_value
70
+
71
+ @property
72
+ def snapshot(self) -> OrderSnapshot:
73
+ """Latest frozen state, without exposing writable state."""
74
+ return self._snapshot
75
+
76
+ @property
77
+ def sizing(self) -> OrderSizingSnapshot | None:
78
+ """Sizing basis frozen at submission; None for targets, lot exits, and internal protection orders."""
79
+ return self._sizing_value
80
+
81
+ @property
82
+ def status(self):
83
+ """Current state; query events for historical states."""
84
+ return self.snapshot.status
85
+
86
+ @property
87
+ def size(self):
88
+ """Original signed asset quantity."""
89
+ return self.request.qty * (1 if self.request.side == "buy" else -1)
90
+
91
+ @property
92
+ def limit(self):
93
+ """Immutable requested limit price."""
94
+ return self.request.limit
95
+
96
+ @property
97
+ def stop(self):
98
+ """Currently effective trigger price; the original condition remains in request."""
99
+ return self.active_stop
100
+
101
+ @property
102
+ def sl(self):
103
+ """Initial requested stop-loss price."""
104
+ return self.request.sl
105
+
106
+ @property
107
+ def tp(self):
108
+ """Initial requested take-profit price."""
109
+ return self.request.tp
110
+
111
+ @property
112
+ def group(self):
113
+ """Associated OCO or protection group; None for ordinary standalone orders."""
114
+ return self._broker.groups.get(self.group_id)
115
+
116
+ @property
117
+ def tag(self):
118
+ """Independent plain-value copy of the user tag."""
119
+ return self.request.tag
120
+
121
+ @property
122
+ def is_long(self):
123
+ """Whether this is a buy-side request."""
124
+ return self.size > 0
125
+
126
+ @property
127
+ def is_short(self):
128
+ """Whether this is a sell-side request."""
129
+ return self.size < 0
130
+
131
+ @property
132
+ def parent_trade(self):
133
+ """Associated active lot; None after closure."""
134
+ return next((t for t in self._broker.trades if t.trade_id == self.request.trade_id), None)
135
+
136
+ @property
137
+ def is_contingent(self):
138
+ """Whether this is a lot protection exit."""
139
+ return self.kind in ("sl", "tp", "trail")
140
+
141
+ def cancel(self):
142
+ """Cancel an unfinished order; calls on terminal states are idempotent."""
143
+ if not self.status.terminal:
144
+ self._broker._permission()
145
+ self._broker.cancel(self, "user_canceled")
146
+ return self.snapshot
147
+
148
+ def __repr__(self):
149
+ return f"<Order {self.order_id} size={self.size:g} status={self.status} tag={self.tag!r}>"
150
+
151
+
152
+ class OrderGroup:
153
+ """Queryable OCO or lot protection group; cancellation does not close positions."""
154
+
155
+ def __init__(self, broker, group_id, kind):
156
+ self._broker = broker
157
+ self.group_id = group_id
158
+ self.kind = kind
159
+ self._orders = []
160
+
161
+ @property
162
+ def orders(self):
163
+ """All orders in this group, including historical terminal orders."""
164
+ return tuple(self._orders)
165
+
166
+ def cancel(self):
167
+ """Cancel all still-active members without automatically exiting positions."""
168
+ for order in self.orders:
169
+ order.cancel()
170
+
171
+
172
+ class Trade:
173
+ """Entry lot that can be exited and protected separately; partial exits preserve trade_id."""
174
+
175
+ def __init__(
176
+ self,
177
+ broker,
178
+ size,
179
+ entry_price,
180
+ entry_bar,
181
+ tag=None,
182
+ *,
183
+ trade_id=None,
184
+ entry_fill_id="",
185
+ entry_fee=0,
186
+ group_id=None,
187
+ ):
188
+ self._broker = broker
189
+ self._size = size
190
+ self.entry_price = entry_price
191
+ self.entry_bar = entry_bar
192
+ self.exit_price = None
193
+ self.exit_bar = None
194
+ self.trade_id = trade_id or broker._id("trade")
195
+ self.entry_fill_id = entry_fill_id
196
+ self.group_id = group_id
197
+ self._tag_json = freeze_tag(tag)
198
+ self._entry_commission = entry_fee
199
+ self._exit_commission = 0.0
200
+ self._sl_order = None
201
+ self._tp_order = None
202
+ self._closed_sl = self._closed_tp = None
203
+
204
+ @property
205
+ def size(self):
206
+ """Signed quantity of the remaining or closed portion."""
207
+ return self._size
208
+
209
+ @property
210
+ def group(self):
211
+ """Lot protection group, including historical replaced members."""
212
+ return self._broker.groups.get(self.group_id)
213
+
214
+ @property
215
+ def tag(self):
216
+ """Copy of the entry tag."""
217
+ import json
218
+
219
+ return json.loads(self._tag_json)
220
+
221
+ @property
222
+ def entry_time(self):
223
+ """Entry bar index."""
224
+ return self._broker._index[self.entry_bar]
225
+
226
+ @property
227
+ def exit_time(self):
228
+ """Closing bar index; None while active."""
229
+ return None if self.exit_bar is None else self._broker._index[self.exit_bar]
230
+
231
+ @property
232
+ def is_long(self):
233
+ """Whether this is a long lot."""
234
+ return self.size > 0
235
+
236
+ @property
237
+ def is_short(self):
238
+ """Whether this is a short lot."""
239
+ return self.size < 0
240
+
241
+ @property
242
+ def _commissions(self):
243
+ return self._entry_commission + self._exit_commission
244
+
245
+ @property
246
+ def pl_gross(self):
247
+ """Gross price profit or loss."""
248
+ price = self._broker.last_price if self.exit_price is None else self.exit_price
249
+ return self.size * (price - self.entry_price)
250
+
251
+ @property
252
+ def pl(self):
253
+ """Net profit or loss including allocated fees; active lots include remaining entry fees."""
254
+ return self.pl_gross - self._commissions
255
+
256
+ @property
257
+ def return_fraction(self):
258
+ """Net return fraction using remaining or allocated entry notional as the denominator."""
259
+ return self.pl / (abs(self.size) * self.entry_price)
260
+
261
+ @property
262
+ def value(self):
263
+ """Absolute notional value at the current or exit price."""
264
+ price = self._broker.last_price if self.exit_price is None else self.exit_price
265
+ return abs(self.size) * price
266
+
267
+ @property
268
+ def sl(self):
269
+ """Current stop-loss price; writes record cancellation and replacement events."""
270
+ return (
271
+ self._sl_order.stop
272
+ if self._sl_order is not None and not self._sl_order.status.terminal
273
+ else self._closed_sl
274
+ )
275
+
276
+ @sl.setter
277
+ def sl(self, price):
278
+ self._broker.protect(self, "sl", price)
279
+
280
+ @property
281
+ def tp(self):
282
+ """Current take-profit price; None cancels protection."""
283
+ return (
284
+ self._tp_order.limit
285
+ if self._tp_order is not None and not self._tp_order.status.terminal
286
+ else self._closed_tp
287
+ )
288
+
289
+ @tp.setter
290
+ def tp(self, price):
291
+ self._broker.protect(self, "tp", price)
292
+
293
+ def close(self, portion=None, *, qty=None):
294
+ """Reduce-only exit; qty and portion are mutually exclusive, and the default closes the entire lot."""
295
+ if qty is not None and portion is not None:
296
+ raise ValueError("qty and portion are mutually exclusive")
297
+ if qty is None:
298
+ portion = 1.0 if portion is None else finite(portion, "portion", positive=True)
299
+ if portion > 1:
300
+ raise ValueError("portion must not exceed 1")
301
+ qty = self._broker.instrument.quantity(
302
+ abs(self.size) * portion, floor=True, opening=False
303
+ )
304
+ else:
305
+ qty = self._broker.instrument.quantity(qty, opening=False)
306
+ if qty <= 0:
307
+ raise ValueError("Closing quantity rounds to zero")
308
+ return self._broker.new_order(
309
+ qty, side="sell" if self.is_long else "buy", trade=self, reduce_only=True, tag=self.tag
310
+ )
311
+
312
+ def set_trailing_stop(self, *, amount=None, percent=None):
313
+ """Create a native trailing stop, replacing the fixed SL; effective from the next eligible bar."""
314
+ if (amount is None) == (percent is None):
315
+ raise ValueError("Exactly one of amount and percent must be supplied")
316
+ value = finite(amount if amount is not None else percent, "trail", positive=True)
317
+ if percent is not None and value >= 1:
318
+ raise ValueError("percent must be less than 1")
319
+ distance = value if amount is not None else self._broker.last_price * value
320
+ price = self._broker.instrument.price(
321
+ self._broker.last_price - copysign(distance, self.size),
322
+ side="sell" if self.is_long else "buy",
323
+ )
324
+ order = self._broker.protect(self, "sl", price, kind="trail")
325
+ order.trail = ("amount" if amount is not None else "percent", value)
326
+ return order
327
+
328
+ def __repr__(self):
329
+ return f"<Trade {self.trade_id} size={self.size:g} pl={self.pl:g} tag={self.tag}>"
330
+
331
+
332
+ class Position:
333
+ """Aggregate active lots; truthiness depends on total exposure."""
334
+
335
+ def __init__(self, broker):
336
+ self._broker = broker
337
+
338
+ @property
339
+ def snapshot(self) -> PositionSnapshot:
340
+ """Frozen snapshot of net position, gross exposure, and fees."""
341
+ return self._broker.ledger.position(self._broker.trades, self._broker.last_price)
342
+
343
+ @property
344
+ def size(self):
345
+ """Signed net quantity."""
346
+ return self.snapshot.net_qty
347
+
348
+ @property
349
+ def pl(self):
350
+ """Unrealized net profit or loss including remaining entry fees."""
351
+ return self.snapshot.unrealized_pnl_net
352
+
353
+ @property
354
+ def return_fraction(self):
355
+ """Net return fraction; None when a hedged or flat position has no common cost basis."""
356
+ return self.snapshot.return_fraction
357
+
358
+ @property
359
+ def has_exposure(self):
360
+ """Whether any lot is still held."""
361
+ return self.snapshot.has_exposure
362
+
363
+ @property
364
+ def is_long(self):
365
+ """Whether the net position is long."""
366
+ return self.size > 0
367
+
368
+ @property
369
+ def is_short(self):
370
+ """Whether the net position is short."""
371
+ return self.size < 0
372
+
373
+ def close(self, portion=None):
374
+ """Submit reduce-only orders for each lot and return an ordered tuple."""
375
+ return tuple(t.close(portion) for t in tuple(self._broker.trades))
376
+
377
+ def __bool__(self):
378
+ return self.has_exposure
379
+
380
+ def __repr__(self):
381
+ return f"<Position: {self.size:g} ({len(self._broker.trades)} trades)>"
382
+
383
+
384
+ class _Broker:
385
+ def __init__(
386
+ self,
387
+ *,
388
+ data,
389
+ cash,
390
+ spread,
391
+ commission,
392
+ margin,
393
+ hedging=False,
394
+ exclusive_orders=False,
395
+ index,
396
+ instrument=None,
397
+ slippage=None,
398
+ allow_short=True,
399
+ max_gross_exposure=None,
400
+ execution_timing="next_open",
401
+ ):
402
+ cash = finite(cash, "cash", positive=True)
403
+ margin = finite(margin, "margin", positive=True)
404
+ if margin > 1:
405
+ raise ValueError("margin must not exceed 1")
406
+ spread = finite(spread, "spread")
407
+ if not 0 <= spread < 1:
408
+ raise ValueError("spread must be in [0,1)")
409
+ if max_gross_exposure is not None:
410
+ max_gross_exposure = finite(max_gross_exposure, "max_gross_exposure", positive=True)
411
+ self._data, self._index = data, index
412
+ self.instrument = instrument or InstrumentSpec()
413
+ self.slippage = slippage or Slippage()
414
+ self.ledger = TradingLedger(cash)
415
+ self.margin, self._spread = margin, spread
416
+ self._hedging, self._exclusive_orders = hedging, exclusive_orders
417
+ self.allow_short, self.max_gross_exposure = allow_short, max_gross_exposure
418
+ self.execution_timing = execution_timing
419
+ self._commission_model = commission
420
+ if not callable(commission):
421
+ self._linear_commission = normalize_commission(commission)
422
+ if not isinstance(commission, Commission):
423
+ self._commission_model = (
424
+ self._linear_commission.fixed,
425
+ self._linear_commission.relative,
426
+ )
427
+ self.orders, self.all_orders = [], []
428
+ self.trades, self.closed_trades = [], []
429
+ self._closed_stats = ClosedTradeStats()
430
+ self.events, self.fills, self.allocations = [], [], []
431
+ self.groups, self.diagnostics, self._notifications = {}, [], []
432
+ self.account_curve, self.position_curve = [], []
433
+ self._counts, self._sequence = {}, 0
434
+ self._i, self.phase = 0, "init"
435
+ self._equity = np.full(len(index), float(cash))
436
+ self._exposure = np.zeros(len(index), dtype=bool)
437
+ self.position = Position(self)
438
+ self._target_order = None
439
+ self.observations = None
440
+
441
+ def _commission_func(self, *args):
442
+ raise TypeError("Trading state from 0.1 cannot resume execution; rerun the backtest with a 0.2 configuration")
443
+
444
+ def _commission(self, qty, price):
445
+ if callable(self._commission_model):
446
+ return self._commission_model(qty, price)
447
+ return self._linear_commission.calculate(qty, price)
448
+
449
+ def order_target(self, target, conditions):
450
+ self._permission()
451
+ if self._hedging:
452
+ raise ValueError("Target positions do not support hedging mode")
453
+ target = finite(target, "target")
454
+ target = copysign(self.instrument.quantity(abs(target), floor=True, opening=False), target)
455
+ if target < 0 and not self.allow_short:
456
+ raise ValueError("short_not_allowed")
457
+ old = self._target_order
458
+ if any(o is not old and not o.is_contingent for o in self.orders):
459
+ raise ValueError("conflicting_pending_order")
460
+ delta = float(Decimal(str(target)) - Decimal(str(self.position.size)))
461
+ identity_values = self._conditions(**conditions)
462
+ if identity_values.get("expires_at") is not None:
463
+ identity_values["expires_at"] = pd.Timestamp(identity_values["expires_at"]).isoformat()
464
+ identity = (target, freeze_tag(identity_values))
465
+ # Validate the new request first; invalid reuse or replacement must not alter a valid existing intent.
466
+ request = (
467
+ None
468
+ if delta == 0
469
+ else self._request(abs(delta), side="buy" if delta > 0 else "sell", **conditions)
470
+ )
471
+ if old is not None and not old.status.terminal and old.target == identity:
472
+ return old
473
+ if old is not None and not old.status.terminal:
474
+ self.cancel(old, "target_replaced")
475
+ if request is None:
476
+ self._target_order = None
477
+ return None
478
+ order = self.new_order(request.qty, side=request.side, **conditions)
479
+ order.target = identity
480
+ self._target_order = order
481
+ return order
482
+
483
+ def _id(self, kind):
484
+ self._counts[kind] = self._counts.get(kind, 0) + 1
485
+ return f"{kind}-{self._counts[kind]}"
486
+
487
+ def _seq(self):
488
+ self._sequence += 1
489
+ return self._sequence
490
+
491
+ @property
492
+ def last_price(self):
493
+ return self._data._current_value("Close")
494
+
495
+ @property
496
+ def account(self):
497
+ return self.ledger.account(self.position.snapshot, self.margin)
498
+
499
+ @property
500
+ def equity(self):
501
+ price = self.last_price
502
+ return self.ledger.balance + sum(t.size * (price - t.entry_price) for t in self.trades)
503
+
504
+ @property
505
+ def margin_available(self):
506
+ return self.account.margin_available
507
+
508
+ @property
509
+ def _cash(self):
510
+ return self.ledger.balance
511
+
512
+ def _permission(self):
513
+ if self.phase not in ("next", "notify"):
514
+ raise RuntimeError("Trading commands are prohibited during this lifecycle phase")
515
+
516
+ def _event(self, order):
517
+ event = OrderEvent(self._seq(), self._i, self._index[self._i], self.phase, order.snapshot)
518
+ self.events.append(event)
519
+ self._notifications.append(event)
520
+
521
+ def _state(self, order, status, reason=None, **changes):
522
+ if order.status.terminal:
523
+ return
524
+ order._snapshot = replace(order.snapshot, status=status, reason=reason, **changes)
525
+ self._event(order)
526
+ if status.terminal:
527
+ self.orders.remove(order)
528
+ group = self.groups.get(order.group_id)
529
+ if group is not None and group.kind == "oco":
530
+ for peer in group.orders:
531
+ if peer is not order and not peer.status.terminal:
532
+ self._state(
533
+ peer,
534
+ OrderStatus.CANCELED,
535
+ "oco_peer_filled"
536
+ if status == OrderStatus.FILLED
537
+ else "oco_peer_terminated",
538
+ )
539
+
540
+ def cancel(self, order, reason):
541
+ self._state(order, OrderStatus.CANCELED, reason)
542
+
543
+ def _conditions(self, **conditions):
544
+ values = _normalize_conditions(**conditions)
545
+ for name in ("limit", "stop", "sl", "tp"):
546
+ if values[name] is not None:
547
+ self.instrument.price(values[name])
548
+ if values["expires_at"] is not None and (
549
+ not isinstance(self._index, pd.DatetimeIndex) or self._index.tz is None
550
+ ):
551
+ raise ValueError("Absolute expiration and market-data index must be timezone-aware")
552
+ return values
553
+
554
+ def _request(
555
+ self,
556
+ qty,
557
+ *,
558
+ side,
559
+ limit=None,
560
+ stop=None,
561
+ sl=None,
562
+ tp=None,
563
+ tag=None,
564
+ trade=None,
565
+ reduce_only=False,
566
+ expires_at=None,
567
+ valid_bars=None,
568
+ ):
569
+ if side not in ("buy", "sell"):
570
+ raise ValueError("side must be buy/sell")
571
+ conditions = self._conditions(
572
+ limit=limit,
573
+ stop=stop,
574
+ sl=sl,
575
+ tp=tp,
576
+ tag=tag,
577
+ reduce_only=reduce_only,
578
+ expires_at=expires_at,
579
+ valid_bars=valid_bars,
580
+ )
581
+ qty = self.instrument.quantity(qty, opening=not reduce_only)
582
+ if qty <= 0:
583
+ raise ValueError("qty must be positive")
584
+ if self._hedging and reduce_only and trade is None:
585
+ raise ValueError("Hedged reduce-only orders must reference a trade")
586
+ reference = limit or stop or self.last_price
587
+ if sl is not None and ((sl >= reference) if side == "buy" else (sl <= reference)):
588
+ raise ValueError("Invalid SL direction")
589
+ if tp is not None and ((tp <= reference) if side == "buy" else (tp >= reference)):
590
+ raise ValueError("Invalid TP direction")
591
+ return NormalizedOrderRequest(
592
+ side=side,
593
+ qty=qty,
594
+ trade_id=trade.trade_id if trade is not None else None,
595
+ **conditions,
596
+ )
597
+
598
+ def _submit(self, request, *, group_id=None, kind="entry", eligible=None, sizing=None):
599
+ order = Order(self, request, group_id=group_id, kind=kind, eligible=eligible, sizing=sizing)
600
+ self.all_orders.append(order)
601
+ if group_id is not None:
602
+ self.groups[group_id]._orders.append(order)
603
+ self.orders.append(order)
604
+ self._event(order)
605
+ if request.expires_at is not None and self._index[self._i] >= request.expires_at:
606
+ self._state(order, OrderStatus.EXPIRED, "expired")
607
+ else:
608
+ self._state(order, OrderStatus.ACCEPTED)
609
+ return order
610
+
611
+ def submit_order(self, intent, *, side, reduce_only=False, **kwargs):
612
+ self._permission()
613
+ sizing = resolve_size(
614
+ intent,
615
+ account=self.account,
616
+ reference_price=self.last_price,
617
+ instrument=self.instrument,
618
+ reduce_only=reduce_only,
619
+ )
620
+ return self.new_order(
621
+ sizing.qty, side=side, reduce_only=reduce_only, _sizing=sizing, **kwargs
622
+ )
623
+
624
+ def new_order(
625
+ self,
626
+ qty,
627
+ *,
628
+ side,
629
+ _internal=False,
630
+ _group=None,
631
+ _kind="entry",
632
+ _eligible=None,
633
+ _sizing=None,
634
+ **kwargs,
635
+ ):
636
+ if not _internal:
637
+ self._permission()
638
+ request = self._request(qty, side=side, **kwargs)
639
+ if self._exclusive_orders and not request.reduce_only and not _internal:
640
+ for order in tuple(self.orders):
641
+ if not order.request.reduce_only:
642
+ self.cancel(order, "exclusive_replaced")
643
+ self.position.close()
644
+ if _group is None and (request.sl is not None or request.tp is not None):
645
+ _group = self._id("group")
646
+ self.groups[_group] = OrderGroup(self, _group, "bracket")
647
+ if (
648
+ _eligible is None
649
+ and self.execution_timing == "same_close"
650
+ and self.phase == "next"
651
+ and request.limit is None
652
+ and request.stop is None
653
+ ):
654
+ _eligible = self._i
655
+ return self._submit(
656
+ request, group_id=_group, kind=_kind, eligible=_eligible, sizing=_sizing
657
+ )
658
+
659
+ def submit_oco(self, *, first: OrderRequest, second: OrderRequest) -> OrderGroup:
660
+ self._permission()
661
+ if not isinstance(first, OrderRequest) or not isinstance(second, OrderRequest):
662
+ raise TypeError("OCO first and second must be OrderRequest instances")
663
+ context = {
664
+ "account": self.account,
665
+ "reference_price": self.last_price,
666
+ "instrument": self.instrument,
667
+ }
668
+ prepared = []
669
+ for item in (first, second):
670
+ sizing = resolve_size(item._size, **context)
671
+ request = self._request(
672
+ qty=sizing.qty,
673
+ **{
674
+ name: getattr(item, name)
675
+ for name in (
676
+ "side",
677
+ "limit",
678
+ "stop",
679
+ "sl",
680
+ "tp",
681
+ "tag",
682
+ "expires_at",
683
+ "valid_bars",
684
+ )
685
+ },
686
+ )
687
+ prepared.append((request, sizing))
688
+ group_id = self._id("group")
689
+ group = self.groups[group_id] = OrderGroup(self, group_id, "oco")
690
+ members = [self._submit(r, group_id=group_id, sizing=s) for r, s in prepared]
691
+ if any(o.status.terminal for o in members):
692
+ group.cancel()
693
+ return group
694
+
695
+ def protect(self, trade, which, price, *, kind=None, eligible=None):
696
+ if self.phase not in ("execute", "close", "terminal"):
697
+ self._permission()
698
+ if trade not in self.trades:
699
+ raise ValueError("Protection can only be created for active lots")
700
+ if price is not None:
701
+ self.instrument.price(price)
702
+ attr = "_sl_order" if which == "sl" else "_tp_order"
703
+ old = getattr(trade, attr)
704
+ if old is not None:
705
+ self.cancel(old, "protection_replaced")
706
+ setattr(trade, attr, None)
707
+ if price is None:
708
+ return None
709
+ if trade.group_id is None:
710
+ trade.group_id = self._id("group")
711
+ self.groups[trade.group_id] = OrderGroup(self, trade.group_id, "bracket")
712
+ params = {"stop": price} if which == "sl" else {"limit": price}
713
+ order = self.new_order(
714
+ abs(trade.size),
715
+ side="sell" if trade.is_long else "buy",
716
+ trade=trade,
717
+ reduce_only=True,
718
+ tag=trade.tag,
719
+ _internal=True,
720
+ _group=trade.group_id,
721
+ _kind=kind or which,
722
+ _eligible=eligible,
723
+ **params,
724
+ )
725
+ setattr(trade, attr, order)
726
+ return order
727
+
728
+ def _expire(self):
729
+ for order in tuple(self.orders):
730
+ request = order.request
731
+ if (request.expires_at is not None and self._index[self._i] >= request.expires_at) or (
732
+ request.valid_bars is not None and self._i > order.created_bar + request.valid_bars
733
+ ):
734
+ self._state(order, OrderStatus.EXPIRED, "expired")
735
+
736
+ def _plan(self, order, qty):
737
+ candidates = [t for t in self.trades if t.size * order.size < 0]
738
+ if order.request.trade_id:
739
+ candidates = [t for t in candidates if t.trade_id == order.request.trade_id]
740
+ if self._hedging and not order.request.reduce_only:
741
+ candidates = []
742
+ plan, left = [], qty
743
+ for trade in candidates:
744
+ used = min(abs(trade.size), left)
745
+ plan.append((trade, used))
746
+ left = float(Decimal(str(left)) - Decimal(str(used)))
747
+ if left == 0:
748
+ break
749
+ return plan, left
750
+
751
+ def _execute(self, order, base, *, phase="execute", reason="execution", bounds=None):
752
+ request = order.request
753
+ qty = request.qty
754
+ plan, opening = self._plan(order, qty)
755
+ if request.reduce_only:
756
+ qty = float(Decimal(str(qty)) - Decimal(str(opening)))
757
+ opening = 0
758
+ if qty <= 0:
759
+ self.cancel(order, "no_position")
760
+ return
761
+ price = execution_price(
762
+ base,
763
+ request.side,
764
+ spread=self._spread,
765
+ slippage=self.slippage,
766
+ instrument=self.instrument,
767
+ limit=request.limit,
768
+ bounds=bounds,
769
+ )
770
+ if price is None:
771
+ return
772
+ sign = 1 if order.is_long else -1
773
+ fee = finite(self._commission(sign * qty, price), "commission")
774
+ realized = sum(copysign(used, t.size) * (price - t.entry_price) for t, used in plan)
775
+ if not request.reduce_only:
776
+ position = self.ledger.position(self.trades, base)
777
+ remaining_pl = position.unrealized_pnl_gross - sum(
778
+ copysign(used, t.size) * (base - t.entry_price) for t, used in plan
779
+ )
780
+ equity = (
781
+ self.ledger.balance
782
+ + realized
783
+ - fee
784
+ + remaining_pl
785
+ + sign * opening * (base - price)
786
+ )
787
+ gross = (position.gross_qty - sum(q for _, q in plan) + opening) * base
788
+ rejection = None
789
+ if opening and sign < 0 and not self.allow_short:
790
+ rejection = "short_not_allowed"
791
+ elif opening and opening < self.instrument.min_quantity:
792
+ rejection = "min_quantity"
793
+ elif opening and opening * price < self.instrument.min_notional:
794
+ rejection = "min_notional"
795
+ elif opening and (equity <= 0 or gross * self.margin > equity):
796
+ rejection = "insufficient_funds"
797
+ elif (
798
+ opening
799
+ and self.max_gross_exposure is not None
800
+ and gross > self.max_gross_exposure * equity
801
+ ):
802
+ rejection = "gross_exposure_limit"
803
+ if rejection:
804
+ self._state(order, OrderStatus.REJECTED, rejection)
805
+ return
806
+ if self.observations is not None:
807
+ self.observations.before(
808
+ self, base, phase, request.limit is not None or request.stop is not None
809
+ )
810
+ fill = Fill(
811
+ self._id("fill"),
812
+ order.order_id,
813
+ self._seq(),
814
+ request.side,
815
+ qty,
816
+ price,
817
+ fee,
818
+ self._i,
819
+ self._index[self._i],
820
+ phase,
821
+ reason,
822
+ )
823
+ self.fills.append(fill)
824
+ self._notifications.append(fill)
825
+ fee_left = fee
826
+ for number, (trade, used) in enumerate(plan):
827
+ allocated_fee = (
828
+ fee_left if not opening and number == len(plan) - 1 else fee * used / qty
829
+ )
830
+ fee_left -= allocated_fee
831
+ self._close_piece(trade, used, price, allocated_fee, fill)
832
+ if opening:
833
+ trade = Trade(
834
+ self,
835
+ sign * opening,
836
+ price,
837
+ self._i,
838
+ request.tag,
839
+ entry_fill_id=fill.fill_id,
840
+ entry_fee=fee_left,
841
+ group_id=order.group_id
842
+ if self.groups.get(order.group_id) is not None
843
+ and self.groups[order.group_id].kind == "bracket"
844
+ else None,
845
+ )
846
+ self.trades.append(trade)
847
+ if self.observations is not None:
848
+ self.observations.opened(
849
+ self, trade, request.limit is not None or request.stop is not None, phase
850
+ )
851
+ self.allocations.append(
852
+ TradeAllocation(
853
+ self._id("allocation"),
854
+ fill.fill_id,
855
+ trade.trade_id,
856
+ fill.fill_id,
857
+ "open",
858
+ opening,
859
+ fee_left,
860
+ 0,
861
+ 0,
862
+ )
863
+ )
864
+ eligible = (
865
+ self._i
866
+ if phase == "execute" and request.limit is None and request.stop is None
867
+ else self._i + 1
868
+ )
869
+ if request.tp is not None:
870
+ self.protect(trade, "tp", request.tp, eligible=eligible)
871
+ if request.sl is not None:
872
+ self.protect(trade, "sl", request.sl, eligible=eligible)
873
+ if eligible > self._i and (request.sl is not None or request.tp is not None):
874
+ self.diagnostics.append(
875
+ {
876
+ "code": "protection_next_bar",
877
+ "order_id": order.order_id,
878
+ "bar_index": self._i,
879
+ }
880
+ )
881
+ self.ledger.apply(realized, fee)
882
+ if self.observations is not None:
883
+ self.observations.sample(self, base, "after_" + phase)
884
+ remaining = float(Decimal(str(request.qty)) - Decimal(str(qty)))
885
+ self._state(
886
+ order,
887
+ OrderStatus.PARTIALLY_FILLED if remaining else OrderStatus.FILLED,
888
+ filled_qty=qty,
889
+ remaining_qty=remaining,
890
+ avg_fill_price=price,
891
+ )
892
+ if remaining:
893
+ self.cancel(order, "reduce_only_remainder")
894
+
895
+ def _close_piece(self, trade, qty, price, fee, fill):
896
+ original = abs(trade.size)
897
+ entry_fee = (
898
+ trade._entry_commission if qty == original else trade._entry_commission * qty / original
899
+ )
900
+ piece = Trade(
901
+ self,
902
+ copysign(qty, trade.size),
903
+ trade.entry_price,
904
+ trade.entry_bar,
905
+ trade.tag,
906
+ trade_id=trade.trade_id,
907
+ entry_fill_id=trade.entry_fill_id,
908
+ entry_fee=entry_fee,
909
+ group_id=trade.group_id,
910
+ )
911
+ piece._closed_sl, piece._closed_tp = trade.sl, trade.tp
912
+ piece.exit_price, piece.exit_bar, piece._exit_commission = price, self._i, fee
913
+ self.closed_trades.append(piece)
914
+ self.allocations.append(
915
+ TradeAllocation(
916
+ self._id("allocation"),
917
+ fill.fill_id,
918
+ trade.trade_id,
919
+ trade.entry_fill_id,
920
+ "close",
921
+ qty,
922
+ fee,
923
+ entry_fee,
924
+ piece.pl_gross,
925
+ )
926
+ )
927
+ piece.allocation_id = self.allocations[-1].allocation_id
928
+ piece.exit_fill_id = fill.fill_id
929
+ piece.exit_sequence = fill.sequence
930
+ if self.observations is not None:
931
+ self.observations.close(trade, piece.allocation_id)
932
+ previous = self._closed_stats
933
+ pnl = piece.pl
934
+ count = previous.closed_count + 1
935
+ wins = previous.win_count + int(pnl > 0)
936
+ winning = previous.winning_profit + max(pnl, 0.0)
937
+ losing = previous.losing_amount + max(-pnl, 0.0)
938
+ net = winning - losing
939
+ self._closed_stats = ClosedTradeStats(
940
+ closed_count=count,
941
+ win_count=wins,
942
+ loss_count=previous.loss_count + int(pnl < 0),
943
+ even_count=previous.even_count + int(pnl == 0),
944
+ winning_profit=winning,
945
+ losing_amount=losing,
946
+ net_profit=net,
947
+ average_profit=net / count,
948
+ win_rate=wins / count,
949
+ )
950
+ protection = [("sl", trade._sl_order), ("tp", trade._tp_order)]
951
+ trade._entry_commission -= entry_fee
952
+ if qty == original:
953
+ self.trades.remove(trade)
954
+ for _, old in protection:
955
+ if old is not None and old.order_id != fill.order_id:
956
+ self.cancel(old, "position_closed")
957
+ else:
958
+ trade._size = copysign(float(Decimal(str(original)) - Decimal(str(qty))), trade.size)
959
+ for which, old in protection:
960
+ if old is not None and not old.status.terminal:
961
+ new = self.protect(
962
+ trade,
963
+ which,
964
+ old.stop if which == "sl" else old.limit,
965
+ kind=old.kind,
966
+ eligible=old.eligible_bar,
967
+ )
968
+ new.trail = old.trail
969
+
970
+ def _protect_ambiguity(self, order, open_, high, low):
971
+ if order.kind not in ("sl", "trail"):
972
+ return
973
+ trade = order.parent_trade
974
+ if trade is not None and trade._tp_order is not None:
975
+ peer = trade._tp_order
976
+ if (
977
+ not peer.status.terminal
978
+ and peer.eligible_bar <= self._i
979
+ and match_price(peer.request, triggered=False, open_=open_, high=high, low=low)[0]
980
+ is not None
981
+ ):
982
+ self.diagnostics.append(
983
+ {
984
+ "code": "ambiguous_bracket_bar",
985
+ "order_id": order.order_id,
986
+ "bar_index": self._i,
987
+ }
988
+ )
989
+
990
+ def process(self, *, close=False):
991
+ if close and self.observations is not None:
992
+ self.observations.bar(self)
993
+ self.phase = "close" if close else "execute"
994
+ self._expire()
995
+ if not self.orders:
996
+ return
997
+ open_, high, low = (self._data._current_value(name) for name in ("Open", "High", "Low"))
998
+ candidates = sorted(self.orders, key=lambda o: (o.kind not in ("sl", "trail"), o.ordinal))
999
+ processed = set()
1000
+ for order in candidates:
1001
+ if order.status.terminal or order.eligible_bar > self._i:
1002
+ continue
1003
+ if close and (
1004
+ order.created_bar != self._i or order.limit is not None or order.stop is not None
1005
+ ):
1006
+ continue
1007
+ request = replace(order.request, stop=order.active_stop)
1008
+ base, triggered = (
1009
+ (self.last_price, False)
1010
+ if close
1011
+ else match_price(
1012
+ request, triggered=order.snapshot.triggered, open_=open_, high=high, low=low
1013
+ )
1014
+ )
1015
+ if triggered and not order.snapshot.triggered:
1016
+ order._snapshot = replace(order.snapshot, triggered=True)
1017
+ self._event(order)
1018
+ if base is None:
1019
+ continue
1020
+ group = self.groups.get(order.group_id)
1021
+ if group is not None and group.kind == "oco":
1022
+ peers = [p for p in group.orders if p is not order and not p.status.terminal]
1023
+ if any(
1024
+ match_price(
1025
+ p.request, triggered=p.snapshot.triggered, open_=open_, high=high, low=low
1026
+ )[0]
1027
+ is not None
1028
+ for p in peers
1029
+ ):
1030
+ self.diagnostics.append(
1031
+ {
1032
+ "code": "ambiguous_oco_bar",
1033
+ "order_id": order.order_id,
1034
+ "bar_index": self._i,
1035
+ }
1036
+ )
1037
+ if not close:
1038
+ self._protect_ambiguity(order, open_, high, low)
1039
+ self._execute(order, base, phase=self.phase, bounds=None if close else (low, high))
1040
+ processed.add(order.order_id)
1041
+ # Process only protection orders created by market entries in this phase; do not re-execute ordinary requests.
1042
+ if not close:
1043
+ for order in sorted(self.orders, key=lambda o: o.kind not in ("sl", "trail")):
1044
+ if (
1045
+ order.status.terminal
1046
+ or not order.is_contingent
1047
+ or order.order_id in processed
1048
+ or order.eligible_bar > self._i
1049
+ ):
1050
+ continue
1051
+ base, _ = match_price(
1052
+ order.request, triggered=False, open_=open_, high=high, low=low
1053
+ )
1054
+ if base is not None:
1055
+ self._protect_ambiguity(order, open_, high, low)
1056
+ self._execute(order, base, bounds=(low, high))
1057
+
1058
+ def update_trails(self):
1059
+ for order in tuple(self.orders):
1060
+ if order.trail is None:
1061
+ continue
1062
+ mode, value = order.trail
1063
+ distance = value if mode == "amount" else self.last_price * value
1064
+ candidate = self.last_price + (distance if order.is_long else -distance)
1065
+ candidate = self.instrument.price(candidate, side="buy" if order.is_long else "sell")
1066
+ order.active_stop = (
1067
+ min(order.active_stop, candidate)
1068
+ if order.is_long
1069
+ else max(order.active_stop, candidate)
1070
+ )
1071
+ order.eligible_bar = max(order.eligible_bar, self._i + 1)
1072
+
1073
+ def capture(self, phase="bar"):
1074
+ position = self.position.snapshot
1075
+ account = self.ledger.account(position, self.margin)
1076
+ prefix = {"bar_index": self._i, "timestamp": self._index[self._i], "phase": phase}
1077
+ account_values, position_values = record(account), record(position)
1078
+ self.account_curve.append(prefix | account_values)
1079
+ self.position_curve.append(prefix | position_values)
1080
+ if self.observations is not None:
1081
+ eligible = phase == "bar" and self.execution_timing != "same_close"
1082
+ if eligible:
1083
+ self.observations.mark_bar(self)
1084
+ self.observations.sample(
1085
+ self,
1086
+ self.last_price,
1087
+ phase,
1088
+ facts=account_values | position_values,
1089
+ range_eligible=eligible and self.observations.changed_bar != self._i,
1090
+ )
1091
+ self._equity[self._i] = account.equity
1092
+ if phase == "bar":
1093
+ self._exposure[self._i] = position.has_exposure
1094
+
1095
+ def finish(self, *, liquidate, reason="end_of_data"):
1096
+ self.phase = "terminal"
1097
+ for order in tuple(self.orders):
1098
+ self.cancel(order, reason)
1099
+ if liquidate:
1100
+ for trade in tuple(self.trades):
1101
+ order = self.new_order(
1102
+ abs(trade.size),
1103
+ side="sell" if trade.is_long else "buy",
1104
+ trade=trade,
1105
+ reduce_only=True,
1106
+ _internal=True,
1107
+ _eligible=self._i,
1108
+ )
1109
+ self._execute(
1110
+ order,
1111
+ self.last_price,
1112
+ phase="terminal",
1113
+ reason="end_liquidation" if reason == "end_of_data" else reason,
1114
+ )
1115
+ self.capture("terminal")
1116
+
1117
+ def __repr__(self):
1118
+ return f"<Broker: {self.equity:g} ({len(self.trades)} trades)>"
1119
+
1120
+
1121
+ for _type in (Position, _OutOfMoneyError, Order, Trade, _Broker):
1122
+ _set_legacy_module(_type, "openbacktesting.engine")