[Polymarket] Sell no base currency issue
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,devdevelop, oranightly) 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
let base_currency = instrument
.base_currency()
.unwrap_or(instrument.quote_currency()); // None -> quote
...
OrderSide::Sell => quantity.as_decimal(), // share count, no price appliedNine 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 allEither 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
- 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_currency—crates/model/src/accounts/cash.rs:600, SELL 100equity_aapl@ 1500.00, passes today assertingMoney::from("100 USD"). 100 shares, labelled as 100 dollars, price ignored. - Repeat the same call with the
binary_option()fixture (crates/model/src/instruments/stubs.rs:741):BinaryOption::base_currency()returnsNone, so it takes the identical branch —SELL 5 @ 0.50reservesMoney(5.00, USDC). - Risk engine: CASH account with a base currency,
free = 2.00 pUSD, aBinaryOption, no cached long position, submitSELL 5 @ 0.50.cum_notional_sell = 2.50(the proceeds) is compared againstfree = 2.00and the order is denied withCumulativeNotionalExceedsFreeBalance— a sale that credits collateral, denied for lack of collateral. A twin with a cached long position of 5 passes viais_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()isNone, 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_balanceworks unmodified —TokenizedAssetalready does exactly this with a per-asset base currency. For Polymarket that means modellingAssetType::Conditionalbalances, 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_traderversion: 2.0.0rc4.dev20260830+18042- Installed from (PyPI wheel, package index wheel, or built from source): built from source
Source: nautechsystems/nautilus_trader