Stop Waiting on Your Backtester: How to Let Yourself Focus on Strategy Logic

DolphinDB
2026-05-28

Two things drain the most time in quant development: slow backtests, and frameworks that fall apart the moment you try to extend them. Wait five minutes for data to load, tweak a parameter, wait five more minutes. Then try adding a second asset class and discover that your matching engine, position tracking, and cash management are all tangled together.

Swordfish is a backtesting framework built around one idea: let the developer focus on strategy logic, and let the framework handle everything else. Matching, capital management, and event dispatch are each isolated into their own modules. It supports equities, futures, options, fixed income, and crypto — and can be driven by minute-bar, snapshot, or tick-level data out of the box.

This article walks through a complete trend-following strategy from start to finish.

The Architecture: You Write the Strategy, the Framework Does the Rest

Swordfish is organized around four core modules:

ModuleWhat you care about
StrategyTemplateBase class for your strategy — lifecycle hooks like initialize, on_bar, on_snapshot
AssetMixinOrder submission interfaces, one per asset class (StockOrderMixin, FuturesOrderMixin, etc.)
ConfigBacktest parameters — date range, capital, commissions, matching mode
BacktesterThe engine itself — loads your strategy and data, runs the simulation

The layered design means that switching from an equity minute-bar strategy to a futures daily strategy doesn't touch your strategy logic. You swap the Config class and the AssetMixin; the core strategy stays the same.

The full workflow is five steps: write, configure, build, run, analyze.

Event-Driven vs. Vectorized: What's the Difference?

If you're coming from a vectorized backtesting background (think pandas-based loops or NumPy array operations), event-driven might feel unfamiliar at first. The key difference is who initiates.

In a vectorized backtest, you pull the data: "What's the price at bar 42? What's my current position? What's the moving average?" You manage timing and state synchronization yourself.

In an event-driven backtest, the framework pushes data to you: "A new bar just arrived — here's the data, what do you want to do?" You write your logic inside callback functions; the framework handles everything else.

Swordfish supports callbacks covering every data frequency you'd realistically need:

CallbackWhen it firesBest for
initialize()Once, before the backtest startsSetup, indicator subscriptions
before_trading()Each morning before market openDaily prep, variable resets
on_bar()On each new K-line (bar)Minute or daily strategies
on_snapshot()On each market snapshotMid- to high-frequency
on_tick()On each individual order/tradeHigh-frequency
on_order()When an order status changesOrder lifecycle tracking
on_trade()On confirmed fillsFill processing
after_trading()Each evening after market closeSettlement, P&L calc
finalize()Once, after the backtest endsExport results, cleanup

You only implement the callbacks your strategy needs. If you're writing a daily bar strategy, you only write initialize and on_bar. The framework guarantees precise triggering at each subscribed data point — no timing drift, no manual offset management.

A Complete Example: Trend-Following Strategy in Under 50 Lines

Let's walk through a minute-bar mean-reversion strategy: if the current close is below the previous bar's close and we have no position, buy; if we have a position and the price rises above the previous close, sell. Fixed lot size of 1,000 shares.

Step 1: Write the Strategy Class

The strategy inherits from both StrategyTemplate (lifecycle callbacks) and StockOrderMixin (equity order submission). In initialize, we subscribe to a "previous close" indicator. In on_bar, we act on it:

class MyStrategy(backtest.StrategyTemplate, backtest.StockOrderMixin):

    def initialize(self, context):
        with sf.meta_code() as m:
            lastp = F.prev(m.col("close"))
        self.subscribe_indicator(backtest.MarketDataType.KLINE, {'lastp': lastp})

    def on_bar(self, context, msg, indicator):
        for istock in msg.keys():
            prevp     = indicator[istock]["lastp"]
            lastPrice = msg[istock]["close"]
            position  = self.accounts[backtest.AccountType.DEFAULT]\
                            .get_position(symbol=istock)["longPosition"]
            if position == 0 and lastPrice < prevp:
                self.submit_stock_order(istock, context["tradeTime"], 5, lastPrice, 1000, 1, label="buy")
            elif position > 0 and lastPrice > prevp:
                self.submit_stock_order(istock, context["tradeTime"], 5, lastPrice, 1000, 2, label="sell")

Step 2: Configure and Run

The config is explicit — no hidden defaults. Set your date range, asset type, data frequency, starting capital, and commission rate. Then build the Backtester, inject market data, and let it run:

config = backtest.StockConfig()
config.start_date = sf.scalar("2021.01.01", type="DATE")
config.end_date   = sf.scalar("2021.12.31", type="DATE")
config.asset_type = backtest.AssetType.STOCK
config.data_type  = backtest.MarketType.MINUTE
config.cash       = 100_000_000
config.commission = 0.00015

stocks = F.loadText('mink_data.csv')  # CSV or sf.sql for flexible field mapping
backtester = backtest.Backtester(MyStrategy, config)
backtester.append_data(stocks)

Step 3: Read the Results

Once the backtest completes, query the account object for everything you need. return_summary gives cumulative return, annualized return, and max drawdown. trade_details gives a full fill log. get_daily_position() shows how your holdings evolved day by day:

account = backtester.accounts[backtest.AccountType.DEFAULT]
print(account.return_summary)
print(account.trade_details)
print(account.get_daily_position())

From a blank file to a full backtest with results: under 50 lines of code.

User-Defined Indicators: Beyond Built-ins

The example above used the built-in prev(close) signal. Real strategies need things like momentum, turnover rate, or order-flow imbalance. Swordfish handles this with the @swordfish_udf decorator: define your indicator as a plain Python function, register it in initialize via subscribe_indicator, and the framework automatically computes and delivers it in every callback — aligned to the correct bar, with no manual timing offset needed.

Here's a percent-change indicator as an example:

# Step 1: Define the indicator (outside the class)
@F.swordfish_udf(mode="translate", is_state=True)
def zdf(last_price, prev_close_price):
    return last_price / prev_close_price - 1

# Step 2: Subscribe in initialize
def initialize(self, context):
    with sf.meta_code() as m:
        m_zdf = zdf(m.col("lastPrice"), m.col("prevClosePrice"))
    self.subscribe_indicator(backtest.MarketDataType.SNAPSHOT, {'zdf': m_zdf})

# Step 3: Use in on_snapshot
def on_snapshot(self, context, msg, indicator):
    if indicator["zdf"] > 0.01 and long_pos <= context["max_pos"]:
        self.submit_stock_order(symbol, context["tradeTime"], 5, best_ask, 100, 1)

To move this indicator from snapshots to bars, change SNAPSHOT to KLINE in subscribe_indicator. That's the only change needed. Indicators and strategy logic are fully decoupled, which makes reuse across strategies straightforward.

Performance Numbers

Framework design only matters if the thing actually runs fast. Here are benchmark results from a production environment, using a single-stock $20M TWAP strategy on snapshot data with latency simulation enabled:

ScenarioUsersData windowConcurrencyBacktest time
Single strategy11 week51.3s
Single strategy11 month52.0s
Two strategies11 week51.7s
Two strategies11 month53.7s
Multi-user11 week22.6s
Multi-user51 week103.0s
Multi-user101 week203.4s
Multi-user201 week403.6s
Multi-user501 week1007.0s

A few things stand out here. First, scaling from one week to one month of data (4× more data) only adds 0.7 seconds — the framework's data throughput is essentially not a bottleneck. Second, going from 1 to 50 concurrent users (50× more load) only 2.7× the wall-clock time. At 100 concurrent backtests, a week of data still finishes in under 10 seconds. Third, adding a second strategy to a run adds around 0.4–1.7 seconds depending on data window — overhead is from actual strategy computation, not framework scheduling.

One additional property worth calling out: the team ran the same backtest at 5×, 10×, and 20× data replay speeds and verified fill-by-fill that the matching results were identical across all speeds. That means you can prototype fast with high-speed replay and validate slowly with tick-by-tick inspection — and trust that you're running the same model both times.

Three Habits That Make Backtests Faster

The framework does heavy lifting under the hood, but your coding patterns still matter — especially with large universes or high-frequency data.

Enable indicator batch optimization. When your strategy uses multiple technical indicators, set config.enable_indicator_optimize = True. The framework statically analyzes indicator dependencies and merges redundant passes over the same data. The more indicators and instruments you have, the bigger the payoff.

Pre-allocate objects ininitialize, not in your callbacks. Callbacks like on_bar or on_tick can fire thousands of times per second in a high-frequency strategy. Allocating new containers on every call adds up quickly. Instead, create all your data structures in initialize and reuse them in callbacks — memory stays stable and performance is predictably fast.

Choose the right matching mode. Swordfish supports multiple matching modes, from simplified full-fill at limit price up to detailed queue simulation with partial fills. Precise matching significantly increases compute time. During early-stage strategy development, set config.matching_mode = 3 for instant full fills at your limit price — it's much faster, and good enough to validate whether your logic makes sense before you commit to a more expensive simulation.

Wrapping Up

Swordfish's design goal is narrow: let the strategy developer focus on strategy logic. Matching, capital management, position tracking, and event dispatch are handled by the framework. You tell it what to do when a bar arrives; it handles the rest.

Moving from a single instrument to a universe, or from minute bars to tick data, means changing your config class and order mixin. Your strategy logic stays untouched. Whether you're quickly validating a new hypothesis or running a production-grade multi-asset portfolio backtest, the architecture scales without requiring rewrites.

The complete code referenced in this article — including a sample data file, the trend strategy script, and an indicator-subscription strategy — is available by emailing: info@dolphindb.com.