Skip to main content

A lightweight Python engine for simulating trading account balances, order states, and exchange-style constraints.

Project description

trading-state

trading-state is a small, focused Python library that models the dynamic state of a trading account — balances, positions, open orders, fills, unsettled flow, exposure, and PnL — under realistic exchange-like constraints.

The library is passive: it never schedules, polls, or talks to an exchange. The caller owns the network — when an exchange responds to a request, or pushes an event over a WebSocket, the caller hands that data to trading_state. All reads are synchronous and return immediately.

Install

$ pip install trading-state

Usage

1) Set up the state

Before placing any order, configure the account, register every Symbol you will trade, set notional limits, set cross-currency allocation weights, then seed initial balances. The library does not fall back to defaults for any of these — a missing setup step makes state.allocate(...) fail fast with a specific exception, by design.

from datetime import datetime
from decimal import Decimal

from trading_state import (
    Balance,
    Symbol,
    TradingConfig,
    TradingState,
)

config = TradingConfig(
    account_currency='USDT',
    # Alt account currencies should be stablecoins pegged to the
    # primary account currency (USDC, FDUSD, DAI, etc.). They are the
    # other quote currencies you can route into when you want to split
    # a trade for depth — the splitting math assumes USDT≈USDC≈primary
    # in value. Non-stablecoin alts will systematically bias the split.
    alt_account_currencies=('USDC',),
    benchmark_assets=('BTC',),
)
state = TradingState(config)

# Register every symbol the strategy will trade.
state.set_symbol(Symbol('BTCUSDT', 'BTC', 'USDT'))
state.set_symbol(Symbol('BTCUSDC', 'BTC', 'USDC'))

# Seed prices used by the valuation paths.
state.set_price('BTCUSDT', Decimal('30000'))
state.set_price('BTCUSDC', Decimal('30000'))

# Hard cap on how much BTC notional the strategy is allowed to hold.
# Required for every base asset the strategy takes exposure on.
state.set_notional_limit('BTC', Decimal('100000'))

# Initial balances from the exchange's account snapshot.
# Balance.time is required: it lets the library tell unsettled flow
# (fills not yet reflected in a balance update) from settled balance.
state.set_balances([
    Balance('USDT', Decimal('10000'), Decimal('0'), datetime.now()),
    Balance('USDC', Decimal('10000'), Decimal('0'), datetime.now()),
])

2) Place a trade and keep state in sync with the exchange

Suppose your strategy decided to buy 0.2 BTC at 30000 USDT. Encode that decision as an OrderTicket and hand it to state.create_order(...). The library registers the resulting Order (or Orders, if you asked it to split — see below) and emits ORDER_CREATED for each. From there on, the caller drives every transition through state.update_order(...).

from trading_state import (
    LimitOrderTicket,
    OrderSide,
    OrderStatus,
    TimeInForce,
)

btcusdt = state.get_symbol('BTCUSDT')

ticket = LimitOrderTicket(
    symbol=btcusdt,
    side=OrderSide.BUY,
    quantity=Decimal('0.2'),
    price=Decimal('30000'),
    time_in_force=TimeInForce.GTC,
)

# `allocate` is a required keyword: pass `None` to skip cross-currency
# splitting and create a single Order on the ticket's symbol. The
# library runs filter normalization and BUY pre-flight (notional cap
# + free balance) regardless of which path you take.
exc, orders = state.create_order(
    ticket,
    data={'strategy': 'momentum'},
    allocate=None,
)
if exc is not None:
    # Setup error (symbol not registered, notional limit not set,
    # allocate vector malformed when splitting, etc.).
    ...
elif not orders:
    # Best-effort outcome: filter or pre-flight knocked the order
    # out; nothing landed in state.
    ...

For each returned Order, push it through the exchange's order-create endpoint, then push the exchange's response back into state. The library does not perform any network I/O — that is the caller's exchange client.

from trading_state.binance import (
    decode_order_create_response,
    encode_order_request,
)

for order in orders:
    # 1. Mark the Order as being submitted to the exchange. This
    #    transitions it from INIT to SUBMITTING. update_order requires
    #    every keyword — pass None for fields you are not touching.
    state.update_order(
        order,
        status=OrderStatus.SUBMITTING,
        updated_at=None,
        id=None,
        filled_quantity=None,
        quote_quantity=None,
        commission_asset=None,
        commission_quantity=None,
    )

    # 2. Encode the ticket for the exchange's POST body.
    exc, body = encode_order_request(order.ticket)
    if exc is not None:
        continue

    # 3. Send to the exchange. trading_state is passive; this network
    #    call is owned by your HTTP client.
    response = exchange_client.post('/api/v3/order', body=body)

    # 4. Decode the response into update_order's keyword set and push
    #    it back into state. The id, status, and fill cumulatives all
    #    come from the exchange — never hand-write them.
    exc, kwargs = decode_order_create_response(response)
    if exc is None:
        state.update_order(order, **kwargs)

After the create response lands, subsequent state changes for this Order — partial fills, full fill, cancellation — typically arrive over the user-data WebSocket as executionReport events. When one arrives, decode it, look up the Order by its client-order-id, and feed the result to update_order.

from trading_state.binance import decode_order_update_event

# When an `executionReport` arrives over the user-data WS (your WS
# infrastructure is out of scope; trading_state never opens sockets):
exc, decoded = decode_order_update_event(ws_payload)
if exc is None:
    client_order_id, kwargs = decoded
    order = state.get_order_by_id(client_order_id)
    if order is not None:
        state.update_order(order, **kwargs)
    # If order is None, the Order is not in local state — see §4 Recovery.

state.update_order silently drops stale writes (status / time / filled-quantity regressions) and emits a diagnostic STALE_UPDATE event. It never raises a business exception.

Splitting a single trade across alt account currencies

When the strategy wants to distribute a trade across the configured alt account currencies (to source depth from multiple books and reduce slippage), pass the per-call weights via allocate=. The vector is aligned with config.alt_account_currencies; the primary account currency has implicit weight 1. Weights are intentionally per-call because in production they are recomputed every decision from live inputs (book depth, stablecoin balances, basis, inventory skew) — none of which the library holds.

Two patterns work, pick whichever fits your routing logic:

Delegate the split to trading_state. Provide a weights vector; the library splits the canonical ticket across alt buckets, filter-normalizes each, runs per-bucket pre-flight, and registers one Order per surviving bucket.

weights = my_router.compute_buy_weights(live_book_depth, free_balances)
exc, orders = state.create_order(ticket, allocate=weights)
# orders has one entry per surviving account-currency bucket, in
# `config.account_currencies` declaration order. The loop above
# (SUBMITTING -> encode -> POST -> decode -> update_order) works
# unchanged.

Split caller-side, then submit each sub-ticket without further splitting. Useful when your router does something the library's split math doesn't model (e.g., venue-aware sizing, latency-weighted shares).

sub_tickets = my_router.split(ticket, live_book_depth)
for sub_ticket in sub_tickets:
    exc, orders = state.create_order(sub_ticket, allocate=None)
    # ... drive each order through the SUBMITTING -> POST -> ... loop

Split reference and pre-flight per ticket type

When allocate= triggers the split flow, each ticket type contributes a different price field as the split reference; the pre-flight checks below use the same reference:

Ticket split reference aggregate BUY notional check per-bucket BUY quote balance aggregate SELL base balance
LimitOrderTicket (incl. post_only) price precise yes yes
MarketOrderTicket(BASE) estimated_price precise yes yes
MarketOrderTicket(QUOTE) estimated_price precise (notional = quantity) yes yes (uses quantity / estimated_price)
StopLossLimit / TakeProfitLimit price (limit) precise yes yes
StopLoss / TakeProfit (no limit) stop_price (or symbol's last set_price if only trailing_delta is set) estimate — actual fill price at trigger may differ estimate yes

For bare stop / take-profit BUYs, the notional pre-flight is an estimate (real fill happens at trigger-time market price). An estimated overshoot results in an empty result list, accepted as the trade-off for catching oversize orders before they hit the exchange.

Stop / take-profit splits inherit a basis-asymmetry risk: in a fast move, one alt symbol may trigger before another, leaving the protective intent partially honoured. The library does not model OCO (one-cancels-other); using split stops means accepting this trade-off in exchange for distributed depth. If that is unacceptable for your strategy, pass allocate=None so the stop stays on a single market.

3) Check exposure when sizing the next trade

Before deciding on the next order size, the strategy typically needs to know how much room is left under the asset's notional cap, accounting for what is held now and for any fills that have not yet been reflected in a balance snapshot.

state.exposure(asset, ...) returns an Exposure value object. Both include_unsettled_* flags are required at every call site — there is no library default, because what counts as "real" depends on whether you are sizing a new BUY (conservative: include unsettled inflows so you don't double-spend the room), a SELL (include outflows), or a diagnostic read (your call).

exc, exposure = state.exposure(
    'BTC',
    include_unsettled_inflow=True,
    include_unsettled_outflow=False,
)
if exc is None:
    room_left = exposure.notional_limit - exposure.notional_value

state.unsettled(asset) returns just the unsettled inflow / outflow magnitudes — it is a diagnostic read only. Do not drive trading decisions from it; go through state.exposure(...) with explicit flags.

4) Recover after restart or a WebSocket gap

The user-data WebSocket does not replay missed events. After a process restart or a WS drop, local state can drift from the exchange. trading_state.binance provides two recovery decoders that consume the same per-order schema returned by GET /api/v3/openOrders, GET /api/v3/allOrders, and GET /api/v3/order; the caller chooses the right one based on whether the Order is already in state.

Decoder Returns Pair with
decode_order_snapshot(item, *, symbol, data=None) a fully-populated Order state.import_order(order)
decode_order_query_response(payload) (client_order_id, update_kwargs) state.update_order(existing, **kwargs)

4a) Cold startup

The process just launched; state is empty. Pull every open order from the exchange and import each one.

from trading_state.binance import (
    decode_account_info_response,
    decode_order_snapshot,
)

# Seed balances from the account snapshot.
account = exchange_client.get_account()
exc, balances = decode_account_info_response(account)
if exc is None:
    state.set_balances(balances)

# Import every open order.
for item in exchange_client.get_open_orders():
    sym = state.get_symbol(item['symbol'])
    if sym is None:
        continue
    exc, order = decode_order_snapshot(item, symbol=sym)
    if exc is None:
        state.import_order(order)

4b) Periodic refresh of one known order

The strategy is tracking a long-lived order (the local state knows it) and wants a status refresh. The Order is already in state, so route through update_order.

from trading_state.binance import decode_order_query_response

order = state.get_order_by_id('order-1')
item = exchange_client.get_order(
    orig_client_order_id='order-1',
    symbol=order.ticket.symbol.name,
)
exc, decoded = decode_order_query_response(item)
if exc is None:
    _, kwargs = decoded
    state.update_order(order, **kwargs)

update_order handles terminal status naturally: if the refresh returns FILLED, it transitions the Order through the same path it would have taken via WS, including the stale-update silent drop.

4c) Mid-session reconnect after a WS gap

WS reconnected; some local Orders may have advanced or terminated during the gap, and orders placed by another client / process during the gap need discovering. Pull openOrders and dispatch per item on existing-in-state, then gap-fill local-open orders that are no longer in the exchange's open set.

api_open = exchange_client.get_open_orders()
exchange_open_ids = set()

# (1) For each open order at the exchange: import if new, refresh if known.
for item in api_open:
    sym = state.get_symbol(item['symbol'])
    if sym is None:
        continue
    cid = item['clientOrderId']
    exchange_open_ids.add(cid)
    existing = state.get_order_by_id(cid)
    if existing is None:
        exc, order = decode_order_snapshot(item, symbol=sym)
        if exc is None:
            state.import_order(order)
    else:
        exc, decoded = decode_order_query_response(item)
        if exc is None:
            _, kwargs = decoded
            state.update_order(existing, **kwargs)

# (2) Local Orders that the exchange no longer lists as open must
#     have terminated during the gap; query each one and push the
#     terminal status via update_order.
for order in state.get_open_orders():
    if order.id in exchange_open_ids:
        continue
    item = exchange_client.get_order(
        orig_client_order_id=order.id,
        symbol=order.ticket.symbol.name,
    )
    exc, decoded = decode_order_query_response(item)
    if exc is None:
        _, kwargs = decoded
        state.update_order(order, **kwargs)

# (3) Refresh balances.
account = exchange_client.get_account()
exc, balances = decode_account_info_response(account)
if exc is None:
    state.set_balances(balances)

state.get_open_orders() returns Orders the exchange has acknowledged (CREATED / PARTIALLY_FILLED / CANCELLING). It does not include INIT / SUBMITTING — those Orders are still caller-side in-flight; the caller holds the reference returned by allocate and is responsible for driving them forward.

5) Observe state changes via events

For dashboards, audit logs, or downstream pipelines that should not sit on the trading hot path, subscribe to TradingStateEvents.

from trading_state import TradingStateEvent

state.on(
    TradingStateEvent.ORDER_CREATED,
    lambda order: ...,
)
state.on(
    TradingStateEvent.ORDER_STATUS_UPDATED,
    lambda order, status: ...,
)
state.on(
    TradingStateEvent.ORDER_FILLED_QUANTITY_UPDATED,
    lambda order, filled_quantity: ...,
)
state.on(
    TradingStateEvent.STALE_UPDATE,
    lambda event: ...,
)

Every Order in state — whether created via allocate or injected via import_order (§4) — was preceded by an ORDER_CREATED, so a single subscription covers both paths.

STALE_UPDATE fires whenever state silently drops a write that would regress a monotonic invariant (out-of-order status, filled-quantity going backwards, balance snapshot older than current). It is the main observability hook for "is my exchange feed misbehaving?".

6) Record snapshots and analyze performance

For backtesting or live PnL tracking, periodically take a performance snapshot — typically at the close of each decision window — and feed the snapshot stream to a PerformanceAnalyzer.

from trading_state import CashFlow

# Account-level external cash flow (deposit / withdrawal).
state.set_cash_flow(
    CashFlow('USDT', Decimal('1000'), datetime.now()),
)

# Snapshot the current account value, holdings, and metrics.
snapshot = state.record()
from trading_state import TradingStateEvent
from trading_state.analyzer import AnalyzerType, PerformanceAnalyzer

analyzer = PerformanceAnalyzer([
    AnalyzerType.TOTAL_RETURN,
    AnalyzerType.SHARPE_RATIO.params(trading_days=365),
    AnalyzerType.MAX_DRAWDOWN,
])

# Push every recorded snapshot into the analyzer as soon as state
# emits it.
state.on(
    TradingStateEvent.PERFORMANCE_SNAPSHOT_RECORDED,
    analyzer.add_snapshots,
)

results = analyzer.analyze()
total_return = results[AnalyzerType.TOTAL_RETURN].value

7) Binance decoders reference

Every wire ↔ domain mapping for Binance Spot lives in trading_state.binance. Each channel pairs with a state method as follows. All decoders return ValueOrException[T]; validation is embedded so malformed wire data cannot accidentally land in state.

Direction Channel Function Output Pair with
→ exchange POST /api/v3/order body encode_order_request(ticket) dict (your HTTP client)
← exchange POST /api/v3/order response decode_order_create_response(response) update_kwargs state.update_order(order, **kwargs)
← exchange GET /api/v3/order response (also a single item from /openOrders / /allOrders) decode_order_query_response(payload) (client_order_id, update_kwargs) state.update_order(existing, **kwargs)
← exchange /openOrders / /allOrders item (recovery — order not in state) decode_order_snapshot(item, *, symbol, data=None) Order state.import_order(order)
← exchange WS executionReport decode_order_update_event(payload) (client_order_id, update_kwargs) state.update_order(order, **kwargs)
← exchange WS outboundAccountPosition decode_account_update_event(payload) Set[Balance] state.set_balances(balances)
← exchange WS balanceUpdate decode_balance_update_event(payload) CashFlow state.set_cash_flow(cash_flow)
← exchange GET /api/v3/account response decode_account_info_response(response) Set[Balance] state.set_balances(balances)
← exchange GET /api/v3/exchangeInfo response decode_exchange_info_response(response) Set[Symbol] state.set_symbol(symbol) per item

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

trading_state-4.0.0.tar.gz (110.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

trading_state-4.0.0-py3-none-any.whl (77.5 kB view details)

Uploaded Python 3

File details

Details for the file trading_state-4.0.0.tar.gz.

File metadata

  • Download URL: trading_state-4.0.0.tar.gz
  • Upload date:
  • Size: 110.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.0

File hashes

Hashes for trading_state-4.0.0.tar.gz
Algorithm Hash digest
SHA256 3c5f13e5d2214554b45ba64c4308c3f06b56f6480538be5ef1382c56c58581ac
MD5 dfd0068585110b9f56259514798779da
BLAKE2b-256 02d8c0afb968cc04ba33232e999a5355416498d29eef36392d207cdd1be2e1de

See more details on using hashes here.

File details

Details for the file trading_state-4.0.0-py3-none-any.whl.

File metadata

  • Download URL: trading_state-4.0.0-py3-none-any.whl
  • Upload date:
  • Size: 77.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.0

File hashes

Hashes for trading_state-4.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7581f9df46ae71dbd7a56c65c7d8a2a65caa3cf4b925d0291ad9e2adb6933f4f
MD5 3eb31f43a952e9c5e8e0380f3d854bb9
BLAKE2b-256 3e77d44effe1e8c441965bd9925c038bb1d2cd488a88f003410f9a104c716170

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page