Custom Indicator Guide¶
This page answers one practical question: when built-in indicators are not enough, how do you write, register, and maintain your own indicators in AKQuant?
Typical use cases:
- private factors or strategy-specific signals;
- rapid prototypes built on pandas;
- stateful indicators updated bar by bar in the event stream;
- indicators that must survive warm-start resume.
Start With The Right Scope¶
In AKQuant, these are related but different tasks:
| Goal | Recommended path | Typical API |
|---|---|---|
| Add a private signal to a strategy | custom Indicator / custom incremental object |
register on Strategy |
Compute a full series from a DataFrame |
indicator_mode="precompute" |
register_precomputed_indicator(...) |
| Maintain state bar by bar | indicator_mode="incremental" |
register_incremental_indicator(...) |
Add a new name to akquant.talib |
modify the compatibility layer source | not runtime plugin registration |
If your goal is simply "use my own indicator inside a strategy", you usually do not need to extend akquant.talib.
Path 1: Precomputed Indicators¶
Use precompute mode when the indicator is naturally vectorized over the full DataFrame.
Minimal example¶
from akquant import Indicator, Strategy
class PrecomputeMomentumStrategy(Strategy):
def __init__(self):
super().__init__()
self.indicator_mode = "precompute"
self.mom10 = Indicator(
"mom10",
lambda df: df["close"] - df["close"].shift(10),
)
self.register_precomputed_indicator("mom10", self.mom10)
def on_bar(self, bar):
value = self.mom10.get_value(bar.symbol, bar.timestamp)
if value == value and value > 0:
self.buy(bar.symbol, 100)
Good fit when¶
- the indicator is naturally vectorized;
- you want to reuse pandas
rolling,shift, orewm; - development speed matters more than streaming-style updates;
- each symbol has a full history slice available up front.
Notes¶
Indicator(name, fn, **kwargs)expects a function returning apd.Series;get_value(symbol, timestamp)reads from the cached series;- results are cached per symbol.
Path 2: Incremental Indicators¶
Use incremental mode when the indicator should evolve inside the event stream.
Minimal example¶
from collections import deque
import pandas as pd
from akquant import Indicator, Strategy
class MyMomentum(Indicator):
def __init__(self, period: int = 10):
super().__init__("my_momentum", lambda df: df["close"] - df["close"].shift(period))
self.period = period
self.buffer: deque[float] = deque(maxlen=period)
self._current_value = float("nan")
def update(self, value: float) -> float:
if pd.isna(value):
return self._current_value
self.buffer.append(float(value))
if len(self.buffer) < self.period:
self._current_value = float("nan")
else:
self._current_value = self.buffer[-1] - self.buffer[0]
return self._current_value
@property
def value(self) -> float:
return self._current_value
class IncrementalMomentumStrategy(Strategy):
def __init__(self):
super().__init__()
self.indicator_mode = "incremental"
def on_start(self):
self.register_incremental_indicator(
"mom10",
indicator_factory=lambda: MyMomentum(period=10),
source="close",
symbols=["AAPL", "MSFT"],
warmup_bars=10,
)
def on_bar(self, bar):
value = self.mom10.value
if value == value and value > 0:
self.buy(bar.symbol, 100)
Why indicator_factory is recommended¶
In a multi-symbol strategy, incremental indicators usually carry internal state. Reusing one instance across multiple symbols can mix state and produce incorrect results.
Recommended:
self.register_incremental_indicator(
"mom10",
indicator_factory=lambda: MyMomentum(period=10),
source="close",
symbols=["AAPL", "MSFT"],
)
Single-instance form, better for quick single-symbol experiments:
self.mom10 = MyMomentum(period=10)
self.register_incremental_indicator("mom10", self.mom10, source="close")
What source means¶
source tells the framework which field from the market event should be fed into the indicator. Common choices:
source="close"source="open"source="high"source="low"source="volume"
If your indicator needs multiple inputs, align your update(...) signature with the incremental input mode expected by the framework.
Using warmup_bars¶
warmup_bars bootstraps the incremental indicator with bars before start_time.
Use it when:
- you want a valid value on the first active bar;
- your indicator depends on a rolling window;
- you do not want to manually skip the first N bars inside
on_bar.
Runnable example:
Warm Start And Serialization¶
If the strategy uses run_warm_start, your custom indicator must preserve its internal state correctly.
Practical rules:
- simple Python objects are often pickle-compatible already;
- if the indicator stores file handles, sockets, locks, or other non-serializable objects, handle them explicitly;
- implement
__getstate__and__setstate__when needed.
Example:
def __getstate__(self):
state = self.__dict__.copy()
return state
def __setstate__(self, state):
self.__dict__.update(state)
See also: Warm Start Guide.
Boundary With akquant.talib¶
Many users mix up "custom strategy indicators" and "extending akquant.talib". A practical mental model:
akquant.talib: built-in TA-Lib-style compatibility layer;- custom strategy indicators: strategy-local building blocks registered on
Strategy; - new Rust high-performance indicators: source-level extension plus recompilation, not runtime hot-plugging.
If you only need a private signal inside one strategy, prefer a custom strategy indicator instead of extending akquant.talib.
Export Indicators For Frontend Use¶
If your goal is not only to use a custom indicator inside the strategy, but also to send the indicator output to a web frontend, treat indicator calculation and indicator output as separate concerns:
- keep indicator calculation inside
Strategy/Indicator; - use
Strategy.record_indicator(...)to record normalized indicator points; - after the run, use
BacktestResult.indicator_df(...)orexport_indicators(...)for downstream systems.
Minimal example¶
from akquant import Bar, Strategy
class IndicatorExportStrategy(Strategy):
def on_bar(self, bar: Bar) -> None:
spread = bar.high - bar.low
self.record_indicator(
name="intrabar_spread",
value=spread,
display_name="Intra Bar Spread",
pane="sub",
render_type="line",
precision=4,
meta={"source": ["high", "low"]},
)
After the run:
result = ...
# 1) Read inside Python
indicator_df = result.indicator_df(name="intrabar_spread", symbol="AAPL")
# 2) Generate a lightweight local preview
fig = result.plot_indicators(
name="intrabar_spread",
symbol="AAPL",
show=False,
filename="indicator_preview.html",
)
# 3) Export for frontend or external services
result.export_indicators("indicator_outputs.json", format="json")
result.export_indicators("indicator_outputs", format="parquet")
When available, the JSON export also includes a top-level run_id so downstream services can correlate offline exports with the streaming event flow.
Built-in Minimal Visualization¶
If you only want a quick history preview before wiring a full frontend, use:
result.plot_indicators(...)from akquant.plot import plot_indicators
This built-in path is intentionally lightweight:
- it keeps
result.plot()focused on the existing account dashboard; - it splits subplots by
pane; - it reuses
render_type, with day-one support for commonlineandbar; - it supports filtering by
name,symbol, andinclude_warmup; - it can write a local HTML file for quick inspection alongside exported JSON.
Example:
fig = result.plot_indicators(
name="intrabar_spread",
symbol="AAPL",
include_warmup=False,
show=False,
filename="indicator_preview.html",
title="Indicator Preview",
)
If you need enterprise-grade multi-panel UX, persistence, permissions, or realtime subscriptions, keep those concerns in external systems and let AKQuant stay responsible for the preview plus normalized data production.
Optional Indicator Section In Reports¶
If you want the indicator preview embedded into the built-in HTML report instead of a separate figure, enable it explicitly:
result.report(
filename="akquant_report.html",
show=False,
include_indicators=True,
indicator_name="intrabar_spread",
indicator_symbol="AAPL",
indicator_include_warmup=False,
)
This path is intentionally constrained:
- it is off by default, so existing
report()output does not change; - it is meant for a lightweight indicator section inside the strategy report;
- if no indicator data exists, the report shows an empty-state notice;
- if you need richer interaction or layout control, keep that in external frontend systems.
Bridging Stream Events To Frontend Messages¶
If your external service is a WebSocket or SSE gateway, keep the raw payload parsing out of your business code and use:
akquant.is_indicator_stream_event(event)akquant.to_indicator_message(event)akquant.to_indicator_messages(events)
Example:
def on_event(event):
if not aq.is_indicator_stream_event(event):
return
message = aq.to_indicator_message(event)
if message is not None:
websocket.broadcast_json(message)
These helpers are meant to:
- bridge only
indicator_pointandindicator_snapshot - coerce numeric fields into frontend-friendly values
- unpack
meta_jsonanditems_json - preserve the outer stream semantics such as
run_id,seq, andts
The bridged snapshot payload now also includes a few shortcut fields so frontend
code does not have to rescan items on every update:
indicator_keyspanesrender_typesvalue_by_keyitems_by_keywarmup_counthas_warmup
The bridge helper also normalizes _unknown or empty symbol values into None,
and accepts already-decoded dict/list values for meta_json and items_json,
which makes gateway-side wrapping easier.
This is not a new transport layer. It is just a normalization layer that turns AKQuant stream events into steadier frontend message objects.
Zero-Dependency Browser Live Preview¶
If you want a browser-based demo that product or frontend teammates can open
immediately, but you do not want to introduce fastapi, uvicorn, or
websockets yet, use examples/64_indicator_live_web.py.
This example does three things:
- consumes stream events with
run_backtest(..., on_event=...) - normalizes them with
aq.to_indicator_message(event) - serves a tiny
/stateJSON endpoint via built-inhttp.server, then lets the browser poll and draw theclose_echoline
It now supports two polling modes:
- request
/statefor the recent full snapshot window - request
/state?since_seq=123for incremental messages withseq > 123, while still returning total counts and the latest cursor
The payload shape now separates common metadata from message bodies:
- shared fields live under
cursor,counts, andlatest_indicator_values - full snapshot mode returns message windows under
window.point_messagesandwindow.snapshot_messages - incremental mode returns only new messages under
delta.point_messagesanddelta.snapshot_messages
Run it with:
For a quick smoke check, keep the server alive for a shorter window:
UV_INDEX_URL=https://pypi.org/simple uv run python examples/64_indicator_live_web.py --keep-seconds 1
The goal is not to become a full frontend product. The goal is to help you:
- verify that indicator stream data is leaving the backtest correctly
- give frontend code a stable
/stateJSON shape to consume first - demonstrate live indicator rendering without adding new dependencies
Current output shape¶
The first implementation exposes three structured layers:
- indicator definitions, such as
display_name,pane, andrender_type - indicator instances grouped by
strategy/symbol/indicator/meta - indicator points as the actual time series values
This keeps AKQuant focused on producing stable indicator data instead of coupling the framework to a specific charting library.
Recommended boundary¶
Suggested split of responsibilities:
AKQuantowns:- indicator calculation
- indicator recording
- indicator query
- indicator export
- external systems own:
- persistence
- APIs
- websocket delivery
- frontend applications
In short, AKQuant should act as the indicator producer, not the full enterprise frontend platform.
Recommended examples¶
- 60_custom_indicator_demo.py
- 61_indicator_visualization_export_demo.py
- 62_indicator_streaming_demo.py
- 63_indicator_ws_bridge_demo.py
- 64_indicator_live_web.py
Common Pitfalls¶
- Pitfall 1: every custom indicator must inherit from
Indicator - Not always.
Indicator(name, fn)is enough for many precompute cases. - Pitfall 2: one incremental instance can be shared safely across symbols
- Usually no. Prefer
indicator_factoryfor production multi-symbol strategies. - Pitfall 3:
warmup_bars=20double-consumes the first active bar - It does not. Warmup only uses history before the active start boundary.
- Pitfall 4: custom indicators automatically work with warm start
- Not guaranteed. Verify serialization.
- Pitfall 5: strategy-local indicators and
akquant.talibextensions are the same thing - They solve different problems at different layers.
Recommendation Matrix¶
| Goal | Recommended approach |
|---|---|
| Validate an idea quickly | Indicator(name, fn) + precompute |
| Single-symbol bar-by-bar state | incremental + single instance |
| Multi-symbol production strategy | incremental + indicator_factory |
| Need valid values on the first active bar | incremental + warmup_bars |
| Need resumable state | ensure the indicator is serializable |
| Need maximum performance | consider a Rust implementation later |