How to wire a custom strategy module in dgbit
A trading framework’s strategy interface is its load-bearing decision. Get it wrong and every user pays for it: either they fight the abstraction (in which case the framework is in the way) or they conform to it and end up unable to express the thing they actually want. dgbit takes a deliberately minimal position: a strategy is a Python class that subclasses BaseStrategy, declares some metadata, implements generate_signal, and registers itself with strategy_registry. That is the whole contract.
This post walks through what each piece is for, why the framework chose this shape over a YAML/JSON DSL, and what happens after registration when the strategy moves from a notebook through the backtester and into the live engine.
The four pieces
The README’s custom-strategy example shows the canonical shape. We will use it verbatim:
from dgbit_core.trading.strategy import (
BaseStrategy,
StrategyMetadata,
SignalType,
strategy_registry
)
class MyMomentumStrategy(BaseStrategy):
"""Custom momentum-based trading strategy."""
metadata = StrategyMetadata(
name="my_momentum",
description="Custom momentum strategy with volume confirmation",
author="Your Name",
version="1.0.0",
signal_type=SignalType.MOMENTUM,
parameters={
"lookback_period": {"type": "int", "default": 14},
"volume_threshold": {"type": "float", "default": 1.5},
},
)
def generate_signal(self, data):
momentum = data['close'].pct_change(self.lookback_period).iloc[-1]
volume_ratio = data['volume'].iloc[-1] / data['volume'].mean()
if momentum > 0.02 and volume_ratio > self.volume_threshold:
return 0.8
elif momentum < -0.02 and volume_ratio > self.volume_threshold:
return 0.2
return 0.5
strategy_registry.register(MyMomentumStrategy)
There are exactly four moving parts: the base class, the metadata object, the signal method, and the registry call. Each one earns its place.
BaseStrategy
BaseStrategy is the protocol both the backtester and the live execution engine consume. Inheriting from it gets you the constructor that binds parameter values to instance attributes (so self.lookback_period and self.volume_threshold are populated from the metadata defaults or runtime overrides) and the interface contract the rest of the system expects.
The choice to use a class — not a function, not a YAML file — is the one most worth defending. Function-based strategies tend to grow side state quickly, and once they do you are reinventing classes badly. Configuration-file strategies look elegant in a demo and then immediately hit a wall when the user wants if volume_ratio > threshold inside their signal logic. A subclass is the lowest-friction way to express “a parameterized thing that does some computation and returns a value.”
StrategyMetadata
The metadata attribute is a structured descriptor of what the strategy is, who wrote it, and what knobs it exposes. The parameter schema ({"type": "int", "default": 14}) is the contract the UI and API surfaces consume: when the Vue 3 dashboard lists available strategies, it reads this metadata to build a parameter form, and when /api/strategies/{name}/signal accepts a request, it validates the body against this schema.
The signal_type field (SignalType.MOMENTUM in the example) is a categorical tag that lets the framework group strategies sensibly. The README ships built-in strategies across mean reversion (Wavelet Reversal), trend following (MA Crossover), momentum (RSI), and volatility (Bollinger Bands), so the signal-type vocabulary already covers the obvious shapes.
The metadata is declarative on the class, not on instances, which means a strategy’s identity is fixed at definition time. You cannot register two different versions of my_momentum from the same Python module without changing the name field. That is good discipline.
generate_signal
generate_signal(self, data) is the only required method. It takes a pandas-like DataFrame (the example reads data['close'] and data['volume'] and uses .pct_change() and .iloc[-1]) and returns a number between 0 and 1.
The return value is a probability-like score: the example returns 0.8 for a strong buy, 0.2 for a strong sell, and 0.5 for neutral. The framework’s downstream layers — position sizing, order routing, risk checks — consume this number, not a discrete BUY/SELL enum. That is a useful abstraction for two reasons:
- Composability. A higher-level meta-strategy can blend several signal scores into one decision without rewriting the underlying strategies’ output formats.
- Threshold tunability. The built-in
WaveletReversalStrategyexposesmin_signal_threshold=0.75. Externalizing the threshold means you can re-tune sensitivity without touching the signal logic itself.
The trade-off: signals are stateless per call. If your strategy needs cross-bar memory (a rolling state machine, a Markov regime classifier), you have to maintain that state on self. The BaseStrategy constructor’s parameter binding gives you the place to do it cleanly, but the framework will not auto-magically persist it across processes. If you bring up the live engine after a crash, you start with a fresh strategy instance.
strategy_registry.register
The registry call at the bottom is the only step that connects your file to the rest of the framework. After the call, the strategy is visible to:
GET /api/strategies, which returns the list the UI consumes.POST /api/strategies/{name}/signal, which callsgenerate_signalwith a request body.- The backtester, which can pick a strategy by name when scheduling a job via
POST /api/backtests. - The live execution engine, which reads from the same registry when binding a deployed strategy.
There is no separate “register for backtest” and “register for live” call. One registration, both surfaces. That is the property that lets a strategy move from a research notebook to a deployed bot without an intermediate translation step.
From notebook to live
The typical lifecycle:
- Author. Write the class in a
.pyfile under your own package. Test the signal logic in a notebook by instantiating the class directly and callinggenerate_signalon a slice of data. - Backtest. Use the README’s pattern:
Backtester(config=config); backtester.strategy = MyMomentumStrategy(...); backtester.run(data). Iterate on parameter defaults until total return, win rate, and max drawdown match what you expect. - Register. Add the
strategy_registry.register(MyMomentumStrategy)call. Once registered, the strategy is available through the REST API. - Paper trade. Configure
BYBIT_TESTNET=truein.env. Bring up the stack withdocker-compose up -d. Schedule the strategy through the dashboard orPOST /api/execution/orders. Watch trades fire in the testnet account. - Go live. Flip
BYBIT_TESTNET=false, set realBYBIT_API_KEYandBYBIT_API_SECRET, and restart. Subscribe to the WebSocket event stream at/api/ws/eventsto observesignal.generated,trade.entered, andtrade.exitedevents in real time.
Each gate has a purpose. The notebook step is where you find logic bugs cheaply. The backtest step is where you find the strategy is not actually profitable, before any infrastructure cost. The paper-trade step is where you find that the live data path differs subtly from the historical one. The live step is where the money is at risk and there should be no remaining unknowns.
What we deliberately do not do
It is worth naming the things this design does not give you:
- No DSL. There is no
WHEN volume > 1.5x AND momentum > 0.02 BUY 0.8configuration language. You write Python. - No auto-vectorization.
generate_signalis called per evaluation point. If you need vectorized backtesting for grid search, you build that on top. - No multi-symbol abstraction. The example signal works on one DataFrame for one symbol. Multi-asset strategies are a composition problem you solve in your own code.
These are conscious omissions. Every one of them is something a future version of dgbit could add — but adding them prematurely would mean every user pays the complexity cost for something most of them do not need. The framework’s job is to give you a minimal contract that does not lie. The strategy’s job is to be honest about what it actually does. The split feels right.
Closing
The whole strategy interface is forty lines of Python. That is on purpose. A minimal contract makes it easier to reason about correctness, easier to test in isolation, and easier to swap implementations without breaking everything downstream. If the framework had asked you to express a momentum-with-volume-confirmation strategy in a configuration language, the example above would be three times as long and twice as fragile. Because it asks for a class, you can write exactly what you mean.