Bot PnL disagrees with the exchange: fees, funding payments and mark price
Our perpetual futures bot reported a monthly PnL 6 percent higher than the exchange's account history. It ignored trading fees on some fills, never recorded funding payments, and valued open positions at last trade price instead of mark price. Building PnL from the income ledger fixed it.
At month end, the finance spreadsheet built from the exchange's transaction history showed a different result from our bot's dashboard. The bot reported higher profit, by an amount equal to about 6 percent of gross PnL. Nobody could explain the difference line by line.
Reproducing it
We exported the exchange's income history for the month and grouped it by type:
incomeType sum (USDT)
REALIZED_PNL 18,412.33
COMMISSION -2,104.87
FUNDING_FEE -936.20
TRANSFER -5,000.00
The bot's realised PnL was 18,397.10, close to the exchange's realised PnL. But the bot subtracted commissions only for fills where the execution report included a commission in USDT, and it never recorded funding at all. Its "unrealised PnL" used the last trade price, while the exchange uses the mark price.
What we ruled out
- Missing fills. Trade counts and quantities matched between bot and exchange.
- Price precision. Differences were too large and systematic for rounding.
Root cause
Three accounting gaps:
- Fees: commissions paid in an asset other than USDT (for example when fee discounts in another token were enabled on the account) were ignored by the bot's PnL code, which only subtracted fees denominated in the margin asset.
- Funding: perpetual futures exchange funding payments between longs and shorts every few hours. The bot held positions across funding times and paid net funding, but had no record of it.
- Valuation: unrealised PnL used last traded price. The exchange computes unrealised PnL, margin and liquidation using the mark price, which smooths out short spikes and can differ from the last price, especially in fast markets.
What is happening under the hood
For a perpetual futures account, the change in wallet balance over a period is the sum of ledger entries: realised PnL from closing trades, commissions, funding payments, transfers, and sometimes insurance or liquidation fees. The exchange's income history (or ledger, depending on the venue) is the authoritative record of those entries.
Funding: at each funding time, if the rate is positive, longs pay shorts; if negative, shorts pay longs. The amount is position notional (at mark price) times the funding rate. Missing it biases PnL for any strategy that holds positions across funding times.
Mark price is derived from index prices on several spot venues plus a basis component; it is designed to resist manipulation of the last traded price. Risk and unrealised PnL calculations on the exchange use it.
Environment
| Component | Version / value |
|---|---|
| Exchange | Binance USD-M perpetual futures |
| Bot | Python 3.12, PnL computed from own fill records |
| Account | Fee discount in another token enabled |
| Funding | Every 8 hours for the traded contracts |
Minimal reproduction
Hold a position across a funding time, then compare the bot's PnL, computed from fills only, with the change in wallet balance. The difference is the funding payment.
The fix
1. A ledger built from exchange income history. A job pulls income records incrementally and stores them in a table keyed by the exchange's transaction id:
async def sync_income(client, store):
since = store.last_income_time() or START
while True:
rows = await client.futures_income_history(startTime=since, limit=1000)
if not rows:
break
store.upsert_income(rows) # idempotent by tranId
since = rows[-1]["time"] + 1
PnL reports are built from this ledger:
SELECT date_trunc('day', ts) AS day,
sum(amount) FILTER (WHERE income_type = 'REALIZED_PNL') AS realized,
sum(amount) FILTER (WHERE income_type = 'COMMISSION') AS fees,
sum(amount) FILTER (WHERE income_type = 'FUNDING_FEE') AS funding,
sum(amount) FILTER (WHERE income_type NOT IN ('TRANSFER')) AS net_trading
FROM futures_income
GROUP BY 1 ORDER BY 1;
Fees paid in other assets are converted to USDT at the time of the fee using recorded prices, and reported separately.
2. Mark-price valuation for open positions, taken from the exchange's position risk endpoint or the mark price stream:
unrealized = sum((mark[p.symbol] - p.entry_price) * p.qty for p in positions)
3. Funding awareness in the strategy. The bot now records expected funding before each funding time and compares it with the actual payment, which is useful for strategy evaluation and catches surprises.
4. Daily reconciliation. The bot's computed wallet balance (start balance plus ledger entries) is compared with the exchange's reported balance every day, with an alert on any difference above a small tolerance.
Alternatives and trade-offs
- Trust the exchange numbers only. Simple for reporting; the bot still needs its own accurate PnL for risk decisions in real time.
- Compute everything from fills and rates yourself. Possible, but you will chase small differences forever. Use the exchange ledger as the source of truth and your own computation as a real-time estimate.
How to test the fix
We rebuilt the previous month from the income ledger; the result matched the finance spreadsheet to the cent. A unit test covers funding sign conventions for long and short positions with positive and negative rates.
Confirming in production
- Daily reconciliation differences are now below 0.01 USDT except for timing at the day boundary.
- The dashboard shows realised PnL, fees and funding separately; funding turned out to be a meaningful cost for one strategy, which changed how it was evaluated.
Regression guard
- Daily reconciliation alert.
- Income sync job monitored for gaps (missing time ranges) and for new income types it does not recognise.
Security implications
Accurate PnL and balances are part of risk control: limits on drawdown and exposure depend on them. The income sync job uses a read-only API key, separate from the trading key. Nothing here is advice on trading strategy; it is about measuring results correctly.
Prevention checklist
- Build PnL from the exchange's income or ledger history.
- Include fees in every asset, funding payments and transfers.
- Value open positions at mark price, like the exchange does.
- Reconcile computed balances with the exchange daily.
- Use read-only keys for reporting jobs.
References
- Binance USD-M futures, get income history — https://developers.binance.com/docs/derivatives/usds-margined-futures/account/rest-api/Get-Income-History
- Binance USD-M futures, mark price and funding rate endpoint — https://developers.binance.com/docs/derivatives/usds-margined-futures/market-data/rest-api/Mark-Price
- Binance USD-M futures, funding rate history — https://developers.binance.com/docs/derivatives/usds-margined-futures/market-data/rest-api/Get-Funding-Rate-History
Add a label
Embed this post
<iframe src="https://hibug.me/futures-pnl-mismatch-fees-funding-mark-price" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.
Related Stories
Reconciling a trading bot's internal ledger with exchange balances
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.