Skip to content

Backtesting: Incorrect MarginAccount total balance #2632

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
4 tasks done
bartolootrit opened this issue May 14, 2025 · 3 comments
Closed
4 tasks done

Backtesting: Incorrect MarginAccount total balance #2632

bartolootrit opened this issue May 14, 2025 · 3 comments
Assignees
Labels
bug Something isn't working

Comments

@bartolootrit
Copy link
Contributor

bartolootrit commented May 14, 2025

Confirmation

Before opening a bug report, please confirm:

  • 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 confirmed the issue is reproducible with the latest development version of nautilus_trader (it may have been fixed already).

Expected Behavior

The final account balance should be 1000245 USDT in the example below.

Actual Behavior

The final account balance is 999535 USDT in the example below.

Steps to Reproduce the Problem

Run the code below on commit 9b5cf5d (Mon May 12 15:06:07 2025 +0530) and get the final account balance 999535 USDT.

Run the code below on commit c882a9f (Tue Mar 11 10:34:31 2025 +1100) and get the final account balance 1000245 USDT.

Code Snippets or Logs

from datetime import UTC
from datetime import datetime
from decimal import Decimal

import pandas as pd
from fsspec.implementations.local import LocalFileSystem

from nautilus_trader.accounting.accounts.margin import MarginAccount
from nautilus_trader.backtest.engine import BacktestEngine
from nautilus_trader.backtest.engine import BacktestEngineConfig
from nautilus_trader.config import LoggingConfig
from nautilus_trader.model import Bar
from nautilus_trader.model import InstrumentId
from nautilus_trader.model import Price
from nautilus_trader.model import Quantity
from nautilus_trader.model.currencies import BTC
from nautilus_trader.model.currencies import USDT
from nautilus_trader.model.data import BarType
from nautilus_trader.model.enums import AccountType
from nautilus_trader.model.enums import OmsType
from nautilus_trader.model.enums import OrderSide
from nautilus_trader.model.events import PositionClosed
from nautilus_trader.model.events import PositionEvent
from nautilus_trader.model.events import PositionOpened
from nautilus_trader.model.identifiers import TraderId
from nautilus_trader.model.identifiers import Venue
from nautilus_trader.model.instruments import CryptoPerpetual
from nautilus_trader.model.instruments import Instrument
from nautilus_trader.model.objects import Money
from nautilus_trader.persistence.wranglers import QuoteTickDataWrangler
from nautilus_trader.persistence.wranglers import TradeTickDataWrangler
from nautilus_trader.test_kit.providers import TestDataProvider
from nautilus_trader.trading import Strategy
from nautilus_trader.trading.config import StrategyConfig


class StratTestConfig(StrategyConfig):
    instrument: Instrument
    bar_type: BarType


class StratTest(Strategy):
    def __init__(self, config: StratTestConfig | None = None) -> None:
        super().__init__(config)
        self._account: MarginAccount = None
        self._bar_count = 0

    def on_start(self) -> None:
        self._account: MarginAccount = self.cache.accounts()[0]
        self.subscribe_bars(self.config.bar_type)

    def on_stop(self):
        self.unsubscribe_bars(self.config.bar_type)

    def on_bar(self, bar: Bar) -> None:
        if self._bar_count == 0:
            self.submit_order(
                self.order_factory.market(
                    instrument_id=self.config.instrument.id,
                    order_side=OrderSide.BUY,
                    quantity=self.config.instrument.make_qty(10),
                ),
            )
        elif self._bar_count == 10:
            self.submit_order(
                self.order_factory.market(
                    instrument_id=self.config.instrument.id,
                    order_side=OrderSide.SELL,
                    quantity=self.config.instrument.make_qty(10),
                ),
            )
        self._bar_count += 1

    def on_position_event(self, event: PositionEvent):
        super().on_position_event(event)
        if isinstance(event, PositionOpened):
            self.log.warning("> position opened")
        elif isinstance(event, PositionClosed):
            self.log.warning("> position closed")
        else:
            self.log.warning("> position changed")
        self.log.warning(
            f"> account balance: total {round(self.total_balance(), 2)}"
        )

    def total_balance(self) -> Decimal:
        return self._account.balance(USDT).total.as_decimal()


def main():
    config = BacktestEngineConfig(
        trader_id=TraderId("BACKTESTER-001"),
        logging=LoggingConfig(
            log_level="INFO",
            log_colors=True,
            use_pyo3=False,
        ),
    )

    engine = BacktestEngine(config=config)
    binance = Venue("BINANCE")

    engine.add_venue(
        venue=binance,
        oms_type=OmsType.NETTING,
        account_type=AccountType.MARGIN,
        base_currency=USDT,
        starting_balances=[Money(1000000.0, USDT)],
    )

    instrument_id = InstrumentId.from_str("BTCUSDT-PERP.BINANCE")
    instrument = CryptoPerpetual(
        instrument_id=instrument_id,
        raw_symbol=instrument_id.symbol,
        base_currency=BTC,
        quote_currency=USDT,
        settlement_currency=USDT,
        is_inverse=False,
        price_precision=2,
        size_precision=3,
        price_increment=Price(0.10, 2),
        size_increment=Quantity(0.001, 3),
        ts_event=datetime.now(UTC).timestamp() * 10**9,
        ts_init=datetime.now(UTC).timestamp() * 10**9,
        margin_init=Decimal('0.0500'),
        margin_maint=Decimal('0.0250'),
        maker_fee=Decimal('0.000200'),
        taker_fee=Decimal('0.000500'),
    )
    engine.add_instrument(instrument)

    data_provider = TestDataProvider()
    data_provider.fs = LocalFileSystem()
    bars = data_provider.read_csv_bars("btc-perp-20211231-20220201_1m.csv")

    quote_tick_wrangler = QuoteTickDataWrangler(instrument=instrument)
    ticks = quote_tick_wrangler.process_bar_data(
        bid_data=bars,
        ask_data=bars,
    )
    engine.add_data(ticks[:60])

    trade_tick_wrangler = TradeTickDataWrangler(instrument=instrument)
    ticks = trade_tick_wrangler.process_bar_data(data=bars)
    engine.add_data(ticks[:60])

    strategy = StratTest(
        StratTestConfig(
            instrument=instrument,
            bar_type=BarType.from_str("BTCUSDT-PERP.BINANCE-1-MINUTE-BID-INTERNAL")
        )
    )
    engine.add_strategy(strategy=strategy)

    engine.run()

    with pd.option_context(
        "display.max_rows",
        100,
        "display.max_columns",
        None,
        "display.width",
        300,
    ):
        print(engine.trader.generate_account_report(binance))
        print(engine.trader.generate_positions_report())

    engine.reset()
    engine.dispose()


if __name__ == "__main__":
    main()

Specifications

  • OS platform: Debian
  • Python version: 3.12
  • nautilus_trader version: develop branch
@bartolootrit bartolootrit added the bug Something isn't working label May 14, 2025
@bartolootrit bartolootrit changed the title Incorrect MarginAccount total balance Backtesting: Incorrect MarginAccount total balance May 14, 2025
@cjdsellers
Copy link
Member

cjdsellers commented May 15, 2025

Hi @bartolootrit,

Thanks for the report.

I believe there was a regression from this commit e9b9f77. The original calculation method is correct, so at least there is a reference and testing around this needs to be improved.

I'll investigate this soon.

@cjdsellers
Copy link
Member

Hi @bartolootrit,

This is now fixed from commit 5f6b89f by partially reverting a previous commit which changed the way Portfolio subscribes for position events and updates accounts based on those.

Your provided backtest snippet now runs with the correct ending balance:

2025-05-16T05:09:14.053705070Z [INFO] BACKTESTER-001.BacktestEngine: Balances ending:
2025-05-16T05:09:14.053709538Z [INFO] BACKTESTER-001.BacktestEngine: 1_000_245.87500000 USDT

As a follow-up, I'll be adding a test based on the code snippet you provided.

@cjdsellers cjdsellers moved this from In progress to Done in NautilusTrader Kanban Board May 16, 2025
@cjdsellers
Copy link
Member

I've now added the MRE you provided to the automated tests a1840bb. Many thanks again for such a clear report!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
Development

No branches or pull requests

2 participants