[Polymarket] Sell no base currency issue

Author: yashwardhan-gautamCreated Sep 13, 2026Updated Sep 14, 2026
Labelsbug

Confirmation

  • I've re-read the relevant sections of the documentation.
  • I've searched existing issues and discussions to avoid duplicates.
  • I've reviewed or skimmed the source code (or examples) to confirm the behavior is not by design.
  • I've tested this issue using a recent pre-release or development wheel (2.0.0rcN, dev develop, or a nightly) and can still reproduce it.

Follow-up to #4911, split out at the maintainer's request in https://github.com/nautechsystems/nautilus_trader/pull/4940#issuecomment-5612001306 ("Your cash-account SELL-check warning remains relevant and needs a separate follow-up").

Expected behavior

A SELL commits the asset being delivered, so the reservation and the pre-trade check belong in that asset. docs/concepts/accounting.md states the contract:

Accounting values retain their source currency until an explicit conversion succeeds. This prevents a valid number from being labeled with the wrong currency or an unavailable value from being treated as zero.

When instrument.base_currency() is None there is no modelled asset to reserve, so a SELL should not produce a reservation labelled in the quote currency, and the cash check should not be satisfied by quote collateral.

Actual behavior

A share count is labelled as quote money, and the inventory is never checked.

1. Reservation — crates/model/src/accounts/base.rs, base_calculate_balance_locked

rust
let base_currency = instrument
    .base_currency()
    .unwrap_or(instrument.quote_currency());   // None -> quote
...
OrderSide::Sell => quantity.as_decimal(),      // share count, no price applied

Nine instrument types return base_currency() == None: betting, binary_option, commodity, equity, futures_contract, futures_spread, index_instrument, option_contract, option_spread. BettingAccount overrides the calculation; on a CashAccount the rest take the fallback. Selling 100 shares reserves "100 USD"; selling 5 outcome tokens reserves "5.00 pUSD".

2. Pre-trade check — crates/risk/src/engine/mod.rs, check_orders_risk_for_account

The branch is selected by the account's base currency, while the check it guards needs the instrument's:

SELL on a CASH account
   │
   ├─ account.base_currency.is_some()          e.g. POLYMARKET (pUSD), single-currency cash
   │     └─> accumulate cum_notional_sell = +notional   (sale PROCEEDS)
   │         deny if cum_notional_sell > free           → collateral question, wrong asset
   │
   └─ account.base_currency.is_none()          e.g. multi-currency spot cash
         └─> check_cash_sell_balance(base_currency = instrument.base_currency())
             correct inventory check: quantity as a balance vs balance_free(base_currency)
             …but the guard is `if let Some(base_currency)`, and it is None here → no check at all

Either way the outcome-token / share inventory is never checked. check_cash_sell_balance shows the intended design is inventory-based; the predicate just selects on the wrong base currency.

Position-reducing SELLs skip the check entirely (is_position_reducing), which has been masking this. It becomes reachable on Polymarket now that free is realistic after 3cc6c9d9.

Steps to reproduce

  1. Run the existing test, which already asserts the behaviour: cargo test -p nautilus-model --lib accounts::cash::tests::test_calculate_balance_locked_sell_no_base_currencycrates/model/src/accounts/cash.rs:600, SELL 100 equity_aapl @ 1500.00, passes today asserting Money::from("100 USD"). 100 shares, labelled as 100 dollars, price ignored.
  2. Repeat the same call with the binary_option() fixture (crates/model/src/instruments/stubs.rs:741): BinaryOption::base_currency() returns None, so it takes the identical branch — SELL 5 @ 0.50 reserves Money(5.00, USDC).
  3. Risk engine: CASH account with a base currency, free = 2.00 pUSD, a BinaryOption, no cached long position, submit SELL 5 @ 0.50. cum_notional_sell = 2.50 (the proceeds) is compared against free = 2.00 and the order is denied with CumulativeNotionalExceedsFreeBalance — a sale that credits collateral, denied for lack of collateral. A twin with a cached long position of 5 passes via is_position_reducing. (Steps 1 and 2 are the model path; step 3 is read from the branch above and is the half I have not yet pinned with a test.)

Code snippets or logs

The venue's own rejections show that the balance a Polymarket SELL is checked against is denominated in outcome tokens, not collateral (2026-08-31 session; pUSD collateral was 43.24 at the time of the second, so these numbers cannot be money):

SELL 5 @ 0.27
not enough balance / allowance: the balance is not enough ->
  balance: 30000000, sum of active orders: 20000000,
  sum of matched orders: 10000000, order amount (inc. fees): 5000000
                                                  ^^^^^^^ 5 shares, not 5 x 0.27 = 1.35 pUSD

SELL 5 @ 0.49
not enough balance / allowance: the balance is not enough ->
  balance: 0, order amount: 5000000        (tokens bought but not yet settled)

9 of the 24 balance rejections in that session are SELLs of this shape. Every BUY rejection in the same log states its order amount as quantity x price, so the unit difference is the venue's, not a parsing artefact.

Proposed direction

  • Minimal: when instrument.base_currency() is None, do not fabricate a quote-currency reservation for a SELL, and do not satisfy the SELL check from quote collateral.
  • Full: give such instruments an inventory representation so the existing check_cash_sell_balance works unmodified — TokenizedAsset already does exactly this with a per-asset base currency. For Polymarket that means modelling AssetType::Conditional balances, which the adapter does not currently query.

Fixing this changes what test_calculate_balance_locked_sell_no_base_currency asserts, so I have not opened a PR — happy to once you have confirmed the intended semantics.

Specifications

  • OS platform: linux (pop os 24.04)
  • Python version: 3.13.15
  • nautilus_trader version: 2.0.0rc4.dev20260830+18042
  • Installed from (PyPI wheel, package index wheel, or built from source): built from source

Source: nautechsystems/nautilus_trader