Skip to content

fix(backtest): guard zero cost_ratio in _get_buy_amount_by_cash_limit - #2324

Open
mateosandoval10 wants to merge 1 commit into
microsoft:mainfrom
mateosandoval10:fix/exchange-zero-cost-ratio-division
Open

fix(backtest): guard zero cost_ratio in _get_buy_amount_by_cash_limit#2324
mateosandoval10 wants to merge 1 commit into
microsoft:mainfrom
mateosandoval10:fix/exchange-zero-cost-ratio-division

Conversation

@mateosandoval10

Copy link
Copy Markdown

Description

Exchange._get_buy_amount_by_cash_limit raises ZeroDivisionError whenever the backtest is configured without a proportional trading fee.

The helper works out the cash level at which the proportional fee overtakes the flat min_cost floor:

critical_price = self.min_cost / cost_ratio + self.min_cost

cost_ratio is open_cost + adj_cost_ratio, and adj_cost_ratio derives from impact_cost, which defaults to 0.0. So Exchange(open_cost=0, close_cost=0) makes cost_ratio exactly zero and the division raises. Setting min_cost=0 as well does not help — 0.0 / 0.0 raises too.

When there is no proportional fee the service fee is always min_cost, so no critical price exists and the min_cost branch is already the correct answer. This PR returns it directly, which leaves every non-zero cost_ratio path byte-identical:

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

Motivation and Context

No related issue — found while reading the backtest cost model.

The helper is reached from the buy branch of _calc_trade_info_by_order when an order is larger than the available cash:

elif cash < trade_val + max(trade_val * cost_ratio, self.min_cost):
    max_buy_amount = self._get_buy_amount_by_cash_limit(trade_price, cash, cost_ratio)

With cost_ratio == 0 that condition reduces to cash < trade_val + min_cost, the ordinary "order doesn't quite fit in the account" case — so the crash fires on the first partially-funded buy rather than in some corner of the parameter space.

A zero-cost run is a routine baseline: it is how you separate how much of a strategy's result comes from the signal and how much is being consumed by frictions. That configuration currently cannot complete a backtest.

Minimal reproduction:

from qlib.backtest.exchange import Exchange

ex = object.__new__(Exchange)   # the method is pure arithmetic over min_cost
ex.min_cost = 5.0

ex._get_buy_amount_by_cash_limit(trade_price=10.0, cash=1000.0, cost_ratio=0.0015)  # 99.5
ex._get_buy_amount_by_cash_limit(trade_price=10.0, cash=1000.0, cost_ratio=0.0)     # ZeroDivisionError

How Has This Been Tested?

  • Pass the test by running: pytest qlib/tests/test_all_pipeline.py under upper directory of qlib.
  • If you are adding a new feature, test on your own test scripts.

I was not able to run test_all_pipeline.py, since it needs the official dataset that the README notes is currently disabled. Happy to run it if there is a data source you would like me to use.

Added tests/backtest/test_exchange_cost.py — five unit tests, no data dependency:

  • the default fee schedule below the critical price (min_cost branch),
  • a cash level above the critical price (proportional branch),
  • zero cost_ratio with a non-zero min_cost,
  • zero cost_ratio with min_cost=0, where the full balance is investable,
  • cash below min_cost, which buys nothing.

Screenshots of Test Results (if appropriate):

  1. Pipeline test: not run — see above.

  2. Your own tests:

$ python -m pytest tests/backtest/test_exchange_cost.py -q
.....                                                                    [100%]
5 passed in 1.73s

Against unpatched main the two zero-cost_ratio tests fail with ZeroDivisionError and the other three pass, which confirms the existing behaviour is unchanged:

$ python -m pytest tests/backtest/test_exchange_cost.py -q
FAILED tests/backtest/test_exchange_cost.py::TestBuyAmountByCashLimit::test_frictionless
FAILED tests/backtest/test_exchange_cost.py::TestBuyAmountByCashLimit::test_zero_cost_ratio_with_min_cost
2 failed, 3 passed in 1.51s

black . -l 120 --check --exclude qlib/_version.py is clean across the repository (334 files unchanged).

Types of changes

  • Fix bugs
  • Add new feature
  • Update documentation

`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.
@mateosandoval10

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant