Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ This framework is built around the full loop: **create strategies → vector bac
- 📄 **[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
- 📦 **[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.
- 🌐 **[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
- 📝 **[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()`
- � **[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.
- �📝 **[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()`
- 🚀 **[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

</details>
Expand Down
252 changes: 252 additions & 0 deletions docusaurus/docs/Advanced Concepts/portfolio-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
---
sidebar_position: 4
---

# Portfolio Sync & Deposit Schedules

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.

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).

## TL;DR — Declarative deposit schedule on a market

The most common case — a recurring deposit (e.g. monthly paycheck DCA) — needs **zero strategy code**:

```python
from investing_algorithm_framework import (
create_app,
ScheduledDeposit,
TimeUnit,
)

app = create_app()
app.add_market(
market="BITVAVO",
trading_symbol="EUR",
initial_balance=2500,
deposit_schedule=[
ScheduledDeposit(
amount=100.0, time_unit=TimeUnit.DAY, interval=30
),
],
auto_sync=True,
)
```

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.

## `ScheduledDeposit`

A frozen dataclass describing **one** deposit rule. Two mutually exclusive forms:

| Form | Fields | Behaviour |
|------|--------|-----------|
| **Recurring** | `amount`, `time_unit`, `interval` | Fires every `interval` units of `time_unit` from the anchor (the first time the loop sees the market). |
| **One-shot** | `amount`, `on` (timezone-aware `datetime`) | Fires once, at or after `on`. |

```python
from datetime import datetime, timezone
from investing_algorithm_framework import ScheduledDeposit, TimeUnit

# Weekly DCA top-up
ScheduledDeposit(amount=50.0, time_unit=TimeUnit.DAY, interval=7)

# One-time bonus on a fixed date
ScheduledDeposit(amount=1000.0, on=datetime(2025, 12, 24, tzinfo=timezone.utc))

# Negative amount = scheduled withdrawal
ScheduledDeposit(amount=-200.0, time_unit=TimeUnit.DAY, interval=30)
```

`MONTH` cadence is approximated as 30 days (`timedelta` has no calendar months). For exact calendar dates use one-shot deposits.

You can pass a list of `ScheduledDeposit` to `add_market(...)`, or add them later:

```python
app.add_deposit_schedule(
market="BITVAVO",
schedule=[ScheduledDeposit(amount=100.0, time_unit=TimeUnit.DAY, interval=30)],
)
app.set_market_auto_sync("BITVAVO", enabled=True)
```

## `context.sync_portfolio(market, allow_withdrawals=False, tolerance=1e-9) -> SyncResult`

This is the canonical reconciliation call. Behaviour depends on the environment:

| Mode | "Broker available" comes from |
|------|-------------------------------|
| **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. |
| **Backtest** | The `BrokerBalanceTracker` materialises the schedule. Due deposits accumulate in a "pending" bucket and are drained into `unallocated`. |

The returned `SyncResult` has fields:

```python
@dataclass(frozen=True)
class SyncResult:
market: str
kind: Literal["noop", "deposit", "withdrawal"]
delta: float # broker_available - previous_unallocated
broker_available: float
previous_unallocated: float
new_unallocated: float
within_tolerance: bool = False # |delta| <= tolerance, but not zero
reserved_for_pending_orders: float = 0.0
```

### Tolerance

Pass `tolerance=` to ignore sub-cent rounding drift between the broker and your local books:

```python
# Treat anything under 1 cent of drift as a no-op
context.sync_portfolio(market="BITVAVO", tolerance=0.01)
```

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.

### Withdrawal semantics

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.

Opt in to draining with `allow_withdrawals=True`:

```python
try:
result = context.sync_portfolio(market="BITVAVO")
except PortfolioOutOfSyncError as err:
log.warning(
"Out of sync on %s: local=%s broker=%s delta=%s",
err.market, err.local_unallocated, err.broker_available, err.delta,
)
# Decide: pause the bot, alert, or drain.
context.sync_portfolio(market="BITVAVO", allow_withdrawals=True)
```

`unallocated` will never be pushed below zero regardless of the flag.

## Auto-sync error handling

`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`:

| Mode | Behaviour on transient broker error |
|------|-------------------------------------|
| `"raise"` *(default)* | Propagate the exception. The event loop stops. Best during development — fail loudly. |
| `"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. |
| `"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. |

```python
app.add_market(
market="BITVAVO",
trading_symbol="EUR",
initial_balance=2500,
auto_sync=True,
auto_sync_error_mode="warn",
)
```

`PortfolioOutOfSyncError` is **always** re-raised regardless of mode — that's a hard data-integrity signal, not a transient.

## `fire_on_anchor` — deposit at t=0

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:

```python
ScheduledDeposit(
amount=500.0,
time_unit=TimeUnit.DAY,
interval=30,
fire_on_anchor=True, # also fires on day 0
)
```

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).

## TWR-aware return metrics

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:

$$ r_t = \frac{V_t - \text{cash\_flow}_t}{V_{t-1}} - 1 $$

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.

> **Equity curve and drawdown** are exposed in **two flavours**:
>
> - `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?".
> - `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.
>
> ```python
> from investing_algorithm_framework.services.metrics import (
> get_twr_equity_curve, get_twr_max_drawdown,
> )
> # Growth-of-$1 alpha curve
> curve = get_twr_equity_curve(snapshots, base=1.0)
> dd = get_twr_max_drawdown(snapshots) # e.g. 0.18 for 18%
> ```

## Mid-run schedule changes

`tracker.set_schedule(market, [...])` and `add_deposit_schedule` can be called at any time. The tracker preserves:

- **`pending`** — any deposits that already fired but haven't been absorbed by `sync_portfolio` yet.
- **`cash_flow_since_snapshot`** — TWR bookkeeping waiting to be drained by the next snapshot.

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.

## Manual sync (without `auto_sync`)

If you prefer explicit control, leave `auto_sync=False` and call `sync_portfolio` from your strategy:

```python
class MyStrategy(TradingStrategy):
def run_strategy(self, context, data):
context.sync_portfolio(market="BITVAVO")
cash = context.get_unallocated()
...
```

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).
or if you want to run a one-shot sync declare a task that calls `sync_portfolio` once at startup:

```python
from investing_algorithm_framework import Task

class SyncTask(Task):
interval = None # run once at startup

def run(self, context):
context.sync_portfolio(market="BITVAVO")
log.info("Initial sync complete")

## Vector backtests

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.

```python
from investing_algorithm_framework import PortfolioConfiguration, ScheduledDeposit, TimeUnit

PortfolioConfiguration(
market="BITVAVO",
trading_symbol="EUR",
initial_balance=2500,
deposit_schedule=[
ScheduledDeposit(amount=100.0, time_unit=TimeUnit.DAY, interval=30),
],
)
```

## Worked example

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.

## API summary

| Symbol | Where | Purpose |
|--------|-------|---------|
| `ScheduledDeposit` | `investing_algorithm_framework` | Declarative deposit rule (recurring or one-shot). |
| `SyncResult` | `investing_algorithm_framework` | Outcome of a sync call. |
| `PortfolioOutOfSyncError` | `investing_algorithm_framework` | Raised when broker balance < local unallocated and withdrawals are not opted in. |
| `app.add_market(deposit_schedule=, auto_sync=)` | App | Register a market with optional schedule and auto-sync flag. |
| `app.add_deposit_schedule(market, schedule)` | App | Attach a schedule to an existing market. |
| `app.set_market_auto_sync(market, enabled=True)` | App | Toggle per-market auto-sync. |
| `context.sync_portfolio(market, allow_withdrawals=False)` | Context | Reconcile local cash with broker / tracker. |
14 changes: 14 additions & 0 deletions docusaurus/docs/Advanced Concepts/vector-backtesting.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ Use vector backtesting when:
- Needing to complete backtests quickly
- Working with large strategy sets (100+ strategies)

:::warning Order types not modelled in vector backtests
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.

This means the following are **only evaluated in event-driven backtests** and live trading:

- `OrderType.MARKET` slippage and next-bar fill behaviour
- `OrderType.LIMIT` resting fill logic
- `OrderType.STOP` and `OrderType.STOP_LIMIT` trigger logic
- Take-profit / stop-loss rules attached to trades
- Blotter slippage / commission / volume-based fill models

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.
:::

## Basic Usage

### Single Strategy, Single Date Range
Expand Down
71 changes: 53 additions & 18 deletions docusaurus/docs/Getting Started/orders.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,41 +96,76 @@ algorithm.create_sell_order(

### Stop Orders

Trigger market orders when price reaches a specified level:
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.

- **SELL stop** triggers when the market drops to (or below) `stop_price` — used for stop-losses or trend-following exits.
- **BUY stop** triggers when the market rises to (or above) `stop_price` — used for breakout entries.

```python
# Stop loss - sell if price drops to 45,000
algorithm.create_sell_order(
from investing_algorithm_framework import OrderType, OrderSide

# SELL stop — exit if BTC drops to 45,000 EUR
self.create_order(
target_symbol="BTC",
percentage=1.0,
order_type="STOP",
stop_price=45000
order_side=OrderSide.SELL,
amount=0.5,
order_type=OrderType.STOP,
stop_price=45000,
)

# Buy stop - buy if price rises to 52,000 (breakout strategy)
algorithm.create_buy_order(
# BUY stop — enter on a breakout above 52,000 EUR
self.create_order(
target_symbol="BTC",
amount=100,
order_type="STOP",
stop_price=52000
order_side=OrderSide.BUY,
amount=0.1,
order_type=OrderType.STOP,
stop_price=52000,
price=52000, # used as the cash-reservation reference
)
```

### Stop-Limit Orders

Combine stop and limit order features:
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.

```python
# Stop-limit sell order
algorithm.create_sell_order(
# SELL stop-limit — trigger at 45,000, but only sell at 44,500 or better
self.create_order(
target_symbol="BTC",
percentage=1.0,
order_type="STOP_LIMIT",
stop_price=45000, # Trigger when price hits 45,000
price=44500 # But only sell at 44,500 or better
order_side=OrderSide.SELL,
amount=0.5,
order_type=OrderType.STOP_LIMIT,
stop_price=45000, # trigger price
price=44500, # limit price after trigger
)
```

**Validation rules**

- `stop_price` is required for both `STOP` and `STOP_LIMIT` orders.
- For `STOP_LIMIT`, `price` is also required and must be:
- `price <= stop_price` for SELL stop-limit orders
- `price >= stop_price` for BUY stop-limit orders

:::note Where stop orders are evaluated
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).

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.
:::

#### Backtest Trigger Semantics

In an event-driven backtest, the trigger condition is evaluated against each candle's High / Low after the order's `updated_at`:

| Order side | Triggers when |
|------------|---------------|
| SELL STOP / STOP_LIMIT | `Low <= stop_price` |
| BUY STOP / STOP_LIMIT | `High >= stop_price` |

- 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).
- 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).
- The triggering timestamp is stored on the order as `triggered_at` for auditability.

## Order Parameters

### Market Order Parameters
Expand Down
4 changes: 4 additions & 0 deletions docusaurus/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ const sidebars = {
type: 'doc',
id: 'Advanced Concepts/recording-variables',
},
{
type: 'doc',
id: 'Advanced Concepts/portfolio-sync',
},
{
type: 'doc',
id: 'Advanced Concepts/pipelines',
Expand Down
Loading
Loading