From fefa5d9866b65643e61e4dc7fbc46c97126b9c1a Mon Sep 17 00:00:00 2001 From: mateosandoval10 Date: Tue, 18 Aug 2026 20:24:51 -0400 Subject: [PATCH] fix(backtest): guard zero cost_ratio in _get_buy_amount_by_cash_limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Exchange._get_buy_amount_by_cash_limit` computes the cash level at which the proportional fee overtakes `min_cost`: critical_price = self.min_cost / cost_ratio + self.min_cost `cost_ratio` is `open_cost + impact_cost`, and `impact_cost` defaults to 0.0, so any backtest configured with `open_cost=0` raises ZeroDivisionError as soon as a buy order exceeds available cash — the branch that calls this helper. Setting `min_cost=0` as well does not help, since 0.0 / 0.0 raises too. A zero-cost run is a routine baseline for isolating how much of a strategy's result is being consumed by frictions, and it currently crashes. When there is no proportional fee the service fee is always `min_cost`, so no critical price exists and the min_cost branch is the correct one. Returning it directly keeps every non-zero `cost_ratio` path byte-identical. Adds unit tests over the default fee schedule, the above/below critical-price branches, zero cost_ratio with and without min_cost, and cash below min_cost. --- qlib/backtest/exchange.py | 5 +++ tests/backtest/test_exchange_cost.py | 60 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 tests/backtest/test_exchange_cost.py diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 69262fcbbad..b29db09f897 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -846,6 +846,11 @@ def _get_buy_amount_by_cash_limit(self, trade_price: float, cash: float, cost_ra """ max_trade_amount = 0.0 if cash >= self.min_cost: + if cost_ratio <= 0: + # Without a proportional fee the service fee is always `min_cost`, + # so there is no critical price to compare against. + max_trade_amount = (cash - self.min_cost) / trade_price + return max_trade_amount # critical_price means the stock transaction price when the service fee is equal to min_cost. critical_price = self.min_cost / cost_ratio + self.min_cost if cash >= critical_price: diff --git a/tests/backtest/test_exchange_cost.py b/tests/backtest/test_exchange_cost.py new file mode 100644 index 00000000000..6b563905439 --- /dev/null +++ b/tests/backtest/test_exchange_cost.py @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import unittest + +from qlib.backtest.exchange import Exchange + + +def make_exchange(min_cost: float) -> Exchange: + """Build an Exchange for testing `_get_buy_amount_by_cash_limit` only. + + The method is pure arithmetic over `min_cost`, so the instance is created + without `__init__` to keep the test free of the data layer. + """ + exchange = object.__new__(Exchange) + exchange.min_cost = min_cost + return exchange + + +class TestBuyAmountByCashLimit(unittest.TestCase): + """`_get_buy_amount_by_cash_limit` must handle a zero proportional fee.""" + + TRADE_PRICE = 10.0 + CASH = 1000.0 + + def test_default_cost_ratio(self): + """With the default fees, the min_cost branch applies at this cash level.""" + exchange = make_exchange(min_cost=5.0) + # critical_price = 5 / 0.0015 + 5 = 3338.3 > cash, so the fee is min_cost. + amount = exchange._get_buy_amount_by_cash_limit(self.TRADE_PRICE, self.CASH, cost_ratio=0.0015) + self.assertAlmostEqual(amount, (self.CASH - 5.0) / self.TRADE_PRICE) + + def test_cost_ratio_above_critical_price(self): + """Above the critical price the proportional fee applies.""" + exchange = make_exchange(min_cost=5.0) + cash = 10_000.0 + amount = exchange._get_buy_amount_by_cash_limit(self.TRADE_PRICE, cash, cost_ratio=0.0015) + self.assertAlmostEqual(amount, cash / 1.0015 / self.TRADE_PRICE) + + def test_zero_cost_ratio_with_min_cost(self): + """A zero proportional fee must not divide by zero; min_cost still applies.""" + exchange = make_exchange(min_cost=5.0) + amount = exchange._get_buy_amount_by_cash_limit(self.TRADE_PRICE, self.CASH, cost_ratio=0.0) + self.assertAlmostEqual(amount, (self.CASH - 5.0) / self.TRADE_PRICE) + + def test_frictionless(self): + """With no fee at all the whole cash balance is investable.""" + exchange = make_exchange(min_cost=0.0) + amount = exchange._get_buy_amount_by_cash_limit(self.TRADE_PRICE, self.CASH, cost_ratio=0.0) + self.assertAlmostEqual(amount, self.CASH / self.TRADE_PRICE) + + def test_cash_below_min_cost(self): + """Cash that cannot even cover the minimum fee buys nothing.""" + exchange = make_exchange(min_cost=5.0) + amount = exchange._get_buy_amount_by_cash_limit(self.TRADE_PRICE, 1.0, cost_ratio=0.0) + self.assertEqual(amount, 0.0) + + +if __name__ == "__main__": + unittest.main()