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.
- {quantex-0.4.8 → quantex-0.4.9}/PKG-INFO +1 -1
- {quantex-0.4.8 → quantex-0.4.9}/pyproject.toml +1 -1
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/backtester/backtester.py +7 -0
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/backtester/metrics.py +6 -1
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/backtester/reports.py +8 -2
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/broker/broker.py +288 -137
- {quantex-0.4.8 → quantex-0.4.9}/LICENSE.md +0 -0
- {quantex-0.4.8 → quantex-0.4.9}/README.md +0 -0
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/__init__.py +0 -0
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/backtester/__init__.py +0 -0
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/backtester/constants.py +0 -0
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/backtester/data_splits.py +0 -0
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/backtester/montecarlo.py +0 -0
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/backtester/parallel.py +0 -0
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/broker/__init__.py +0 -0
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/broker/types.py +0 -0
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/datasource.py +0 -0
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/helpers.py +0 -0
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/indicators.py +0 -0
- {quantex-0.4.8 → quantex-0.4.9}/src/quantex/strategy.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
78
|
-
self.leverage: float = 1.0
|
|
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
|
|
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
|
|
217
|
-
|
|
218
|
-
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.
|
|
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
|
|
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):
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
raise ValueError("
|
|
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):
|
|
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
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
self.
|
|
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
|
-
|
|
502
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
self.
|
|
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
|
-
|
|
519
|
-
|
|
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
|
-
|
|
533
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
624
|
-
equity
|
|
625
|
-
#
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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()
|
|
637
|
-
|
|
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
|
|
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
|