Skip to content

Commit 39432ee

Browse files
committed
feat(backtest): scheduled deposits in vector backtest
Vector backtest engine now honours BrokerBalanceTracker schedules so DCA-style accumulation strategies can be backtested with realistic external cash flows. Adds scenario test for deposit schedules.
1 parent dc2c6ab commit 39432ee

4 files changed

Lines changed: 212 additions & 3 deletions

File tree

docusaurus/docs/Advanced Concepts/vector-backtesting.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ Use vector backtesting when:
4040
- Needing to complete backtests quickly
4141
- Working with large strategy sets (100+ strategies)
4242

43+
:::warning Order types not modelled in vector backtests
44+
Vector backtesting evaluates strategy **signals** (`generate_buy_signals`, `generate_sell_signals`) against the price series. It does **not** simulate the order book, intra-bar price paths, partial fills, or order-type semantics.
45+
46+
This means the following are **only evaluated in event-driven backtests** and live trading:
47+
48+
- `OrderType.MARKET` slippage and next-bar fill behaviour
49+
- `OrderType.LIMIT` resting fill logic
50+
- `OrderType.STOP` and `OrderType.STOP_LIMIT` trigger logic
51+
- Take-profit / stop-loss rules attached to trades
52+
- Blotter slippage / commission / volume-based fill models
53+
54+
The recommended workflow is to use vector backtests to **screen parameter combinations quickly** based on signal quality, then promote the survivors to **event-driven backtests** where realistic execution is simulated. See [Orders](../Getting%20Started/orders) for the full order-type reference.
55+
:::
56+
4357
## Basic Usage
4458

4559
### Single Strategy, Single Date Range

investing_algorithm_framework/infrastructure/services/backtesting/vector_backtest_service.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,20 @@ def run(
257257
total_allocated = 0.0 # Track total allocated in static mode
258258
open_trades_value = {} # Track value of open trades per symbol
259259

260+
# Pre-compute scheduled external deposits (e.g. monthly paychecks)
261+
# for the backtest window. Vector backtests are single-pass and
262+
# have no Context, so we eagerly resolve the full schedule into a
263+
# sorted (timestamp, amount) list and credit ``current_unallocated``
264+
# the first bar at-or-after each timestamp. Net effect for the
265+
# strategy: the simulated broker balance grows on cadence, and the
266+
# equity curve / metrics include the external cash flows just like
267+
# the event backtest does after a sync_portfolio call.
268+
deposit_events = self._resolve_deposit_schedule(
269+
portfolio_configuration=portfolio_configuration,
270+
backtest_date_range=backtest_date_range,
271+
)
272+
deposit_event_idx = 0
273+
260274
def _close_trade(sym, sym_data, price, date):
261275
"""Helper to close an open trade for a symbol."""
262276
nonlocal current_unallocated, total_realized_gains, \
@@ -537,6 +551,17 @@ def _partial_close(sym, sym_data, price, date, sell_pct):
537551
if current_date.tzinfo is None:
538552
current_date = current_date.replace(tzinfo=timezone.utc)
539553

554+
# Apply any scheduled external deposits whose timestamp has
555+
# been reached by ``current_date``. Each event fires exactly
556+
# once at the first bar at-or-after its scheduled time.
557+
while (
558+
deposit_event_idx < len(deposit_events)
559+
and deposit_events[deposit_event_idx][0] <= current_date
560+
):
561+
_, deposit_amount = deposit_events[deposit_event_idx]
562+
current_unallocated += deposit_amount
563+
deposit_event_idx += 1
564+
540565
# Process each symbol at this timestamp
541566
for symbol, data in symbol_data.items():
542567
current_price = float(data['close'].iloc[i])
@@ -728,13 +753,31 @@ def _partial_close(sym, sym_data, price, date, sell_pct):
728753
unallocated = initial_amount
729754
total_net_gain = 0.0
730755
open_trades = []
756+
# Replay deposit events for snapshot bookkeeping. Each snapshot
757+
# gets ``cash_flow`` set to whatever external cash landed between
758+
# the previous snapshot and this one — this is what enables
759+
# TWR-aware return metrics (CAGR, monthly/yearly returns) to
760+
# subtract external deposits before computing returns.
761+
deposit_replay_idx = 0
731762

732763
# Create portfolio snapshots
733764
for ts in index:
734765
allocated = 0
735766
interval_datetime = pd.Timestamp(ts).to_pydatetime()
736767
interval_datetime = interval_datetime.replace(tzinfo=timezone.utc)
737768

769+
# Apply any deposits that fired between the previous snapshot
770+
# and this one, accumulating them into snapshot_cash_flow.
771+
snapshot_cash_flow = 0.0
772+
while (
773+
deposit_replay_idx < len(deposit_events)
774+
and deposit_events[deposit_replay_idx][0] <= interval_datetime
775+
):
776+
_, deposit_amount = deposit_events[deposit_replay_idx]
777+
unallocated += deposit_amount
778+
snapshot_cash_flow += deposit_amount
779+
deposit_replay_idx += 1
780+
738781
for trade in trades:
739782

740783
if trade.opened_at == interval_datetime:
@@ -761,14 +804,15 @@ def _partial_close(sym, sym_data, price, date, sell_pct):
761804
allocated += open_trade.filled_amount * price
762805

763806
# total_value = invested_value + unallocated
764-
# total_net_gain = total_value - initial_amount
807+
# total_net_gain = total_value - initial_amount - sum(cash_flow)
765808
snapshots.append(
766809
PortfolioSnapshot(
767810
portfolio_id=portfolio.identifier,
768811
created_at=interval_datetime,
769812
unallocated=unallocated,
770813
total_value=unallocated + allocated,
771-
total_net_gain=total_net_gain
814+
total_net_gain=total_net_gain,
815+
cash_flow=snapshot_cash_flow,
772816
)
773817
)
774818

@@ -830,6 +874,34 @@ def _partial_close(sym, sym_data, price, date, sell_pct):
830874
)
831875
return run
832876

877+
@staticmethod
878+
def _resolve_deposit_schedule(
879+
portfolio_configuration,
880+
backtest_date_range,
881+
):
882+
"""Materialise a portfolio's deposit schedule for the backtest window.
883+
884+
Returns a chronologically sorted list of ``(timestamp, amount)``
885+
tuples. Empty when the configuration has no schedule.
886+
"""
887+
schedule = list(
888+
getattr(portfolio_configuration, "deposit_schedule", []) or []
889+
)
890+
if not schedule:
891+
return []
892+
# Local import to avoid a circular import between domain and
893+
# infrastructure layers.
894+
from investing_algorithm_framework.services.portfolios \
895+
.broker_balance_tracker import BrokerBalanceTracker
896+
tracker = BrokerBalanceTracker()
897+
market = portfolio_configuration.market
898+
tracker.set_schedule(market, schedule)
899+
return tracker.project_total(
900+
market=market,
901+
start=backtest_date_range.start_date,
902+
end=backtest_date_range.end_date,
903+
)
904+
833905
@staticmethod
834906
def _convert_recorded_values(raw_recorded):
835907
"""

investing_algorithm_framework/services/trade_order_evaluator/backtest_trade_oder_evaluator.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,64 @@ def _check_has_executed(self, order, ohlcv_df):
132132
)
133133
return
134134

135-
# Limit orders: check if OHLCV data triggers a fill
135+
# Stop / Stop-Limit orders: first check whether the trigger
136+
# condition is met. Once triggered, a STOP becomes a market
137+
# order (fill at trigger price) and a STOP_LIMIT becomes a
138+
# limit order at the configured limit price.
139+
is_stop = OrderType.STOP.equals(order.order_type)
140+
is_stop_limit = OrderType.STOP_LIMIT.equals(order.order_type)
141+
142+
if (is_stop or is_stop_limit) and not order.is_triggered():
143+
stop_price = order.get_stop_price()
144+
145+
if stop_price is None:
146+
return
147+
148+
# SELL stop triggers when price drops to or below stop_price;
149+
# BUY stop triggers when price rises to or above stop_price.
150+
if OrderSide.SELL.equals(order_side):
151+
trigger_candles = ohlcv_data_after_order.filter(
152+
pl.col('Low') <= stop_price
153+
)
154+
elif OrderSide.BUY.equals(order_side):
155+
trigger_candles = ohlcv_data_after_order.filter(
156+
pl.col('High') >= stop_price
157+
)
158+
else:
159+
return
160+
161+
if trigger_candles.is_empty():
162+
return
163+
164+
triggered_candle = trigger_candles.head(1)
165+
order.set_triggered_at(triggered_candle["Datetime"][0])
166+
167+
if is_stop:
168+
# STOP becomes a market order — fill at the stop price
169+
# using the triggering candle's volume.
170+
volume = (
171+
triggered_candle["Volume"][0]
172+
if "Volume" in triggered_candle.columns
173+
else None
174+
)
175+
self._apply_fill(
176+
order, stop_price, order_side, volume,
177+
is_market_order=True
178+
)
179+
return
180+
181+
# STOP_LIMIT: continue and fall through to limit-fill logic
182+
# using the configured limit price (`order.price`). Restrict
183+
# the fill search to candles at or after the trigger.
184+
ohlcv_data_after_order = ohlcv_data_after_order.filter(
185+
pl.col('Datetime') >= order.get_triggered_at()
186+
)
187+
188+
if ohlcv_data_after_order.is_empty():
189+
return
190+
191+
# Limit orders (including triggered STOP_LIMIT):
192+
# check if OHLCV data triggers a fill
136193
if OrderSide.BUY.equals(order_side):
137194
fill_candles = ohlcv_data_after_order.filter(
138195
pl.col('Low') <= order_price
@@ -233,6 +290,11 @@ def _apply_fill(
233290
'order_fee': accumulated_fee,
234291
}
235292

293+
# Persist trigger timestamp for stop / stop-limit orders so
294+
# subsequent evaluations don't re-trigger.
295+
if order.is_triggered():
296+
update_data['triggered_at'] = order.get_triggered_at()
297+
236298
# Market order portfolio reconciliation
237299
if is_market_order and new_remaining <= 0:
238300
estimated_price = order.estimated_price
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Unit test for VectorBacktestService._resolve_deposit_schedule."""
2+
from datetime import datetime, timezone
3+
from unittest import TestCase
4+
5+
from investing_algorithm_framework import (
6+
BacktestDateRange,
7+
PortfolioConfiguration,
8+
ScheduledDeposit,
9+
TimeUnit,
10+
)
11+
from investing_algorithm_framework.infrastructure.services.backtesting \
12+
.vector_backtest_service import VectorBacktestService
13+
14+
15+
def _utc(year, month, day):
16+
return datetime(year, month, day, tzinfo=timezone.utc)
17+
18+
19+
class TestVectorBacktestDepositSchedule(TestCase):
20+
21+
def test_no_schedule_returns_empty(self):
22+
config = PortfolioConfiguration(
23+
market="BITVAVO", trading_symbol="EUR", initial_balance=1000
24+
)
25+
events = VectorBacktestService._resolve_deposit_schedule(
26+
portfolio_configuration=config,
27+
backtest_date_range=BacktestDateRange(
28+
start_date=_utc(2024, 1, 1),
29+
end_date=_utc(2024, 6, 1),
30+
),
31+
)
32+
self.assertEqual([], events)
33+
34+
def test_recurring_and_one_shot_combined(self):
35+
config = PortfolioConfiguration(
36+
market="BITVAVO",
37+
trading_symbol="EUR",
38+
initial_balance=1000,
39+
deposit_schedule=[
40+
ScheduledDeposit(
41+
amount=100.0, time_unit=TimeUnit.DAY, interval=30
42+
),
43+
ScheduledDeposit(amount=500.0, on=_utc(2024, 2, 15)),
44+
],
45+
)
46+
events = VectorBacktestService._resolve_deposit_schedule(
47+
portfolio_configuration=config,
48+
backtest_date_range=BacktestDateRange(
49+
start_date=_utc(2024, 1, 1),
50+
end_date=_utc(2024, 4, 1),
51+
),
52+
)
53+
self.assertEqual(
54+
[
55+
(_utc(2024, 1, 31), 100.0),
56+
(_utc(2024, 2, 15), 500.0),
57+
(_utc(2024, 3, 1), 100.0),
58+
(_utc(2024, 3, 31), 100.0),
59+
],
60+
events,
61+
)

0 commit comments

Comments
 (0)