Hawk-Backtester hawk-bt

hawk-bt

A library for controlling the browser-based WASM simulation engine from Python.

The Role of hawk-bt in the Overall System Architecture»

During simulation, Python and the browser are directly connected via WebSocket. hawk-bt sits between them, handling all connection management, protocol translation, and synchronization control.

Your Python
Write a Strategy class
Make trading decisions in step(ctx)
hawk-bt (this library)
Connection/reconnection management / Binary RPC translation
Sync policy application / Loop execution
WebSocket
Browser WASM
OHLC storage / TP/SL evaluation
Position management / P&L
Report generation

Quick Start»

pip install hawk-bt

* hawk-backtester on PyPI is a separate, unrelated package.

  1. Strategy クラスを書く(下の例 を参照)
  2. Open the simulation screen in the browser
  3. python my_strategy.py を実行
  4. Press "Start" on the browser side

When a session ends, the results are output to the log. Even if the browser disconnects and reconnects, the next session is automatically accepted.

Example: SMA Crossover»

Buy when the short SMA crosses above the long SMA; Sell when it crosses below.Buy when the short-term SMA crosses above the long-term SMA, Sell when it crosses below. Auto-close via TP/SL. No manual closing.

Template (nearly the same every time) Custom section (strategy logic)
from collections import deque from hawk_bt import Strategy, Context, HawkEngine, configure_logging class SMACross(Strategy): def __init__(self, short=10, long=30, tp=5.0, sl=3.0, units=10): self.short_p, self.long_p = short, long self.tp, self.sl, self.units = tp, sl, units self._hist: deque = deque(maxlen=long) self._prev = (None, None) def _sma(self, n): if len(self._hist) < n: return None return sum(list(self._hist)[-n:]) / n async def step(self, ctx: Context): # ← Entry point called every barB price = ctx.state.snapshot.priceC self._hist.append(price) s, l = self._sma(self.short_p), self._sma(self.long_p) if s is None or l is None: self._prev = (s, l) return ps, pl = self._prev self._prev = (s, l) if ps is None: return # Cross Detection if ps - pl <= 0 < s - l: await ctx.engine.place_ticket(side="buy", units=self.units,D take_profit=self.tp, stop_loss=self.sl) elif ps - pl >= 0 > s - l: await ctx.engine.place_ticket(side="sell", units=self.units, take_profit=self.tp, stop_loss=self.sl) configure_logging(2) HawkEngine().start(SMACross())A

Code Walkthrough»

Explains how hawk-bt works, using the SMA crossover example above.Using the SMA crossover example above, we explain how hawk-bt operates.

Simulation Loop

A HawkEngine().start(SMACross()) When executed, it waits for a connection from the browser. Once connected, the backtest begins and repeats the following 3 steps for each bar:

hawk-bt
WASM Market Simulator
1get_snapshot()
Stores the received snapshot in
ctx.state.snapshot
Information
Returns the current market state
balance, equity, price …
2strategy.step(ctx)
Executes the user's strategy
Trading decision -> Order submission
Order instruction
Accepts and fills orders
3step_next()
Advance instruction
Advance to the next bar
TP/SL evaluation and fill processing

Inside step()

B async def step(self, ctx: Context) is the entry point called every bar. Read prices, make decisions, and place orders here.

  • C ctx.state.snapshot.price — Get the current price and calculate SMA
  • D place_ticket(side="buy", ...) — Place a market order on golden cross detection
  • D take_profit=self.tp, stop_loss=self.sl — Specify TP/SL as absolute price differences

Sync Policy: eager vs deferred

D place_ticket() changes the state on the WASM side. The sync policy controls when those changes are reflected on the Python side.The sync policy controls when those changes are reflected on the Python side.

deferred (default)

step(ctx): snap = ctx.state.snapshot ← Value at step start place_ticket("buy", 10) └ No sync Snapshot remains unchanged place_ticket("sell", 5) └ No sync Snapshot is still stale step_next() ← Batch sync happens here └ On the next get_snapshot() tickets_num: 0 to 2 is now reflected

eager (UI "High Precision" button ON)

step(ctx): snap = ctx.state.snapshot ← Latest place_ticket("buy", 10) └ sync ← Snapshot updated immediately tickets_num: 0 → 1 place_ticket("sell", 5) └ sync ← Updated again tickets_num: 1 → 2 tickets = get_ticket_list() └ sync ← Both entries are visible
deferred is the default. It syncs only at step boundaries, making it fast. Suitable for strategies that place multiple orders within a single step. However, the snapshot remains stale within the same step.
eager syncs with WASM on every operation, so the state is always up to date. Safer, but incurs more communication overhead.
The sync policy is toggled via the "High Precision" button in the browser UI. hawk-bt automatically retrieves this setting on connection, so no configuration is needed in your code.

TP/SL Evaluation Timing

D take_profit=self.tp TP/SL set here are not evaluated on the bar where the order was placed.TP/SL values set here are not evaluated on the bar where the order was placed. They are evaluated against the high/low of the bar when step_next() advances to the next bar.

The trigger check and the fill price differ. TP/SL hit detection uses the bar's high / low, but the settlement price (fill price) is the bar's close. If both TP and SL are hit on the same bar, the adverse side (SL) takes priority.

API -- Retrieving Information»

ctx.state.snapshot

Account and market state auto-updated at the start of every step. No call needed.Account and market state, automatically updated at the beginning of each step. No explicit call needed.

s = ctx.state.snapshot s.price # Current price s.balance # Account balance s.equity # Equity (balance + unrealized P&L) s.margin_level # Margin level s.tickets_num # Number of open positions s.step # Current bar number (0-indexed)
FieldTypeDescription
balancefloatAccount balance
equityfloatEquity (balance + unrealized P&L)
used_marginfloatUsed margin
margin_levelfloatMargin level
pricefloatCurrent price
timestamp_msfloatTimestamp (ms)
stepintBar number (0-indexed)
pending_ordersfloatNumber of pending orders
tickets_numfloatNumber of open positions
ticket_all_numfloatTotal positions (including closed)
bar_countintNumber of processed bars
ticket_stat_0..3floatPosition Statistics
ticket_long_countfloatNumber of long positions
ticket_short_countfloatNumber of short positions
pending_long_countfloatNumber of pending long orders
pending_short_countfloatNumber of pending short orders
total_stepsintTotal number of bars in the simulation
ctx.state.candles

Full-period OHLC + timestamps. Bulk-loaded at session start.OHLC + timestamps for the entire period. Fetched in bulk at session start. No further communication occurs after that. Since the data is a numpy array, slicing and arithmetic operations work directly.

c = ctx.state.candles i = ctx.state.snapshot.step c.close[i] # Current price (== snapshot.price) c.close[i-1] # Previous bar's close c.high[:i].max() # Highest price so far c.close[i-20:i].mean() # 20-period SMA
candles contains data for the entire period. Always slice with candles.close[:step] for indicator calculations, and never reference data beyond the current bar. Strategies that use future prices will invalidate backtest results.
FieldDescription
timeTimestamp
openOpen
highHigh
lowLow
closeClose
Information Retrieval await ctx.engine.get_ticket_list()

List of open positions. Communication occurs on each call.

Returns

np.ndarray — Each row represents one position

Information Retrieval await ctx.engine.fetch_events()

Events since the last retrieval (closures, margin calls, etc.). Terminal state detection is also handled internally.

Returns

np.ndarray — shape (N, 5)

Information Retrieval await ctx.engine.refresh()

Manually re-fetches only the snapshot. Does not retrieve events.

API — Orders & Closing»

Trade Instructions await ctx.engine.place_ticket(side, units, ...)

Market order. Fills immediately at the current market price.

ParamTypeNote
sidestr"buy" / "sell"
unitsintLot size (1 or more)
take_profitfloat?TP distance (absolute value)
stop_lossfloat?SL distance (absolute value)
trailing_stopfloat?Trailing stop distance
TP/SL is specified as an absolute price difference. take_profit=5.0 means "take profit when price moves 5.0 from entry." Not a percentage.
Trade Instructions await ctx.engine.place_order(side, order_type, price, units, ...)

Limit ("limit") / Stop ("stop") order. Fills when the specified price is reached.

ParamTypeNote
sidestr"buy" / "sell"
order_typestr"limit" / "stop"
pricefloatOrder Price
unitsintLot Size
take_profitfloat?TP Distance
stop_lossfloat?SL Distance
trailing_stopfloat?Trailing stop distance
time_limitfloat?Expiration (number of bars)
Trade Instructions await ctx.engine.close_positions(position_ids, actions, ratios)

Position closing. Full or partial.

ParamTypeNote
position_idslist[int]Target position ID
actionslist[int]Action code (see table below)
ratioslist[float]Close ratio (0.0–1.0). Used only when action=2Close ratio (0.0-1.0). Used only when action=2

Action codes

CodeBehaviorHandling of ratios
1Full CloseIgnored
2Partial Close1.0 = full close, 0.5 = half close
action=1 immediately closes the entire position. action=2, ratio=1.0 also results in a full close, but action=1 is simpler.
Control await ctx.engine.end_session()

Terminates the simulation mid-way. Normally ends automatically when all bars are consumed or on margin call.

Settings»

HawkEngine

ParamDefaultNote
host"127.0.0.1"Bind Address
port8787Port Number
on_resultNoneResult callback (receives BacktestResult)

BacktestResult

The result object passed to the on_result callback after session completion.

Field / MethodTypeDescription
stepsintCompleted Bar Count
balancendarrayBalance per bar
equityndarrayEquity per bar
pricendarrayPrice per bar
total_ordersintTotal Orders
win_countintWinning Trades
loss_countintLosing Trades
gross_profitfloatGross Profit (sum of winning trades)
gross_lossfloatGross Loss (sum of losing trades)
final_balance()floatFinal Balance
max_drawdown()floatMax DD (e.g., -0.15)
max_drawdown_before_end()floatMax DD (up to balance reaching 0)
def on_done(r): print(f"{r.steps} bars, balance={r.final_balance():.2f}, DD={r.max_drawdown():.2%}") HawkEngine(on_result=on_done).start(MyStrategy())

configure_logging(level)

LevelOutput
1WARNING only
2INFO — Connection/disconnection/result reports
3DEBUG — Detailed log per order

ctx.user

A dictionary for carrying data between steps. Can hold indicator buffers, trade count counters, or anything else.

async def step(self, ctx: Context): ctx.user.setdefault("n", 0) ctx.user["n"] += 1

Reference: Strategy Using Custom Indicators»

Since ctx.state.candles holds the entire period's OHLC as a numpy array, you can compute any indicator on the fly by slicing up to the current bar position.

import numpy as np from hawk_bt import HawkEngine, Strategy, Context class BollingerBreakout(Strategy): period = 20 k = 2.0 async def step(self, ctx: Context): i = ctx.state.snapshot.step if i < self.period: return # Slice closing prices up to the current bar close = ctx.state.candles.close[:i] # Calculate Bollinger Bands sma = close[-self.period:].mean() std = close[-self.period:].std() upper = sma + self.k * std lower = sma - self.k * std price = ctx.state.snapshot.price if price > upper: await ctx.engine.place_ticket( side="buy", units=1, take_profit=std, stop_loss=std * 0.5, ) elif price < lower: await ctx.engine.place_ticket( side="sell", units=1, take_profit=std, stop_loss=std * 0.5, ) HawkEngine().start(BollingerBreakout())

Since candles is a numpy array, any indicator such as RSI, ATR, MACD, etc. can be implemented in the same manner.

Reference: Matching with External Data»

To use data other than OHLC (volume, fundamentals, etc.), load your own data and match it by timestamp with candles.time.

import pandas as pd from hawk_bt import HawkEngine, Strategy, Context class VolumeBreakout(Strategy): def __init__(self, volume_csv: str): # Load external volume data and create a Series keyed by timestamp df = pd.read_csv(volume_csv) self.volume = df.set_index("timestamp")["volume"] async def step(self, ctx: Context): i = ctx.state.snapshot.step if i < 20: return # Get volume by the current bar's timestamp ts = ctx.state.candles.time[i] vol = self.volume.get(ts, 0) # Average volume of the last 20 bars recent_ts = ctx.state.candles.time[i-20:i] avg_vol = self.volume.reindex(recent_ts).mean() # Volume >= 2x average and bullish candle → Entry if vol > avg_vol * 2 and ctx.state.snapshot.price > ctx.state.candles.open[i]: await ctx.engine.place_ticket( side="buy", units=1, take_profit=10.0, stop_loss=5.0, ) HawkEngine().start(VolumeBreakout("volume_data.csv"))

The simulator provides only OHLC + timestamps. Any other data can be freely brought in.

Security»

WebSocket Connection

  • hawk-bt's WS server binds to 127.0.0.1:8787. It is not exposed to external networks.
  • Origin header validation is performed on connection; connections from origins not on the allow list are rejected with 403 Forbidden.
  • The Agent and browser must be running on the same machine.