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()