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.
- openbacktesting/__init__.py +85 -0
- openbacktesting/aggregation.py +8 -0
- openbacktesting/broker/__init__.py +54 -0
- openbacktesting/broker/commission.py +57 -0
- openbacktesting/broker/core.py +1122 -0
- openbacktesting/broker/execution.py +62 -0
- openbacktesting/broker/instrument.py +106 -0
- openbacktesting/broker/ledger.py +60 -0
- openbacktesting/broker/models.py +236 -0
- openbacktesting/broker/observations.py +99 -0
- openbacktesting/broker/requests.py +192 -0
- openbacktesting/broker/sizing.py +76 -0
- openbacktesting/colors.py +175 -0
- openbacktesting/data/__init__.py +20 -0
- openbacktesting/data/aggregation.py +39 -0
- openbacktesting/data/context.py +30 -0
- openbacktesting/data/core.py +218 -0
- openbacktesting/data/generators.py +54 -0
- openbacktesting/data/strategy_data.py +103 -0
- openbacktesting/data/timeframe.py +69 -0
- openbacktesting/engine.py +34 -0
- openbacktesting/helpers.py +77 -0
- openbacktesting/indicator_api.py +10 -0
- openbacktesting/indicator_methods.py +123 -0
- openbacktesting/indicator_types.py +19 -0
- openbacktesting/indicators/__init__.py +7 -0
- openbacktesting/indicators/api.py +97 -0
- openbacktesting/indicators/calculus.py +75 -0
- openbacktesting/indicators/candles/__init__.py +0 -0
- openbacktesting/indicators/candles/cdl_doji.py +17 -0
- openbacktesting/indicators/candles/cdl_engulfing.py +14 -0
- openbacktesting/indicators/candles/cdl_eveningstar.py +15 -0
- openbacktesting/indicators/candles/cdl_hammer.py +14 -0
- openbacktesting/indicators/candles/cdl_harami.py +14 -0
- openbacktesting/indicators/candles/cdl_inside.py +14 -0
- openbacktesting/indicators/candles/cdl_marubozu.py +14 -0
- openbacktesting/indicators/candles/cdl_morningstar.py +15 -0
- openbacktesting/indicators/candles/cdl_shootingstar.py +14 -0
- openbacktesting/indicators/candles/cdl_z.py +28 -0
- openbacktesting/indicators/candles/ha.py +19 -0
- openbacktesting/indicators/catalog.json +8348 -0
- openbacktesting/indicators/common.py +94 -0
- openbacktesting/indicators/cycles/__init__.py +0 -0
- openbacktesting/indicators/cycles/dsp.py +10 -0
- openbacktesting/indicators/cycles/ebsw.py +10 -0
- openbacktesting/indicators/cycles/ht_dcperiod.py +10 -0
- openbacktesting/indicators/cycles/ht_dcphase.py +10 -0
- openbacktesting/indicators/cycles/ht_phasor.py +17 -0
- openbacktesting/indicators/cycles/ht_sine.py +17 -0
- openbacktesting/indicators/cycles/ht_trendmode.py +10 -0
- openbacktesting/indicators/cycles/msw.py +19 -0
- openbacktesting/indicators/methods.py +5762 -0
- openbacktesting/indicators/momentum/__init__.py +0 -0
- openbacktesting/indicators/momentum/ac.py +9 -0
- openbacktesting/indicators/momentum/ao.py +10 -0
- openbacktesting/indicators/momentum/apo.py +10 -0
- openbacktesting/indicators/momentum/bias.py +10 -0
- openbacktesting/indicators/momentum/bop.py +10 -0
- openbacktesting/indicators/momentum/brar.py +26 -0
- openbacktesting/indicators/momentum/cci.py +10 -0
- openbacktesting/indicators/momentum/cfo.py +10 -0
- openbacktesting/indicators/momentum/cg.py +10 -0
- openbacktesting/indicators/momentum/cmo.py +10 -0
- openbacktesting/indicators/momentum/coppock.py +10 -0
- openbacktesting/indicators/momentum/crsi.py +28 -0
- openbacktesting/indicators/momentum/cti.py +10 -0
- openbacktesting/indicators/momentum/dm.py +17 -0
- openbacktesting/indicators/momentum/er.py +10 -0
- openbacktesting/indicators/momentum/eri.py +17 -0
- openbacktesting/indicators/momentum/fisher.py +17 -0
- openbacktesting/indicators/momentum/fosc.py +10 -0
- openbacktesting/indicators/momentum/gator.py +13 -0
- openbacktesting/indicators/momentum/ibs.py +7 -0
- openbacktesting/indicators/momentum/inertia.py +22 -0
- openbacktesting/indicators/momentum/kdj.py +18 -0
- openbacktesting/indicators/momentum/kst.py +30 -0
- openbacktesting/indicators/momentum/lrsi.py +10 -0
- openbacktesting/indicators/momentum/macd.py +27 -0
- openbacktesting/indicators/momentum/macdext.py +27 -0
- openbacktesting/indicators/momentum/macdfix.py +18 -0
- openbacktesting/indicators/momentum/mom.py +10 -0
- openbacktesting/indicators/momentum/pgo.py +10 -0
- openbacktesting/indicators/momentum/pmo.py +13 -0
- openbacktesting/indicators/momentum/po.py +10 -0
- openbacktesting/indicators/momentum/ppo.py +20 -0
- openbacktesting/indicators/momentum/psl.py +12 -0
- openbacktesting/indicators/momentum/pvo.py +20 -0
- openbacktesting/indicators/momentum/qqe.py +31 -0
- openbacktesting/indicators/momentum/rci.py +20 -0
- openbacktesting/indicators/momentum/rmi.py +7 -0
- openbacktesting/indicators/momentum/roc.py +10 -0
- openbacktesting/indicators/momentum/rocp.py +10 -0
- openbacktesting/indicators/momentum/rocr.py +10 -0
- openbacktesting/indicators/momentum/rocr100.py +10 -0
- openbacktesting/indicators/momentum/rsi.py +10 -0
- openbacktesting/indicators/momentum/rsx.py +10 -0
- openbacktesting/indicators/momentum/rvgi.py +26 -0
- openbacktesting/indicators/momentum/slope.py +17 -0
- openbacktesting/indicators/momentum/smc_sweep.py +18 -0
- openbacktesting/indicators/momentum/smi.py +21 -0
- openbacktesting/indicators/momentum/smi_stochastic.py +15 -0
- openbacktesting/indicators/momentum/squeeze.py +47 -0
- openbacktesting/indicators/momentum/squeeze_pro.py +53 -0
- openbacktesting/indicators/momentum/stc.py +20 -0
- openbacktesting/indicators/momentum/stoch.py +19 -0
- openbacktesting/indicators/momentum/stochf.py +19 -0
- openbacktesting/indicators/momentum/stochrsi.py +19 -0
- openbacktesting/indicators/momentum/td_seq.py +17 -0
- openbacktesting/indicators/momentum/trix.py +19 -0
- openbacktesting/indicators/momentum/trixh.py +20 -0
- openbacktesting/indicators/momentum/tsi.py +26 -0
- openbacktesting/indicators/momentum/uo.py +22 -0
- openbacktesting/indicators/momentum/vwmacd.py +20 -0
- openbacktesting/indicators/momentum/wavetrend.py +15 -0
- openbacktesting/indicators/momentum/willr.py +10 -0
- openbacktesting/indicators/overlap/__init__.py +0 -0
- openbacktesting/indicators/overlap/alligator.py +13 -0
- openbacktesting/indicators/overlap/alma.py +33 -0
- openbacktesting/indicators/overlap/anchored_vwap.py +50 -0
- openbacktesting/indicators/overlap/anchored_vwap_bands.py +13 -0
- openbacktesting/indicators/overlap/avgprice.py +10 -0
- openbacktesting/indicators/overlap/dema.py +10 -0
- openbacktesting/indicators/overlap/ema.py +12 -0
- openbacktesting/indicators/overlap/frama.py +29 -0
- openbacktesting/indicators/overlap/fwma.py +10 -0
- openbacktesting/indicators/overlap/hilo.py +26 -0
- openbacktesting/indicators/overlap/hl2.py +10 -0
- openbacktesting/indicators/overlap/hlc3.py +10 -0
- openbacktesting/indicators/overlap/hma.py +10 -0
- openbacktesting/indicators/overlap/ht_trendline.py +10 -0
- openbacktesting/indicators/overlap/hwma.py +10 -0
- openbacktesting/indicators/overlap/ichimoku.py +44 -0
- openbacktesting/indicators/overlap/jma.py +10 -0
- openbacktesting/indicators/overlap/kama.py +10 -0
- openbacktesting/indicators/overlap/linreg.py +15 -0
- openbacktesting/indicators/overlap/linregangle.py +10 -0
- openbacktesting/indicators/overlap/linregintercept.py +10 -0
- openbacktesting/indicators/overlap/linregslope.py +10 -0
- openbacktesting/indicators/overlap/ma.py +10 -0
- openbacktesting/indicators/overlap/ma_envelopes.py +16 -0
- openbacktesting/indicators/overlap/mama.py +17 -0
- openbacktesting/indicators/overlap/mavp.py +17 -0
- openbacktesting/indicators/overlap/mcgd.py +10 -0
- openbacktesting/indicators/overlap/medprice.py +10 -0
- openbacktesting/indicators/overlap/midpoint.py +10 -0
- openbacktesting/indicators/overlap/midprice.py +10 -0
- openbacktesting/indicators/overlap/mmar.py +21 -0
- openbacktesting/indicators/overlap/ohlc4.py +10 -0
- openbacktesting/indicators/overlap/pwma.py +10 -0
- openbacktesting/indicators/overlap/rainbow.py +25 -0
- openbacktesting/indicators/overlap/rma.py +10 -0
- openbacktesting/indicators/overlap/sinwma.py +10 -0
- openbacktesting/indicators/overlap/sma.py +14 -0
- openbacktesting/indicators/overlap/ssf.py +10 -0
- openbacktesting/indicators/overlap/supertrend.py +21 -0
- openbacktesting/indicators/overlap/swma.py +10 -0
- openbacktesting/indicators/overlap/t3.py +10 -0
- openbacktesting/indicators/overlap/tema.py +10 -0
- openbacktesting/indicators/overlap/trima.py +10 -0
- openbacktesting/indicators/overlap/tsf.py +10 -0
- openbacktesting/indicators/overlap/typprice.py +10 -0
- openbacktesting/indicators/overlap/vidya.py +10 -0
- openbacktesting/indicators/overlap/vwap.py +10 -0
- openbacktesting/indicators/overlap/vwma.py +10 -0
- openbacktesting/indicators/overlap/wcp.py +10 -0
- openbacktesting/indicators/overlap/wma.py +10 -0
- openbacktesting/indicators/overlap/zlma.py +10 -0
- openbacktesting/indicators/performance/__init__.py +0 -0
- openbacktesting/indicators/performance/drawdown.py +18 -0
- openbacktesting/indicators/performance/log_return.py +10 -0
- openbacktesting/indicators/performance/percent_return.py +10 -0
- openbacktesting/indicators/predicates.py +88 -0
- openbacktesting/indicators/registry.py +571 -0
- openbacktesting/indicators/runtime.py +290 -0
- openbacktesting/indicators/statistics/__init__.py +0 -0
- openbacktesting/indicators/statistics/beta.py +10 -0
- openbacktesting/indicators/statistics/correl.py +10 -0
- openbacktesting/indicators/statistics/entropy.py +10 -0
- openbacktesting/indicators/statistics/kurtosis.py +10 -0
- openbacktesting/indicators/statistics/mad.py +10 -0
- openbacktesting/indicators/statistics/md.py +10 -0
- openbacktesting/indicators/statistics/median.py +10 -0
- openbacktesting/indicators/statistics/mode.py +48 -0
- openbacktesting/indicators/statistics/quantile.py +10 -0
- openbacktesting/indicators/statistics/skew.py +10 -0
- openbacktesting/indicators/statistics/stderr.py +10 -0
- openbacktesting/indicators/statistics/stdev.py +10 -0
- openbacktesting/indicators/statistics/variance.py +10 -0
- openbacktesting/indicators/statistics/zscore.py +10 -0
- openbacktesting/indicators/trend/__init__.py +0 -0
- openbacktesting/indicators/trend/adx.py +28 -0
- openbacktesting/indicators/trend/adxr.py +28 -0
- openbacktesting/indicators/trend/amat.py +19 -0
- openbacktesting/indicators/trend/aroon.py +18 -0
- openbacktesting/indicators/trend/atr_stop.py +33 -0
- openbacktesting/indicators/trend/chop.py +20 -0
- openbacktesting/indicators/trend/cksp.py +17 -0
- openbacktesting/indicators/trend/cpr.py +57 -0
- openbacktesting/indicators/trend/decay.py +10 -0
- openbacktesting/indicators/trend/decreasing.py +18 -0
- openbacktesting/indicators/trend/dpo.py +12 -0
- openbacktesting/indicators/trend/dx.py +19 -0
- openbacktesting/indicators/trend/edecay.py +10 -0
- openbacktesting/indicators/trend/increasing.py +18 -0
- openbacktesting/indicators/trend/long_run.py +10 -0
- openbacktesting/indicators/trend/minus_dm.py +10 -0
- openbacktesting/indicators/trend/pivot_points.py +89 -0
- openbacktesting/indicators/trend/pivothigh.py +23 -0
- openbacktesting/indicators/trend/pivotlow.py +23 -0
- openbacktesting/indicators/trend/plus_dm.py +10 -0
- openbacktesting/indicators/trend/pmax.py +18 -0
- openbacktesting/indicators/trend/psar.py +19 -0
- openbacktesting/indicators/trend/qstick.py +10 -0
- openbacktesting/indicators/trend/sarext.py +35 -0
- openbacktesting/indicators/trend/short_run.py +10 -0
- openbacktesting/indicators/trend/tsignals.py +26 -0
- openbacktesting/indicators/trend/ttm_trend.py +16 -0
- openbacktesting/indicators/trend/vhf.py +10 -0
- openbacktesting/indicators/trend/volatility_stop.py +33 -0
- openbacktesting/indicators/trend/vortex.py +17 -0
- openbacktesting/indicators/trend/xsignals.py +29 -0
- openbacktesting/indicators/types.py +344 -0
- openbacktesting/indicators/utility/__init__.py +0 -0
- openbacktesting/indicators/utility/barssince.py +21 -0
- openbacktesting/indicators/utility/change.py +7 -0
- openbacktesting/indicators/utility/cross.py +12 -0
- openbacktesting/indicators/utility/crossover.py +11 -0
- openbacktesting/indicators/utility/crossunder.py +11 -0
- openbacktesting/indicators/utility/cum.py +7 -0
- openbacktesting/indicators/utility/falling.py +10 -0
- openbacktesting/indicators/utility/highest.py +7 -0
- openbacktesting/indicators/utility/highestbars.py +29 -0
- openbacktesting/indicators/utility/lowest.py +7 -0
- openbacktesting/indicators/utility/lowestbars.py +29 -0
- openbacktesting/indicators/utility/percentile_linear_interpolation.py +12 -0
- openbacktesting/indicators/utility/percentile_nearest_rank.py +16 -0
- openbacktesting/indicators/utility/percentrank.py +7 -0
- openbacktesting/indicators/utility/range.py +8 -0
- openbacktesting/indicators/utility/rising.py +10 -0
- openbacktesting/indicators/utility/rolling_count.py +12 -0
- openbacktesting/indicators/utility/rolling_sum.py +7 -0
- openbacktesting/indicators/utility/running_max.py +7 -0
- openbacktesting/indicators/utility/running_min.py +7 -0
- openbacktesting/indicators/utility/shift.py +7 -0
- openbacktesting/indicators/utility/valuewhen.py +25 -0
- openbacktesting/indicators/volatility/__init__.py +0 -0
- openbacktesting/indicators/volatility/aberration.py +21 -0
- openbacktesting/indicators/volatility/accbands.py +20 -0
- openbacktesting/indicators/volatility/adr.py +7 -0
- openbacktesting/indicators/volatility/atr.py +12 -0
- openbacktesting/indicators/volatility/avolume.py +10 -0
- openbacktesting/indicators/volatility/bbands.py +20 -0
- openbacktesting/indicators/volatility/ce.py +25 -0
- openbacktesting/indicators/volatility/cvi.py +10 -0
- openbacktesting/indicators/volatility/donchian.py +20 -0
- openbacktesting/indicators/volatility/hvol.py +10 -0
- openbacktesting/indicators/volatility/hwc.py +27 -0
- openbacktesting/indicators/volatility/kc.py +27 -0
- openbacktesting/indicators/volatility/massi.py +10 -0
- openbacktesting/indicators/volatility/natr.py +19 -0
- openbacktesting/indicators/volatility/pdist.py +10 -0
- openbacktesting/indicators/volatility/rvi.py +21 -0
- openbacktesting/indicators/volatility/starc.py +11 -0
- openbacktesting/indicators/volatility/thermo.py +28 -0
- openbacktesting/indicators/volatility/true_range.py +31 -0
- openbacktesting/indicators/volatility/ui.py +10 -0
- openbacktesting/indicators/volume/__init__.py +0 -0
- openbacktesting/indicators/volume/ad.py +10 -0
- openbacktesting/indicators/volume/adosc.py +19 -0
- openbacktesting/indicators/volume/aobv.py +31 -0
- openbacktesting/indicators/volume/cmf.py +12 -0
- openbacktesting/indicators/volume/efi.py +12 -0
- openbacktesting/indicators/volume/emv.py +10 -0
- openbacktesting/indicators/volume/eom.py +19 -0
- openbacktesting/indicators/volume/iii.py +9 -0
- openbacktesting/indicators/volume/kvo.py +28 -0
- openbacktesting/indicators/volume/marketfi.py +10 -0
- openbacktesting/indicators/volume/mfi.py +12 -0
- openbacktesting/indicators/volume/nvi.py +26 -0
- openbacktesting/indicators/volume/obv.py +10 -0
- openbacktesting/indicators/volume/pvi.py +26 -0
- openbacktesting/indicators/volume/pvol.py +10 -0
- openbacktesting/indicators/volume/pvr.py +10 -0
- openbacktesting/indicators/volume/pvt.py +10 -0
- openbacktesting/indicators/volume/rvol.py +8 -0
- openbacktesting/indicators/volume/vfi.py +18 -0
- openbacktesting/indicators/volume/vosc.py +10 -0
- openbacktesting/indicators/volume/wad.py +10 -0
- openbacktesting/indicators/volume/wvad.py +9 -0
- openbacktesting/inputs.py +51 -0
- openbacktesting/lib.py +31 -0
- openbacktesting/logs.py +129 -0
- openbacktesting/optimization.py +8 -0
- openbacktesting/optimizer/__init__.py +25 -0
- openbacktesting/optimizer/_semantics.py +155 -0
- openbacktesting/optimizer/identity.py +45 -0
- openbacktesting/optimizer/models.py +92 -0
- openbacktesting/optimizer/plan.py +129 -0
- openbacktesting/optimizer/portfolio-result.schema.json +4984 -0
- openbacktesting/optimizer/query.py +257 -0
- openbacktesting/optimizer/runner.py +98 -0
- openbacktesting/optimizer/storage.py +261 -0
- openbacktesting/optimizer/validation.py +64 -0
- openbacktesting/parallel.py +9 -0
- openbacktesting/plotting/__init__.py +23 -0
- openbacktesting/plotting/collector.py +509 -0
- openbacktesting/plotting/encoding.py +102 -0
- openbacktesting/plotting/types.py +92 -0
- openbacktesting/portfolio/__init__.py +15 -0
- openbacktesting/portfolio/analysis.py +245 -0
- openbacktesting/portfolio/builder.py +381 -0
- openbacktesting/portfolio/catalog.json +1404 -0
- openbacktesting/portfolio/helpers.py +92 -0
- openbacktesting/portfolio/metrics.py +186 -0
- openbacktesting/portfolio/models.py +63 -0
- openbacktesting/portfolio/periods.py +164 -0
- openbacktesting/portfolio/rates.py +14 -0
- openbacktesting/portfolio/snapshot.py +111 -0
- openbacktesting/portfolio/stats.py +272 -0
- openbacktesting/portfolio/trades.py +136 -0
- openbacktesting/portfolio/trading.py +215 -0
- openbacktesting/py.typed +0 -0
- openbacktesting/runtime.py +330 -0
- openbacktesting/simulation/__init__.py +17 -0
- openbacktesting/simulation/collection.py +13 -0
- openbacktesting/simulation/config.py +241 -0
- openbacktesting/simulation/core.py +528 -0
- openbacktesting/simulation/optimization.py +326 -0
- openbacktesting/simulation/parallel.py +145 -0
- openbacktesting/simulation/variants.py +107 -0
- openbacktesting/strategy/__init__.py +19 -0
- openbacktesting/strategy/bar_state.py +14 -0
- openbacktesting/strategy/base.py +810 -0
- openbacktesting/strategy/declaration.py +90 -0
- openbacktesting/strategy/helpers.py +198 -0
- openbacktesting/strategy/inputs.py +460 -0
- openbacktesting/strategy/sizing.py +80 -0
- openbacktesting/strategy/templates.py +160 -0
- openbacktesting/strategy_data.py +10 -0
- openbacktesting/test/BTCUSD.csv +157 -0
- openbacktesting/test/EURUSD.csv +5001 -0
- openbacktesting/test/GOOG.csv +2149 -0
- openbacktesting/test/__init__.py +29 -0
- openbacktesting/test/__main__.py +11 -0
- openbacktesting/test/_test.py +1312 -0
- openbacktesting/util.py +31 -0
- openbacktesting-0.2.0.dist-info/METADATA +151 -0
- openbacktesting-0.2.0.dist-info/RECORD +350 -0
- openbacktesting-0.2.0.dist-info/WHEEL +4 -0
- 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")
|