Skip to content

Commit 566c1c2

Browse files
authored
Merge pull request #477 from coding-kitties/feature/record-function
feat: add context.record() for tracking custom variables during backtests
2 parents 127c3b7 + 4292871 commit 566c1c2

10 files changed

Lines changed: 466 additions & 2 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ This framework is built around the full loop: **create strategies → backtest t
7777
- 📉 **Benchmark Comparison** — Beat-rate analysis vs Buy & Hold, DCA, risk-free & custom benchmarks
7878
- 📄 **One-Click HTML Report** — Self-contained file, no server, dark & light theme, shareable
7979
- 🌐 **Load External Data** — Fetch CSV, JSON, or Parquet from any URL with caching and auto-refresh
80-
- 🚀 **Build → Backtest → Deploy** — Local dev, cloud deploy (AWS / Azure), or monetize on Finterion
80+
-**[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()`
81+
- �🚀 **Build → Backtest → Deploy** — Local dev, cloud deploy (AWS / Azure), or monetize on Finterion
8182

8283
</details>
8384

@@ -272,6 +273,7 @@ report.save("my_report.html")
272273
| **[Cloud Deployment](https://coding-kitties.github.io/investing-algorithm-framework/Getting%20Started/deployment)** | Deploy to AWS Lambda, Azure Functions, or run as a web service |
273274
| **[Market Data Providers](https://coding-kitties.github.io/investing-algorithm-framework/Advanced%20Concepts/custom-data-providers)** | Built-in providers for CCXT, Yahoo Finance, Alpha Vantage, and Polygon — or build your own |
274275
| **[Load External Data](https://coding-kitties.github.io/investing-algorithm-framework/Data/external-data)** | Fetch CSV, JSON, or Parquet from any URL with caching, date parsing, and pre/post-processing |
276+
| **[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()` |
275277
| **[Strategies](https://coding-kitties.github.io/investing-algorithm-framework/Getting%20Started/strategies)** | OHLCV, tickers, custom data — Polars and Pandas native |
276278
| **[Extensible](https://coding-kitties.github.io/investing-algorithm-framework/Advanced%20Concepts/custom-data-providers)** | Custom data providers, order executors, and strategy classes |
277279

@@ -306,7 +308,7 @@ python -m unittest discover -s tests
306308

307309
- [Open an issue](https://github.com/coding-kitties/investing-algorithm-framework/issues/new) for bugs or ideas
308310
- Read the [Contributing Guide](https://coding-kitties.github.io/investing-algorithm-framework/Contributing%20Guide/contributing)
309-
- PRs go against the `develop` branch
311+
- PRs go against the `dev` branch
310312

311313
## Risk Disclaimer
312314

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
---
2+
sidebar_position: 3
3+
---
4+
5+
# Recording Custom Variables
6+
7+
During backtesting you often want to track custom indicators, metrics, or signals alongside your trades — for example an RSI value, a moving average, or a custom score. The `record()` API lets you store **any** key-value pair at each backtest iteration so you can analyse it after the run completes.
8+
9+
Recorded values are stored on the `BacktestRun` object and fully support serialization (save & load).
10+
11+
## Event-Driven Backtests
12+
13+
In an event-driven backtest your strategy's `on_run` method receives a `context` object. Call `context.record()` with arbitrary keyword arguments:
14+
15+
```python
16+
from investing_algorithm_framework import TradingStrategy, TimeUnit
17+
18+
class MyStrategy(TradingStrategy):
19+
time_unit = TimeUnit.DAY
20+
interval = 1
21+
symbols = ["BTC"]
22+
23+
def on_run(self, context, data):
24+
ohlcv = data["BTC/EUR_1d"]
25+
close = ohlcv["Close"]
26+
27+
# Compute any indicator you like
28+
sma_20 = close.rolling(20).mean().iloc[-1]
29+
rsi = compute_rsi(close).iloc[-1]
30+
31+
# Record them — keys can be anything
32+
context.record(
33+
sma_20=sma_20,
34+
rsi=rsi,
35+
signal_strength=0.85,
36+
)
37+
```
38+
39+
Each call stores the values together with the current backtest timestamp. You can call `record()` multiple times per iteration — values are appended.
40+
41+
:::tip
42+
`context.record()` is a **no-op** in live mode, so you can leave the calls in your production strategy without any overhead.
43+
:::
44+
45+
## Vectorized Backtests
46+
47+
Vectorized backtests don't use a `context` object. Instead, override `generate_recorded_values()` on your strategy and return a dictionary of `pandas.Series`:
48+
49+
```python
50+
import pandas as pd
51+
from investing_algorithm_framework import TradingStrategy
52+
53+
class MyVectorStrategy(TradingStrategy):
54+
symbols = ["BTC"]
55+
56+
def generate_buy_signals(self, data):
57+
# ... your buy logic ...
58+
pass
59+
60+
def generate_sell_signals(self, data):
61+
# ... your sell logic ...
62+
pass
63+
64+
def generate_recorded_values(self, data):
65+
ohlcv = data["BTC/EUR_1d"]
66+
close = ohlcv["Close"]
67+
68+
return {
69+
"sma_20": close.rolling(20).mean(),
70+
"rsi": compute_rsi(close),
71+
}
72+
```
73+
74+
Each key becomes a recorded variable with the Series index as timestamps.
75+
76+
## Accessing Recorded Values
77+
78+
After a backtest completes the recorded values are available on the `BacktestRun`:
79+
80+
```python
81+
from investing_algorithm_framework import create_app
82+
83+
app = create_app()
84+
# ... configure app, add strategy, data sources ...
85+
86+
backtest = app.run_backtest()
87+
run = backtest.backtest_runs[0]
88+
89+
# Dict[str, List[Tuple[datetime, Any]]]
90+
print(run.recorded_values)
91+
92+
# Example: extract RSI time series
93+
for dt, value in run.recorded_values["rsi"]:
94+
print(f"{dt}: RSI = {value}")
95+
```
96+
97+
### Converting to a DataFrame
98+
99+
You can easily convert recorded values into a pandas DataFrame for plotting or further analysis:
100+
101+
```python
102+
import pandas as pd
103+
104+
rsi_series = pd.Series(
105+
{dt: val for dt, val in run.recorded_values["rsi"]}
106+
)
107+
sma_series = pd.Series(
108+
{dt: val for dt, val in run.recorded_values["sma_20"]}
109+
)
110+
111+
df = pd.DataFrame({"rsi": rsi_series, "sma_20": sma_series})
112+
print(df)
113+
```
114+
115+
## Serialization
116+
117+
Recorded values are included when you save and load a `BacktestRun`:
118+
119+
```python
120+
# Save
121+
run.save("/path/to/output")
122+
123+
# Load
124+
from investing_algorithm_framework import BacktestRun
125+
loaded = BacktestRun.open("/path/to/output")
126+
print(loaded.recorded_values)
127+
```
128+
129+
The values are stored in the `run.json` file under the `recorded_values` key.
130+
131+
## Supported Value Types
132+
133+
You can record any JSON-serializable value:
134+
135+
| Type | Example |
136+
|------|---------|
137+
| `float` | `context.record(rsi=70.5)` |
138+
| `int` | `context.record(signal=1)` |
139+
| `str` | `context.record(regime="bullish")` |
140+
| `dict` | `context.record(meta={"score": 0.9})` |
141+
| `list` | `context.record(weights=[0.3, 0.7])` |
142+
| `bool` | `context.record(is_trending=True)` |

docusaurus/sidebar.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ const sidebars = {
121121
type: 'doc',
122122
id: 'Advanced Concepts/PARALLEL_PROCESSING_GUIDE',
123123
},
124+
{
125+
type: 'doc',
126+
id: 'Advanced Concepts/recording-variables',
127+
},
124128
],
125129
},
126130
{

docusaurus/sidebars.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ const sidebars = {
121121
type: 'doc',
122122
id: 'Advanced Concepts/PARALLEL_PROCESSING_GUIDE',
123123
},
124+
{
125+
type: 'doc',
126+
id: 'Advanced Concepts/recording-variables',
127+
},
124128
],
125129
},
126130
{

investing_algorithm_framework/app/context.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def __init__(
5353
self._blotter = None
5454
self._fx_rate_provider = None
5555
self._base_currency = None
56+
self._recorded_values = {} # key -> list of (datetime, value)
5657

5758
def _validate_target_symbol(self, target_symbol, market=None):
5859
"""
@@ -2334,3 +2335,64 @@ def get_transactions(self):
23342335
list[Transaction]: Recorded transactions.
23352336
"""
23362337
return self._blotter.get_transactions()
2338+
2339+
def record(self, **kwargs):
2340+
"""
2341+
Record arbitrary key-value pairs at the current backtest timestamp.
2342+
2343+
This method allows you to store any custom indicator, metric, or
2344+
variable during a backtest. Each key creates a time series of
2345+
values that can be retrieved after the backtest completes via
2346+
``BacktestRun.recorded_values``.
2347+
2348+
The values are stored as a list of ``(datetime, value)`` tuples
2349+
per key, allowing you to track any indicator over time.
2350+
2351+
This method only records during backtesting. In live mode it is
2352+
a no-op.
2353+
2354+
Args:
2355+
**kwargs: Arbitrary key-value pairs to record. Keys are
2356+
strings, values can be any type (float, int, str,
2357+
dict, list, etc.).
2358+
2359+
Example::
2360+
2361+
def on_run(self, context, data):
2362+
context.record(
2363+
rsi=compute_rsi(data),
2364+
sma_20=compute_sma(data, 20),
2365+
signal_strength=0.85,
2366+
)
2367+
"""
2368+
is_backtest = self.configuration_service.config.get(
2369+
BACKTESTING_FLAG, False
2370+
)
2371+
2372+
if not is_backtest:
2373+
return
2374+
2375+
current_datetime = self.configuration_service.config.get(
2376+
INDEX_DATETIME
2377+
)
2378+
2379+
for key, value in kwargs.items():
2380+
if key not in self._recorded_values:
2381+
self._recorded_values[key] = []
2382+
self._recorded_values[key].append((current_datetime, value))
2383+
2384+
def get_recorded_values(self):
2385+
"""
2386+
Get all recorded values from the context.
2387+
2388+
Returns:
2389+
dict: A dictionary mapping keys to lists of
2390+
``(datetime, value)`` tuples.
2391+
"""
2392+
return self._recorded_values
2393+
2394+
def clear_recorded_values(self):
2395+
"""
2396+
Clear all recorded values from the context.
2397+
"""
2398+
self._recorded_values = {}

investing_algorithm_framework/app/strategy.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,32 @@ def generate_scale_out_signals(
850850
"""
851851
return None
852852

853+
def generate_recorded_values(
854+
self, data: Dict[str, Any]
855+
) -> Union[Dict[str, pd.Series], None]:
856+
"""
857+
Optional method to generate recorded values for vectorized
858+
backtesting. Override this to record arbitrary indicators,
859+
metrics, or variables as time series during a vectorized
860+
backtest.
861+
862+
Each key in the returned dict becomes a recorded variable
863+
with the Series index as timestamps and the Series values
864+
as the recorded data.
865+
866+
This is the vectorized equivalent of calling
867+
``context.record()`` in event-driven backtests.
868+
869+
Args:
870+
data (Dict[str, Any]): The market data for the strategy.
871+
872+
Returns:
873+
Dict[str, Series] | None: A dictionary where keys are
874+
variable names and values are pandas Series with the
875+
recorded values. Return None to not record anything.
876+
"""
877+
return None
878+
853879
def on_trade_closed(self, context: Context, trade: Trade):
854880
pass
855881

investing_algorithm_framework/domain/backtesting/backtest_run.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ class BacktestRun:
112112
metadata: Dict[str, str] = field(default_factory=dict)
113113
signals: Dict[str, Dict[str, Any]] = field(default_factory=dict)
114114
signal_events: List[Dict[str, Any]] = field(default_factory=list)
115+
recorded_values: Dict[str, List] = field(default_factory=dict)
115116

116117
def to_dict(self) -> dict:
117118
"""
@@ -167,6 +168,16 @@ def ensure_iso(value):
167168
"date": ensure_iso(evt["date"])
168169
} for evt in self.signal_events
169170
],
171+
"recorded_values": {
172+
key: [
173+
{
174+
"datetime": ensure_iso(entry[0]),
175+
"value": entry[1]
176+
}
177+
for entry in entries
178+
]
179+
for key, entries in self.recorded_values.items()
180+
},
170181
}
171182

172183
@staticmethod
@@ -330,10 +341,26 @@ def open(directory_path: Union[str, Path]) -> 'BacktestRun':
330341
pass
331342
signal_events.append(parsed)
332343

344+
# Parse recorded_values
345+
raw_recorded = data.pop("recorded_values", {})
346+
recorded_values = {}
347+
for key, entries in raw_recorded.items():
348+
parsed_entries = []
349+
for entry in entries:
350+
dt = entry.get("datetime")
351+
if isinstance(dt, str):
352+
try:
353+
dt = datetime.fromisoformat(dt)
354+
except (ValueError, TypeError):
355+
pass
356+
parsed_entries.append((dt, entry.get("value")))
357+
recorded_values[key] = parsed_entries
358+
333359
return BacktestRun(
334360
backtest_metrics=backtest_metrics,
335361
signals=signals,
336362
signal_events=signal_events,
363+
recorded_values=recorded_values,
337364
**data
338365
)
339366

investing_algorithm_framework/infrastructure/services/backtesting/event_backtest_service.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ def run(
112112
backtest_date_range=backtest_date_range,
113113
number_of_runs=event_loop_service.total_number_of_runs,
114114
risk_free_rate=risk_free_rate,
115+
recorded_values=event_loop_service.context
116+
.get_recorded_values(),
115117
)
116118

117119
def generate_schedule(
@@ -175,6 +177,7 @@ def _create_backtest_run(
175177
backtest_date_range: BacktestDateRange,
176178
number_of_runs: int,
177179
risk_free_rate: float,
180+
recorded_values: dict = None,
178181
) -> BacktestRun:
179182
"""
180183
Create a BacktestRun from the current state after event loop execution.
@@ -184,6 +187,7 @@ def _create_backtest_run(
184187
backtest_date_range: The date range of the backtest.
185188
number_of_runs: Total number of strategy executions.
186189
risk_free_rate: Risk-free rate for metrics calculation.
190+
recorded_values: Optional dict of recorded values from context.
187191
188192
Returns:
189193
BacktestRun: The completed backtest run with metrics.
@@ -215,6 +219,7 @@ def _create_backtest_run(
215219
positions=self._position_repository.get_all(
216220
{"portfolio": portfolio.id}
217221
),
222+
recorded_values=recorded_values or {},
218223
)
219224

220225
# Calculate and add metrics

0 commit comments

Comments
 (0)