Skip to content

Commit fefa5d9

Browse files
fix(backtest): guard zero cost_ratio in _get_buy_amount_by_cash_limit
`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.
1 parent 79633dd commit fefa5d9

2 files changed

Lines changed: 65 additions & 0 deletions

File tree

qlib/backtest/exchange.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,11 @@ def _get_buy_amount_by_cash_limit(self, trade_price: float, cash: float, cost_ra
846846
"""
847847
max_trade_amount = 0.0
848848
if cash >= self.min_cost:
849+
if cost_ratio <= 0:
850+
# Without a proportional fee the service fee is always `min_cost`,
851+
# so there is no critical price to compare against.
852+
max_trade_amount = (cash - self.min_cost) / trade_price
853+
return max_trade_amount
849854
# critical_price means the stock transaction price when the service fee is equal to min_cost.
850855
critical_price = self.min_cost / cost_ratio + self.min_cost
851856
if cash >= critical_price:
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
import unittest
5+
6+
from qlib.backtest.exchange import Exchange
7+
8+
9+
def make_exchange(min_cost: float) -> Exchange:
10+
"""Build an Exchange for testing `_get_buy_amount_by_cash_limit` only.
11+
12+
The method is pure arithmetic over `min_cost`, so the instance is created
13+
without `__init__` to keep the test free of the data layer.
14+
"""
15+
exchange = object.__new__(Exchange)
16+
exchange.min_cost = min_cost
17+
return exchange
18+
19+
20+
class TestBuyAmountByCashLimit(unittest.TestCase):
21+
"""`_get_buy_amount_by_cash_limit` must handle a zero proportional fee."""
22+
23+
TRADE_PRICE = 10.0
24+
CASH = 1000.0
25+
26+
def test_default_cost_ratio(self):
27+
"""With the default fees, the min_cost branch applies at this cash level."""
28+
exchange = make_exchange(min_cost=5.0)
29+
# critical_price = 5 / 0.0015 + 5 = 3338.3 > cash, so the fee is min_cost.
30+
amount = exchange._get_buy_amount_by_cash_limit(self.TRADE_PRICE, self.CASH, cost_ratio=0.0015)
31+
self.assertAlmostEqual(amount, (self.CASH - 5.0) / self.TRADE_PRICE)
32+
33+
def test_cost_ratio_above_critical_price(self):
34+
"""Above the critical price the proportional fee applies."""
35+
exchange = make_exchange(min_cost=5.0)
36+
cash = 10_000.0
37+
amount = exchange._get_buy_amount_by_cash_limit(self.TRADE_PRICE, cash, cost_ratio=0.0015)
38+
self.assertAlmostEqual(amount, cash / 1.0015 / self.TRADE_PRICE)
39+
40+
def test_zero_cost_ratio_with_min_cost(self):
41+
"""A zero proportional fee must not divide by zero; min_cost still applies."""
42+
exchange = make_exchange(min_cost=5.0)
43+
amount = exchange._get_buy_amount_by_cash_limit(self.TRADE_PRICE, self.CASH, cost_ratio=0.0)
44+
self.assertAlmostEqual(amount, (self.CASH - 5.0) / self.TRADE_PRICE)
45+
46+
def test_frictionless(self):
47+
"""With no fee at all the whole cash balance is investable."""
48+
exchange = make_exchange(min_cost=0.0)
49+
amount = exchange._get_buy_amount_by_cash_limit(self.TRADE_PRICE, self.CASH, cost_ratio=0.0)
50+
self.assertAlmostEqual(amount, self.CASH / self.TRADE_PRICE)
51+
52+
def test_cash_below_min_cost(self):
53+
"""Cash that cannot even cover the minimum fee buys nothing."""
54+
exchange = make_exchange(min_cost=5.0)
55+
amount = exchange._get_buy_amount_by_cash_limit(self.TRADE_PRICE, 1.0, cost_ratio=0.0)
56+
self.assertEqual(amount, 0.0)
57+
58+
59+
if __name__ == "__main__":
60+
unittest.main()

0 commit comments

Comments
 (0)