Strategy API¶
Code blocks come from tests/docs/strategy_api_doc_test.cpp (ctest docs.strategy_api). The headers are listed in Public API; #include "fastmm/strategy.hpp" brings in everything a strategy header needs.
Strategy class¶
A strategy is a default-constructible class with a static name() and a static schema() (concept StrategyLike). StrategyBase<Params> provides schema() and the parameters:
Member of StrategyBase<Params> |
Meaning |
|---|---|
params() -> const Params& |
the current parameters; read-only |
configure(const ParamMap&) -> std::optional<std::string> |
applies name -> value strings, runs validate(), returns the first error; on error the parameters are unchanged. Startup only |
describe_params() -> std::string |
name=value pairs that parse back to the same values |
verify_strategy<S>() checks every hook with stand-in context and book types, so the check runs in the strategy header instead of when an engine is built.
Hooks¶
Hooks are public member functions; every hook is optional:
void on_start(auto& ctx) noexcept {
hit(kStart);
static_cast<void>(ctx.every(params().timer_ms, /*tag=*/7));
}
void on_stop(auto& /*ctx*/) noexcept { hit(kStop); }
void on_book(auto& ctx, InstrumentId id, const auto& book) noexcept {
hit(kBook);
if (!book.is_valid()) return ctx.pull_quotes(id);
DesiredQuotes q;
q.bid(book.best_bid().price, params().quote_qty);
q.ask(book.best_ask().price, params().quote_qty);
static_cast<void>(ctx.set_quotes(id, q));
}
void on_book_ticker(auto& /*ctx*/, InstrumentId /*id*/, const BookTickerMsg& /*m*/) noexcept {
hit(kBookTicker);
}
void on_trade(auto& /*ctx*/, InstrumentId /*id*/, const TradeMsg& /*m*/) noexcept { hit(kTrade); }
void on_option_ticker(auto& /*ctx*/, InstrumentId /*id*/, const OptionTickerMsg& /*m*/) noexcept {
hit(kOptionTicker);
}
void on_fill(auto& /*ctx*/, const Fill& /*fill*/) noexcept { hit(kFill); }
void on_order_update(auto& /*ctx*/, const OmsUpdate& /*u*/) noexcept { hit(kOrderUpdate); }
void on_timer(auto& ctx, TimerId /*id*/, std::uint64_t tag) noexcept {
if (tag != 7) return;
hit(kTimer);
// Once: a price off the tick is refused by the engine's own check (on_risk_reject).
if (count(kTimer) == 1)
static_cast<void>(ctx.send(NewOrderRequest::limit(
InstrumentId{0}, Side::Buy, Price::from_decimal("99.995").value(), params().quote_qty)));
}
void on_connection(auto& /*ctx*/, const ConnectionStateMsg& /*m*/) noexcept { hit(kConnection); }
void on_quoting(auto& /*ctx*/, bool /*enabled*/) noexcept { hit(kQuoting); }
void on_params(auto& /*ctx*/) noexcept { hit(kParams); }
void on_balance(auto& /*ctx*/, const BalanceMsg& /*m*/) noexcept { hit(kBalance); }
void on_perp_state(auto& /*ctx*/, InstrumentId /*id*/, const PerpStateMsg& /*m*/) noexcept {
hit(kPerpState);
}
void on_risk_reject(auto& /*ctx*/, const RiskReject& r) noexcept {
if (r.reason == RejectReason::InvalidTick && !r.replace) hit(kRiskReject);
}
| Hook | Called |
|---|---|
on_start(ctx) |
once, before the first event; ctx.quoting_enabled() is true in a dry run, whose orders are on paper |
on_stop(ctx) |
once, when the engine finishes; quotes are not pulled here, shutdown's cancel-all does that |
on_book(ctx, id, book) |
after a snapshot or delta is applied, the position is marked and risk has seen the mid; the book may be invalid (empty or crossed) |
on_book_ticker(ctx, id, m) |
a top-of-book update without depth |
on_trade(ctx, id, m) |
a public trade |
on_option_ticker(ctx, id, m) |
an option's mark, implied volatilities and greeks (Options) |
on_fill(ctx, fill) |
one of our executions, after the position and fees are updated and before the on_order_update of the same execution |
on_order_update(ctx, u) |
an OMS state change of one of our orders, including the end of a reconciliation |
on_timer(ctx, id, tag) |
a timer from ctx.every or ctx.once fired |
on_connection(ctx, m) |
a venue channel changed state; for any state other than Live the engine has already pulled that venue's quotes and, on the market-data channel, cleared its books |
on_quoting(ctx, enabled) |
ctx.quoting_enabled() changed (below) |
on_params(ctx) |
a parameter update was applied; params() holds the new values (Parameter updates) |
on_balance(ctx, m) |
a venue reported one asset of the account (Balances); ctx.balance already holds it |
on_perp_state(ctx, id, m) |
a venue's mark, index or funding of a derivative arrived (Perpetuals); ctx.mark and ctx.funding already hold it |
on_risk_reject(ctx, r) |
the engine's own risk check refused a new order or replace (below); nothing was sent |
on_batch_end(ctx) |
once after the events that were waiting when the strategy called ctx.request_batch_end() (below) |
Rules:
- Hooks return
void.auto& ctxandtemplate <class Ctx> void on_x(Ctx& ctx, ...)are the same. Hooks run insidenoexceptengine code; declare themnoexcept. - Instrument-scoped hooks (
on_book,on_book_ticker,on_trade,on_option_ticker,on_perp_state,on_fill) fire only for instruments in the table, soctx.book(id)andctx.instrument(id)are valid in them. on_fillfires for every execution on an instrument in the table, including late fills (the order was already terminal) and fills for ids the OMS does not know. Fills on other instruments are counted inEngineStats::unknown_instrument_fills.- Hooks must not block, allocate on every event or read anything that is not an engine input (the system clock,
std::random_device, files); see Determinism.
on_quoting¶
set_quotes is ignored while quoting is disabled: an operator pull (PullQuotes), the kill switch, a reconciliation, or parameters older than max_param_age_ms (Parameter updates). A dry run quotes: its orders are acknowledged on paper and never sent. on_quoting(ctx, enabled) reports changes, so a strategy can requote as soon as quoting is back.
- The engine compares
ctx.quoting_enabled()before and after each event, fired timer andon_start, and calls the hook after the triggering hook has returned and all flags are final. At the end of a reconciliation that is after the engine has placed the quotes it paused, so a requote fromon_quotingreplaces them. - It never fires from inside a context call: a kill switch tripped by
set_quotesorsendis reported after the hook that made the call returns. - It does not fire for the initial state;
on_startreadsctx.quoting_enabled(). - A lost connection does not change
quoting_enabled();on_connectionreports it.
on_batch_end¶
ctx.request_batch_end() from any hook asks for one on_batch_end(ctx) call once the engine has handled the events waiting in its input rings: the rings are empty, or the step's max_events_per_step is used up. Requests before the call make one call. Work a burst would repeat for every event goes there: a strategy that requotes on each balance report marks the instruments in on_balance and requotes them once at the batch end. A request from inside on_batch_end makes one more call, after the step's timers or at the next step.
- The engine journals each call, so a replay calls it after the same event.
- Orders sent from it carry the receive time (T0) of the event that asked last.
[engine] feed_budget_per_ringsets how many events the engine takes from one input ring before the next ring's turn; 1 interleaves an account's order events with the market data.
on_risk_reject¶
A send, replace or set_quotes the [risk] limits refuse sends nothing: send and replace return the RejectReason, set_quotes returns true (the quote manager keeps the level for the next requote) and the engine counts and logs the reject. on_risk_reject(ctx, const RiskReject& r) reports each one to the strategy: instrument, side, type, reason, price, qty, user_tag (a quote's tag is the QuoteManager's), replace with order (the id a replace would have changed; invalid for a new order), and time.
- It is delivered after the hook that asked has returned, in the order the rejects happened, together with
on_quoting(above); never from inside the context call that was refused. - An order refused again from inside
on_risk_rejectis reported in a further round, a few rounds at most: the queue holds 64 rejects per event, and what does not fit is counted inEngineStats::risk_reject_notices_dropped. - A strategy without the hook pays nothing: the queue is compiled out with it.
- Venue rejects are not risk rejects: an order the venue refused comes back through
on_order_updatewithOrderState::Rejected.
Signature checks¶
The engine checks each hook name when it is compiled. A member with a hook's name whose call does not compile stops the build, one error per hook:
error: static assertion failed: fastmm: on_trade has the wrong signature or is not public; expected void on_trade(auto& ctx, InstrumentId id, const TradeMsg& m)
- Likely misspellings (
on_fills,on_trades,on_order_book,on_tick,on_bbo,on_order,on_execution,on_disconnect,onBook, ...) produce the warningfastmm: on_fills is not a hook; did you mean on_fill?, an error withFASTMM_WERROR. Silence it withstatic constexpr bool fastmm_allow_near_miss_names = true;in the strategy. - Limits: a
finalclass is only call-checked, so a wrong signature is silently not called; a hook that is ambiguous across two bases is reported as a wrong signature; a hook with a deduced (auto) return type must not be checked withverify_strategy; private hooks are rejected, also withfriend; implicit argument conversions are accepted. Engine::start()logs the implemented hooks:strategy first_mm hooks: start book fill timer connection quoting.
Context¶
ctx is a StrategyContext<Engine>, a non-owning view of the engine:
// time and reference data
static_assert(std::same_as<decltype(lvalue<Ctx>().now()), Timestamp>);
static_assert(std::same_as<decltype(lvalue<Ctx>().instrument(InstrumentId{})), const Instrument&>);
static_assert(std::same_as<decltype(lvalue<Ctx>().instruments()), const InstrumentTable&>);
static_assert(std::same_as<decltype(lvalue<Ctx>().contains(InstrumentId{})), bool>);
// market data
static_assert(std::same_as<decltype(lvalue<Ctx>().book(InstrumentId{})), const Book&>);
static_assert(
std::same_as<decltype(lvalue<Ctx>().own_qty(InstrumentId{}, Side::Buy, Price{})), Qty>);
static_assert(
std::same_as<decltype(lvalue<Ctx>().own_qty(InstrumentId{}, Side::Buy, Price{}, Timestamp{})),
Qty>);
static_assert(std::same_as<decltype(lvalue<Ctx>().best_ex_self(InstrumentId{}, Side::Buy)), Level>);
// portfolio
static_assert(std::same_as<decltype(lvalue<Ctx>().position(InstrumentId{})), const Position&>);
static_assert(std::same_as<decltype(lvalue<Ctx>().portfolio()), Portfolio>);
// quoting
static_assert(
std::same_as<decltype(lvalue<Ctx>().set_quotes(InstrumentId{}, DesiredQuotes{})), bool>);
static_assert(std::same_as<decltype(lvalue<Ctx>().pull_quotes(InstrumentId{})), void>);
static_assert(std::same_as<decltype(lvalue<Ctx>().pull_all_quotes()), void>);
static_assert(std::same_as<decltype(lvalue<Ctx>().working_quote(InstrumentId{}, Side::Buy, 0)),
const Order*>);
// direct orders
static_assert(std::same_as<decltype(lvalue<Ctx>().send(NewOrderRequest{})),
Result<ClientOrderId, RejectReason>>);
static_assert(
std::same_as<decltype(lvalue<Ctx>().cancel(ClientOrderId{})), Result<void, RejectReason>>);
static_assert(std::same_as<decltype(lvalue<Ctx>().replace(ClientOrderId{}, Price{}, Qty{})),
Result<void, RejectReason>>);
static_assert(std::same_as<decltype(lvalue<Ctx>().order(ClientOrderId{})), const Order*>);
static_assert(std::same_as<decltype(lvalue<Ctx>().open_qty(InstrumentId{}, Side::Buy)), Qty>);
static_assert(std::same_as<decltype(lvalue<Ctx>().oms()), const Oms&>);
static_assert(
std::same_as<decltype(lvalue<Ctx>().queue_ahead(ClientOrderId{})), std::optional<Qty>>);
static_assert(
std::same_as<decltype(lvalue<Ctx>().order_times(ClientOrderId{})), const OrderTimes*>);
// timers
static_assert(std::same_as<decltype(lvalue<Ctx>().every(Duration{}, std::uint64_t{})), TimerId>);
static_assert(std::same_as<decltype(lvalue<Ctx>().once(Duration{}, std::uint64_t{})), TimerId>);
static_assert(std::same_as<decltype(lvalue<Ctx>().cancel_timer(TimerId{})), bool>);
// control
static_assert(std::same_as<decltype(lvalue<Ctx>().quoting_enabled()), bool>);
static_assert(std::same_as<decltype(lvalue<Ctx>().killed()), bool>);
static_assert(std::same_as<decltype(lvalue<Ctx>().venue_killed(VenueId{})), bool>);
static_assert(std::same_as<decltype(lvalue<Ctx>().request_stop()), void>);
static_assert(std::same_as<decltype(lvalue<Ctx>().trip_kill(KillReason::StrategyError)), void>);
// randomness (seeded from the configuration)
static_assert(std::same_as<decltype(lvalue<Ctx>().rng()), Xoshiro256ss&>);
| Method | Notes |
|---|---|
now() |
the engine clock of the current event; virtual time in backtests and replay |
instrument(id), instruments(), contains(id) |
the instrument table; iterate instruments() for all instruments |
book(id) |
the L2 book of an instrument (Book) |
position(id) |
qty (signed, base units), average price, realised PnL (funding included) and fees of one instrument, in its settlement currency |
portfolio() |
realized, unrealized, fees and net (realised + unrealised - fees) over all instruments, in the [accounting] reporting currency when one is set; a loop, not for every event |
set_quotes(id, q) |
the desired ladder; the quote manager sends the difference to the working orders. Returns false when the quotes were ignored: quoting disabled or instrument not in the table |
pull_quotes(id), pull_all_quotes() |
cancel the quotes of one or every instrument |
working_quote(id, side, level) |
the open order in a quote slot (level 0 is closest to the mid), or nullptr |
send(req) |
a direct order, risk-checked and journaled. Fails with the RejectReason of a risk check, the OMS or the kill switch; InvalidTag when user_tag lies in the quote manager's range |
cancel(id), replace(id, px, qty) |
direct order management; cancels are always allowed |
order(id) |
an open order, or nullptr once it is terminal; valid until the next context call |
open_qty(id, side) |
unfilled quantity of our open orders on one side, quotes included |
oms() |
read-only order management state |
every(period, tag), once(delay, tag) |
timers; on_timer receives the id and tag. They fire in engine time and are journaled |
cancel_timer(id) |
false when the timer no longer exists |
quoting_enabled(), killed(), venue_killed(venue) |
quoting state, the global kill switch and one venue's kill switch (new orders to that venue are refused and set_quotes ignores its instruments) |
note_quote(id, side, reason, budget = {}) |
why the strategy asks what it asks on a side ("inventory_cap", "model_off"; empty: none, up to 23 bytes) and the budget it gave the side, for the quote table of fastmm-top (Monitoring); kept until the next note on the side |
metric(name, value) |
publishes a number by name (a budget split, a model state) in the status file, fastmm-top and fastmm_strategy_metric; the first 32 names, up to 23 bytes each |
request_stop() |
sets the engine's stop flag: a backtest ends after the current engine step; replay always drains the journal |
trip_kill(reason) |
trips the global kill switch with a KillReason: quoting stops, quotes are pulled and every working order is cancelled |
rng() |
a Xoshiro256ss seeded from [engine] rng_seed, identical in replay |
own_qty(id, side, px), own_qty(id, side, px, at), best_ex_self(id, side), queue_ahead(order_id), order_times(order_id) |
our own orders as the market sees them (Execution view) |
set_quotes applies hysteresis ([engine] min_requote_ticks, min_requote_interval_ms), skips orders awaiting a venue response and uses replace where the venue supports it. A direct order returns a Result:
auto id = ctx.send(NewOrderRequest::limit(inst.id, Side::Buy, px, qty).post_only());
if (!id) return FASTMM_LOG_WARN("order refused: {}", id.error()); // e.g. RejectReason::MaxPosition
Fees, risk headroom and venue health¶
static_assert(std::same_as<decltype(lvalue<Ctx>().fees(InstrumentId{})), const FeeRates&>);
static_assert(std::same_as<decltype(lvalue<Ctx>().risk_headroom(InstrumentId{})), RiskHeadroom>);
static_assert(std::same_as<decltype(lvalue<Ctx>().venue_health(VenueId{})), VenueHealthView>);
static_assert(std::same_as<decltype(lvalue<Ctx>().order_budget(VenueId{})), OrderBudget>);
| Method | Returns |
|---|---|
fees(id) |
FeeRates: maker_cbps, taker_cbps (1 cbps = 0.01 bps; positive is a fee, negative a rebate), maker_bps(), taker_bps(), fee(notional, liquidity). The instrument's maker_bps / taker_bps, else its venue's [venues.<x>.fees]; with fetch_fees the account's own rates (Binance Spot). A backtest charges these rates; a live venue reports the commission of each fill itself |
risk_headroom(id) |
RiskHeadroom: what each [risk] limit still admits on the instrument now, computed from the inputs the next check uses; an order of exactly a room passes that check and one lot more is refused. order_tokens (rate limiter), open_orders, max_order_qty, max_order_notional, buy_qty / sell_qty (max_position, open orders on that side counted, rounded down to the lot), underlying_buy_qty / underlying_sell_qty ([risk.underlying] max_net over the underlying's instruments, in this instrument's contracts; 0 while an inverse contract of it has no mark), gross_notional, net_buy_notional / net_sell_notional (exposure an order that does not reduce its position may add), exposure_buy_notional / exposure_sell_notional (those two caps as the notional of one buy / sell on this instrument in its settlement currency, converted at the rate the check uses: unlimited for the side that reduces the position, 0 while the currency's rate is unknown), loss_budget (max_loss plus net PnL; the kill switch trips at 0). A limit that is off reads RiskHeadroom::kUnlimited or the type's max(). Notionals are in the reporting currency with [accounting]. fastmm-gateway's account guards are not included |
venue_health(venue) |
VenueHealthView: feed_lag (recv_ts - exch_ts of the venue's latest market-data message with a venue time), feed_lag_base (its minimum over the last 8 s), feed_lag_excess, ack_rtt and ack_rtt_smoothed (engine time from sending a new order to consuming its first ack; smoothed as srtt += (rtt - srtt) / 8), md_updated, ack_updated, sample counts, gate_engagements and gated (Feed-lag gate). Zero before the first sample |
order_budget(venue) |
OrderBudget (core/order_budget.hpp): local_tokens, the orders the [risk] orders_per_sec bucket admits now (kUnlimited with the limit off), and local_wait_ns, the time until it admits the next one (0 while it admits one now: a strategy refused RateLimit waits this long instead of resending on every event); the venue's own counts as RateWindows (window_ms, used, limit, cap, known(), remaining(); cap is where the connector's rate limiter stops sending, 90 % of limit, and remaining() counts up to it): orders_10s, orders_1m, orders_1d (Binance ORDERS per 10 s, minute and day, the count the venue last reported plus the orders sent since, those the connector has not taken yet included) and weight (request weight over the venue's shortest window: 6000 a minute on Binance Spot, 2400 on USDⓈ-M); venue_paused while the connector sends nothing (429 Retry-After, 418); venue_known; orders_remaining(), the least of them |
The first three are computed from journaled inputs, so replay returns the same values. venue_health costs one lookup; risk_headroom does the work of a risk check. While the feed-lag gate holds a venue, set_quotes on its instruments returns false. order_budget's venue fields are the connector's latest publication from its own thread (its rate limiter, venues/rate_limiter.hpp, published with its status and after each response that carried the venue's counts), not journaled: a backtest, a replay and a strategy attached to fastmm-gateway see venue_known == false and every window unlimited; only local_tokens is deterministic.
Balances¶
static_assert(std::same_as<decltype(lvalue<Ctx>().balance(VenueId{}, "USDT")), Balance>);
static_assert(std::same_as<decltype(lvalue<Ctx>().margin(VenueId{})), Margin>);
static_assert(
std::same_as<decltype(lvalue<Ctx>().balance_room(InstrumentId{}, Side::Buy, Price{})), Qty>);
static_assert(std::same_as<decltype(Balance::free), Notional>);
static_assert(std::same_as<decltype(Balance::known), bool>);
static_assert(std::same_as<decltype(Margin::available), Notional>);
static_assert(std::same_as<decltype(lvalue<Ctx>().balances_live()), bool>);
| Method | Returns |
|---|---|
balance(venue, asset) |
Balance: free, locked, total (the venue's last report moved by this engine's orders and fills since; Balance check), equity and maintenance as reported, as_of (the report's venue time), known (false until the venue reports the asset, and for an asset no instrument of the venue uses). Amounts are in the asset |
margin(venue) |
Margin: available, initial, maintenance, equity, wallet, asset: the venue's account-wide margin where it reports one (account true, usually valued in USD), else the settlement asset of its first derivative |
balance_room(id, side, px) |
the largest quantity of id on side at px the balance covers, rounded down to the lot: a spot buy's quote with the taker fee, a spot sell's base, a derivative's initial margin; Qty::max() while the venue has not reported that balance. What this instrument's open orders on that side hold is not counted as room |
balances_live() |
a venue has reported balances; before that nothing is estimated or checked |
RiskHeadroom::balance_buy_qty / balance_sell_qty are balance_room at the book's mid.
Account pools¶
Several accounts of one exchange behind one venue ([venues.<x>] pool_of, Account pools): the instruments, books, tickers and positions are the primary's; each member has its own balance(), margin() and order_budget() under its own venue id.
static_assert(std::same_as<decltype(lvalue<Ctx>().pool(VenueId{})), PoolMembers>);
static_assert(std::same_as<decltype(lvalue<Ctx>().pool_primary(VenueId{})), VenueId>);
static_assert(std::same_as<decltype(lvalue<Ctx>().account_usable(VenueId{})), bool>);
static_assert(std::same_as<
decltype(lvalue<Ctx>().balance_room(InstrumentId{}, Side::Buy, Price{}, VenueId{})),
Qty>);
static_assert(std::same_as<decltype(lvalue<const PoolMembers>().size()), std::size_t>);
static_assert(std::same_as<decltype(lvalue<const PoolMembers>()[0]), VenueId>);
static_assert(std::same_as<decltype(NewOrderRequest::account), VenueId>);
| Method | Returns |
|---|---|
pool(primary) |
PoolMembers: the accounts that take orders for the venue's instruments, the primary first (size(), operator[], contains(venue), iterable); just the venue itself without a pool |
pool_primary(venue) |
the venue whose instruments venue takes orders for: its primary, or itself |
account_usable(venue) |
the automatic routing would send to it now: its order link is live and its kill switch is not engaged |
balance_room(id, side, px, account) |
balance_room on one account of the pool. The three-argument form is what one usable account can hold: the largest over the members of its room (a sell: its free base; a buy: its free quote at the price) plus its own open orders on the side, less open_qty(id, side), the total over every account, and never below zero. fit_to_balance (room plus open_qty) therefore cuts a ladder to what one account covers, and a quote resting on one account is not resized by another's balance; balance(member, asset) shows each account |
A new order goes to one account and stays there: Order::venue, the OmsUpdate and Fill events (their msg->hdr.venue) and the store's rows carry the member's id, and cancel / replace follow it. NewOrderRequest::account (.account(venue) on the builder) names the account; it must be the primary or one of its members, else send fails with InvalidAccount. Without one the engine picks: for a spot buy the usable members whose free quote covers the notional, for a spot sell those whose free base covers the quantity (every usable member for a derivative), and among them, skipping those whose connector is paused or would refuse the order (an order window at its cap), the one with the most of the 10 s window left (order_budget(member).orders_10s.remaining()), then of the daily one. The orders sent to an account since its connector last published count against it, so the orders of one burst spread over the pool. Ties take turns: the search starts after the account the last automatic choice with a known budget went to. A backtest without window limits knows no budget and sends everything the balances allow to the primary. When no member covers the order it goes to the primary, which refuses it as the venue does. An order whose account (named, or the primary when no account with window room covers it) has a window at its cap or a paused connector is refused by send with RateLimit, without reaching the connector. Cancels count in no order window. A member whose order link is down, or whose kill switch is engaged, gets no new orders; a kill of the primary stops the pool. Quotes (set_quotes) are routed the same way, one order at a time.
Perpetuals¶
static_assert(std::same_as<decltype(lvalue<Ctx>().mark(InstrumentId{})), RefPrice>);
static_assert(std::same_as<decltype(lvalue<Ctx>().index(InstrumentId{})), RefPrice>);
static_assert(std::same_as<decltype(lvalue<Ctx>().funding(InstrumentId{})), FundingView>);
static_assert(std::same_as<decltype(lvalue<Ctx>().perp_state(InstrumentId{})), const PerpRow&>);
static_assert(std::same_as<decltype(RefPrice::price), Price>);
static_assert(std::same_as<decltype(RefPrice::stale), bool>);
static_assert(std::same_as<decltype(FundingView::rate), double>);
static_assert(std::same_as<decltype(FundingView::interval), Duration>);
static_assert(std::same_as<decltype(lvalue<const FundingView>().over(Duration{})), double>);
| Method | Returns |
|---|---|
mark(id), index(id) |
RefPrice: the venue's mark or index price of a derivative (PerpStateMsg, venues), at (engine time it arrived), stale (older than [accounting] stale_mark_ms, or never reported), usable() (reported and not stale) |
funding(id) |
FundingView: rate (per interval, a decimal; positive: longs pay shorts), interval, next (venue time of the next payment; zero when funding is continuous), at, stale (against stale_funding_ms), usable(), over(d) (the rate over a holding time d: rate * d / interval) |
perp_state(id) |
const PerpRow&: every field as last reported, open interest included, with the time each arrived and no staleness applied |
A venue that publishes mark, index and funding on separate channels (OKX) sends one message per channel; PerpStateMsg::fields names what a message carries and the table keeps the rest. With [accounting] mark = "venue" (the default) a position whose instrument has a fresh mark is valued at it (Valuation); on_book then no longer moves its unrealized PnL. fit_to_balance(ctx, id, inst, q) (strategies/quoting.hpp) cuts a DesiredQuotes ladder to what the balance covers, the side's resting orders counted as room, and drops a level under the lot, min_qty or min_notional; basic_mm and lead_mm use it.
Execution view¶
The engine derives these from the order events and the market data it journals, so a replay reproduces them.
| Method | Returns |
|---|---|
own_qty(id, side, px), own_qty(id, side, px, at) |
our resting quantity that the venue's feed shows at px, as of the book's last update or of venue time at (a BookTicker's hdr.exch_ts). An order counts from its ack's venue time to its end's (cancel ack, last fill, expiry, a reconciliation that no longer lists it; with whole-millisecond venue times to the end of that millisecond), less its fills, so a depth update stamped before our cancel still includes it. 0 in the simulator |
best_ex_self(id, side) |
the book's best Level on one side with own_qty taken out; a level that was only ours is skipped; Level{} when none is left |
queue_ahead(order_id) |
std::optional<Qty>: the estimated quantity resting ahead of an open order at its price; nullopt before its ack and once it is terminal |
order_times(order_id) |
const OrderTimes* of an open order, nullptr once terminal: sent (engine clock), venue_ack (the ack's exch_ts, whole ms on Binance; 0 when the venue gives none), local_ack (the ack's recv_ts). After a replace they describe the replacement from its ack on. OmsUpdate::times carries them in on_order_update, the terminal update included, and through Fill::update in on_fill |
Whether a venue's feed shows our orders is a transport property (own_in_feed(venue)): yes for LiveTransport and for the replay of a live journal (one with a TSC calibration), no for SimTransport.
queue_ahead runs the l2_queue fill model's queue model (core/queue_model.hpp) on the market data the strategy sees, with [engine] queue_conservatism ([backtest] queue_conservatism in a backtest):
| Event | Quantity ahead |
|---|---|
| ack | the displayed quantity at the price less own_qty; at the touch of a newer book ticker, the touch's quantity less ours; 0 better than that touch; either less what the trades printed since took from the level |
| depth delta at the price | a shrink from old to new takes (old - new) * ahead / old * (1 - conservatism), where old is less what the trades since the last update took; a level that goes away leaves 0; a snapshot caps it at the level |
| trade at the price | consumed first; a trade through the price leaves 0 (and fills the print's quantity: the aggressor would have taken that much from us before reaching the deeper level). One print is shared by our orders on its side in price priority, best for the aggressor first: each gets what the better ones left, after our maker fills at the print's venue time (live, the print carries them) |
| book ticker newer than the depth (venue update id when both carry one, else venue time) | 0 for an order priced better than its touch; at most the touch's quantity less ours for an order at the touch |
| replace ack | kept at the same price and no more than the leaves; otherwise as for an ack |
A level that shows less than we have resting at its price was published before our order reached it, and all of it counts as others' quantity. The engine tracks queues from the strategy's first queue_ahead call, so a strategy that never calls it pays one load per book update and trade; an order already resting at that call starts at the back of its level as the book then shows it. Call it in on_start to cover every order from its ack. With fill_model = "l2_queue" and no latency the value equals the fill model's (tests/sim/exec_view_test.cpp); with latency the simulated venue sees the market before the strategy does. A replay handles events in arrival order, fastmm-data fill-check in venue-time order: on a 3 h Binance Spot session the replayed estimate at the ack equals fill-check's for 2077 of 2079 orders, on a session that joined existing levels for 1639 of 1726.
Hedge executor¶
HedgeExecutor (strategies/hedge_executor.hpp, in fastmm/strategy.hpp) is a member a strategy owns: it hedges the positions of source instruments with IOC orders on hedge instruments, fails over between them and de-risks (Hedge with HedgeExecutor).
static_assert(std::same_as<decltype(lvalue<HedgeExecutor>().add_source(InstrumentId{})), bool>);
static_assert(
std::same_as<decltype(lvalue<HedgeExecutor>().add_hedge(InstrumentId{}, Ratio{})), bool>);
static_assert(
std::same_as<decltype(lvalue<HedgeExecutor>().start(lvalue<Ctx>(), HedgeExecutor::Config{})),
bool>);
static_assert(std::same_as<decltype(lvalue<HedgeExecutor>().on_fill(lvalue<Ctx>(), Fill{})), bool>);
static_assert(
std::same_as<decltype(lvalue<HedgeExecutor>().on_order_update(lvalue<Ctx>(), OmsUpdate{})),
bool>);
static_assert(
std::same_as<decltype(lvalue<const HedgeExecutor>().residual(lvalue<const Ctx>())), Qty>);
static_assert(
std::same_as<decltype(lvalue<const HedgeExecutor>().can_hedge(lvalue<const Ctx>())), bool>);
static_assert(std::same_as<decltype(lvalue<const HedgeExecutor>().status(lvalue<const Ctx>())),
HedgeExecutor::Status>);
| Method | Does |
|---|---|
reset(), add_source(id), add_hedge(id, tolerance) |
name the legs (at most 4 of each), hedges in order of preference; false when full |
start(ctx, config) |
checks the legs (configured, linear, each once) and starts; false, logged, otherwise |
set_config(config), set_tolerance(i, tolerance), set_target(qty), restart() |
new parameters, a new net target in base units, a halt cleared |
on_fill, on_order_update, on_book, on_timer, on_connection, on_balance, update |
the strategy's hooks forwarded; the bool ones return whether the event concerned a leg or one of its orders |
residual(ctx) |
sources plus hedges minus the target, base units |
can_hedge(ctx) |
some hedge instrument can take a hedge now and hedging is not halted |
why(ctx, i, now) |
Reason hedge instrument i is not usable: Down, Killed, NoBook, Stale, Gated, Benched, or Usable |
held(), halted(), derisking(), state(), status(ctx), stats() |
monitoring (Monitor it) |
hedge_order(inst, residual, bid, ask, tolerance, tag) |
static: the IOC that brings residual towards zero on inst, or none under its lot, min_qty or min_notional |
Book¶
on_book receives the engine's L2Book; any type modelling BookView has the same read API:
static_assert(BookView<Book>); // best_bid(), best_ask(), mid(), spread(), level(side, i), ...
static_assert(std::same_as<decltype(lvalue<const Book>().best_bid()), Level>);
static_assert(std::same_as<decltype(lvalue<const Book>().mid()), Price>);
static_assert(std::same_as<decltype(lvalue<const Book>().is_valid()), bool>);
| Method | Meaning |
|---|---|
best_bid(), best_ask() |
the touch as Level{price, qty}; zero when the side is empty |
mid(), spread() |
(bid + ask) / 2 truncated, ask - bid |
level(side, i), depth(side), top<N>(side) |
levels from the touch outwards |
qty_at_or_better(side, px), price_for_qty(side, qty) |
depth queries |
is_valid() |
both sides present and not crossed |
seq(), last_update() |
the venue sequence number and venue time (exch_ts, else receive time) of the last update |
Fill¶
Fill is built on the engine's stack; update and msg are valid only during the call.
static_assert(std::same_as<decltype(Fill::instrument), InstrumentId>);
static_assert(std::same_as<decltype(Fill::side), Side>);
static_assert(std::same_as<decltype(Fill::price), Price>);
static_assert(std::same_as<decltype(Fill::qty), Qty>);
static_assert(std::same_as<decltype(Fill::position_delta), Qty>);
static_assert(std::same_as<decltype(Fill::fee), Notional>);
static_assert(std::same_as<decltype(Fill::fee_converted), bool>);
static_assert(std::same_as<decltype(Fill::liquidity), Liquidity>);
static_assert(std::same_as<decltype(Fill::known), bool>);
static_assert(std::same_as<decltype(Fill::late), bool>);
static_assert(std::same_as<decltype(Fill::order_done), bool>);
static_assert(std::same_as<decltype(Fill::update), const OmsUpdate*>);
static_assert(std::same_as<decltype(Fill::msg), const OrderFillMsg*>);
| Field | Meaning |
|---|---|
instrument, side |
the order's, else the fill message's |
price, qty |
this execution |
position_delta |
signed change of the position; differs from qty when the commission is charged in the base asset |
fee, fee_converted |
fee in the settlement currency as booked; fee_converted is false when the commission was in a third asset (for example BNB) and not booked |
liquidity |
Maker, Taker or Unknown |
known |
the id matched an order; update->order holds its snapshot (for an order that was already terminal: id, instrument, side, state and filled quantity) |
late |
the order was already terminal when the fill arrived |
order_done |
the order reached a terminal state with this fill |
update, msg |
the OMS update (non-null when known) and the raw OrderFillMsg |
Messages¶
The messages hooks receive are fixed-size, trivially copyable structs (core/messages.hpp):
static_assert(std::same_as<decltype(TradeMsg::price), Price>);
static_assert(std::same_as<decltype(TradeMsg::qty), Qty>);
static_assert(std::same_as<decltype(TradeMsg::aggressor), Side>);
static_assert(std::same_as<decltype(TradeMsg::trade_id), std::uint64_t>);
static_assert(std::same_as<decltype(BookTickerMsg::bid_px), Price>);
static_assert(std::same_as<decltype(BookTickerMsg::bid_qty), Qty>);
static_assert(std::same_as<decltype(BookTickerMsg::ask_px), Price>);
static_assert(std::same_as<decltype(BookTickerMsg::ask_qty), Qty>);
static_assert(std::same_as<decltype(OptionTickerMsg::mark_price), Price>);
static_assert(std::same_as<decltype(OptionTickerMsg::underlying_price), Price>);
static_assert(std::same_as<decltype(OptionTickerMsg::mark_iv), double>);
static_assert(std::same_as<decltype(OptionTickerMsg::delta), double>);
static_assert(std::same_as<decltype(ConnectionStateMsg::state), ConnState>);
static_assert(std::same_as<decltype(ConnectionStateMsg::channel), std::uint8_t>);
static_assert(std::same_as<decltype(EventHeader::instrument), InstrumentId>);
static_assert(std::same_as<decltype(EventHeader::venue), VenueId>);
static_assert(std::same_as<decltype(EventHeader::recv_ts), Timestamp>);
static_assert(std::same_as<decltype(EventHeader::exch_ts), Timestamp>);
static_assert(std::same_as<decltype(OmsUpdate::order), Order>);
static_assert(std::same_as<decltype(OmsUpdate::prev), OrderState>);
static_assert(std::same_as<decltype(OmsUpdate::terminal), bool>);
static_assert(std::same_as<decltype(BalanceMsg::free), Notional>);
static_assert(std::same_as<decltype(BalanceMsg::asset), FixedString<8>>);
static_assert(std::same_as<decltype(PerpStateMsg::mark_price), Price>);
static_assert(std::same_as<decltype(PerpStateMsg::funding_rate), double>);
static_assert(std::same_as<decltype(PerpStateMsg::next_funding), Timestamp>);
static_assert(std::same_as<decltype(PerpStateMsg::fields), std::uint8_t>);
- Every message starts with an
EventHeader:type,venue,instrument,exch_ts(venue event time) andrecv_ts(receive time), plusseqand flags. TradeMsg::aggressoris the taker's side.OptionTickerMsg:mark_priceis in the instrument's price unit,underlying_priceandindex_pricein the underlying's quote currency, implied volatilities are annualised decimals (0.312 is 31.2 %), greeks are the venue's; NaN marks a field the venue did not send.ConnectionStateMsg::stateisDisconnected,Connecting,Live,Stale,ResyncingorDead;channelis 0 for market data and 1 for the order and user stream;reason_codeis venue-specific.OmsUpdatecarries the order snapshot after the transition (order), the previous state (prev), andknown,changedandterminalflags.BalanceMsg: one asset of a venue's account as the venue reports it, absolute amounts inassetatexch_ts:free,locked,total,equity,maintenance(for a derivative: available margin, initial margin in use, wallet balance, wallet plus unrealised PnL, maintenance margin). Flags:kSnapshot(part of a full snapshot),kSnapshotEnd(its last message; an asset it did not name holds nothing),kAccount(the account-wide margin).
Parameters¶
struct AllHooksParams {
FASTMM_PARAMS(AllHooksParams)
FASTMM_PARAM(int, levels, 1, 1, 8, "quote levels per side")
FASTMM_PARAM(double, gamma, 0.1, 0.0, 10.0, "risk aversion")
FASTMM_PARAM(bool, hedge, false, false, true, "hedge fills")
FASTMM_PARAM(Qty, quote_qty, 0.001_qty, 0_qty, 1000_qty, "quantity per side")
FASTMM_PARAM_BPS(half_spread_bps, 5_bps, 0_bps, 1000_bps, "half spread, bps")
FASTMM_PARAM_MS(timer_ms, milliseconds(100), milliseconds(1), milliseconds(60000), "timer, ms")
// Optional: runs after all keys of a configure() call are applied.
[[nodiscard]] std::optional<std::string> validate() const {
if (hedge && levels > 4) return "hedge supports at most 4 levels";
return std::nullopt;
}
};
| Declaration | Field type | Schema type | Configuration value |
|---|---|---|---|
FASTMM_PARAM(int, ...) |
any integer type | int |
whole number: 3, 3.0, 3e0 |
FASTMM_PARAM(double, ...) |
floating point | double |
any finite number |
FASTMM_PARAM(bool, ...) |
bool |
bool |
true/false, 1/0, yes/no, on/off |
FASTMM_PARAM(Qty, ...) |
Price, Qty, Notional |
decimal |
exact, up to 8 decimals |
FASTMM_PARAM_BPS(name, ...) |
Ratio |
bps |
basis points, exact, up to 4 decimals |
FASTMM_PARAM_MS(name, ...) |
Duration |
ms |
whole milliseconds; bounds are Durations |
- The arguments are the field name, default, minimum, maximum and a description;
FASTMM_PARAMS(Self)comes first. At most 128 parameters. decimal,bpsandmsvalues are parsed as decimal text, never through a double; exponents are accepted (Fixed point).- Ranges are checked on the typed value:
parameter 'quote_qty': value 1000.5 outside [0, 1000]. - A strategy whose parameters name instruments can declare
std::optional<std::string> check_instruments(const InstrumentTable&) const; it runs afterconfigure(), before the engine is built, and an error stops the session the way a bad parameter does.lead_mmuses it for itstarget,leaderandfxindices. - The schema drives configuration checks,
--list-strategies,--param key=valueandfastmm.strategies()in Python.
Parameter updates¶
A running engine takes new parameter values as an input event. ParamPublisher (fastmm/strategies/param_publisher.hpp) builds the event on another thread and pushes it into a ring of the engine's feed; in a backtest, sim::ParamSchedule (fastmm/sim/param_schedule.hpp) delivers events at simulated times through bt::BacktestSession::set_param_schedule.
ParamPublisher pub(ParamSink::to_ring(ring), strategy.params());
bool sent = pub.publish({{"half_spread_bps", "7.5"}, {"quote_qty", "0.02"}});
publish(values, inst)parses each value with the parameter's type and range into a copy of the parameters, then runsvalidate(). An unknown name, a name given twice, a value that does not parse or is out of range, a failedvalidate()and more than 32 values throwstd::invalid_argument; nothing is sent then.- It returns false when the ring is full or after
close(); the engine never waits for the publisher. - The copy starts from the parameters passed to the constructor and follows every update that was sent. A
StrategyBasekeeps one parameter set, so its updates name no instrument and apply to all. - The engine assigns all values of an update at one event, journals it and then calls
on_params(ctx). Parameters the update does not name keep their values. [strategy] max_param_age_ms(0, the default, is off) disables quoting before the first update and whenever none was applied for that long in engine time: the engine pulls the quotes,set_quotesreturns false andon_quoting(false)fires. The next update fireson_quoting(true).- Replay applies the journaled updates at the same events and matches their fields to the strategy's parameters by name (Journal format).
State across sessions¶
A strategy that keeps something worth more than one session (an online model, learned sizes) gives the engine its bytes: std::string_view state() returns them (the engine copies them at once) and bool restore(std::string_view) takes bytes saved before, false when they are not its own. Both or neither; the signatures are checked at registration like the hooks. The format is the strategy's; a version prefix is worth having.
With [strategy] state_file set, the engine calls restore once, after on_start and before the first event, with the file's contents (no file is a first start; an unreadable one is a configuration error), and takes state() every state_interval_s (300 by default) of engine time and at the end, never from inside another hook. The live session and the backtest write the bytes to the file atomically (a temporary file, then a rename) off the engine thread. A backtest's end state is what the next backtest, or a live session, starts from; a replay of a journal neither reads nor writes the file, so it sees the session as the journal recorded it.
A warm standby (fastmm-live --standby) is the exception: it runs on market data before the session it replaces has written the file for the last time, so restore comes once it takes over, after on_start and the market data it warmed up on (Hand over a running session).
Fixed-point helpers¶
static_assert(100.25_px == Price::from_raw(10'025'000'000));
static_assert(0.01_qty == Qty::from_raw(1'000'000));
static_assert(5_bps == Ratio::from_raw(50'000)); // 1 bp = 10'000 raw, 1.0 = 1e8 raw
static_assert(100_px * 5_bps == 0.05_px); // Price * Ratio, one truncation toward zero
static_assert(ratio(1_px, 4_px) == Ratio::from_raw(25'000'000));
static_assert(mid(100.00_px, 100.02_px) == 100.01_px);
static_assert(microprice(Level{100.00_px, 3_qty}, Level{100.04_px, 1_qty}) == 100.03_px);
static_assert(spread_ratio(99_px, 101_px) == 200_bps);
static_assert(away_from(100_px, Side::Buy, 0.5_px) == 99.5_px);
static_assert(inventory_allows(Side::Buy, 0.003_qty, 0.001_qty, 0.004_qty));
static_assert(!inventory_allows(Side::Buy, 0.004_qty, 0.001_qty, 0.004_qty));
static_assert(inventory_allows(Side::Sell, 5_qty, 1_qty, Qty{})); // a zero limit is no limit
static_assert(std::same_as<decltype(lvalue<const Instrument>().ticks(3)), Price>);
static_assert(std::same_as<decltype(lvalue<DesiredQuotes>().bid(Price{}, Qty{})), bool>);
static_assert(std::same_as<decltype(lvalue<DesiredQuotes>().uncross(Price{})), void>);
static_assert(
std::same_as<decltype(keep_passive(lvalue<DesiredQuotes>(), Price{}, Price{}, Price{})), void>);
static_assert(
std::same_as<decltype(NewOrderRequest::limit(InstrumentId{}, Side::Buy, Price{}, Qty{})
.post_only()
.reduce_only()
.ioc()
.tag(7)
.account(VenueId{})),
LimitOrder>);
static_assert(std::convertible_to<LimitOrder, NewOrderRequest>);
NewOrderRequest::limit(id, side, px, qty) builds a GTC limit order. Fixed point has the types, literals, rounding rules, quoting helpers and ranges.
Test harness¶
fastmm::sim::StrategyHarness<S> (fastmm/testing/strategy_harness.hpp, in fastmm::sim) runs a strategy in a real Engine<S, SimClock, SimTransport, InlineFeed> against a simulated venue on virtual time. Each call runs the engine until it is idle:
sim::StrategyHarness<AllHooks> h({{"quote_qty", "0.001"}, {"timer_ms", "5"}}); // on_start
h.book("100.00", "100.02"); // on_book; quotes go out
h.advance(milliseconds(10)); // acks (on_order_update), the timer (on_timer)
REQUIRE(h.working_orders().size() == 2);
REQUIRE(h.fill(Side::Buy)); // on_fill, then on_order_update
h.trade(100.02_px, 0.5_qty, Side::Buy); // on_trade
BookTickerMsg ticker{};
init_header(ticker, EventType::BookTicker, h.instrument(), VenueId{0});
ticker.bid_px = 100.00_px;
ticker.ask_px = 100.02_px;
h.push(ticker.hdr); // on_book_ticker
OptionTickerMsg option{};
init_header(option, EventType::OptionTicker, h.instrument(), VenueId{0});
h.push(option.hdr); // on_option_ticker
h.disconnect(); // on_connection (market data lost)
h.reconnect(); // on_connection (live again)
h.pull_quotes(); // on_quoting(false)
h.resume_quotes(); // on_quoting(true)
h.publish({{"half_spread_bps", "7.5"}}); // on_params
BalanceMsg usdt{};
init_header(usdt, EventType::Balance, InstrumentId::invalid(), VenueId{0});
usdt.asset.assign("USDT");
usdt.free = Notional::from_int(1000);
h.push(usdt.hdr); // on_balance
PerpStateMsg perp{};
init_header(perp, EventType::PerpState, h.instrument(), VenueId{0});
perp.mark_price = 100.01_px;
perp.fields = PerpStateMsg::kMark;
h.push(perp.hdr); // on_perp_state
h.engine().finish(); // on_stop
| Call | Effect |
|---|---|
StrategyHarness<S>(params, options) |
configures the strategy (std::invalid_argument on a bad value) and starts the engine; HarnessOptions sets instruments, EngineConfig, latency (100 µs each way) and start time |
book(bid, ask[, size, id]) |
a one-level snapshot each side; prices as strings or Price |
trade(px, qty, aggressor[, id]) |
a public trade |
fill(side[, qty, id]) |
a taker at the venue fills our best working order on side (all of it when qty is 0), then advances by the ack latency; false when there is none |
disconnect([channel, venue]), reconnect(...) |
a ConnectionStateMsg; channel 0 is market data |
pull_quotes(), resume_quotes() |
operator control, on_quoting(false) and on_quoting(true) |
publish(values) |
a parameter update for all instruments at the current time: the parameters change, then on_params runs; std::invalid_argument when a publisher would reject it |
push(msg.hdr) |
any other message at the current time |
advance(duration) |
moves virtual time: orders reach the venue, acks and fills return, timers fire |
working_orders([id]) |
our working orders, bids then asks, best first |
engine(), strategy(), now(), instrument(), price(s), quantity(s) |
access and conversions |
The default instrument is BTCUSDT on venue 0 with a tick of 0.01 and a lot of 0.001. The harness allocates and is single-threaded.
Registration¶
void register_strategies(StrategyRegistry& r) {
register_strategy<AllHooks>(r); // Sim + Replay + Live
}
static_assert(std::same_as<decltype(®ister_strategies), StrategyModule>);
register_strategy<S>(r)adds the Sim, Replay and Live factories ofS. The file that calls it includesfastmm/strategies/factories.hpp, which compiles the three engines; without it the link fails and names the missing factory.- A strategy library exports one such function (
StrategyModule); apps pass it tofastmm::cli::live,backtestorreplay. The built-in strategies are always registered first. - Registering the same function twice is a no-op; a name registered by different code throws
StrategyConflict(the command lines exit with code 3). bt::run_backtest<S>(cfg, source)andStrategyHarness<S>need no registration.
Build setups: Register a strategy.
Logging¶
FASTMM_LOG_TRACE, FASTMM_LOG_DEBUG, FASTMM_LOG_INFO, FASTMM_LOG_WARN and FASTMM_LOG_ERROR take a fmt format string (FASTMM_LOG_INFO("filled {} @ {}", qty, px)); Price, Qty, Side and the enums format directly. Records go through a per-thread ring to a background thread; when the ring is full the record is dropped and counted. Logs are not journaled: strategy logic must not depend on them.