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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: quantex
3
- Version: 0.4.7
3
+ Version: 0.4.9
4
4
  Summary: A simple quant strategy creation and backtesting package.
5
5
  License: MIT
6
6
  Author: Daniel Green
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quantex"
3
- version = "0.4.7"
3
+ version = "0.4.9"
4
4
  description = "A simple quant strategy creation and backtesting package."
5
5
  authors = [
6
6
  {name = "Daniel Green",email = "dangreen07@outlook.com"}
@@ -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
- # Get full reports for best parameters
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
- if len(returns) < 2 or returns.std(ddof=1) == 0:
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
- if len(returns) < 2 or returns.std(ddof=1) == 0:
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 and margin calls
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 ## 50% of the cash value
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 current position or as an absolute amount.
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 current
207
- position (0 < quantity <= 1). Defaults to 1 (full position).
208
- For example: 0.5 = sell 50% of current position.
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 size sell
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): ## Give money to the market (buy shares)
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
- purchasing shares or paying commissions.
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 cash.
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
- if (self.cash - amount < 0):
383
- ## Order fail
384
- raise ValueError("Tried to purchase more than account balance")
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): ## Take money from the market (sell shares)
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
- selling shares or receiving funds.
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
- old_pos = self.position
484
- new_pos = old_pos + order.quantity
485
- if (old_pos == 0):
486
- self.position_avg_price = order.price
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
- self.position_avg_price = order.price
492
- self._debit(order.price * order.quantity)
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
- old_pos = self.position
499
- new_pos = old_pos - order.quantity
500
- if (old_pos == 0):
501
- self.position_avg_price = order.price
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
- self.position_avg_price = order.price
507
- self._credit(order.price * order.quantity)
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
- if (order.side == OrderSide.BUY):
519
- old_pos = self.position
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
- close_order = Order(
563
- side=OrderSide.SELL,
564
- quantity=order.quantity,
565
- type=OrderType.MARKET,
566
- price= None,
567
- stop_loss= None,
568
- take_profit= None,
569
- status=OrderStatus.PENDING,
570
- timestamp=self.source.Index[self._i],
571
- reduce_only=True,
572
- )
573
- self._enqueue_order(close_order)
574
- order.status = OrderStatus.COMPLETE
575
- if self.active_order is order:
576
- self.active_order = None
577
- self.complete_orders.append(order)
578
- to_delete.append(order)
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
- close_order = Order(
585
- side=OrderSide.BUY,
586
- quantity=order.quantity,
587
- type=OrderType.MARKET,
588
- price=None,
589
- stop_loss=None,
590
- take_profit=None,
591
- status=OrderStatus.PENDING,
592
- timestamp=self.source.Index[self._i],
593
- reduce_only=True,
594
- )
595
- self._enqueue_order(close_order)
596
- order.status = OrderStatus.COMPLETE
597
- if self.active_order is order:
598
- self.active_order = None
599
- self.complete_orders.append(order)
600
- to_delete.append(order)
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
- unrealized = self.position * self.source.CClose
606
- equity = self.cash + unrealized
607
- margin_call = self.margin_call * abs(self.position) * self.source.CClose
608
- if equity < margin_call and self.position < 0:
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
- "margin_call_threshold": margin_call,
744
+ "used_margin": used_margin,
745
+ "margin_call_threshold": margin_call_threshold,
614
746
  "position": self.position,
615
747
  })
616
- self.close() ## Close all positions immediately, margin call
617
- self.PnLRecord[self._i] = equity
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
- ... self.add_data(CSVDataSource("EURUSD.csv"), "EURUSD")
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