quantex 0.4.7__tar.gz → 0.4.9__tar.gz
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.
- {quantex-0.4.7 → quantex-0.4.9}/PKG-INFO +1 -1
- {quantex-0.4.7 → quantex-0.4.9}/pyproject.toml +1 -1
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/backtester/backtester.py +32 -2
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/backtester/metrics.py +6 -1
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/backtester/montecarlo.py +4 -0
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/backtester/parallel.py +5 -1
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/backtester/reports.py +8 -2
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/broker/broker.py +300 -129
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/strategy.py +3 -1
- {quantex-0.4.7 → quantex-0.4.9}/LICENSE.md +0 -0
- {quantex-0.4.7 → quantex-0.4.9}/README.md +0 -0
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/__init__.py +0 -0
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/backtester/__init__.py +0 -0
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/backtester/constants.py +0 -0
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/backtester/data_splits.py +0 -0
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/broker/__init__.py +0 -0
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/broker/types.py +0 -0
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/datasource.py +0 -0
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/helpers.py +0 -0
- {quantex-0.4.7 → quantex-0.4.9}/src/quantex/indicators.py +0 -0
|
@@ -38,6 +38,7 @@ class SimpleBacktester:
|
|
|
38
38
|
- Position management
|
|
39
39
|
- Margin calls
|
|
40
40
|
- P&L tracking
|
|
41
|
+
- Leverage for amplified position sizing
|
|
41
42
|
|
|
42
43
|
Example:
|
|
43
44
|
>>> from quantex import SimpleBacktester, CSVDataSource
|
|
@@ -54,7 +55,8 @@ class SimpleBacktester:
|
|
|
54
55
|
commission: float = 0.002,
|
|
55
56
|
commission_type: CommissionType = CommissionType.PERCENTAGE,
|
|
56
57
|
lot_size: int = 1,
|
|
57
|
-
margin_call: float = 0.5 ## 50% of the cash lost
|
|
58
|
+
margin_call: float = 0.5, ## 50% of the cash lost
|
|
59
|
+
leverage: float = 1.0 ## Leverage multiplier (1.0 = no leverage)
|
|
58
60
|
):
|
|
59
61
|
"""
|
|
60
62
|
Initialize the backtester with strategy and configuration parameters.
|
|
@@ -70,16 +72,25 @@ class SimpleBacktester:
|
|
|
70
72
|
lot_size (int, optional): Size of trading lots. Defaults to 1.
|
|
71
73
|
margin_call (float, optional): Margin call threshold as fraction of
|
|
72
74
|
cash value. Defaults to 0.5 (50%).
|
|
75
|
+
leverage (float, optional): Leverage multiplier for position sizing.
|
|
76
|
+
Defaults to 1.0 (no leverage). For example:
|
|
77
|
+
- 2.0 = 2x leverage (control 2x the position with same cash)
|
|
78
|
+
- 0.5 = half leverage (control half the position)
|
|
73
79
|
|
|
74
80
|
Raises:
|
|
75
81
|
ValueError: If strategy is None or commission rate is negative.
|
|
76
82
|
"""
|
|
83
|
+
if leverage < 0.1:
|
|
84
|
+
raise ValueError("leverage must be at least 0.1")
|
|
85
|
+
if leverage > 100:
|
|
86
|
+
raise ValueError("leverage cannot exceed 100")
|
|
77
87
|
self.strategy = copy.deepcopy(strategy)
|
|
78
88
|
self.cash = cash
|
|
79
89
|
self.commission = commission
|
|
80
90
|
self.commission_type = commission_type
|
|
81
91
|
self.lot_size = lot_size
|
|
82
92
|
self.margin_call = margin_call
|
|
93
|
+
self.leverage = leverage
|
|
83
94
|
source = self.strategy.positions[list(self.strategy.positions.keys())[0]].source
|
|
84
95
|
self.PnLRecord = np.zeros(len(source.data['Close']), dtype=np.float64)
|
|
85
96
|
|
|
@@ -140,6 +151,7 @@ class SimpleBacktester:
|
|
|
140
151
|
broker.cash = per_position_cash
|
|
141
152
|
broker.lot_size = self.lot_size
|
|
142
153
|
broker.margin_call = self.margin_call
|
|
154
|
+
broker.leverage = self.leverage
|
|
143
155
|
broker.commision = np.float64(self.commission)
|
|
144
156
|
broker.commision_type = self.commission_type
|
|
145
157
|
|
|
@@ -302,6 +314,7 @@ class SimpleBacktester:
|
|
|
302
314
|
commission=self.commission,
|
|
303
315
|
commission_type=self.commission_type,
|
|
304
316
|
lot_size=self.lot_size,
|
|
317
|
+
leverage=self.leverage,
|
|
305
318
|
)
|
|
306
319
|
report = bt.run(progress_bar=False)
|
|
307
320
|
|
|
@@ -504,6 +517,7 @@ class SimpleBacktester:
|
|
|
504
517
|
self.commission,
|
|
505
518
|
self.commission_type,
|
|
506
519
|
self.lot_size,
|
|
520
|
+
self.leverage,
|
|
507
521
|
),
|
|
508
522
|
) as exe:
|
|
509
523
|
# map the worker over param item tuples
|
|
@@ -561,6 +575,7 @@ class SimpleBacktester:
|
|
|
561
575
|
commission=self.commission,
|
|
562
576
|
commission_type=self.commission_type,
|
|
563
577
|
lot_size=self.lot_size,
|
|
578
|
+
leverage=self.leverage,
|
|
564
579
|
)
|
|
565
580
|
best_report = bt.run(progress_bar=False)
|
|
566
581
|
|
|
@@ -589,6 +604,7 @@ class SimpleBacktester:
|
|
|
589
604
|
random_seed: int | None = None,
|
|
590
605
|
workers: int | None = None,
|
|
591
606
|
progress_bar: bool = True,
|
|
607
|
+
verbose: bool = False,
|
|
592
608
|
) -> OptimizationResult:
|
|
593
609
|
"""
|
|
594
610
|
Optimize strategy parameters using Optuna (Bayesian optimization).
|
|
@@ -624,6 +640,8 @@ class SimpleBacktester:
|
|
|
624
640
|
workers (int | None, optional): Number of parallel workers for Optuna
|
|
625
641
|
study. Defaults to None (sequential).
|
|
626
642
|
progress_bar (bool, optional): Whether to show progress bar. Defaults to True.
|
|
643
|
+
verbose (bool, optional): Whether to show Optuna trial logs. Defaults to False
|
|
644
|
+
(suppresses verbose output).
|
|
627
645
|
|
|
628
646
|
Returns:
|
|
629
647
|
OptimizationResult: Object containing:
|
|
@@ -657,6 +675,10 @@ class SimpleBacktester:
|
|
|
657
675
|
"""
|
|
658
676
|
try:
|
|
659
677
|
import optuna
|
|
678
|
+
# Control optuna verbosity - logs every trial by default
|
|
679
|
+
optuna.logging.set_verbosity(
|
|
680
|
+
optuna.logging.INFO if verbose else optuna.logging.WARNING
|
|
681
|
+
)
|
|
660
682
|
except ImportError:
|
|
661
683
|
raise ImportError(
|
|
662
684
|
"optuna is required for optimize_optuna. "
|
|
@@ -679,6 +701,7 @@ class SimpleBacktester:
|
|
|
679
701
|
commission: float,
|
|
680
702
|
commission_type: CommissionType,
|
|
681
703
|
lot_size: int,
|
|
704
|
+
leverage: float,
|
|
682
705
|
objective: str,
|
|
683
706
|
risk_tolerance: dict[str, float] | None,
|
|
684
707
|
constraint: Callable[[dict[str, Any]], bool] | None,
|
|
@@ -750,6 +773,7 @@ class SimpleBacktester:
|
|
|
750
773
|
commission=commission,
|
|
751
774
|
commission_type=commission_type,
|
|
752
775
|
lot_size=lot_size,
|
|
776
|
+
leverage=leverage,
|
|
753
777
|
)
|
|
754
778
|
report = bt.run(progress_bar=False)
|
|
755
779
|
|
|
@@ -790,6 +814,7 @@ class SimpleBacktester:
|
|
|
790
814
|
commission=self.commission,
|
|
791
815
|
commission_type=self.commission_type,
|
|
792
816
|
lot_size=self.lot_size,
|
|
817
|
+
leverage=self.leverage,
|
|
793
818
|
objective=objective,
|
|
794
819
|
risk_tolerance=risk_tolerance,
|
|
795
820
|
constraint=constraint,
|
|
@@ -843,6 +868,7 @@ class SimpleBacktester:
|
|
|
843
868
|
commission=self.commission,
|
|
844
869
|
commission_type=self.commission_type,
|
|
845
870
|
lot_size=self.lot_size,
|
|
871
|
+
leverage=self.leverage,
|
|
846
872
|
)
|
|
847
873
|
best_report = bt.run(progress_bar=False)
|
|
848
874
|
best_metrics = _compute_backtest_metrics(best_report)
|
|
@@ -1024,6 +1050,7 @@ class SimpleBacktester:
|
|
|
1024
1050
|
commission=self.commission,
|
|
1025
1051
|
commission_type=self.commission_type,
|
|
1026
1052
|
lot_size=self.lot_size,
|
|
1053
|
+
leverage=self.leverage,
|
|
1027
1054
|
)
|
|
1028
1055
|
report = bt.run(progress_bar=False)
|
|
1029
1056
|
metrics = _compute_backtest_metrics(report)
|
|
@@ -1086,7 +1113,7 @@ class SimpleBacktester:
|
|
|
1086
1113
|
best_params = {}
|
|
1087
1114
|
best_validate_score = -np.inf
|
|
1088
1115
|
|
|
1089
|
-
|
|
1116
|
+
# Get full reports for best parameters
|
|
1090
1117
|
train_report = None
|
|
1091
1118
|
validate_report = None
|
|
1092
1119
|
test_report = None
|
|
@@ -1108,6 +1135,7 @@ class SimpleBacktester:
|
|
|
1108
1135
|
commission=self.commission,
|
|
1109
1136
|
commission_type=self.commission_type,
|
|
1110
1137
|
lot_size=self.lot_size,
|
|
1138
|
+
leverage=self.leverage,
|
|
1111
1139
|
)
|
|
1112
1140
|
report = bt.run(progress_bar=False)
|
|
1113
1141
|
metrics = _compute_backtest_metrics(report)
|
|
@@ -1302,6 +1330,7 @@ class SimpleBacktester:
|
|
|
1302
1330
|
commission=self.commission,
|
|
1303
1331
|
commission_type=self.commission_type,
|
|
1304
1332
|
lot_size=self.lot_size,
|
|
1333
|
+
leverage=self.leverage,
|
|
1305
1334
|
)
|
|
1306
1335
|
report = bt.run(progress_bar=False)
|
|
1307
1336
|
metrics = _compute_backtest_metrics(report)
|
|
@@ -1425,6 +1454,7 @@ class SimpleBacktester:
|
|
|
1425
1454
|
commission=self.commission,
|
|
1426
1455
|
commission_type=self.commission_type,
|
|
1427
1456
|
lot_size=self.lot_size,
|
|
1457
|
+
leverage=self.leverage,
|
|
1428
1458
|
)
|
|
1429
1459
|
report = bt.run(progress_bar=False)
|
|
1430
1460
|
metrics = _compute_backtest_metrics(report)
|
|
@@ -76,7 +76,12 @@ def _compute_backtest_metrics(report: BacktestReport) -> dict[str, Any]:
|
|
|
76
76
|
annual_rf = report.annual_rf
|
|
77
77
|
rf_per_period = annual_rf / report.periods_per_year
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
# If margin calls occurred, the backtest is invalid - position was forcibly closed
|
|
80
|
+
# This means the strategy was using too much leverage and the Sharpe is meaningless
|
|
81
|
+
margin_calls = len(report.margin_call_events or [])
|
|
82
|
+
if margin_calls > 0:
|
|
83
|
+
sharpe = float("nan")
|
|
84
|
+
elif len(returns) < 2 or returns.std(ddof=1) == 0:
|
|
80
85
|
sharpe = float("nan")
|
|
81
86
|
else:
|
|
82
87
|
excess = returns - rf_per_period
|
|
@@ -462,6 +462,7 @@ def _run_price_path_simulation(
|
|
|
462
462
|
commission: float,
|
|
463
463
|
commission_type,
|
|
464
464
|
lot_size: int,
|
|
465
|
+
leverage: float = 1.0,
|
|
465
466
|
seed: int | None = None,
|
|
466
467
|
) -> pd.Series:
|
|
467
468
|
"""
|
|
@@ -477,6 +478,7 @@ def _run_price_path_simulation(
|
|
|
477
478
|
commission (float): Commission rate.
|
|
478
479
|
commission_type: Commission type enum.
|
|
479
480
|
lot_size (int): Lot size for trading.
|
|
481
|
+
leverage (float): Leverage multiplier for position sizing.
|
|
480
482
|
seed (int | None): Random seed for reproducibility.
|
|
481
483
|
|
|
482
484
|
Returns:
|
|
@@ -581,6 +583,7 @@ def _run_price_path_simulation(
|
|
|
581
583
|
commission=commission,
|
|
582
584
|
commission_type=commission_type,
|
|
583
585
|
lot_size=lot_size,
|
|
586
|
+
leverage=leverage,
|
|
584
587
|
)
|
|
585
588
|
report = bt.run(progress_bar=False)
|
|
586
589
|
|
|
@@ -705,6 +708,7 @@ def monte_carlo(
|
|
|
705
708
|
self.commission,
|
|
706
709
|
self.commission_type,
|
|
707
710
|
self.lot_size,
|
|
711
|
+
leverage=self.leverage,
|
|
708
712
|
seed=iter_seed,
|
|
709
713
|
)
|
|
710
714
|
equity_curves.append(curve)
|
|
@@ -20,7 +20,8 @@ def _worker_init(
|
|
|
20
20
|
cash: float,
|
|
21
21
|
commision: float,
|
|
22
22
|
commision_type: Any,
|
|
23
|
-
lot_size: int
|
|
23
|
+
lot_size: int,
|
|
24
|
+
leverage: float,
|
|
24
25
|
) -> None:
|
|
25
26
|
"""
|
|
26
27
|
Initializer for worker processes in parallel optimization.
|
|
@@ -35,6 +36,7 @@ def _worker_init(
|
|
|
35
36
|
commision (float): Commission rate for trades.
|
|
36
37
|
commision_type: Type of commission calculation (CommissionType enum).
|
|
37
38
|
lot_size (int): Size of trading lots.
|
|
39
|
+
leverage (float): Leverage multiplier for position sizing.
|
|
38
40
|
|
|
39
41
|
Note:
|
|
40
42
|
This function is designed to be called by worker processes
|
|
@@ -47,6 +49,7 @@ def _worker_init(
|
|
|
47
49
|
"commision": commision,
|
|
48
50
|
"commision_type": commision_type,
|
|
49
51
|
"lot_size": lot_size,
|
|
52
|
+
"leverage": leverage,
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
|
|
@@ -176,6 +179,7 @@ def _worker_eval(param_items: tuple[tuple[str, Any], ...]) -> dict[str, Any]:
|
|
|
176
179
|
commission=config["commision"],
|
|
177
180
|
commission_type=config["commision_type"],
|
|
178
181
|
lot_size=config["lot_size"],
|
|
182
|
+
leverage=config["leverage"],
|
|
179
183
|
)
|
|
180
184
|
report = bt.run(progress_bar=False)
|
|
181
185
|
|
|
@@ -178,7 +178,14 @@ class BacktestReport:
|
|
|
178
178
|
# Risk-free per period from an annual rate
|
|
179
179
|
rf_per_period = self.annual_rf / self.periods_per_year
|
|
180
180
|
|
|
181
|
-
|
|
181
|
+
# If margin calls occurred, the backtest is invalid - position was forcibly closed
|
|
182
|
+
# This means the strategy was using too much leverage and the Sharpe is meaningless
|
|
183
|
+
margin_calls = len(self.margin_call_events or [])
|
|
184
|
+
if margin_calls > 0:
|
|
185
|
+
sharpe = np.nan
|
|
186
|
+
lo = np.nan
|
|
187
|
+
hi = np.nan
|
|
188
|
+
elif len(returns) < 2 or returns.std(ddof=1) == 0:
|
|
182
189
|
sharpe = np.nan
|
|
183
190
|
lo = np.nan
|
|
184
191
|
hi = np.nan
|
|
@@ -203,7 +210,6 @@ class BacktestReport:
|
|
|
203
210
|
tot_return = float(equity.iloc[-1] / equity.iloc[0] - 1.0)
|
|
204
211
|
annualized_return = float((1.0 + tot_return) ** (self.periods_per_year / max(len(returns), 1)) - 1.0)
|
|
205
212
|
tot_orders = len(self.orders)
|
|
206
|
-
margin_calls = len(self.margin_call_events or [])
|
|
207
213
|
|
|
208
214
|
return (
|
|
209
215
|
f"Starting Cash: ${self.starting_cash:,.2f}\n"
|
|
@@ -47,11 +47,20 @@ class Broker:
|
|
|
47
47
|
|
|
48
48
|
The broker manages:
|
|
49
49
|
- Current position and average price
|
|
50
|
-
- Cash management
|
|
50
|
+
- Cash/balance management (realized P&L + commissions)
|
|
51
51
|
- Order execution (market and limit orders)
|
|
52
52
|
- Commission calculations
|
|
53
53
|
- Stop loss and take profit order management
|
|
54
54
|
- P&L record tracking
|
|
55
|
+
- Leverage for amplified position sizing
|
|
56
|
+
- Margin tracking separate from equity
|
|
57
|
+
|
|
58
|
+
Accounting Model:
|
|
59
|
+
- balance: Realized account balance after P&L and commissions
|
|
60
|
+
- used_margin = abs(position) * mark_price / leverage
|
|
61
|
+
- unrealized_pnl = position * (mark_price - position_avg_price)
|
|
62
|
+
- equity = balance + unrealized_pnl
|
|
63
|
+
- free_cash = equity - used_margin
|
|
55
64
|
|
|
56
65
|
Example:
|
|
57
66
|
>>> source = CSVDataSource("data.csv")
|
|
@@ -73,7 +82,8 @@ class Broker:
|
|
|
73
82
|
self.commision: np.float64 = np.float64(0.002)
|
|
74
83
|
self.commision_type: CommissionType = CommissionType.PERCENTAGE
|
|
75
84
|
self.lot_size: int = 1
|
|
76
|
-
self.margin_call: float = 0.5
|
|
85
|
+
self.margin_call: float = 0.5 # Margin call threshold (50% of used margin)
|
|
86
|
+
self.leverage: float = 1.0 # Leverage multiplier (1.0 = no leverage)
|
|
77
87
|
self.share_decimals = 1
|
|
78
88
|
self.orders: list[Order] = []
|
|
79
89
|
self.complete_orders = []
|
|
@@ -86,6 +96,60 @@ class Broker:
|
|
|
86
96
|
self.PnLRecord = np.full(len(self.source.data['Close']), self.cash, dtype=np.float64)
|
|
87
97
|
self.cashRecord = []
|
|
88
98
|
|
|
99
|
+
def _get_equity(self) -> np.float64:
|
|
100
|
+
"""
|
|
101
|
+
Calculate current equity based on realized balance and unrealized P&L.
|
|
102
|
+
|
|
103
|
+
Equity is always: balance + unrealized_pnl
|
|
104
|
+
This is consistent regardless of leverage.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
np.float64: Current equity value.
|
|
108
|
+
"""
|
|
109
|
+
unrealized_pnl = self._get_unrealized_pnl()
|
|
110
|
+
return self.cash + unrealized_pnl
|
|
111
|
+
|
|
112
|
+
def _get_unrealized_pnl(self) -> np.float64:
|
|
113
|
+
"""
|
|
114
|
+
Calculate unrealized P&L based on current position and entry price.
|
|
115
|
+
|
|
116
|
+
unrealized_pnl = position * (mark_price - position_avg_price)
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
np.float64: Unrealized P&L, or 0 if position is zero.
|
|
120
|
+
"""
|
|
121
|
+
if self.position == 0:
|
|
122
|
+
return np.float64(0)
|
|
123
|
+
mark_price = self.source.CClose
|
|
124
|
+
return self.position * (mark_price - self.position_avg_price)
|
|
125
|
+
|
|
126
|
+
def _get_used_margin(self) -> np.float64:
|
|
127
|
+
"""
|
|
128
|
+
Calculate margin currently used by open positions.
|
|
129
|
+
|
|
130
|
+
used_margin = abs(position) * mark_price / leverage
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
np.float64: Margin used by current position.
|
|
134
|
+
"""
|
|
135
|
+
if self.position == 0:
|
|
136
|
+
return np.float64(0)
|
|
137
|
+
mark_price = self.source.CClose
|
|
138
|
+
return abs(self.position) * mark_price / self.leverage
|
|
139
|
+
|
|
140
|
+
def _get_free_cash(self) -> np.float64:
|
|
141
|
+
"""
|
|
142
|
+
Calculate free cash available for new positions.
|
|
143
|
+
|
|
144
|
+
free_cash = equity - used_margin
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
np.float64: Free cash available.
|
|
148
|
+
"""
|
|
149
|
+
equity = self._get_equity()
|
|
150
|
+
used_margin = self._get_used_margin()
|
|
151
|
+
return equity - used_margin
|
|
152
|
+
|
|
89
153
|
def _enqueue_order(self, order: Order) -> None:
|
|
90
154
|
"""
|
|
91
155
|
Add an order while keeping the active-order queue compact.
|
|
@@ -154,7 +218,11 @@ class Broker:
|
|
|
154
218
|
during the next iteration.
|
|
155
219
|
- Market orders execute immediately at current open price.
|
|
156
220
|
- Limit orders only execute when price reaches the specified level.
|
|
157
|
-
|
|
221
|
+
- Leverage amplifies position size - with 2x leverage and quantity=1,
|
|
222
|
+
you control 2x the shares while only using 1x cash as margin.
|
|
223
|
+
- Opening positions deducts only commission from cash; equity remains
|
|
224
|
+
continuous because unrealized P&L is properly tracked.
|
|
225
|
+
|
|
158
226
|
Example:
|
|
159
227
|
>>> broker = Broker(source)
|
|
160
228
|
>>> # Buy with 25% of available cash
|
|
@@ -176,9 +244,15 @@ class Broker:
|
|
|
176
244
|
else:
|
|
177
245
|
type = OrderType.MARKET
|
|
178
246
|
current_price = self.source.Close[-1]
|
|
179
|
-
total_shares = round((self.cash * quantity) / current_price, self.share_decimals)
|
|
180
247
|
if (amount):
|
|
248
|
+
# When using absolute amount, still apply leverage to the base calculation
|
|
249
|
+
# but the user-provided amount is the final leveraged position size
|
|
181
250
|
total_shares = amount
|
|
251
|
+
else:
|
|
252
|
+
# Calculate shares: base on cash * quantity, then apply leverage
|
|
253
|
+
base_shares = round((self.cash * quantity) / current_price, self.share_decimals)
|
|
254
|
+
# Apply leverage to increase position size
|
|
255
|
+
total_shares = round(base_shares * self.leverage, self.share_decimals)
|
|
182
256
|
order = Order(
|
|
183
257
|
side=OrderSide.BUY,
|
|
184
258
|
quantity=total_shares,
|
|
@@ -200,12 +274,12 @@ class Broker:
|
|
|
200
274
|
|
|
201
275
|
This method creates a sell order with optional limit price, stop loss,
|
|
202
276
|
and take profit conditions. The quantity can be specified as a
|
|
203
|
-
percentage of
|
|
277
|
+
percentage of available cash or as an absolute amount.
|
|
204
278
|
|
|
205
279
|
Args:
|
|
206
|
-
quantity (float, optional): Quantity to sell as fraction of
|
|
207
|
-
|
|
208
|
-
For example: 0.5 = sell 50% of
|
|
280
|
+
quantity (float, optional): Quantity to sell as fraction of available
|
|
281
|
+
cash (0 < quantity <= 1). Defaults to 1 (full cash amount).
|
|
282
|
+
For example: 0.5 = sell shares worth 50% of available cash.
|
|
209
283
|
limit (optional): Limit price for the order (same as limit parameter
|
|
210
284
|
in buy method). If None, creates a market order. Defaults to None.
|
|
211
285
|
amount (np.float64 | None, optional): Absolute number of shares
|
|
@@ -228,7 +302,10 @@ class Broker:
|
|
|
228
302
|
during the next iteration.
|
|
229
303
|
- Selling reduces the current position and increases cash balance.
|
|
230
304
|
- Stop loss and take profit apply to remaining position after sale.
|
|
231
|
-
|
|
305
|
+
- Realized P&L is credited to cash immediately on close.
|
|
306
|
+
- Leverage amplifies short position size - with 2x leverage and quantity=1,
|
|
307
|
+
you can short 2x the shares while only using 1x cash as margin.
|
|
308
|
+
|
|
232
309
|
Example:
|
|
233
310
|
>>> broker = Broker(source)
|
|
234
311
|
>>> broker.buy(quantity=1) # First buy full position
|
|
@@ -237,7 +314,7 @@ class Broker:
|
|
|
237
314
|
>>> # Sell exactly 100 shares at limit price $52
|
|
238
315
|
>>> broker.sell(amount=100, limit=52.0)
|
|
239
316
|
"""
|
|
240
|
-
## Default to full account
|
|
317
|
+
## Default to full account sell
|
|
241
318
|
if (quantity > 1 or quantity <= 0):
|
|
242
319
|
raise ValueError("Quantity must be between 0 and 1")
|
|
243
320
|
if (limit and limit < 0):
|
|
@@ -249,9 +326,15 @@ class Broker:
|
|
|
249
326
|
else:
|
|
250
327
|
type = OrderType.MARKET
|
|
251
328
|
current_price = self.source.Close[-1]
|
|
252
|
-
total_shares = round((self.cash * quantity) / current_price, self.share_decimals)
|
|
253
329
|
if (amount):
|
|
330
|
+
# When using absolute amount, still apply leverage to the base calculation
|
|
331
|
+
# but the user-provided amount is the final leveraged position size
|
|
254
332
|
total_shares = amount
|
|
333
|
+
else:
|
|
334
|
+
# Calculate shares: base on cash * quantity, then apply leverage
|
|
335
|
+
base_shares = round((self.cash * quantity) / current_price, self.share_decimals)
|
|
336
|
+
# Apply leverage to increase position size
|
|
337
|
+
total_shares = round(base_shares * self.leverage, self.share_decimals)
|
|
255
338
|
order = Order(
|
|
256
339
|
side=OrderSide.SELL,
|
|
257
340
|
quantity=total_shares,
|
|
@@ -362,41 +445,35 @@ class Broker:
|
|
|
362
445
|
"""
|
|
363
446
|
return self.position == 0
|
|
364
447
|
|
|
365
|
-
def _debit(self, amount: np.float64):
|
|
448
|
+
def _debit(self, amount: np.float64):
|
|
366
449
|
"""
|
|
367
450
|
Deduct amount from cash balance (internal method).
|
|
368
451
|
|
|
369
452
|
This is an internal method used to reduce the cash balance when
|
|
370
|
-
|
|
453
|
+
paying commissions or fees. It does NOT deduct position notional
|
|
454
|
+
value, as equity is tracked separately via unrealized P&L.
|
|
371
455
|
|
|
372
456
|
Args:
|
|
373
457
|
amount (np.float64): Amount to deduct from cash balance.
|
|
374
458
|
|
|
375
459
|
Raises:
|
|
376
|
-
ValueError: If attempting to deduct more than available
|
|
377
|
-
|
|
378
|
-
Note:
|
|
379
|
-
This is an internal method and should not be called directly
|
|
380
|
-
by strategy code. Use public methods like buy() instead.
|
|
460
|
+
ValueError: If attempting to deduct more than available equity
|
|
461
|
+
(accounting for used margin).
|
|
381
462
|
"""
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
raise ValueError("
|
|
463
|
+
available = self._get_free_cash()
|
|
464
|
+
if (available - amount < 0):
|
|
465
|
+
raise ValueError("Insufficient equity for this operation")
|
|
385
466
|
self.cash -= amount
|
|
386
467
|
|
|
387
|
-
def _credit(self, amount: np.float64):
|
|
468
|
+
def _credit(self, amount: np.float64):
|
|
388
469
|
"""
|
|
389
470
|
Add amount to cash balance (internal method).
|
|
390
471
|
|
|
391
472
|
This is an internal method used to increase the cash balance when
|
|
392
|
-
|
|
473
|
+
realizing P&L from closing positions or receiving funds.
|
|
393
474
|
|
|
394
475
|
Args:
|
|
395
476
|
amount (np.float64): Amount to add to cash balance.
|
|
396
|
-
|
|
397
|
-
Note:
|
|
398
|
-
This is an internal method and should not be called directly
|
|
399
|
-
by strategy code. Use public methods like sell() instead.
|
|
400
477
|
"""
|
|
401
478
|
self.cash += amount
|
|
402
479
|
|
|
@@ -435,15 +512,99 @@ class Broker:
|
|
|
435
512
|
Args:
|
|
436
513
|
quantity (np.float64): Number of shares/contracts traded.
|
|
437
514
|
price (np.float64): Execution price per share/contract.
|
|
438
|
-
|
|
439
|
-
Note:
|
|
440
|
-
This is an internal method and should not be called directly
|
|
441
|
-
by strategy code. Commission is automatically applied during
|
|
442
|
-
order execution.
|
|
443
515
|
"""
|
|
444
516
|
debit = self._calc_commission(quantity, price)
|
|
445
517
|
self._debit(debit)
|
|
446
518
|
|
|
519
|
+
def _execute_opening_order(self, side: OrderSide, quantity: np.float64, price: np.float64):
|
|
520
|
+
"""
|
|
521
|
+
Execute an order that opens or increases a position.
|
|
522
|
+
|
|
523
|
+
This handles the accounting for opening positions:
|
|
524
|
+
- Updates position size and average price
|
|
525
|
+
- Deducts only commission from balance (not notional exposure)
|
|
526
|
+
- Equity remains continuous because unrealized P&L is properly tracked
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
side: BUY or SELL side
|
|
530
|
+
quantity: Number of shares/contracts
|
|
531
|
+
price: Execution price
|
|
532
|
+
"""
|
|
533
|
+
old_pos = self.position
|
|
534
|
+
if side == OrderSide.BUY:
|
|
535
|
+
new_pos = old_pos + quantity
|
|
536
|
+
else:
|
|
537
|
+
new_pos = old_pos - quantity
|
|
538
|
+
|
|
539
|
+
# Update average price using weighted average
|
|
540
|
+
if old_pos == 0:
|
|
541
|
+
# No existing position - new avg price is fill price
|
|
542
|
+
self.position_avg_price = price
|
|
543
|
+
elif same_sign(old_pos, new_pos):
|
|
544
|
+
if abs(new_pos) > abs(old_pos):
|
|
545
|
+
# Increasing exposure - update weighted average
|
|
546
|
+
self.position_avg_price = (
|
|
547
|
+
old_pos * self.position_avg_price + quantity * price
|
|
548
|
+
) / new_pos
|
|
549
|
+
# If reducing exposure, keep avg price unchanged
|
|
550
|
+
else:
|
|
551
|
+
# Crossing through zero - remainder becomes new position with fill price
|
|
552
|
+
self.position_avg_price = price
|
|
553
|
+
|
|
554
|
+
# Only deduct commission from balance (NOT margin/notional)
|
|
555
|
+
# Equity remains continuous because unrealized P&L calculation
|
|
556
|
+
# accounts for the new position at current market price
|
|
557
|
+
self._apply_commission(quantity, price)
|
|
558
|
+
|
|
559
|
+
self.position = new_pos
|
|
560
|
+
|
|
561
|
+
# Reset avg price if position is closed
|
|
562
|
+
if self.position == 0:
|
|
563
|
+
self.position_avg_price = np.float64(0)
|
|
564
|
+
|
|
565
|
+
def _execute_closing_order(self, side: OrderSide, quantity: np.float64, price: np.float64):
|
|
566
|
+
"""
|
|
567
|
+
Execute an order that closes or reduces a position.
|
|
568
|
+
|
|
569
|
+
This handles the accounting for closing positions:
|
|
570
|
+
- Realizes P&L into balance
|
|
571
|
+
- Updates position size
|
|
572
|
+
- Keeps remaining avg price unchanged if partially reduced
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
side: BUY or SELL side
|
|
576
|
+
quantity: Number of shares/contracts to close
|
|
577
|
+
price: Execution price
|
|
578
|
+
"""
|
|
579
|
+
old_pos = self.position
|
|
580
|
+
if side == OrderSide.BUY:
|
|
581
|
+
new_pos = old_pos + quantity # Buying to close short
|
|
582
|
+
else:
|
|
583
|
+
new_pos = old_pos - quantity # Selling to close long
|
|
584
|
+
|
|
585
|
+
# Calculate realized P&L for the closed portion
|
|
586
|
+
# P&L = closed_quantity * (exit_price - entry_price)
|
|
587
|
+
# For long: exit_price - entry_price
|
|
588
|
+
# For short: entry_price - exit_price (we bought at lower to cover, profit)
|
|
589
|
+
closed_quantity = abs(old_pos - new_pos)
|
|
590
|
+
if old_pos > 0: # Closing long position
|
|
591
|
+
realized_pnl = closed_quantity * (price - self.position_avg_price)
|
|
592
|
+
elif old_pos < 0: # Closing short position
|
|
593
|
+
realized_pnl = closed_quantity * (self.position_avg_price - price)
|
|
594
|
+
else:
|
|
595
|
+
realized_pnl = np.float64(0)
|
|
596
|
+
|
|
597
|
+
# Credit realized P&L and commission to balance
|
|
598
|
+
self._credit(realized_pnl)
|
|
599
|
+
self._apply_commission(quantity, price)
|
|
600
|
+
|
|
601
|
+
self.position = new_pos
|
|
602
|
+
|
|
603
|
+
# Reset avg price if position is fully closed
|
|
604
|
+
if self.position == 0:
|
|
605
|
+
self.position_avg_price = np.float64(0)
|
|
606
|
+
# If partially closed but still same direction, keep avg price unchanged
|
|
607
|
+
|
|
447
608
|
def _iterate(self, current_index: int):
|
|
448
609
|
"""
|
|
449
610
|
Process orders and update broker state for current time step (internal method).
|
|
@@ -480,69 +641,27 @@ class Broker:
|
|
|
480
641
|
if (order.side == OrderSide.BUY):
|
|
481
642
|
if (not order.price == None and self.source.COpen <= order.price):
|
|
482
643
|
## We can buy it
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
self.
|
|
487
|
-
elif same_sign(old_pos, new_pos):
|
|
488
|
-
if (abs(new_pos) > abs(old_pos)):
|
|
489
|
-
self.position_avg_price = (old_pos * self.position_avg_price + order.quantity * order.price) / new_pos
|
|
644
|
+
self._execute_order(order.side, order.quantity, order.price)
|
|
645
|
+
if (order.stop_loss or order.take_profit):
|
|
646
|
+
order.status = OrderStatus.ACTIVE ## Will need to be checked on for each update
|
|
647
|
+
self.active_order = order
|
|
490
648
|
else:
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
self._apply_commission(order.quantity, order.price)
|
|
494
|
-
self.position = new_pos
|
|
649
|
+
order.status = OrderStatus.COMPLETE ## We are done with it
|
|
650
|
+
to_delete.append(order)
|
|
495
651
|
else:
|
|
496
652
|
if (not order.price == None and self.source.COpen >= order.price):
|
|
497
653
|
## We can sell it
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
self.
|
|
502
|
-
elif same_sign(old_pos, new_pos):
|
|
503
|
-
if (abs(new_pos) > abs(old_pos)):
|
|
504
|
-
self.position_avg_price = (old_pos * self.position_avg_price + order.quantity * order.price) / new_pos
|
|
654
|
+
self._execute_order(order.side, order.quantity, order.price)
|
|
655
|
+
if (order.stop_loss or order.take_profit):
|
|
656
|
+
order.status = OrderStatus.ACTIVE ## Will need to be checked on for each update
|
|
657
|
+
self.active_order = order
|
|
505
658
|
else:
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
self._apply_commission(order.quantity, order.price)
|
|
509
|
-
self.position = new_pos
|
|
510
|
-
if (order.stop_loss or order.take_profit):
|
|
511
|
-
order.status = OrderStatus.ACTIVE ## Will need to be checked on for each update
|
|
512
|
-
self.active_order = order
|
|
513
|
-
else:
|
|
514
|
-
order.status = OrderStatus.COMPLETE ## We are done with it
|
|
515
|
-
to_delete.append(order)
|
|
659
|
+
order.status = OrderStatus.COMPLETE ## We are done with it
|
|
660
|
+
to_delete.append(order)
|
|
516
661
|
else:
|
|
517
662
|
try:
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
new_pos = old_pos + order.quantity
|
|
521
|
-
price = self.source.COpen
|
|
522
|
-
if (old_pos == 0):
|
|
523
|
-
self.position_avg_price = price
|
|
524
|
-
elif same_sign(old_pos, new_pos):
|
|
525
|
-
if (abs(new_pos) > abs(old_pos)):
|
|
526
|
-
self.position_avg_price = (old_pos * self.position_avg_price + order.quantity * price) / new_pos
|
|
527
|
-
else:
|
|
528
|
-
self.position_avg_price = price
|
|
529
|
-
self._debit(self.source.COpen * order.quantity)
|
|
530
|
-
self._apply_commission(order.quantity, self.source.COpen)
|
|
531
|
-
self.position = new_pos
|
|
532
|
-
else:
|
|
533
|
-
old_pos = self.position
|
|
534
|
-
new_pos = old_pos - order.quantity
|
|
535
|
-
price = self.source.COpen
|
|
536
|
-
if (old_pos == 0):
|
|
537
|
-
self.position_avg_price = price
|
|
538
|
-
elif same_sign(old_pos, new_pos):
|
|
539
|
-
if (abs(new_pos) > abs(old_pos)):
|
|
540
|
-
self.position_avg_price = (old_pos * self.position_avg_price + order.quantity * price) / new_pos
|
|
541
|
-
else:
|
|
542
|
-
self.position_avg_price = price
|
|
543
|
-
self._credit(self.source.COpen * order.quantity)
|
|
544
|
-
self._apply_commission(order.quantity, self.source.COpen)
|
|
545
|
-
self.position = new_pos
|
|
663
|
+
price = self.source.COpen
|
|
664
|
+
self._execute_order(order.side, order.quantity, price)
|
|
546
665
|
if (order.stop_loss or order.take_profit):
|
|
547
666
|
order.status = OrderStatus.ACTIVE
|
|
548
667
|
self.active_order = order
|
|
@@ -559,59 +678,111 @@ class Broker:
|
|
|
559
678
|
(order.take_profit and self.source.COpen >= order.take_profit)
|
|
560
679
|
or (order.stop_loss and self.source.COpen <= order.stop_loss)
|
|
561
680
|
)):
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
681
|
+
close_order = Order(
|
|
682
|
+
side=OrderSide.SELL,
|
|
683
|
+
quantity=order.quantity,
|
|
684
|
+
type=OrderType.MARKET,
|
|
685
|
+
price= None,
|
|
686
|
+
stop_loss= None,
|
|
687
|
+
take_profit= None,
|
|
688
|
+
status=OrderStatus.PENDING,
|
|
689
|
+
timestamp=self.source.Index[self._i],
|
|
690
|
+
reduce_only=True,
|
|
691
|
+
)
|
|
692
|
+
self._enqueue_order(close_order)
|
|
693
|
+
order.status = OrderStatus.COMPLETE
|
|
694
|
+
if self.active_order is order:
|
|
695
|
+
self.active_order = None
|
|
696
|
+
self.complete_orders.append(order)
|
|
697
|
+
to_delete.append(order)
|
|
579
698
|
elif(order.side == OrderSide.SELL
|
|
580
699
|
and (
|
|
581
700
|
(order.take_profit and self.source.COpen <= order.take_profit)
|
|
582
701
|
or (order.stop_loss and self.source.COpen >= order.stop_loss)
|
|
583
702
|
)):
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
703
|
+
close_order = Order(
|
|
704
|
+
side=OrderSide.BUY,
|
|
705
|
+
quantity=order.quantity,
|
|
706
|
+
type=OrderType.MARKET,
|
|
707
|
+
price=None,
|
|
708
|
+
stop_loss=None,
|
|
709
|
+
take_profit=None,
|
|
710
|
+
status=OrderStatus.PENDING,
|
|
711
|
+
timestamp=self.source.Index[self._i],
|
|
712
|
+
reduce_only=True,
|
|
713
|
+
)
|
|
714
|
+
self._enqueue_order(close_order)
|
|
715
|
+
order.status = OrderStatus.COMPLETE
|
|
716
|
+
if self.active_order is order:
|
|
717
|
+
self.active_order = None
|
|
718
|
+
self.complete_orders.append(order)
|
|
719
|
+
to_delete.append(order)
|
|
601
720
|
for item in to_delete:
|
|
602
721
|
self.orders.remove(item)
|
|
603
722
|
if self.pending_close_order is item:
|
|
604
723
|
self.pending_close_order = None
|
|
605
|
-
|
|
606
|
-
equity
|
|
607
|
-
|
|
608
|
-
|
|
724
|
+
|
|
725
|
+
# Calculate equity: balance + unrealized P&L
|
|
726
|
+
# This is always correct regardless of leverage
|
|
727
|
+
equity = self._get_equity()
|
|
728
|
+
|
|
729
|
+
# Clamp equity to 0 minimum to prevent negative equity
|
|
730
|
+
equity = max(np.float64(0), equity)
|
|
731
|
+
self.PnLRecord[self._i] = equity
|
|
732
|
+
|
|
733
|
+
# Check margin call using equity and used margin
|
|
734
|
+
# Margin call triggers when equity falls below margin_call threshold of used margin
|
|
735
|
+
used_margin = self._get_used_margin()
|
|
736
|
+
margin_call_threshold = self.margin_call * used_margin
|
|
737
|
+
|
|
738
|
+
# Only trigger margin call if there's an open position and equity is below threshold
|
|
739
|
+
if used_margin > 0 and equity < margin_call_threshold:
|
|
609
740
|
self.margin_call_triggered = True
|
|
610
741
|
self.margin_call_events.append({
|
|
611
742
|
"timestamp": self.source.Index[self._i],
|
|
612
743
|
"equity": equity,
|
|
613
|
-
"
|
|
744
|
+
"used_margin": used_margin,
|
|
745
|
+
"margin_call_threshold": margin_call_threshold,
|
|
614
746
|
"position": self.position,
|
|
615
747
|
})
|
|
616
|
-
self.close()
|
|
617
|
-
|
|
748
|
+
self.close() # Close all positions immediately, margin call
|
|
749
|
+
|
|
750
|
+
def _execute_order(self, side: OrderSide, quantity: np.float64, price: np.float64):
|
|
751
|
+
"""
|
|
752
|
+
Execute an order, routing to opening or closing logic.
|
|
753
|
+
|
|
754
|
+
Args:
|
|
755
|
+
side: BUY or SELL side
|
|
756
|
+
quantity: Number of shares/contracts
|
|
757
|
+
price: Execution price
|
|
758
|
+
"""
|
|
759
|
+
# Determine if this is opening or closing based on position direction
|
|
760
|
+
old_pos = self.position
|
|
761
|
+
|
|
762
|
+
if old_pos == 0:
|
|
763
|
+
# No position - any order opens a new position
|
|
764
|
+
self._execute_opening_order(side, quantity, price)
|
|
765
|
+
elif side == OrderSide.BUY:
|
|
766
|
+
if old_pos > 0:
|
|
767
|
+
# Currently long - buying adds to position
|
|
768
|
+
self._execute_opening_order(side, quantity, price)
|
|
769
|
+
else:
|
|
770
|
+
# Currently short - buying closes/reduces position
|
|
771
|
+
close_qty = min(quantity, abs(old_pos))
|
|
772
|
+
if close_qty > 0:
|
|
773
|
+
self._execute_closing_order(side, close_qty, price)
|
|
774
|
+
if quantity > close_qty:
|
|
775
|
+
# Remainder opens new long position
|
|
776
|
+
self._execute_opening_order(side, quantity - close_qty, price)
|
|
777
|
+
else: # side == OrderSide.SELL
|
|
778
|
+
if old_pos < 0:
|
|
779
|
+
# Currently short - selling adds to position
|
|
780
|
+
self._execute_opening_order(side, quantity, price)
|
|
781
|
+
else:
|
|
782
|
+
# Currently long - selling closes/reduces position
|
|
783
|
+
close_qty = min(quantity, abs(old_pos))
|
|
784
|
+
if close_qty > 0:
|
|
785
|
+
self._execute_closing_order(side, close_qty, price)
|
|
786
|
+
if quantity > close_qty:
|
|
787
|
+
# Remainder opens new short position
|
|
788
|
+
self._execute_opening_order(side, quantity - close_qty, price)
|
|
@@ -119,7 +119,9 @@ class Strategy(ABC):
|
|
|
119
119
|
>>> class MyStrategy(Strategy):
|
|
120
120
|
... def init(self):
|
|
121
121
|
... self.add_data(CSVDataSource("AAPL.csv"), "AAPL")
|
|
122
|
-
...
|
|
122
|
+
... def next(self):
|
|
123
|
+
... if self.data['AAPL'].CClose > self.data['AAPL'].Close[-1]:
|
|
124
|
+
... self.positions['AAPL'].buy(quantity=0.1)
|
|
123
125
|
"""
|
|
124
126
|
self.data[symbol] = source
|
|
125
127
|
self.positions[symbol] = Broker(source)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|