This project was inspired by backtrader.
backtrader is a great Python project, but it is constrained by Python's GIL.
Cerebro aims to solve that with Go's concurrency model: an event-driven core
where the market feed, strategies, and broker run as independent, context-aware
goroutines that shut down gracefully in order.
go get github.com/gobenpark/cerebroRequires Go 1.26+.
The fastest way to see Cerebro run is the bundled replay market, which streams historical candles and simulates fills — no real exchange needed:
go run ./examples/backtestrunning backtest over 13 bars ...
notify [dip] AAA status=2
notify [dip] AAA status=5
notify [dip] AAA status=2
notify [dip] AAA status=5
final balance: 10118.82
no open positions
performance
trades=1 winRate=100% profitFactor=0.00 netPnL=118.82
maxDrawdown=40.60 (0.40%) sharpe=7.62 totalReturn=1.19%
The zero profitFactor is expected: with no losses it is undefined and reported as zero.
That example (examples/backtest/main.go) wires the
replay market, a small dip-buying strategy, and a risk gate through Cerebro and
prints both the final state and a performance summary (Cerebro.Performance()). It
is the place to start reading; the sections below show how to plug in a real
exchange for live trading.
To run Cerebro you provide two things:
- A
market.Marketimplementation — your adapter to an exchange (Binance, Upbit, a brokerage API, …) or the bundledmarket/replayfor backtests. It feeds candles/ticks and order/balance events into Cerebro and executes the orders the broker submits. - One or more
strategy.Strategyimplementations — your trading logic. Each strategy receives ticks and places orders through thebroker.Submitterhanded to it (scoped to the strategy, so its orders are attributed to it).
Cerebro wires these together with an internal broker (cash/position accounting) and an event engine (per-listener event dispatch), then runs the whole graph until the context is canceled.
package main
import (
"context"
"github.com/shopspring/decimal"
"github.com/gobenpark/cerebro/indicator"
"github.com/gobenpark/cerebro/item"
"github.com/gobenpark/cerebro/market"
"github.com/gobenpark/cerebro/order"
"github.com/gobenpark/cerebro/position"
)
// market.Market interface:
//
// Stocks(ctx) []*item.Item
// Candles(ctx, code, level) (indicator.Candles, error)
// Subscribe(ctx, handler market.TickEventHandler) error
// Order(ctx, o order.Order) error
// AccountPositions(ctx) []position.Position
// AccountBalance(ctx) decimal.Decimal
// Events(ctx) <-chan any
// Commission() market.Rate
type exchange struct{}
func (e *exchange) Stocks(ctx context.Context) []*item.Item { panic("implement me") }
func (e *exchange) Candles(ctx context.Context, code string, level market.CandleType) (indicator.Candles, error) {
panic("implement me")
}
// Subscribe is called once per watchlist item; start streaming its ticks here.
// The handler reports which items to subscribe.
func (e *exchange) Subscribe(ctx context.Context, handler market.TickEventHandler) error {
panic("implement me")
}
// Order submits an order to the exchange.
func (e *exchange) Order(ctx context.Context, o order.Order) error { panic("implement me") }
func (e *exchange) AccountPositions(ctx context.Context) []position.Position { panic("implement me") }
func (e *exchange) AccountBalance(ctx context.Context) decimal.Decimal { panic("implement me") }
// Events streams market events to Cerebro: indicator.Tick for price updates,
// indicator.OrderBook for order-book (호가) snapshots, and market.ChangeOrderEvent /
// market.ChangeBalanceEvent for fills and settlement.
// Liveness contract: a live adapter survives a transient disconnect by reconnecting
// internally and keeping this channel open (a drop must not close it), and SHOULD
// emit a market.FeedStatusEvent on disconnect/reconnect — it surfaces feed health and
// resets Cerebro's staleness watchdog (see WithFeedTimeout) during quiet periods.
func (e *exchange) Events(ctx context.Context) <-chan any { panic("implement me") }
// Commission is the fee rate applied to an order's value, as a market.Rate whose
// unit is explicit at the build site — market.Percent(0.15) or market.Fraction(0.0015),
// both 0.15%. It must be a cheap, non-blocking accessor (no I/O) — it is read on
// every fill.
func (e *exchange) Commission() market.Rate { panic("implement me") }Run runs in its own goroutine and decides over a strategy.Universe — the
set of instruments it trades together plus their merged tick stream — until the
context is canceled. A single-instrument strategy reads u.Items()[0] and ranges
over u.Ticks(); a pairs/portfolio strategy ranges over u.Items() and
demultiplexes u.Ticks() by indicator.Tick.Code. When the market adapter
publishes order books, u.OrderBooks() carries indicator.OrderBook snapshots
(bids/asks with BestBid / BestAsk / Spread / Mid / Imbalance helpers).
Place orders through the broker; NotifyOrder is called whenever one of your orders
changes state.
package main
import (
"context"
"github.com/shopspring/decimal"
"github.com/gobenpark/cerebro/broker"
"github.com/gobenpark/cerebro/order"
"github.com/gobenpark/cerebro/strategy"
)
type MyStrategy struct{ code string }
// Name must be unique per running instance. When spawned per item by a WithScreener
// factory, derive it from the item's Code so each instance differs.
func (s *MyStrategy) Name() string { return "my-strategy:" + s.code }
func (s *MyStrategy) Run(ctx context.Context, u strategy.Universe, b broker.Submitter) {
it := u.Items()[0] // single-instrument strategy
for {
select {
case <-ctx.Done():
return
case tk, ok := <-u.Ticks():
if !ok {
return
}
// Example: buy 10 units at the current price when cash allows.
qty := decimal.NewFromInt(10)
if b.Available().GreaterThanOrEqual(tk.Price.Mul(qty)) {
o := order.NewOrder(it, order.Buy, order.Limit, qty, tk.Price)
if err := b.Order(ctx, o, true /* safe: one open order per code */); err != nil {
// e.g. broker.ErrNotEnoughMoney
}
}
}
}
}
func (s *MyStrategy) NotifyOrder(o order.Order) {
switch o.Status() {
case order.Submitted:
// order accepted by the broker and sent to the exchange
case order.Completed:
// fully filled
case order.Canceled, order.Expired, order.Rejected, order.Margin:
// terminal, non-filled outcomes
}
}
func (s *MyStrategy) NotifyTrade() {}
func (s *MyStrategy) NotifyFund() {}package main
import (
"context"
"log/slog"
"os"
"os/signal"
"time"
"github.com/gobenpark/cerebro"
"github.com/gobenpark/cerebro/item"
"github.com/gobenpark/cerebro/strategy"
)
func main() {
cb := cerebro.NewCerebro(
cerebro.WithMarket(&exchange{}),
// Trade an explicit, fixed universe with one strategy (give several codes for a
// pairs/portfolio strategy that decides over them in one Run):
cerebro.WithStrategy(&MyStrategy{code: "KRW-BTC"}, "KRW-BTC"),
// Or drive a dynamic, real-time watchlist from a streaming screener — a fresh
// MyStrategy is spawned per screened item and retired (per the eviction policy)
// when it drops out:
//
// cerebro.WithScreener(myScreener, func(it *item.Item) strategy.Strategy {
// return &MyStrategy{code: it.Code}
// }, cerebro.WithEviction(cerebro.Flatten)),
cerebro.WithStrategyTimeout(5*time.Second),
cerebro.WithLogLevel(slog.LevelInfo),
// Or route logs into your own pipeline: cerebro.WithLogger(myLogger).
)
// Start returns immediately after spawning the producers. Cancel the context
// (or call cb.Shutdown()) to trigger a graceful, ordered shutdown.
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := cb.Start(ctx); err != nil {
panic(err)
}
<-ctx.Done() // wait for Ctrl-C
cb.Shutdown() // blocks until every component has drained
}| Option | Description |
|---|---|
WithMarket(market.Market) |
Exchange adapter (required). |
WithStrategy(strategy.Strategy, codes...) |
Register one strategy over a fixed, explicit universe (at least one code; give several for a pairs/portfolio strategy). |
WithScreener(cerebro.Screener, factory, ...ScreenOption) |
Register a dynamic screening group: the screener streams watchlist snapshots and a per-item strategy is spawned from factory for each, retired (per the eviction policy) when it drops out. Call again for an independent group. For a fixed universe, prefer WithStrategy. |
WithEviction(cerebro.EvictionPolicy) |
A WithScreener group option: what to do with a dropped item's strategy — KeepUntilFlat (default), Flatten, DropImmediately, or your own. |
WithStrategyTimeout(time.Duration) |
Per-strategy Run timeout budget. |
WithRisk(...risk.Rule) |
Pre-trade risk gate (position/order/rate limits). |
WithRiskPolicy(name, risk.Policy) |
Per-strategy reactive exit (stop-loss / trailing-stop / take-profit). |
WithStorage(broker.Storage) |
Persist/restore the per-strategy ledger (realized PnL, fees, open lots) across restarts. |
WithOrderTimeout(time.Duration) |
Bound the adapter's Order call; on timeout the submission is held in-doubt (kept open and reserved), not rejected (off by default; for live adapters). |
WithInDoubtHandler(func(order.Order, error)) |
Operator hook for an in-doubt submission (timed-out Order call) — query/alert/cancel by the order's client id. Requires WithOrderTimeout. |
WithFeedTimeout(time.Duration) |
Arm a live-feed staleness watchdog: trip the feed-loss handler if no tick or market.FeedStatusEvent heartbeat arrives in time (off by default; for live feeds, not backtests). |
WithFeedLossHandler(func(reason string)) |
Handle a lost feed (gone stale, or its channel closed mid-run). Replaces the default fail-safe Shutdown. |
WithLogLevel(slog.Level) |
Level of the default stderr slog logger. |
WithLogger(*slog.Logger) |
Route logs through your own slog.Logger (use slog.DiscardHandler to silence). |
Cerebro is composed of a few cooperating parts:
- Cerebro — the orchestrator. It builds the dependency graph, starts every component, and tears them down in order on shutdown.
- Market — a user-implemented adapter to an external exchange (candles, ticks, order execution, account state, and an event stream).
- Strategy — your trading logic. Each strategy runs as its own goroutine over a Universe (one or more instruments and their merged tick stream), so one slow strategy never starves another. Register a strategy per item for a watchlist, or over several codes for a pairs/portfolio strategy.
- Broker — tracks cash, positions, and open orders. Accounting is exchange-authoritative: settled balance comes from the exchange, while open buy orders reserve cash so the broker never over-commits before settlement.
- Event engine — fans market and order events out to each listener through a dedicated per-listener queue, with context-aware broadcast and drain on shutdown.
- Indicators (methods on
indicator.Candles)- Bollinger Band
- MACD
- Stochastic (Fast / Slow)
- Envelope
- Volume Ratio
- RMA (Wilder moving average)
- Decimal money — prices, sizes, balances, and order values use
shopspring/decimal, so financial math carries no float64 rounding error. Commission is a typedmarket.Ratebuilt withmarket.Percentormarket.Fraction, so a fee's unit is explicit at the call site. - Replay market —
market/replaystreams historical candles and simulates fills locally, so strategies run end-to-end with no real exchange (see the Quickstart).Done()signals when a backtest run finishes. - Risk gate — compose pre-trade rules via
cerebro.WithRisk(MaxPositionPct,MaxOrderValue,MaxOpenPositions,OrderRateLimit,MaxLoss, or customrisk.Func). - Reactive exit policies — attach a per-strategy stop-loss / trailing-stop /
take-profit with
cerebro.WithRiskPolicy. A monitor tracks each strategy's attributed position and submits a market exit on its behalf when a trigger fires. - Multi-asset strategies — a strategy decides over a Universe of
instruments. Register one over several codes for pairs/portfolio trading
(
cerebro.WithStrategy(s, "AAA", "BBB")) — oneRunsees every leg's ticks and trades them together. See the runnableexamples/pairs. - Dynamic screening —
cerebro.WithScreener(screener, factory, ...)registers a group: the screener streams watchlist snapshots and a per-item strategy is spawned fromfactoryfor each screened item, then retired — per aWithEvictionpolicy (KeepUntilFlatby default, orFlatten) — when it drops out. Register several groups for several screener→strategy pipelines (a fixed universe is better expressed withWithStrategy). It is the seam connecting "what to trade" (screening) to "when to trade" (the strategy). - Order book (호가) — when the market adapter publishes
indicator.OrderBooksnapshots, each strategy receives them for its universe onu.OrderBooks()(best-first bids/asks withBestBid/BestAsk/Spread/Mid/Imbalancehelpers), delivered best-effort alongside ticks. Adapters emit them on the existing event stream — no interface change. - Strategy attribution — each strategy submits through a broker handle scoped
to its
Name(), so orders and their fills are attributed back to it. - Realized PnL & reporting — the broker books realized PnL and fees per
strategy from attributed fills (average-cost basis);
Cerebro.Report()returns a per-strategy snapshot of realized PnL, fees, and open positions. - Performance analytics — the broker logs each closed round-trip and samples
account equity daily;
Cerebro.Performance()turns them into a summary (win rate, profit factor, expectancy, average win/loss, max drawdown, Sharpe, total return) via theanalysispackage.Cerebro.Trades()andCerebro.EquityCurve()expose the raw series for custom analysis (e.g.analysis.Summarize(..., 252)for trading-day Sharpe). - Persistence & crash recovery — attach a
broker.Storagewithcerebro.WithStorageand the broker restores its per-strategy ledger (realized PnL, fees, and open lots) on start and writes it back after each booked fill, so attributed trading state survives a restart.store.NewFileStorage(atomic JSON file) andstore.NewMemoryStorageship in the box. Cash balance, account positions, and in-flight orders are exchange-authoritative and recovered from the exchange on start (see Open-order recovery), so they are not persisted here. - Open-order recovery — a live adapter that implements the optional
market.OpenOrderReporter(OpenOrders(ctx)) lets Cerebro recover the orders the exchange still has working when the process restarts mid-order. On start the broker seeds them into its open set — reserving their cash again and tracking partial-fill progress — so their later fill/cancel events are recognized instead of arriving as unknown, andAvailablestays correct across a restart. Recovered orders are unattributed (the exchange does not record which strategy placed them). A backtest adapter need not implement it; recovery is then a no-op. - Idempotent submission & in-doubt safety — every order carries a stable client
order id (
order.ID()) an adapter sends to the exchange as an idempotency key, so a retried submission is de-duplicated rather than doubled. Withcerebro.WithOrderTimeoutthe broker bounds the adapter'sOrdercall and treats a timeout as in-doubt, not a rejection: the order may have reached the exchange, so its cash stays reserved and it stays open (rejecting would risk a double exposure). A later fill/cancel event, a restart reconcile, or acerebro.WithInDoubtHandlerresolves it. - Feed resilience — arm a market-data staleness watchdog with
cerebro.WithFeedTimeout: if ticks (or amarket.FeedStatusEventheartbeat) stop flowing, or the feed's channel closes while the run is still live, Cerebro fails safe by shutting down — or runs yourcerebro.WithFeedLossHandler. Live adapters reconnect internally and keep theirEventschannel open across transient drops (themarket.Marketliveness contract); backtests leave the watchdog off. - Resampling — build OHLCV candles from raw ticks:
indicator.Resamplefor a batch, or a statefulindicator.Resamplerfor a strategy's tick loop — itsAddreports each bar as it closes, with an optionalWithWindowmemory cap. - Concurrency — event-driven core with per-listener dispatch and graceful, ordered shutdown (producers stop first, the dispatcher last).
Planned for future releases:
- Runtime kill switch / control surface
- Broker slippage modeling
This project follows Semantic Versioning:
- MAJOR for incompatible API changes,
- MINOR for backwards-compatible functionality,
- PATCH for backwards-compatible bug fixes.
Additional labels for pre-release and build metadata are available as extensions
to the MAJOR.MINOR.PATCH format.
Have an idea or want to discuss the project? Open an issue or send a mail to qjadn0914@naver.com.