Skip to content

Commit ddcfe50

Browse files
committed
feat: add Currency/FX conversion support (#456)
- Add FXRateProvider ABC and StaticFXRateProvider implementation - Wire FX rate provider and base currency into App and Context - Add context.get_fx_rate(), convert_to_base_currency(), get_portfolio_value() - Auto-convert multi-market portfolio values to base currency - StaticFXRateProvider supports inverse rates and case-insensitive lookups - Add 17 unit tests for FX models - Add docs page for FX conversion (Advanced Concepts)
1 parent 0c01673 commit ddcfe50

9 files changed

Lines changed: 660 additions & 2 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
---
2+
sidebar_position: 4
3+
---
4+
5+
# Currency / FX Conversion
6+
7+
When you trade across multiple markets that use different currencies, you need a way to express all position values in a single **base currency**. The framework provides a pluggable FX conversion system that handles this automatically.
8+
9+
## Quick Start
10+
11+
```python
12+
from investing_algorithm_framework import (
13+
create_app, StaticFXRateProvider
14+
)
15+
16+
app = create_app()
17+
18+
# 1. Set the base currency for portfolio reporting
19+
app.set_base_currency("EUR")
20+
21+
# 2. Register an FX rate provider
22+
app.add_fx_rate_provider(StaticFXRateProvider({
23+
("USD", "EUR"): 0.92,
24+
("GBP", "EUR"): 1.17,
25+
}))
26+
27+
# 3. Add your markets as usual
28+
app.add_market(
29+
market="binance",
30+
trading_symbol="USD",
31+
# ...
32+
)
33+
app.add_market(
34+
market="bitvavo",
35+
trading_symbol="EUR",
36+
# ...
37+
)
38+
```
39+
40+
Inside a strategy you can now call:
41+
42+
```python
43+
class MyStrategy(TradingStrategy):
44+
def run_strategy(self, context: Context, **kwargs):
45+
# Total portfolio value across all markets, in EUR
46+
total_value = context.get_portfolio_value()
47+
48+
# Convert a specific amount
49+
usd_amount = 1000
50+
eur_amount = context.convert_to_base_currency(usd_amount, "USD")
51+
52+
# Get a raw FX rate
53+
rate = context.get_fx_rate("GBP", "EUR")
54+
```
55+
56+
## Concepts
57+
58+
### Base Currency
59+
60+
The base currency is the single currency in which the total portfolio value is reported. Set it once on the app:
61+
62+
```python
63+
app.set_base_currency("EUR")
64+
```
65+
66+
If no base currency is set, `context.get_portfolio_value()` returns the value of the first portfolio in its own trading currency — no conversion takes place.
67+
68+
### FX Rate Provider
69+
70+
An FX rate provider is any class that implements `FXRateProvider`:
71+
72+
```python
73+
from investing_algorithm_framework import FXRateProvider
74+
75+
class FXRateProvider(ABC):
76+
def get_rate(
77+
self,
78+
from_currency: str,
79+
to_currency: str,
80+
date: datetime = None
81+
) -> float:
82+
"""Return rate such that amount_in_to = amount_in_from * rate."""
83+
...
84+
```
85+
86+
The `date` parameter is passed automatically during backtesting (the current simulation date) and during live trading (`datetime.now()`). This lets you build providers that look up historical rates for accurate backtesting.
87+
88+
### StaticFXRateProvider
89+
90+
A built-in provider for fixed rates. Inverse rates are computed automatically.
91+
92+
```python
93+
from investing_algorithm_framework import StaticFXRateProvider
94+
95+
provider = StaticFXRateProvider({
96+
("USD", "EUR"): 0.92,
97+
("GBP", "EUR"): 1.17,
98+
})
99+
100+
# Direct rate
101+
provider.get_rate("USD", "EUR") # 0.92
102+
103+
# Inverse is automatic
104+
provider.get_rate("EUR", "USD") # ~1.087
105+
106+
# Same currency always returns 1.0
107+
provider.get_rate("EUR", "EUR") # 1.0
108+
```
109+
110+
You can also add rates dynamically:
111+
112+
```python
113+
provider.add_rate("JPY", "EUR", 0.0062)
114+
```
115+
116+
## Building a Custom FX Rate Provider
117+
118+
For production use you'll want live or historical rates. Implement `FXRateProvider` and fetch rates from your preferred source:
119+
120+
```python
121+
import requests
122+
from investing_algorithm_framework import FXRateProvider
123+
124+
125+
class LiveFXRateProvider(FXRateProvider):
126+
"""Fetch rates from an external API."""
127+
128+
def __init__(self, api_key: str):
129+
self._api_key = api_key
130+
self._cache = {}
131+
132+
def get_rate(self, from_currency, to_currency, date=None):
133+
if from_currency == to_currency:
134+
return 1.0
135+
136+
pair = f"{from_currency}/{to_currency}"
137+
138+
if pair not in self._cache:
139+
# Replace with your actual API call
140+
resp = requests.get(
141+
f"https://api.example.com/fx/{pair}",
142+
headers={"Authorization": f"Bearer {self._api_key}"}
143+
)
144+
resp.raise_for_status()
145+
self._cache[pair] = resp.json()["rate"]
146+
147+
return self._cache[pair]
148+
149+
150+
app.add_fx_rate_provider(LiveFXRateProvider(api_key="..."))
151+
```
152+
153+
## Context Methods
154+
155+
| Method | Description |
156+
|--------|-------------|
157+
| `context.get_fx_rate(from_currency, to_currency)` | Get the exchange rate between two currencies |
158+
| `context.convert_to_base_currency(amount, from_currency)` | Convert an amount to the base currency |
159+
| `context.get_portfolio_value()` | Total portfolio value across all markets in the base currency |
160+
161+
## How It Works
162+
163+
When `context.get_portfolio_value()` is called:
164+
165+
1. For each portfolio (market), the framework computes the **local value** — the sum of all position values denominated in that portfolio's `trading_symbol`.
166+
2. If a `base_currency` and `FXRateProvider` are registered, the local value is converted to the base currency using `fx_rate_provider.get_rate(trading_symbol, base_currency)`.
167+
3. All converted values are summed to produce the total portfolio value.
168+
169+
```
170+
Portfolio "binance" (USD) Portfolio "bitvavo" (EUR)
171+
BTC: 0.5 × $60,000 = $30,000 ETH: 2 × €3,000 = €6,000
172+
+ USDT: $5,000 + EUR cash: €1,000
173+
= $35,000 = €7,000
174+
│ │
175+
│ × 0.92 (USD→EUR) │ × 1.0 (same currency)
176+
▼ ▼
177+
€32,200 €7,000
178+
╲ ╱
179+
╲ ╱
180+
Total = €39,200
181+
```

docusaurus/sidebars.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ const sidebars = {
8181
type: 'doc',
8282
id: 'Advanced Concepts/blotter',
8383
},
84+
{
85+
type: 'doc',
86+
id: 'Advanced Concepts/fx-conversion',
87+
},
8488
{
8589
type: 'doc',
8690
id: 'Advanced Concepts/custom-data-providers',

investing_algorithm_framework/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
SlippageModel, NoSlippage, PercentageSlippage, FixedSlippage, \
3030
VolumeImpactSlippage, \
3131
CommissionModel, NoCommission, PercentageCommission, FixedCommission, \
32-
FillModel, FullFill, VolumeBasedFill
32+
FillModel, FullFill, VolumeBasedFill, \
33+
FXRateProvider, StaticFXRateProvider
3334
from .infrastructure import AzureBlobStorageStateHandler, \
3435
CSVOHLCVDataProvider, CSVTickerDataProvider, CSVURLDataProvider, \
3536
JSONURLDataProvider, ParquetURLDataProvider, \
@@ -251,4 +252,6 @@
251252
"FillModel",
252253
"FullFill",
253254
"VolumeBasedFill",
255+
"FXRateProvider",
256+
"StaticFXRateProvider",
254257
]

investing_algorithm_framework/app/app.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ def __init__(self, state_handler=None, name=None):
7272
self._run_history = None
7373
self._name = name
7474
self._blotter = None
75+
self._fx_rate_provider = None
76+
self._base_currency = None
7577

7678
@property
7779
def context(self):
@@ -81,6 +83,8 @@ def context(self):
8183
ctx = self.container.context()
8284
ctx._blotter = self._blotter \
8385
if self._blotter is not None else DefaultBlotter()
86+
ctx._fx_rate_provider = self._fx_rate_provider
87+
ctx._base_currency = self._base_currency
8488

8589
return ctx
8690

@@ -2252,6 +2256,65 @@ def get_blotter(self):
22522256
"""
22532257
return self._blotter
22542258

2259+
def set_base_currency(self, currency: str) -> None:
2260+
"""
2261+
Set the base currency for multi-currency portfolio reporting.
2262+
2263+
When a base currency is set and an FX rate provider is registered,
2264+
the framework will automatically convert position values from
2265+
their local currency to the base currency when computing
2266+
portfolio totals.
2267+
2268+
Args:
2269+
currency: Currency code (e.g. "EUR", "USD", "GBP").
2270+
2271+
Returns:
2272+
None
2273+
"""
2274+
self._base_currency = currency.upper()
2275+
2276+
def get_base_currency(self) -> str:
2277+
"""
2278+
Get the configured base currency.
2279+
2280+
Returns:
2281+
str or None: The base currency code, or None if not set.
2282+
"""
2283+
return self._base_currency
2284+
2285+
def add_fx_rate_provider(self, fx_rate_provider) -> None:
2286+
"""
2287+
Register an FX rate provider for multi-currency portfolio
2288+
support. The provider supplies exchange rates between
2289+
currency pairs.
2290+
2291+
Args:
2292+
fx_rate_provider: Instance of FXRateProvider.
2293+
2294+
Returns:
2295+
None
2296+
"""
2297+
from investing_algorithm_framework.domain.fx import FXRateProvider
2298+
2299+
if inspect.isclass(fx_rate_provider):
2300+
fx_rate_provider = fx_rate_provider()
2301+
2302+
if not isinstance(fx_rate_provider, FXRateProvider):
2303+
raise OperationalException(
2304+
"FX rate provider should be an instance of FXRateProvider"
2305+
)
2306+
2307+
self._fx_rate_provider = fx_rate_provider
2308+
2309+
def get_fx_rate_provider(self):
2310+
"""
2311+
Get the configured FX rate provider.
2312+
2313+
Returns:
2314+
FXRateProvider or None: The FX rate provider instance.
2315+
"""
2316+
return self._fx_rate_provider
2317+
22552318
def add_order_executor(self, order_executor):
22562319
"""
22572320
Function to add an order executor to the app. The order executor

0 commit comments

Comments
 (0)