diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index c869e6a92..117efd956 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -20,6 +20,7 @@ from freqtrade.data.btanalysis import ( load_trades, load_trades_from_db, ) +from freqtrade.data.btanalysis.trade_parallelism import balance_distribution_over_time from freqtrade.data.history import load_data, load_pair_history from freqtrade.data.metrics import ( calculate_cagr, @@ -649,3 +650,189 @@ def test_load_file_from_zip(tmp_path): with pytest.raises(ValueError, match=r"File .* not found in zip.*"): load_file_from_zip(zip_file, "testfile55.txt") + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_balance_distribution_over_time(is_short): + """ + Test balance_distribution_over_time for both long and short trades. + """ + # Create a minimal trades DataFrame with 4 trades over time + # Base dates for trades + start_date = dt_utc(2023, 1, 1) + base_date = start_date + timedelta(hours=15) + stake_currency = "USDT" + start_balance = 1000.0 + fee = 0.001 # 0.1% fee + + # Create trades spanning different time periods + trades_data = { + "pair": ["BTC/USDT", "ETH/USDT", "XRP/USDT", "LTC/USDT"], + "stake_amount": [100.0, 150.0, 80.0, 120.0], + "open_date": [ + base_date, + base_date + timedelta(hours=2), + base_date + timedelta(hours=5), + base_date + timedelta(hours=8), + ], + "close_date": [ + base_date + timedelta(hours=3), + base_date + timedelta(hours=6), + base_date + timedelta(hours=9), + base_date + timedelta(hours=12), + ], + "open_rate": [40000.0, 2000.0, 0.5, 100.0], + "close_rate": [41000.0, 2100.0, 0.52, 105.0], + "fee_open": [fee, fee, fee, fee], + "fee_close": [fee, fee, fee, fee], + "is_short": [is_short, is_short, is_short, is_short], + "leverage": [1.0, 1.0, 1.0, 1.0], + "orders": [ + # Trade 1: BTC/USDT - entry at 40000, exit at 41000 + [ + { + "amount": 0.0025, # 100 / 40000 + "filled": 0.0025, + "safe_price": 40000.0, + "ft_order_side": "sell" if is_short else "buy", + "order_filled_timestamp": int(base_date.timestamp() * 1000), + "ft_is_entry": True, + }, + { + "amount": 0.0025, + "filled": 0.0025, + "safe_price": 41000.0, + "ft_order_side": "buy" if is_short else "sell", + "order_filled_timestamp": int( + (base_date + timedelta(hours=3)).timestamp() * 1000 + ), + "ft_is_entry": False, + }, + ], + # Trade 2: ETH/USDT - entry at 2000, exit at 2100 + [ + { + "amount": 0.075, # 150 / 2000 + "filled": 0.075, + "safe_price": 2000.0, + "ft_order_side": "sell" if is_short else "buy", + "order_filled_timestamp": int( + (base_date + timedelta(hours=2)).timestamp() * 1000 + ), + "ft_is_entry": True, + }, + { + "amount": 0.075, + "filled": 0.075, + "safe_price": 2100.0, + "ft_order_side": "buy" if is_short else "sell", + "order_filled_timestamp": int( + (base_date + timedelta(hours=6)).timestamp() * 1000 + ), + "ft_is_entry": False, + }, + ], + # Trade 3: XRP/USDT - entry at 0.5, exit at 0.52 + [ + { + "amount": 160.0, # 80 / 0.5 + "filled": 160.0, + "safe_price": 0.5, + "ft_order_side": "sell" if is_short else "buy", + "order_filled_timestamp": int( + (base_date + timedelta(hours=5)).timestamp() * 1000 + ), + "ft_is_entry": True, + }, + { + "amount": 160.0, + "filled": 160.0, + "safe_price": 0.52, + "ft_order_side": "buy" if is_short else "sell", + "order_filled_timestamp": int( + (base_date + timedelta(hours=9)).timestamp() * 1000 + ), + "ft_is_entry": False, + }, + ], + # Trade 4: LTC/USDT - entry at 100, exit at 105 + [ + { + "amount": 1.2, # 120 / 100 + "filled": 1.2, + "safe_price": 100.0, + "ft_order_side": "sell" if is_short else "buy", + "order_filled_timestamp": int( + (base_date + timedelta(hours=8)).timestamp() * 1000 + ), + "ft_is_entry": True, + }, + { + "amount": 1.2, + "filled": 1.2, + "safe_price": 105.0, + "ft_order_side": "buy" if is_short else "sell", + "order_filled_timestamp": int( + (base_date + timedelta(hours=12)).timestamp() * 1000 + ), + "ft_is_entry": False, + }, + ], + ], + } + + trades_df = DataFrame(trades_data) + pairlist = ["BTC/USDT", "ETH/USDT", "XRP/USDT", "LTC/USDT"] + + min_date = start_date + max_date = start_date + timedelta(hours=35) + + result = balance_distribution_over_time( + trades=trades_df, + min_date=min_date, + max_date=max_date, + timeframe="1h", + stake_currency=stake_currency, + start_balance=start_balance, + pairlist=pairlist, + ) + + # Verify basic structure + assert isinstance(result, DataFrame) + assert stake_currency in result.columns + for pair in pairlist: + assert pair in result.columns + + # Verify the index is a DatetimeIndex + assert isinstance(result.index, Timestamp.__class__.__bases__[0]) + + # Verify we have entries over the full time period (36h) + assert len(result) == 36 + + # First trade opens 15h after the start date + assert result.iloc[0][stake_currency] == 1000 + expected_first_balance = start_balance - (100.0 + 100.0 * fee) + assert result.iloc[15][stake_currency] == pytest.approx(expected_first_balance) + + # Check that pair columns have non-zero values during trade periods + # Trade 1 (BTC/USDT) is open from hour 15 to hour 18 + # At hour 16, BTC/USDT should have position + btc_during_trade = result.loc[base_date + timedelta(hours=1), "BTC/USDT"] + assert btc_during_trade > 0, "Trade should have positive position during open period" + + # After Trade 1 closes at hour 3, BTC/USDT position should be 0 + btc_after_close = result.loc[base_date + timedelta(hours=4) :, "BTC/USDT"] + assert all(btc_after_close == 0), "Position should be 0 after trade closes" + + # Final stake currency should reflect all trades' cash flows minus fees + # The function tracks cash flow: entries subtract stake, exits add stake + # Both long and short use the same formula based on order prices + final_balance = result.iloc[-1][stake_currency] + + # Verify the balance changed (trades had effect) + assert final_balance != start_balance, "Balance should change after trading" + + # Since all exit prices > entry prices, exits return more cash than entries spent + # This means final balance > start balance for both long and short trades + # (the function tracks cash flow, not P&L from long/short perspective) + assert final_balance > start_balance, "Exit prices > entry prices should increase balance"