quantex 0.4.8__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.8
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.8"
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"}
@@ -604,6 +604,7 @@ class SimpleBacktester:
604
604
  random_seed: int | None = None,
605
605
  workers: int | None = None,
606
606
  progress_bar: bool = True,
607
+ verbose: bool = False,
607
608
  ) -> OptimizationResult:
608
609
  """
609
610
  Optimize strategy parameters using Optuna (Bayesian optimization).
@@ -639,6 +640,8 @@ class SimpleBacktester:
639
640
  workers (int | None, optional): Number of parallel workers for Optuna
640
641
  study. Defaults to None (sequential).
641
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).
642
645
 
643
646
  Returns:
644
647
  OptimizationResult: Object containing:
@@ -672,6 +675,10 @@ class SimpleBacktester:
672
675
  """
673
676
  try:
674
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
+ )
675
682
  except ImportError:
676
683
  raise ImportError(
677
684
  "optuna is required for optimize_optuna. "
@@ -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
@@ -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,12 +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
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
56
64
 
57
65
  Example:
58
66
  >>> source = CSVDataSource("data.csv")
@@ -74,8 +82,8 @@ class Broker:
74
82
  self.commision: np.float64 = np.float64(0.002)
75
83
  self.commision_type: CommissionType = CommissionType.PERCENTAGE
76
84
  self.lot_size: int = 1
77
- self.margin_call: float = 0.5 ## 50% of the cash value
78
- self.leverage: float = 1.0 ## Leverage multiplier (1.0 = no leverage)
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)
79
87
  self.share_decimals = 1
80
88
  self.orders: list[Order] = []
81
89
  self.complete_orders = []
@@ -88,6 +96,60 @@ class Broker:
88
96
  self.PnLRecord = np.full(len(self.source.data['Close']), self.cash, dtype=np.float64)
89
97
  self.cashRecord = []
90
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
+
91
153
  def _enqueue_order(self, order: Order) -> None:
92
154
  """
93
155
  Add an order while keeping the active-order queue compact.
@@ -158,6 +220,8 @@ class Broker:
158
220
  - Limit orders only execute when price reaches the specified level.
159
221
  - Leverage amplifies position size - with 2x leverage and quantity=1,
160
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.
161
225
 
162
226
  Example:
163
227
  >>> broker = Broker(source)
@@ -210,12 +274,12 @@ class Broker:
210
274
 
211
275
  This method creates a sell order with optional limit price, stop loss,
212
276
  and take profit conditions. The quantity can be specified as a
213
- percentage of current position or as an absolute amount.
277
+ percentage of available cash or as an absolute amount.
214
278
 
215
279
  Args:
216
- quantity (float, optional): Quantity to sell as fraction of current
217
- position (0 < quantity <= 1). Defaults to 1 (full position).
218
- 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.
219
283
  limit (optional): Limit price for the order (same as limit parameter
220
284
  in buy method). If None, creates a market order. Defaults to None.
221
285
  amount (np.float64 | None, optional): Absolute number of shares
@@ -238,7 +302,10 @@ class Broker:
238
302
  during the next iteration.
239
303
  - Selling reduces the current position and increases cash balance.
240
304
  - Stop loss and take profit apply to remaining position after sale.
241
-
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
+
242
309
  Example:
243
310
  >>> broker = Broker(source)
244
311
  >>> broker.buy(quantity=1) # First buy full position
@@ -247,7 +314,7 @@ class Broker:
247
314
  >>> # Sell exactly 100 shares at limit price $52
248
315
  >>> broker.sell(amount=100, limit=52.0)
249
316
  """
250
- ## Default to full account size sell
317
+ ## Default to full account sell
251
318
  if (quantity > 1 or quantity <= 0):
252
319
  raise ValueError("Quantity must be between 0 and 1")
253
320
  if (limit and limit < 0):
@@ -259,9 +326,15 @@ class Broker:
259
326
  else:
260
327
  type = OrderType.MARKET
261
328
  current_price = self.source.Close[-1]
262
- total_shares = round((self.cash * quantity) / current_price, self.share_decimals)
263
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
264
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)
265
338
  order = Order(
266
339
  side=OrderSide.SELL,
267
340
  quantity=total_shares,
@@ -372,41 +445,35 @@ class Broker:
372
445
  """
373
446
  return self.position == 0
374
447
 
375
- def _debit(self, amount: np.float64): ## Give money to the market (buy shares)
448
+ def _debit(self, amount: np.float64):
376
449
  """
377
450
  Deduct amount from cash balance (internal method).
378
451
 
379
452
  This is an internal method used to reduce the cash balance when
380
- 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.
381
455
 
382
456
  Args:
383
457
  amount (np.float64): Amount to deduct from cash balance.
384
458
 
385
459
  Raises:
386
- ValueError: If attempting to deduct more than available cash.
387
-
388
- Note:
389
- This is an internal method and should not be called directly
390
- by strategy code. Use public methods like buy() instead.
460
+ ValueError: If attempting to deduct more than available equity
461
+ (accounting for used margin).
391
462
  """
392
- if (self.cash - amount < 0):
393
- ## Order fail
394
- 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")
395
466
  self.cash -= amount
396
467
 
397
- def _credit(self, amount: np.float64): ## Take money from the market (sell shares)
468
+ def _credit(self, amount: np.float64):
398
469
  """
399
470
  Add amount to cash balance (internal method).
400
471
 
401
472
  This is an internal method used to increase the cash balance when
402
- selling shares or receiving funds.
473
+ realizing P&L from closing positions or receiving funds.
403
474
 
404
475
  Args:
405
476
  amount (np.float64): Amount to add to cash balance.
406
-
407
- Note:
408
- This is an internal method and should not be called directly
409
- by strategy code. Use public methods like sell() instead.
410
477
  """
411
478
  self.cash += amount
412
479
 
@@ -445,15 +512,99 @@ class Broker:
445
512
  Args:
446
513
  quantity (np.float64): Number of shares/contracts traded.
447
514
  price (np.float64): Execution price per share/contract.
448
-
449
- Note:
450
- This is an internal method and should not be called directly
451
- by strategy code. Commission is automatically applied during
452
- order execution.
453
515
  """
454
516
  debit = self._calc_commission(quantity, price)
455
517
  self._debit(debit)
456
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
+
457
608
  def _iterate(self, current_index: int):
458
609
  """
459
610
  Process orders and update broker state for current time step (internal method).
@@ -490,77 +641,27 @@ class Broker:
490
641
  if (order.side == OrderSide.BUY):
491
642
  if (not order.price == None and self.source.COpen <= order.price):
492
643
  ## We can buy it
493
- old_pos = self.position
494
- new_pos = old_pos + order.quantity
495
- if (old_pos == 0):
496
- self.position_avg_price = order.price
497
- elif same_sign(old_pos, new_pos):
498
- if (abs(new_pos) > abs(old_pos)):
499
- 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
500
648
  else:
501
- self.position_avg_price = order.price
502
- # Calculate margin (cash used) for leveraged positions
503
- margin = order.price * order.quantity / self.leverage
504
- self._debit(margin)
505
- self._apply_commission(order.quantity, order.price)
506
- self.position = new_pos
649
+ order.status = OrderStatus.COMPLETE ## We are done with it
650
+ to_delete.append(order)
507
651
  else:
508
652
  if (not order.price == None and self.source.COpen >= order.price):
509
653
  ## We can sell it
510
- old_pos = self.position
511
- new_pos = old_pos - order.quantity
512
- if (old_pos == 0):
513
- self.position_avg_price = order.price
514
- elif same_sign(old_pos, new_pos):
515
- if (abs(new_pos) > abs(old_pos)):
516
- 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
517
658
  else:
518
- self.position_avg_price = order.price
519
- # Calculate margin released for leveraged positions
520
- margin = order.price * order.quantity / self.leverage
521
- self._credit(margin)
522
- self._apply_commission(order.quantity, order.price)
523
- self.position = new_pos
524
- if (order.stop_loss or order.take_profit):
525
- order.status = OrderStatus.ACTIVE ## Will need to be checked on for each update
526
- self.active_order = order
527
- else:
528
- order.status = OrderStatus.COMPLETE ## We are done with it
529
- to_delete.append(order)
659
+ order.status = OrderStatus.COMPLETE ## We are done with it
660
+ to_delete.append(order)
530
661
  else:
531
662
  try:
532
- if (order.side == OrderSide.BUY):
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
- # Calculate margin (cash used) for leveraged positions
544
- margin = self.source.COpen * order.quantity / self.leverage
545
- self._debit(margin)
546
- self._apply_commission(order.quantity, self.source.COpen)
547
- self.position = new_pos
548
- else:
549
- old_pos = self.position
550
- new_pos = old_pos - order.quantity
551
- price = self.source.COpen
552
- if (old_pos == 0):
553
- self.position_avg_price = price
554
- elif same_sign(old_pos, new_pos):
555
- if (abs(new_pos) > abs(old_pos)):
556
- self.position_avg_price = (old_pos * self.position_avg_price + order.quantity * price) / new_pos
557
- else:
558
- self.position_avg_price = price
559
- # Calculate margin released for leveraged positions
560
- margin = self.source.COpen * order.quantity / self.leverage
561
- self._credit(margin)
562
- self._apply_commission(order.quantity, self.source.COpen)
563
- self.position = new_pos
663
+ price = self.source.COpen
664
+ self._execute_order(order.side, order.quantity, price)
564
665
  if (order.stop_loss or order.take_profit):
565
666
  order.status = OrderStatus.ACTIVE
566
667
  self.active_order = order
@@ -577,61 +678,111 @@ class Broker:
577
678
  (order.take_profit and self.source.COpen >= order.take_profit)
578
679
  or (order.stop_loss and self.source.COpen <= order.stop_loss)
579
680
  )):
580
- close_order = Order(
581
- side=OrderSide.SELL,
582
- quantity=order.quantity,
583
- type=OrderType.MARKET,
584
- price= None,
585
- stop_loss= None,
586
- take_profit= None,
587
- status=OrderStatus.PENDING,
588
- timestamp=self.source.Index[self._i],
589
- reduce_only=True,
590
- )
591
- self._enqueue_order(close_order)
592
- order.status = OrderStatus.COMPLETE
593
- if self.active_order is order:
594
- self.active_order = None
595
- self.complete_orders.append(order)
596
- 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)
597
698
  elif(order.side == OrderSide.SELL
598
699
  and (
599
700
  (order.take_profit and self.source.COpen <= order.take_profit)
600
701
  or (order.stop_loss and self.source.COpen >= order.stop_loss)
601
702
  )):
602
- close_order = Order(
603
- side=OrderSide.BUY,
604
- quantity=order.quantity,
605
- type=OrderType.MARKET,
606
- price=None,
607
- stop_loss=None,
608
- take_profit=None,
609
- status=OrderStatus.PENDING,
610
- timestamp=self.source.Index[self._i],
611
- reduce_only=True,
612
- )
613
- self._enqueue_order(close_order)
614
- order.status = OrderStatus.COMPLETE
615
- if self.active_order is order:
616
- self.active_order = None
617
- self.complete_orders.append(order)
618
- 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)
619
720
  for item in to_delete:
620
721
  self.orders.remove(item)
621
722
  if self.pending_close_order is item:
622
723
  self.pending_close_order = None
623
- unrealized = self.position * self.source.CClose
624
- equity = self.cash + unrealized
625
- # Calculate actual margin used, accounting for leverage
626
- actual_margin = abs(self.position) * self.source.CClose / self.leverage
627
- margin_call_threshold = self.margin_call * actual_margin
628
- if equity < margin_call_threshold 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:
629
740
  self.margin_call_triggered = True
630
741
  self.margin_call_events.append({
631
742
  "timestamp": self.source.Index[self._i],
632
743
  "equity": equity,
744
+ "used_margin": used_margin,
633
745
  "margin_call_threshold": margin_call_threshold,
634
746
  "position": self.position,
635
747
  })
636
- self.close() ## Close all positions immediately, margin call
637
- 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)
File without changes
File without changes
File without changes
File without changes
File without changes