Skip to content

Commit 0dbd880

Browse files
authored
Merge pull request #535 from coding-kitties/dev
Release v8.8.0 — TWR metrics, broker balance tracker, stop orders
2 parents c024586 + d34fdb7 commit 0dbd880

138 files changed

Lines changed: 8315 additions & 202 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ This framework is built around the full loop: **create strategies → vector bac
8585
- 📄 **[One-Click HTML Report](https://coding-kitties.github.io/investing-algorithm-framework/Getting%20Started/backtest-reports)** — Self-contained file, no server, dark & light theme, shareable
8686
- 📦 **[Custom `.iafbt` Backtest Bundle Format](https://coding-kitties.github.io/investing-algorithm-framework/Data/backtest_data)** — An explicit, versioned, compressed, language-portable container (zstd + msgpack with magic-byte header) plus a separate parquet index for fast filtering without loading. ~21× smaller and ~27× fewer files than standard filebased directory layouts, with parallel I/O for fast load/save of large amounts of backtests.
8787
- 🌐 **[Load External Data](https://coding-kitties.github.io/investing-algorithm-framework/Data/external-data)** — Fetch CSV, JSON, or Parquet from any URL with caching and auto-refresh
88-
- 📝 **[Record Custom Variables](https://coding-kitties.github.io/investing-algorithm-framework/Advanced%20Concepts/recording-variables)** — Track any indicator or metric during backtests with `context.record()`
88+
-**[Per-Market Deposit Schedules & Portfolio Sync](https://coding-kitties.github.io/investing-algorithm-framework/Advanced%20Concepts/portfolio-sync)** — Declare recurring or one-shot external cash flows on a market with `deposit_schedule=` / `auto_sync=True`. Backtests simulate the deposits; live mode reconciles with the broker — same `context.sync_portfolio()` API in both modes.
89+
- �📝 **[Record Custom Variables](https://coding-kitties.github.io/investing-algorithm-framework/Advanced%20Concepts/recording-variables)** — Track any indicator or metric during backtests with `context.record()`
8990
- 🚀 **[Build → Backtest → Deploy](https://coding-kitties.github.io/investing-algorithm-framework/Getting%20Started/application-setup)** — Local dev, cloud deploy (AWS / Azure), or monetize on Finterion
9091

9192
</details>
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
---
2+
sidebar_position: 4
3+
---
4+
5+
# Portfolio Sync & Deposit Schedules
6+
7+
Real trading bots rarely operate on a static initial balance. Cash flows in (paychecks, transfers, profit reinvestment) and sometimes out (withdrawals). The framework treats external cash movement as a **first-class concept** so the same code works in backtests and in live deployments.
8+
9+
The contract is a single API: `context.sync_portfolio(market=...)`. It reconciles the portfolio's local `unallocated` cash with what is *actually* available on the broker (live) or with the simulated deposit schedule (backtest).
10+
11+
## TL;DR — Declarative deposit schedule on a market
12+
13+
The most common case — a recurring deposit (e.g. monthly paycheck DCA) — needs **zero strategy code**:
14+
15+
```python
16+
from investing_algorithm_framework import (
17+
create_app,
18+
ScheduledDeposit,
19+
TimeUnit,
20+
)
21+
22+
app = create_app()
23+
app.add_market(
24+
market="BITVAVO",
25+
trading_symbol="EUR",
26+
initial_balance=2500,
27+
deposit_schedule=[
28+
ScheduledDeposit(
29+
amount=100.0, time_unit=TimeUnit.DAY, interval=30
30+
),
31+
],
32+
auto_sync=True,
33+
)
34+
```
35+
36+
With `auto_sync=True` the event loop calls `context.sync_portfolio(market=...)` at the start of every iteration. The strategy simply checks `context.get_unallocated()` and the new cash is already there.
37+
38+
## `ScheduledDeposit`
39+
40+
A frozen dataclass describing **one** deposit rule. Two mutually exclusive forms:
41+
42+
| Form | Fields | Behaviour |
43+
|------|--------|-----------|
44+
| **Recurring** | `amount`, `time_unit`, `interval` | Fires every `interval` units of `time_unit` from the anchor (the first time the loop sees the market). |
45+
| **One-shot** | `amount`, `on` (timezone-aware `datetime`) | Fires once, at or after `on`. |
46+
47+
```python
48+
from datetime import datetime, timezone
49+
from investing_algorithm_framework import ScheduledDeposit, TimeUnit
50+
51+
# Weekly DCA top-up
52+
ScheduledDeposit(amount=50.0, time_unit=TimeUnit.DAY, interval=7)
53+
54+
# One-time bonus on a fixed date
55+
ScheduledDeposit(amount=1000.0, on=datetime(2025, 12, 24, tzinfo=timezone.utc))
56+
57+
# Negative amount = scheduled withdrawal
58+
ScheduledDeposit(amount=-200.0, time_unit=TimeUnit.DAY, interval=30)
59+
```
60+
61+
`MONTH` cadence is approximated as 30 days (`timedelta` has no calendar months). For exact calendar dates use one-shot deposits.
62+
63+
You can pass a list of `ScheduledDeposit` to `add_market(...)`, or add them later:
64+
65+
```python
66+
app.add_deposit_schedule(
67+
market="BITVAVO",
68+
schedule=[ScheduledDeposit(amount=100.0, time_unit=TimeUnit.DAY, interval=30)],
69+
)
70+
app.set_market_auto_sync("BITVAVO", enabled=True)
71+
```
72+
73+
## `context.sync_portfolio(market, allow_withdrawals=False, tolerance=1e-9) -> SyncResult`
74+
75+
This is the canonical reconciliation call. Behaviour depends on the environment:
76+
77+
| Mode | "Broker available" comes from |
78+
|------|-------------------------------|
79+
| **Live** (`PROD` / `DEV`) | `PortfolioProvider.get_position(...).amount` — the exchange's free balance for the trading symbol, **minus** cash reserved for orders the framework has created but the exchange has not yet acknowledged (`OrderStatus.CREATED` BUYs). This avoids flagging a brief race window between `create_order()` and the exchange ack as a phantom withdrawal. |
80+
| **Backtest** | The `BrokerBalanceTracker` materialises the schedule. Due deposits accumulate in a "pending" bucket and are drained into `unallocated`. |
81+
82+
The returned `SyncResult` has fields:
83+
84+
```python
85+
@dataclass(frozen=True)
86+
class SyncResult:
87+
market: str
88+
kind: Literal["noop", "deposit", "withdrawal"]
89+
delta: float # broker_available - previous_unallocated
90+
broker_available: float
91+
previous_unallocated: float
92+
new_unallocated: float
93+
within_tolerance: bool = False # |delta| <= tolerance, but not zero
94+
reserved_for_pending_orders: float = 0.0
95+
```
96+
97+
### Tolerance
98+
99+
Pass `tolerance=` to ignore sub-cent rounding drift between the broker and your local books:
100+
101+
```python
102+
# Treat anything under 1 cent of drift as a no-op
103+
context.sync_portfolio(market="BITVAVO", tolerance=0.01)
104+
```
105+
106+
When the absolute drift is at or below `tolerance` the call returns a `noop` result with `within_tolerance=True` and the raw `delta` still surfaced for diagnostics. The portfolio's `unallocated` is **not** modified.
107+
108+
### Withdrawal semantics
109+
110+
By default, `sync_portfolio` raises `PortfolioOutOfSyncError` if the broker has **less** cash than the portfolio thinks it owns. This is intentional — silently shrinking your bot's cash on a transient broker glitch would be a debugging nightmare.
111+
112+
Opt in to draining with `allow_withdrawals=True`:
113+
114+
```python
115+
try:
116+
result = context.sync_portfolio(market="BITVAVO")
117+
except PortfolioOutOfSyncError as err:
118+
log.warning(
119+
"Out of sync on %s: local=%s broker=%s delta=%s",
120+
err.market, err.local_unallocated, err.broker_available, err.delta,
121+
)
122+
# Decide: pause the bot, alert, or drain.
123+
context.sync_portfolio(market="BITVAVO", allow_withdrawals=True)
124+
```
125+
126+
`unallocated` will never be pushed below zero regardless of the flag.
127+
128+
## Auto-sync error handling
129+
130+
`add_market(..., auto_sync=True)` is convenient but a single flaky API call should not crash a long-running bot. Pick the policy that matches your operational stance with `auto_sync_error_mode`:
131+
132+
| Mode | Behaviour on transient broker error |
133+
|------|-------------------------------------|
134+
| `"raise"` *(default)* | Propagate the exception. The event loop stops. Best during development — fail loudly. |
135+
| `"warn"` | Log a warning and continue. Auto-sync retries on the next iteration. Best for live trading: a single 502 from the exchange shouldn't take the bot down. |
136+
| `"halt"` | Log an error and disable auto-sync **for that market** until the app is restarted or `set_market_auto_sync(...)` is called again. The strategy keeps running on whatever cash it currently has. |
137+
138+
```python
139+
app.add_market(
140+
market="BITVAVO",
141+
trading_symbol="EUR",
142+
initial_balance=2500,
143+
auto_sync=True,
144+
auto_sync_error_mode="warn",
145+
)
146+
```
147+
148+
`PortfolioOutOfSyncError` is **always** re-raised regardless of mode — that's a hard data-integrity signal, not a transient.
149+
150+
## `fire_on_anchor` — deposit at t=0
151+
152+
By default a recurring deposit fires at `anchor + interval`, not at the anchor itself. Set `fire_on_anchor=True` to also fire on the anchor day:
153+
154+
```python
155+
ScheduledDeposit(
156+
amount=500.0,
157+
time_unit=TimeUnit.DAY,
158+
interval=30,
159+
fire_on_anchor=True, # also fires on day 0
160+
)
161+
```
162+
163+
This is useful for "fund the bot immediately at start, then top up monthly" patterns. Only valid for recurring deposits (one-shots fire on `on=` regardless).
164+
165+
## TWR-aware return metrics
166+
167+
Every deposit/withdrawal absorbed by `sync_portfolio` (or replayed by the vector backtest) is stamped onto the next `PortfolioSnapshot.cash_flow`. The framework's return metrics use this to compute **time-weighted returns** so that depositing $1,000 into your bot does not show up as $1,000 of P&L:
168+
169+
$$ r_t = \frac{V_t - \text{cash\_flow}_t}{V_{t-1}} - 1 $$
170+
171+
The following metrics are TWR-adjusted: **CAGR**, **monthly returns**, **yearly returns**, **mean daily return**, **Sharpe**, **Sortino**, **volatility**, **VaR / CVaR**, and the standard-deviation family. Snapshots without a `cash_flow` field (legacy data, mocks) gracefully fall back to the classic `pct_change()` behaviour.
172+
173+
> **Equity curve and drawdown** are exposed in **two flavours**:
174+
>
175+
> - `get_equity_curve` / `get_drawdown_series` / `get_max_drawdown` / `get_max_drawdown_duration` — raw account value, including deposits. Use these when you want to see "how many dollars did the account hold?".
176+
> - `get_twr_equity_curve` / `get_twr_drawdown_series` / `get_twr_max_drawdown` / `get_twr_max_drawdown_duration` — alpha-only path scrubbed of external cash flows. Use these when comparing risk profiles across portfolios funded differently — depositing $1,000 during a drawdown won't artificially erase it.
177+
>
178+
> ```python
179+
> from investing_algorithm_framework.services.metrics import (
180+
> get_twr_equity_curve, get_twr_max_drawdown,
181+
> )
182+
> # Growth-of-$1 alpha curve
183+
> curve = get_twr_equity_curve(snapshots, base=1.0)
184+
> dd = get_twr_max_drawdown(snapshots) # e.g. 0.18 for 18%
185+
> ```
186+
187+
## Mid-run schedule changes
188+
189+
`tracker.set_schedule(market, [...])` and `add_deposit_schedule` can be called at any time. The tracker preserves:
190+
191+
- **`pending`**any deposits that already fired but haven't been absorbed by `sync_portfolio` yet.
192+
- **`cash_flow_since_snapshot`**TWR bookkeeping waiting to be drained by the next snapshot.
193+
194+
Only the schedule itself and `last_fired` anchors are reset — so swapping a 30-day schedule for a 7-day schedule mid-backtest doesn't accidentally "swallow" a pending deposit.
195+
196+
## Manual sync (without `auto_sync`)
197+
198+
If you prefer explicit control, leave `auto_sync=False` and call `sync_portfolio` from your strategy:
199+
200+
```python
201+
class MyStrategy(TradingStrategy):
202+
def run_strategy(self, context, data):
203+
context.sync_portfolio(market="BITVAVO")
204+
cash = context.get_unallocated()
205+
...
206+
```
207+
208+
This pattern is useful if you want to sync only on specific bars, or after specific events (e.g. only after a fill notification in live mode).
209+
or if you want to run a one-shot sync declare a task that calls `sync_portfolio` once at startup:
210+
211+
```python
212+
from investing_algorithm_framework import Task
213+
214+
class SyncTask(Task):
215+
interval = None # run once at startup
216+
217+
def run(self, context):
218+
context.sync_portfolio(market="BITVAVO")
219+
log.info("Initial sync complete")
220+
221+
## Vector backtests
222+
223+
Vector backtests respect the same `deposit_schedule`. Because vector workers run in subprocesses, the schedule travels with `PortfolioConfiguration` (it is pickled). Each iteration's cash track has the due deposits added before signals are evaluated, so the equity curve includes external cash flows.
224+
225+
```python
226+
from investing_algorithm_framework import PortfolioConfiguration, ScheduledDeposit, TimeUnit
227+
228+
PortfolioConfiguration(
229+
market="BITVAVO",
230+
trading_symbol="EUR",
231+
initial_balance=2500,
232+
deposit_schedule=[
233+
ScheduledDeposit(amount=100.0, time_unit=TimeUnit.DAY, interval=30),
234+
],
235+
)
236+
```
237+
238+
## Worked example
239+
240+
See [`examples/strategies_showcase/08_dca_accumulation`](https://github.com/coding-kitties/investing-algorithm-framework/tree/main/examples/strategies_showcase/08_dca_accumulation) for a complete DCA bot that uses `deposit_schedule` + `auto_sync` to model recurring monthly deposits across a 2-year backtest.
241+
242+
## API summary
243+
244+
| Symbol | Where | Purpose |
245+
|--------|-------|---------|
246+
| `ScheduledDeposit` | `investing_algorithm_framework` | Declarative deposit rule (recurring or one-shot). |
247+
| `SyncResult` | `investing_algorithm_framework` | Outcome of a sync call. |
248+
| `PortfolioOutOfSyncError` | `investing_algorithm_framework` | Raised when broker balance < local unallocated and withdrawals are not opted in. |
249+
| `app.add_market(deposit_schedule=, auto_sync=)` | App | Register a market with optional schedule and auto-sync flag. |
250+
| `app.add_deposit_schedule(market, schedule)` | App | Attach a schedule to an existing market. |
251+
| `app.set_market_auto_sync(market, enabled=True)` | App | Toggle per-market auto-sync. |
252+
| `context.sync_portfolio(market, allow_withdrawals=False)` | Context | Reconcile local cash with broker / tracker. |

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

docusaurus/docs/Getting Started/orders.md

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -96,41 +96,76 @@ algorithm.create_sell_order(
9696

9797
### Stop Orders
9898

99-
Trigger market orders when price reaches a specified level:
99+
A **stop order** rests in the book until the market trades through a configured `stop_price`. Once triggered, it becomes a market order and fills at the next available price.
100+
101+
- **SELL stop** triggers when the market drops to (or below) `stop_price` — used for stop-losses or trend-following exits.
102+
- **BUY stop** triggers when the market rises to (or above) `stop_price` — used for breakout entries.
100103

101104
```python
102-
# Stop loss - sell if price drops to 45,000
103-
algorithm.create_sell_order(
105+
from investing_algorithm_framework import OrderType, OrderSide
106+
107+
# SELL stop — exit if BTC drops to 45,000 EUR
108+
self.create_order(
104109
target_symbol="BTC",
105-
percentage=1.0,
106-
order_type="STOP",
107-
stop_price=45000
110+
order_side=OrderSide.SELL,
111+
amount=0.5,
112+
order_type=OrderType.STOP,
113+
stop_price=45000,
108114
)
109115

110-
# Buy stop - buy if price rises to 52,000 (breakout strategy)
111-
algorithm.create_buy_order(
116+
# BUY stop — enter on a breakout above 52,000 EUR
117+
self.create_order(
112118
target_symbol="BTC",
113-
amount=100,
114-
order_type="STOP",
115-
stop_price=52000
119+
order_side=OrderSide.BUY,
120+
amount=0.1,
121+
order_type=OrderType.STOP,
122+
stop_price=52000,
123+
price=52000, # used as the cash-reservation reference
116124
)
117125
```
118126

119127
### Stop-Limit Orders
120128

121-
Combine stop and limit order features:
129+
A **stop-limit order** triggers like a stop, but instead of becoming a market order, it becomes a **limit order** at the configured `price`. This protects against bad fills during gaps but does not guarantee execution.
122130

123131
```python
124-
# Stop-limit sell order
125-
algorithm.create_sell_order(
132+
# SELL stop-limit — trigger at 45,000, but only sell at 44,500 or better
133+
self.create_order(
126134
target_symbol="BTC",
127-
percentage=1.0,
128-
order_type="STOP_LIMIT",
129-
stop_price=45000, # Trigger when price hits 45,000
130-
price=44500 # But only sell at 44,500 or better
135+
order_side=OrderSide.SELL,
136+
amount=0.5,
137+
order_type=OrderType.STOP_LIMIT,
138+
stop_price=45000, # trigger price
139+
price=44500, # limit price after trigger
131140
)
132141
```
133142

143+
**Validation rules**
144+
145+
- `stop_price` is required for both `STOP` and `STOP_LIMIT` orders.
146+
- For `STOP_LIMIT`, `price` is also required and must be:
147+
- `price <= stop_price` for SELL stop-limit orders
148+
- `price >= stop_price` for BUY stop-limit orders
149+
150+
:::note Where stop orders are evaluated
151+
Stop and stop-limit orders are simulated in **event-driven backtests** and executed by supported exchanges in **live trading** (via the unified CCXT `stopPrice` parameter).
152+
153+
They are **not** evaluated in **vector backtests**. Vector backtesting models strategy *signals* only — it does not simulate the order book, intra-bar price paths, or stop triggers. Use vector backtests to filter parameter sets quickly, then promote promising strategies to event-driven backtests where stops, slippage, commissions, and partial fills are evaluated. See [Vector Backtesting](../Advanced%20Concepts/vector-backtesting) for the trade-offs.
154+
:::
155+
156+
#### Backtest Trigger Semantics
157+
158+
In an event-driven backtest, the trigger condition is evaluated against each candle's High / Low after the order's `updated_at`:
159+
160+
| Order side | Triggers when |
161+
|------------|---------------|
162+
| SELL STOP / STOP_LIMIT | `Low <= stop_price` |
163+
| BUY STOP / STOP_LIMIT | `High >= stop_price` |
164+
165+
- A **STOP** that triggers fills at `stop_price` on the triggering candle (then slippage and commission from the configured blotter / `TradingCost` are applied via the same fill path used for market orders).
166+
- A **STOP_LIMIT** that triggers becomes a resting limit order at `price` from the triggering candle onwards, and fills under the normal limit-fill rules (`Low <= price` for BUY, `High >= price` for SELL).
167+
- The triggering timestamp is stored on the order as `triggered_at` for auditability.
168+
134169
## Order Parameters
135170

136171
### Market Order Parameters

docusaurus/sidebars.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ const sidebars = {
133133
type: 'doc',
134134
id: 'Advanced Concepts/recording-variables',
135135
},
136+
{
137+
type: 'doc',
138+
id: 'Advanced Concepts/portfolio-sync',
139+
},
136140
{
137141
type: 'doc',
138142
id: 'Advanced Concepts/pipelines',

0 commit comments

Comments
 (0)