Merge pull request #13019 from freqtrade/feat/capture_wallets

Capture wallet balance
This commit is contained in:
Matthias
2026-04-07 07:16:34 +02:00
committed by GitHub
33 changed files with 1738 additions and 131 deletions
+4
View File
@@ -283,6 +283,10 @@
"month"
]
},
"skip_wallet_history_migration": {
"description": "Disable wallet history migration.",
"type": "boolean"
},
"hyperopt_path": {
"description": "Specify additional lookup path for Hyperopt Loss functions.",
"type": "string"
+109 -101
View File
@@ -211,58 +211,59 @@ A backtesting result will look like that:
│ TOTAL │ │ 77 │ 0.22 │ 54.774 │ 5.48 │ 22:12:00 │ 67 0 10 87.0 │
└───────────┴─────────────┴────────┴──────────────┴─────────────────┴──────────────┴─────────────────┴────────────────────────┘
SUMMARY METRICS
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Metric ┃ Value ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ Backtesting from │ 2025-07-01 00:00:00 │
│ Backtesting to │ 2025-08-01 00:00:00 │
│ Trading Mode │ Isolated Futures │
│ Max open trades │ 3 │
│ │ │
│ Total/Daily Avg Trades │ 77 / 2.48 │
│ Starting balance │ 1000 USDT │
│ Final balance │ 1054.774 USDT │
│ Absolute profit │ 54.774 USDT │
│ Total profit % │ 5.48%
│ CAGR % │ 87.36%
│ Sortino │ 2.48
│ Sharpe │ 3.75
│ Calmar │ 40.99
│ SQN │ 0.69 │
│ Profit factor │ 1.29 │
│ Expectancy (Ratio) │ 0.71 (0.04) │
│ Avg. daily profit │ 1.767 USDT │
│ Avg. stake amount │ 345.016 USDT │
│ Total trade volume │ 53316.954 USDT
│ │ │
│ Long / Short trades │ 67 / 10 │
│ Long / Short profit % │ 8.94% / -3.47%
│ Long / Short profit USDT │ 89.425 / -34.651
│ │ │
│ Best Pair │ LTC/USDT:USDT 5.62% │
│ Worst Pair │ ADA/USDT:USDT -5.21% │
│ Best trade │ ETC/USDT:USDT 2.00% │
│ Worst trade │ ADA/USDT:USDT -10.17% │
│ Best day │ 26.91 USDT │
│ Worst day │ -47.741 USDT │
│ Days win/draw/lose │ 20 / 6 / 5 │
│ Min/Max/Avg. Duration Winners │ 0d 00:35 / 5d 18:15 / 0d 15:49 │
│ Min/Max/Avg. Duration Losers │ 0d 10:40 / 17d 08:00 / 2d 17:00 │
│ Max Consecutive Wins / Loss │ 36 / 3 │
│ Rejected Entry signals │ 258 │
│ Entry/Exit Timeouts │ 0 / 0 │
│ │ │
│ Min balance │ 1003.168 USDT
│ Max balance 1149.421 USDT
│ Max % of account underwater │ 8.23%
Absolute drawdown │ 94.647 USDT (8.23%)
Drawdown duration │ 9 days 08:50:00
Profit at drawdown start │ 149.421 USDT
│ Profit at drawdown end 54.774 USDT │
Drawdown start │ 2025-07-22 15:10:00
│ Drawdown end │ 2025-08-01 00:00:00
Market change30.51%
└───────────────────────────────┴─────────────────────────────────┘
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
┃ Metric ┃ Value
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
│ Backtesting from │ 2025-07-01 00:00:00
│ Backtesting to │ 2025-08-01 00:00:00
│ Trading Mode │ Isolated Futures
│ Max open trades │ 3
│ │
│ Total/Daily Avg Trades │ 77 / 2.48
│ Starting balance │ 1000 USDT
│ Final balance │ 1054.669 USDT
│ Absolute profit │ 54.669 USDT
│ Total profit % │ 5.47%
│ CAGR % │ 87.14%
│ Sortino │ 2.46
│ Sharpe │ 3.73
│ Calmar │ 40.81
│ SQN │ 0.69
│ Profit factor │ 1.29
│ Expectancy (Ratio) │ 0.71 (0.04)
│ Avg. daily profit │ 1.764 USDT
│ Avg. stake amount │ 345.251 USDT
│ Total trade volume │ 53352.96 USDT
│ │
│ Long / Short trades │ 67 / 10
│ Long / Short profit % │ 8.93% / -3.46%
│ Long / Short profit USDT │ 89.262 / -34.593
│ │
│ Best Pair │ LTC/USDT:USDT 5.62%
│ Worst Pair │ ADA/USDT:USDT -5.21%
│ Best trade │ ETC/USDT:USDT 2.00%
│ Worst trade │ ADA/USDT:USDT -10.17%
│ Best day │ 26.931 USDT
│ Worst day │ -47.741 USDT
│ Days win/draw/lose │ 20 / 6 / 5
│ Min/Max/Avg. Duration Winners │ 0d 00:35 / 5d 18:15 / 0d 15:49
│ Min/Max/Avg. Duration Losers │ 0d 10:40 / 17d 08:00 / 2d 17:00
│ Max Consecutive Wins / Loss │ 36 / 3
│ Rejected Entry signals │ 258
│ Entry/Exit Timeouts │ 0 / 0
│ │
│ Min/Max balance realized │ 1003.168 USDT / 1149.577 USDT
Min/Max balance unrealized │ 1000 USDT / 1149.577 USDT │
│ Min/Max balance dates │ 2025-07-01 00:05:00 / 2025-07-22 15:15:00
Max % of account underwater │ 8.26%
Absolute drawdown │ 94.908 USDT (8.26%)
Drawdown duration │ 9 days 08:50:00
│ Profit at drawdown start149.577 USDT
Profit at drawdown end │ 54.669 USDT
│ Drawdown start │ 2025-07-22 15:10:00
Drawdown end 2025-08-01 00:00:00
│ Market change │ 30.51% │
└───────────────────────────────┴───────────────────────────────────────────┘
Backtested 2025-07-01 00:00:00 -> 2025-08-01 00:00:00 | Max open trades : 3
STRATEGY SUMMARY
@@ -329,54 +330,59 @@ The last element of the backtest report is the summary metrics table.
It contains key metrics about the performance of your strategy on backtesting data.
```
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Metric ┃ Value ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ Backtesting from │ 2025-07-01 00:00:00 │
│ Backtesting to │ 2025-08-01 00:00:00 │
│ Trading Mode │ Isolated Futures │
│ Max open trades │ 3 │
│ │ │
│ Total/Daily Avg Trades │ 72 / 2.32
│ Starting balance │ 1000 USDT │
│ Final balance │ 1106.734 USDT
│ Absolute profit │ 106.734 USDT
│ Total profit % │ 10.67%
│ CAGR % │ 230.04%
│ Sortino │ 4.99
│ Sharpe │ 8.00
│ Calmar │ 77.76
│ SQN │ 1.52
│ Profit factor │ 1.79
│ Expectancy (Ratio) │ 1.48 (0.07)
│ Avg. daily profit │ 3.443 USDT
│ Avg. stake amount │ 363.133 USDT
│ Total trade volume │ 52466.174 USDT
│ │ │
Best Pair │ LTC/USDT:USDT 4.48%
Worst Pair │ ADA/USDT:USDT -1.78%
Best trade │ ETC/USDT:USDT 2.00%
Worst tradeADA/USDT:USDT -10.17%
│ Best day 23.535 USDT
│ Worst day -49.813 USDT
Days win/draw/lose │ 21 / 6 / 4
Min/Max/Avg. Duration Winners │ 0d 00:35 / 5d 18:15 / 0d 15:30
Min/Max/Avg. Duration Losers │ 0d 12:00 / 17d 08:00 / 3d 23:28
Max Consecutive Wins / Loss │ 58 / 4
Rejected Entry signals │ 254
Entry/Exit Timeouts │ 0 / 0
│ Min balance │ 1003.168 USDT
Max balance │ 1209 USDT
Max % of account underwater │ 8.46%
Absolute drawdown │ 102.266 USDT (8.46%)
Drawdown duration │ 9 days 08:50:00
Profit at drawdown start 209 USDT
Profit at drawdown end │ 106.734 USDT
Drawdown start │ 2025-07-22 15:10:00
Drawdown end │ 2025-08-01 00:00:00
Market change │ 30.51%
└───────────────────────────────┴─────────────────────────────────┘
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
┃ Metric ┃ Value
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
│ Backtesting from │ 2025-07-01 00:00:00
│ Backtesting to │ 2025-08-01 00:00:00
│ Trading Mode │ Isolated Futures
│ Max open trades │ 3
│ │
│ Total/Daily Avg Trades │ 77 / 2.48
│ Starting balance │ 1000 USDT
│ Final balance │ 1054.669 USDT
│ Absolute profit │ 54.669 USDT
│ Total profit % │ 5.47%
│ CAGR % │ 87.14%
│ Sortino │ 2.46
│ Sharpe │ 3.73
│ Calmar │ 40.81
│ SQN │ 0.69
│ Profit factor │ 1.29
│ Expectancy (Ratio) │ 0.71 (0.04)
│ Avg. daily profit │ 1.764 USDT
│ Avg. stake amount │ 345.251 USDT
│ Total trade volume │ 53352.96 USDT
│ │
Long / Short trades │ 67 / 10
Long / Short profit % │ 8.93% / -3.46%
Long / Short profit USDT │ 89.262 / -34.593
│ Best PairLTC/USDT:USDT 5.62%
│ Worst PairADA/USDT:USDT -5.21%
Best trade │ ETC/USDT:USDT 2.00%
Worst trade │ ADA/USDT:USDT -10.17%
Best day │ 26.931 USDT
Worst day │ -47.741 USDT
Days win/draw/lose │ 20 / 6 / 5
Min/Max/Avg. Duration Winners │ 0d 00:35 / 5d 18:15 / 0d 15:49
Min/Max/Avg. Duration Losers │ 0d 10:40 / 17d 08:00 / 2d 17:00
│ Max Consecutive Wins / Loss │ 36 / 3
Rejected Entry signals │ 258
Entry/Exit Timeouts │ 0 / 0
Min/Max balance realized │ 1003.168 USDT / 1149.577 USDT
Min/Max balance unrealized1000 USDT / 1149.577 USDT
Min/Max balance dates │ 2025-07-01 00:05:00 / 2025-07-22 15:15:00
Max % of account underwater │ 8.26%
Absolute drawdown │ 94.908 USDT (8.26%)
Drawdown duration │ 9 days 08:50:00
│ Profit at drawdown start │ 149.577 USDT │
│ Profit at drawdown end │ 54.669 USDT │
│ Drawdown start │ 2025-07-22 15:10:00 │
│ Drawdown end │ 2025-08-01 00:00:00 │
│ Market change │ 30.51% │
└───────────────────────────────┴───────────────────────────────────────────┘
```
- `Backtesting from` / `Backtesting to`: Backtesting range (usually defined with the `--timerange` option).
@@ -409,7 +415,9 @@ It contains key metrics about the performance of your strategy on backtesting da
- `Max Consecutive Wins / Loss`: Maximum consecutive wins/losses in a row.
- `Rejected Entry signals`: Trade entry signals that could not be acted upon due to `max_open_trades` being reached.
- `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used).
- `Min balance` / `Max balance`: Lowest and Highest Wallet balance during the backtest period.
- `Min/Max balance realized`: Lowest and Highest Wallet balance during the backtest period based on closed trades trades.
- `Min/Max balance unrealized`: Lowest and Highest Wallet balance during the backtest period - including capital tied in open trades.
- `Min/Max balance dates`: Dates when the minimum and maximum unrealized balance occurred.
- `Max % of account underwater`: Maximum percentage your account has decreased from the top since the simulation started. Calculated as the maximum of `(Max Balance - Current Balance) / (Max Balance)`.
- `Absolute drawdown`: Maximum absolute drawdown experienced, including percentage relative to the account calculated as `(Absolute Drawdown) / (DrawdownHigh + startingBalance)`..
- `Drawdown duration`: Duration of the largest drawdown period.
+17
View File
@@ -46,6 +46,23 @@ On this page, you can also interact with the bot by starting and stopping it and
![FreqUI - trade view](assets/freqUI-trade-pane-dark.png#only-dark)
![FreqUI - trade view](assets/freqUI-trade-pane-light.png#only-light)
### Dashboard
The dashboard view provides an overview of the bot's performance and status.
If multiple bots are connected, the dashboard will show an overview of all connected bots, allowing you to easily switch between them or show just a subset of available bots.
#### Wallet Balance
New in freqtrade 2026.4: This shows the balance of the bot over time.
Compared to the "cumulative Profit" chart, this chart will show the actual balance of the bot over time, including unrealized profit and losses, as well as deposits and withdrawals.
Historic data has re-populated based on available exchange data - however is assumed to be best-effort and may not be 100% accurate.
More specifically, it won't cover deposits and withdrawals, and will assume a starting balance of current balance - profit/losses.
For clarity - a "Capture start" marker line is shown on the chart, which indicates the point at which the migration to the new wallet balance tracking system happened.
Only beyond this point, the wallet balance is expected to be accurate.
### Plot Configurator
FreqUI Plots can be configured either via a `plot_config` configuration object in the strategy (which can be loaded via "from strategy" button) or via the UI.
+4
View File
@@ -236,6 +236,10 @@ CONF_SCHEMA = {
"type": "string",
"enum": BACKTEST_CACHE_AGE,
},
"skip_wallet_history_migration": {
"description": "Disable wallet history migration.",
"type": "boolean",
},
# Hyperopt
"hyperopt_path": {
"description": "Specify additional lookup path for Hyperopt Loss functions.",
+1
View File
@@ -7,6 +7,7 @@ from .bt_fileutils import (
get_backtest_market_change,
get_backtest_result,
get_backtest_resultlist,
get_backtest_wallet_change,
get_latest_backtest_filename,
get_latest_hyperopt_file,
get_latest_hyperopt_filename,
+26 -2
View File
@@ -312,6 +312,27 @@ def get_backtest_market_change(filename: Path, include_ts: bool = True) -> pd.Da
return df
def get_backtest_wallet_change(filename: Path, strategy_name: str) -> pd.DataFrame | None:
"""
Read backtest wallet change file.
:param filename: Path to the backtest result zip file
:param strategy_name: Name of the strategy to load
:return: DataFrame with wallet change data
"""
if filename.suffix != ".zip":
return None
try:
data = load_file_from_zip(filename, f"{filename.stem}_{strategy_name}_wallet.feather")
df = pd.read_feather(BytesIO(data))
df.loc[:, "__date_ts"] = df.loc[:, "date"].astype(np.int64) // 1000 // 1000
return df
except ValueError:
pass
return None
def find_existing_backtest_stats(
dirname: Path | str, run_ids: dict[str, str], min_backtest_date: datetime | None = None
) -> dict[str, Any]:
@@ -503,13 +524,16 @@ def load_backtest_analysis_data(
return None
def trade_list_to_dataframe(trades: list[Trade] | list[LocalTrade]) -> pd.DataFrame:
def trade_list_to_dataframe(
trades: list[Trade] | list[LocalTrade], *, minified: bool = True
) -> pd.DataFrame:
"""
Convert list of Trade objects to pandas Dataframe
:param trades: List of trade objects
:param minified: Whether to use minified version of trade JSON
:return: Dataframe with BT_DATA_COLUMNS
"""
df = pd.DataFrame.from_records([t.to_json(True) for t in trades], columns=BT_DATA_COLUMNS)
df = pd.DataFrame.from_records([t.to_json(minified) for t in trades], columns=BT_DATA_COLUMNS)
if len(df) > 0:
df["close_date"] = pd.to_datetime(df["close_timestamp"], unit="ms", utc=True)
df["open_date"] = pd.to_datetime(df["open_timestamp"], unit="ms", utc=True)
@@ -1,9 +1,15 @@
import logging
from datetime import datetime
import numpy as np
import pandas as pd
from freqtrade.constants import IntOrInf
from freqtrade.exchange import (
timeframe_to_prev_date,
timeframe_to_resample_freq,
)
from freqtrade.util import dt_from_ts
logger = logging.getLogger(__name__)
@@ -58,3 +64,95 @@ def evaluate_result_multi(
"""
df_final = analyze_trade_parallelism(trades, timeframe)
return df_final[df_final["open_trades"] > max_open_trades]
def balance_distribution_over_time(
trades: pd.DataFrame,
min_date: datetime,
max_date: datetime,
timeframe: str,
stake_currency: str,
start_balance: float,
pairlist: list[str],
) -> pd.DataFrame:
"""
Return a dataframe with stake_currency and the pairlist as columns
Each column will contain the amount of the currency at the given time
Columns added are:
- stake_currency: amount of stake currency
- <pair>: amount of base currency in the pair
- <pair>_leverage: leverage used for the pair at the time (NaN if no open trade)
- <pair>_is_short: 1 if the open trade is short, 0 if long (NaN if no open trade)
- <pair>_collateral: amount of stake currency used as collateral for open trades
:param trades: Trades Dataframe - can be loaded from backtest, or created
via trade_list_to_dataframe
:param timeframe: Frequency to use for the resulting dataframe
:param min_date: start date
:param max_date: End date (will be rounded down to timeframe)
:param stake_currency: The stake currency
:param start_balance: Starting balance in stake currency
:param pairlist: List of trading pairs to include in the dataframe
Can be obtained via trade_df["pair"].unique()
For pairs without trades, the column will be all zeros
:return: Dataframe with balance distribution over time
"""
min_date_res = timeframe_to_prev_date(timeframe, min_date)
max_date_res = timeframe_to_prev_date(timeframe, max_date)
index = pd.date_range(min_date_res, max_date_res, freq=timeframe_to_resample_freq(timeframe))
pairs_lev = [f"{pair}_leverage" for pair in pairlist]
pairs_is_short = [f"{pair}_is_short" for pair in pairlist]
pairs_collateral = [f"{pair}_collateral" for pair in pairlist]
pairs_lev += pairs_is_short
df = pd.DataFrame(
index=index, columns=[stake_currency] + pairlist + pairs_lev + pairs_collateral, dtype=float
)
# Initialize variables to starting values
df[stake_currency] = float(start_balance)
df[pairlist + pairs_collateral] = 0.0
df[pairs_lev] = np.nan
for trade in trades.sort_values(by=["open_date"]).itertuples():
pair = trade.pair
end_date = trade.close_date if trade.close_date is not pd.NaT else None
# Exclude open orders - these won't have order_filled_timestamp set.
df.loc[trade.open_date : end_date, f"{pair}_leverage"] = trade.leverage
df.loc[trade.open_date : end_date, f"{pair}_is_short"] = 1 if trade.is_short else 0
orders = [o for o in trade.orders if o["order_filled_timestamp"]]
current_position = 0
current_collateral = 0
for order in sorted(orders, key=lambda x: x["order_filled_timestamp"]):
filled_at = pd.Timestamp(dt_from_ts(order["order_filled_timestamp"]))
real_amount = order.get("filled", order["amount"])
stake = order["safe_price"] * real_amount
stake_no_lev = stake / trade.leverage
if order["ft_is_entry"]:
# Entry order: lock collateral and pay fee
# For both long and short: balance decreases by collateral + fee
fee_open = stake * trade.fee_open
current_position += real_amount
current_collateral += stake_no_lev
df.loc[filled_at:end_date, pair] += real_amount
df.loc[filled_at:end_date, f"{pair}_collateral"] += stake_no_lev
df.loc[filled_at:, stake_currency] -= stake_no_lev + fee_open
else:
# Exit order: release collateral and realize profit/loss
fee_close = stake * trade.fee_close
if trade.is_short:
# For SHORT
df.loc[filled_at:, stake_currency] += (
current_collateral * (1 + trade.leverage) - stake
) - fee_close
else:
# For LONG
df.loc[filled_at:, stake_currency] += (
stake - current_collateral * (trade.leverage - 1) - fee_close
)
df.loc[filled_at:end_date, pair] -= real_amount
df.loc[filled_at:end_date, f"{pair}_collateral"] -= stake_no_lev
current_position -= real_amount
current_collateral -= stake_no_lev
# Round to avoid floating point issues
df = df.round(14)
return df
+2 -1
View File
@@ -172,6 +172,7 @@ class FreqtradeBot(LoggingMixin):
self._schedule.every().day.at(t).do(update)
self._schedule.every().day.at("00:02").do(self.exchange.ws_connection_reset)
self._schedule.every().day.at("00:07").do(self.wallets.record_wallet_state)
self.strategy.ft_bot_start()
# Initialize protections AFTER bot start - otherwise parameters are not loaded.
@@ -238,7 +239,7 @@ class FreqtradeBot(LoggingMixin):
Called on startup and after reloading the bot - triggers notifications and
performs startup tasks
"""
migrate_live_content(self.config, self.exchange)
migrate_live_content(self.config, self.exchange, self.wallets.get_starting_balance())
set_startup_time()
self.rpc.startup_messages(self.config, self.pairlists, self.protections)
@@ -55,6 +55,7 @@ class BacktestContentTypeIcomplete(TypedDict, total=False):
backtest_start_time: int
backtest_end_time: int
run_id: str
wallet_summary: DataFrame
class BacktestContentType(BacktestContentTypeIcomplete, total=True):
+20
View File
@@ -51,6 +51,7 @@ from freqtrade.mixins import LoggingMixin
from freqtrade.optimize.backtest_caching import get_strategy_run_id
from freqtrade.optimize.bt_progress import BTProgress
from freqtrade.optimize.optimize_reports import (
convert_bt_wallet_collection,
generate_backtest_stats,
generate_rejected_signals,
generate_trade_signal_candles,
@@ -137,6 +138,7 @@ class Backtesting:
}
self.rejected_dict: dict[str, list] = {}
self.starting_balance: float = 0.0
self.wallet_captures: list = []
self._exchange_name = self.config["exchange"]["name"]
self.__initial_backtest = exchange is None
@@ -451,6 +453,7 @@ class Backtesting:
self.replaced_entry_orders = 0
self.canceled_exit_orders = 0
self.replaced_exit_orders = 0
self.wallet_captures = []
self.dataprovider.clear_cache()
if enable_protections:
self._load_protections(self.strategy)
@@ -1603,6 +1606,7 @@ class Backtesting:
pair_detail_cache: dict[str, list[tuple]] = {}
pair_tradedir_cache: dict[str, LongShort | None] = {}
pairs_with_open_trades = [t.pair for t in LocalTrade.bt_trades_open]
self._capture_wallet(current_time, self.strategy.config["stake_currency"], 1)
for current_time_det, is_first, has_detail, idx, pair in self._time_pair_generator_det(
current_time, pairs
@@ -1627,6 +1631,7 @@ class Backtesting:
)
trade_dir = self.check_for_trade_entry(row)
pair_tradedir_cache[pair] = trade_dir
self._capture_wallet(current_time, pair.split("/")[0], row[OPEN_IDX])
else:
# Detail candle - from cache.
@@ -1680,6 +1685,15 @@ class Backtesting:
yield current_time_det, pair, row, is_last_row, trade_dir
self.progress.increment()
def _capture_wallet(self, current_time: datetime, currency: str, price: float) -> None:
"""
Capture the current wallet state.
"""
if self.dataprovider.runmode != RunMode.BACKTEST:
return
if total := self.wallets.get_total(currency):
self.wallet_captures.append((current_time, currency, price, total))
def backtest(
self, processed: dict, start_date: datetime, end_date: datetime
) -> BacktestContentTypeIcomplete:
@@ -1739,6 +1753,7 @@ class Backtesting:
"canceled_entry_orders": self.canceled_entry_orders,
"replaced_entry_orders": self.replaced_entry_orders,
"final_balance": self.wallets.get_total(self.strategy.config["stake_currency"]),
"wallet_summary": convert_bt_wallet_collection(self.wallet_captures),
}
def backtest_one_strategy(
@@ -1867,6 +1882,11 @@ class Backtesting:
dt_appendix,
market_change_data=combined_res,
analysis_results=self.analysis_results,
wallet_summary={
s: x["wallet_summary"]
for s, x in self.all_bt_content.items()
if "wallet_summary" in x
},
strategy_files={s.get_strategy_name(): s.__file__ for s in self.strategylist},
)
@@ -12,6 +12,7 @@ from freqtrade.optimize.optimize_reports.bt_output import (
)
from freqtrade.optimize.optimize_reports.bt_storage import store_backtest_results
from freqtrade.optimize.optimize_reports.optimize_reports import (
convert_bt_wallet_collection,
generate_all_periodic_breakdown_stats,
generate_backtest_stats,
generate_daily_stats,
@@ -287,6 +287,24 @@ def text_table_add_metrics(strat_results: dict) -> None:
if "trading_mode" in strat_results
else []
)
wallet_metrics: list[tuple[str, str]] = []
if wallet_stats := strat_results.get("wallet_stats"):
wallet_metrics = [
(
"Min/Max balance realized",
f"{fmt_coin(strat_results['csum_min'], stake)} / "
f"{fmt_coin(strat_results['csum_max'], stake)}",
),
(
"Min/Max balance unrealized",
f"{fmt_coin(wallet_stats['low_balance'], stake)} / "
f"{fmt_coin(wallet_stats['high_balance'], stake)}",
),
(
"Min/Max balance dates",
f"{wallet_stats['low_date']} / {wallet_stats['high_date']}",
),
]
# Newly added fields should be ignored if they are missing in strat_results. hyperopt-show
# command stores these results and newer version of freqtrade must be able to handle old
@@ -408,8 +426,7 @@ def text_table_add_metrics(strat_results: dict) -> None:
),
*entry_adjustment_metrics,
("", ""), # Empty line to improve readability
("Min balance", fmt_coin(strat_results["csum_min"], stake)),
("Max balance", fmt_coin(strat_results["csum_max"], stake)),
*wallet_metrics,
*drawdown_metrics,
("Market change", f"{strat_results['market_change']:.2%}"),
]
@@ -52,6 +52,7 @@ def store_backtest_results(
dtappendix: str,
*,
market_change_data: DataFrame | None = None,
wallet_summary: dict[str, DataFrame] | None = None,
analysis_results: dict[str, dict[str, DataFrame]] | None = None,
strategy_files: dict[str, str] | None = None,
) -> Path:
@@ -123,6 +124,15 @@ def store_backtest_results(
market_change_buf.seek(0)
zipf.writestr(market_change_name, market_change_buf.getvalue())
# Add wallet summary if present
if wallet_summary is not None:
for strategy, df in wallet_summary.items():
wallet_name = f"{base_filename.stem}_{strategy}_wallet.feather"
wallet_buf = BytesIO()
df.reset_index().to_feather(wallet_buf, compression_level=9, compression="lz4")
wallet_buf.seek(0)
zipf.writestr(wallet_name, wallet_buf.getvalue())
# Add analysis results if present and running in backtest mode
if (
config.get("export", "none") == "signals"
@@ -29,6 +29,48 @@ from freqtrade.util import decimals_per_coin, fmt_coin, format_duration, get_dry
logger = logging.getLogger(__name__)
def convert_bt_wallet_collection(wallet_captures: list[tuple]) -> DataFrame:
"""
Convert the wallet capture list to a DataFrame.
Assumes the wallet_captures list contains tuples with the following structure:
(date, currency, price, balance).
"""
if len(wallet_captures) == 0:
return DataFrame()
return DataFrame(
wallet_captures,
columns=["date", "currency", "rate", "balance"],
)
def generate_wallet_stats(wallet_df: DataFrame, stake_currency: str) -> dict[str, Any]:
"""Generate wallet statistics from the wallet DataFrame."""
if wallet_df is None or wallet_df.empty:
return {}
wallet_df.loc[:, "total_quote"] = wallet_df["rate"] * wallet_df["balance"]
# Group by date to get total wallet value at each timestamp
wallet = wallet_df.groupby("date")["total_quote"].sum().reset_index()
total_quote = wallet["total_quote"]
low_idx = total_quote.idxmin()
high_idx = total_quote.idxmax()
start_balance = wallet.iloc[0]["total_quote"]
end_balance = wallet.iloc[-1]["total_quote"]
high_balance = total_quote.loc[high_idx]
low_balance = total_quote.loc[low_idx]
low_date = wallet.loc[low_idx, "date"]
high_date = wallet.loc[high_idx, "date"]
return {
"start_balance": start_balance,
"end_balance": end_balance,
"high_balance": high_balance,
"low_balance": low_balance,
"low_date": low_date.strftime(DATETIME_PRINT_FORMAT),
"low_ts": int(low_date.timestamp() * 1000),
"high_date": high_date.strftime(DATETIME_PRINT_FORMAT),
"high_ts": int(high_date.timestamp() * 1000),
}
def generate_trade_signal_candles(
preprocessed_df: dict[str, DataFrame], bt_results: BacktestContentType, date_col: str
) -> dict[str, DataFrame]:
@@ -592,6 +634,7 @@ def generate_strategy_stats(
"sharpe": calculate_sharpe(results, min_date, max_date, start_balance),
"calmar": calculate_calmar(results, min_date, max_date, start_balance),
"sqn": calculate_sqn(results, start_balance),
"wallet_stats": generate_wallet_stats(content.get("wallet_summary"), stake_currency),
"profit_factor": profit_factor,
"backtest_start": min_date.strftime(DATETIME_PRINT_FORMAT),
"backtest_start_ts": int(min_date.timestamp() * 1000),
+1
View File
@@ -10,3 +10,4 @@ from freqtrade.persistence.usedb_context import (
disable_database_use,
enable_database_use,
)
from freqtrade.persistence.wallet_history import WalletHistory
+2
View File
@@ -22,6 +22,8 @@ KeyStoreKeys = Literal[
"bot_start_time",
"startup_time",
"binance_migration",
"wallet_history_migration",
"wallet_history_migration_date",
]
+2
View File
@@ -20,6 +20,7 @@ from freqtrade.persistence.key_value_store import _KeyValueStoreModel
from freqtrade.persistence.migrations import check_migrate
from freqtrade.persistence.pairlock import PairLock
from freqtrade.persistence.trade_model import Order, Trade
from freqtrade.persistence.wallet_history import WalletHistory
logger = logging.getLogger(__name__)
@@ -91,6 +92,7 @@ def init_db(db_url: str) -> None:
_CustomData.session = scoped_session(
sessionmaker(bind=engine, autoflush=True), scopefunc=get_request_or_thread_id
)
WalletHistory.session = Trade.session
previous_tables = inspect(engine).get_table_names()
ModelBase.metadata.create_all(engine)
+50
View File
@@ -0,0 +1,50 @@
from datetime import datetime
from typing import ClassVar
from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from freqtrade.persistence.base import ModelBase, SessionType
class WalletHistory(ModelBase):
"""
Daily wallet state tracking with minimal fields
"""
__tablename__ = "wallet_history"
session: ClassVar[SessionType]
id: Mapped[int] = mapped_column(Integer, primary_key=True)
timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
currency: Mapped[str] = mapped_column(String(25), nullable=False)
# Rate: price of 1 unit of `currency` quoted in `quote_currency`.
# e.g., USDT/ETH -> USDT per ETH
rate: Mapped[float] = mapped_column(Float, nullable=True)
# Quote currency for rate/total fields (e.g., 'USDT')
quote_currency: Mapped[str] = mapped_column(String(25), nullable=False)
# Balance in `currency` units
balance: Mapped[float] = mapped_column(Float, nullable=False)
# Canonical total wallet equity/value denominated in `quote_currency` (if available)
# For futures positions, collateral + PnL is used to compute this value.
total_quote: Mapped[float] = mapped_column(Float, nullable=True)
# Total position value in `quote_currency` - including leverage
total_position_value: Mapped[float] = mapped_column(Float, nullable=True)
collateral: Mapped[float] = mapped_column(Float, nullable=True)
leverage: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
bot_managed: Mapped[bool] = mapped_column(nullable=False, default=True)
__table_args__ = (
# Ensure one record per currency per day
UniqueConstraint("timestamp", "currency", name="unique_wallet_daily"),
)
def __repr__(self) -> str:
return (
f"WalletHistory(timestamp={self.timestamp}, currency={self.currency}, "
f"rate={self.rate}, total_quote={self.total_quote}, "
f"balance={self.balance}, leverage={self.leverage})"
)
+35 -1
View File
@@ -16,10 +16,11 @@ from freqtrade.data.btanalysis import (
get_backtest_market_change,
get_backtest_result,
get_backtest_resultlist,
get_backtest_wallet_change,
load_and_merge_backtest_result,
update_backtest_metadata,
)
from freqtrade.enums import BacktestState
from freqtrade.enums import BacktestState, RunMode
from freqtrade.exceptions import ConfigurationError, DependencyException, OperationalException
from freqtrade.ft_types import get_BacktestResultType_default
from freqtrade.misc import deep_merge_dicts, is_file_in_dir
@@ -29,6 +30,7 @@ from freqtrade.rpc.api_server.api_schemas import (
BacktestMetadataUpdate,
BacktestRequest,
BacktestResponse,
WalletHistoryResponse,
)
from freqtrade.rpc.api_server.deps import get_config, verify_strategy
from freqtrade.rpc.api_server.webserver_bgwork import ApiBG
@@ -106,6 +108,11 @@ def __run_backtest_bg(btconfig: Config):
ApiBG.bt["bt"].results,
datetime.now().strftime("%Y-%m-%d_%H-%M-%S"),
market_change_data=combined_res,
wallet_summary={
s: x["wallet_summary"]
for s, x in ApiBG.bt["bt"].all_bt_content.items()
if "wallet_summary" in x
},
strategy_files={
s.get_strategy_name(): s.__file__ for s in ApiBG.bt["bt"].strategylist
},
@@ -137,6 +144,7 @@ async def api_start_backtest(
verify_strategy(bt_settings.strategy)
btconfig = deepcopy(config)
btconfig["runmode"] = RunMode.BACKTEST
remove_exchange_credentials(btconfig["exchange"], True)
settings = dict(bt_settings)
if settings.get("freqai", None) is not None:
@@ -354,3 +362,29 @@ def api_get_backtest_market_change(file: str, config=Depends(get_config)):
"data": df.values.tolist(),
"length": len(df),
}
@router.get(
"/backtest/history/{file}/{strategy}/wallet",
response_model=WalletHistoryResponse,
tags=["webserver", "backtest"],
)
def api_get_backtest_wallet(file: str, strategy: str, config=Depends(get_config)):
bt_results_base: Path = config["user_data_dir"] / "backtest_results"
file_abs = (bt_results_base / file).with_suffix(".zip")
# Ensure file is in backtest_results directory
if not is_file_in_dir(file_abs, bt_results_base):
raise HTTPException(status_code=400, detail="Unable to retrieve wallet history.")
results = get_backtest_wallet_change(file_abs, strategy)
if results is None:
raise HTTPException(status_code=404, detail="Unable to retrieve wallet history.")
# Consolidate the wallet to the base currency
results.loc[:, "total_quote"] = results["rate"] * results["balance"]
results = results.groupby(["date", "__date_ts"]).agg({"total_quote": "sum"}).reset_index()
return {
"columns": results.columns.tolist(),
"data": results.values.tolist(),
"length": len(results),
}
+9
View File
@@ -679,6 +679,15 @@ class BacktestMarketChange(BaseModel):
data: list[list[Any]]
class WalletHistoryResponse(BaseModel):
columns: list[str]
length: int
data: list[list[Any]]
# start date of the effectively captured data
# Before this date, it's based on a reconstructed wallet history
capture_start_ts: int | None = None
class MarketRequest(ExchangeModePayloadMixin, BaseModel):
base: str | None = None
quote: str | None = None
+17
View File
@@ -31,6 +31,7 @@ from freqtrade.rpc.api_server.api_schemas import (
ResultMsg,
Stats,
StatusMsg,
WalletHistoryResponse,
WhitelistResponse,
)
from freqtrade.rpc.api_server.deps import get_config, get_rpc
@@ -104,6 +105,22 @@ def stats(rpc: RPC = Depends(get_rpc)):
return rpc._rpc_stats()
@router.get(
"/historic_balance",
response_model=WalletHistoryResponse,
tags=["info"],
)
def api_get_wallet_history(rpc: RPC = Depends(get_rpc)):
results, capture_date_ts = rpc._rpc_get_historic_balance()
return {
"columns": results.columns.tolist(),
"data": results.values.tolist(),
"length": len(results),
"capture_start_ts": capture_date_ts,
}
@router.get("/daily", response_model=DailyWeeklyMonthly, tags=["Trading-info"])
def daily(
timescale: int = Query(7, ge=1, description="Number of days to fetch data for"),
+2 -1
View File
@@ -69,7 +69,8 @@ logger = logging.getLogger(__name__)
# 2.45: Add price to forceexit endpoint
# 2.46: Add prepend_data to download-data endpoint
# 2.47: Add Strategy parameters
API_VERSION = 2.47
# 2.48: add /backtest/history/wallets endpoint
API_VERSION = 2.48
# Public API, requires no auth.
router_public = APIRouter()
+21 -1
View File
@@ -12,7 +12,7 @@ import psutil
from dateutil.relativedelta import relativedelta
from dateutil.tz import tzlocal
from numpy import inf, int64, isnan, mean, nan
from pandas import DataFrame, NaT
from pandas import DataFrame, NaT, read_sql
from sqlalchemy import func, select
from freqtrade import __version__
@@ -785,6 +785,26 @@ class RPC:
"bot_start_date": format_date(bot_start),
}
def _rpc_get_historic_balance(self) -> tuple[DataFrame, int]:
"""
Returns the historic balance of the bot
:return: DataFrame with the balance history and the timestamp of the migration
"""
results = read_sql("wallet_history", con=Trade.session.bind, parse_dates=["timestamp"])
results = results.rename({"timestamp": "date"}, axis=1)
results.loc[:, "__date_ts"] = results.loc[:, "date"].astype("int64") // 1000 // 1000
# Exclude non-bot managed for now
results_filtered = results.loc[results["bot_managed"]]
results_final = (
results_filtered.groupby(["date", "__date_ts"])
.agg({"total_quote": "sum"})
.reset_index()
)
hist = KeyValueStore.get_datetime_value("wallet_history_migration_date")
return results_final, dt_ts_def(hist, 0)
def __balance_get_est_stake(
self, coin: str, stake_currency: str, amount: float, balance: Wallet
) -> tuple[float, float]:
+5 -4
View File
@@ -1,8 +1,10 @@
from freqtrade.constants import Config
from freqtrade.exchange import Exchange
from freqtrade.util.migrations.funding_rate_mig import migrate_funding_fee_timeframe
from freqtrade.util.migrations.migrate_wallet_history import migrate_wallet_history
def migrate_data(config, exchange: Exchange | None = None) -> None:
def migrate_data(config: Config, exchange: Exchange | None = None) -> None:
"""
Migrate persisted data from old formats to new formats
"""
@@ -10,10 +12,9 @@ def migrate_data(config, exchange: Exchange | None = None) -> None:
migrate_funding_fee_timeframe(config, exchange)
def migrate_live_content(config, exchange: Exchange | None = None) -> None:
def migrate_live_content(config: Config, exchange: Exchange, starting_balance: float) -> None:
"""
Migrate database content from old formats to new formats
Used for dry/live mode.
"""
# Currently not used
pass
migrate_wallet_history(config, exchange, starting_balance)
@@ -0,0 +1,214 @@
import logging
import numpy as np
import pandas as pd
from freqtrade.constants import Config
from freqtrade.data.btanalysis.bt_fileutils import trade_list_to_dataframe
from freqtrade.data.btanalysis.trade_parallelism import balance_distribution_over_time
from freqtrade.exchange import Exchange
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_prev_date
from freqtrade.persistence import KeyValueStore, Trade, WalletHistory
from freqtrade.util import dt_now, dt_ts
logger = logging.getLogger(__name__)
def migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: float):
if config.get("skip_wallet_history_migration") or not exchange.get_option(
"ohlcv_has_history", True
):
# we can't fill up wallet history without ohlcv history
return
if KeyValueStore.get_int_value("wallet_history_migration"):
logger.debug("Wallet history migration already completed.")
return
logger.info("Starting wallet history migration...")
_migrate_wallet_history(config, exchange, starting_balance)
logger.info("Wallet history migration completed.")
KeyValueStore.store_value("wallet_history_migration", 1)
KeyValueStore.store_value("wallet_history_migration_date", dt_now())
def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: float):
# Prepare balance distribution data with OHLCV rates
balance_dist, pairlist_valid = _prepare_balance_distribution(config, exchange, starting_balance)
if not balance_dist.empty and pairlist_valid:
_create_wallet_history_entries(
config, exchange, balance_dist, pairlist_valid, config["stake_currency"]
)
def _prepare_balance_distribution(
config: Config, exchange: Exchange, starting_balance: float
) -> tuple[pd.DataFrame, list[str]]:
trade_df = trade_list_to_dataframe(Trade.get_trades_proxy(), minified=False)
if trade_df.empty:
# no trades, nothing to do
return pd.DataFrame(), []
pairlist = list(trade_df["pair"].unique())
timeframe = "1d"
stake_currency = config["stake_currency"]
min_date = timeframe_to_prev_date(timeframe, KeyValueStore.get_datetime_value("bot_start_time"))
balance_dist = balance_distribution_over_time(
trade_df,
min_date=min_date,
max_date=dt_now(),
start_balance=starting_balance,
stake_currency=stake_currency,
timeframe=timeframe,
pairlist=pairlist,
)
pairlist_valid = [p for p in pairlist if p in exchange.markets]
pairlist_invalid = set(pairlist) - set(pairlist_valid)
if pairlist_invalid:
logger.warning(
f"The following trading pairs from the trade history are not available on the exchange "
f"and will be skipped during wallet history migration: {', '.join(pairlist_invalid)}"
)
logger.info("Wallet History migration: Fetching OHLCV data ...")
data = exchange.refresh_latest_ohlcv(
[(p, timeframe, config["candle_type_def"]) for p in pairlist_valid],
since_ms=dt_ts(min_date),
cache=False,
drop_incomplete=False,
)
logger.info(
"Wallet History migration: Done fetching OHLCV data for wallet history migration..."
)
dfs = []
# Combine all dataframes into one using the open rate
for p, x in data.items():
x = x.set_index("date", drop=True)
col = f"{p[0]}_open"
x[col] = x["open"]
dfs.append(x[[col]])
if not dfs:
logger.warning(
"No OHLCV data available for the trading pairs; skipping wallet history migration."
)
return pd.DataFrame(), []
merged = pd.concat(dfs, axis=1)
balance_dist = balance_dist.join(merged, how="left")
df_value = pd.DataFrame(
index=balance_dist.index, columns=[f"{p}_value" for p in pairlist_valid], dtype=float
)
for p in pairlist_valid:
# df_value[f"{p}_value"] = balance_dist[f"{p}_open"] * balance_dist[p]
# Identical calculation to rpc and wallets.py
df_value[f"{p}_value"] = np.where(
balance_dist[f"{p}_is_short"] == 0,
(balance_dist[f"{p}_open"] * balance_dist[p])
- balance_dist[f"{p}_collateral"] * (balance_dist[f"{p}_leverage"] - 1),
(
balance_dist[f"{p}_collateral"] * (1 + balance_dist[f"{p}_leverage"])
- balance_dist[f"{p}_open"] * balance_dist[p]
),
)
balance_dist = pd.concat([balance_dist, df_value], axis=1)
# Aggregate total value at each point in time
balance_dist["total_value"] = balance_dist[
[f"{p}_value" for p in pairlist_valid] + [stake_currency]
].sum(axis=1)
return balance_dist, pairlist_valid
def _create_wallet_history_entries(
config: Config,
exchange: Exchange,
balance_dist: pd.DataFrame,
pairlist_valid: list[str],
stake_currency: str,
):
is_futures = config["trading_mode"] == "futures"
# Precompute column indices for faster tuple-based iteration
# Assume the first column is the index (date)
stake_idx = balance_dist.columns.get_loc(stake_currency)
pair_balance_idx = {pair: balance_dist.columns.get_loc(pair) + 1 for pair in pairlist_valid}
pair_leverage_idx = {
pair: balance_dist.columns.get_loc(f"{pair}_leverage") + 1 for pair in pairlist_valid
}
pair_collateral_idx = {
pair: balance_dist.columns.get_loc(f"{pair}_collateral") + 1 for pair in pairlist_valid
}
pair_is_short_idx = {
pair: balance_dist.columns.get_loc(f"{pair}_is_short") + 1 for pair in pairlist_valid
}
pair_rate_idx = {
pair: balance_dist.columns.get_loc(f"{pair}_open") + 1 for pair in pairlist_valid
}
# Convert balance_dist to WalletHistory entries
wallet_entries = []
for row in balance_dist.itertuples(index=True, name=None):
date = row[0]
# Add stake currency entry
stake_balance = row[stake_idx + 1]
if not pd.isna(stake_balance):
wallet_entries.append(
WalletHistory(
timestamp=date,
currency=stake_currency,
rate=1.0, # Stake currency price is always 1.0
balance=stake_balance,
total_quote=stake_balance,
quote_currency=stake_currency,
leverage=1.0,
bot_managed=True,
)
)
# Add entries for each trading pair
for pair in pairlist_valid:
base_currency = exchange.get_pair_base_currency(pair)
balance = row[pair_balance_idx[pair]]
leverage = row[pair_leverage_idx[pair]]
# Only add entry if balance is not empty/NaN
if not pd.isna(balance) and balance > 0:
rate_value = row[pair_rate_idx[pair]]
rate = rate_value if not pd.isna(rate_value) else None
total_quote = balance * rate if rate else None
collateral: float | None = None
if is_futures:
collateral = row[pair_collateral_idx[pair]]
is_short = row[pair_is_short_idx[pair]]
if collateral is not None and not pd.isna(collateral):
# Same formula than in rpc's _rpc_balance
total_quote = (
(rate * balance - collateral * (leverage - 1))
if is_short == 0
else (collateral * (1 + leverage) - rate * balance)
)
wallet_entries.append(
WalletHistory(
timestamp=date,
currency=base_currency,
quote_currency=stake_currency,
rate=rate,
balance=balance,
total_quote=total_quote,
leverage=leverage if not pd.isna(leverage) else 1.0,
bot_managed=True,
total_position_value=balance * rate if is_futures and rate else None,
# collateral=collateral,
)
)
# Save entries to database
if wallet_entries:
try:
# Use bulk_save_objects for better performance
WalletHistory.session.bulk_save_objects(wallet_entries)
WalletHistory.session.commit()
logger.info(f"Successfully created {len(wallet_entries)} wallet balance records")
except Exception as e:
WalletHistory.session.rollback()
logger.error(f"Error saving wallet balance records: {e}")
+70 -2
View File
@@ -10,8 +10,8 @@ from freqtrade.enums import RunMode, TradingMode
from freqtrade.exceptions import DependencyException
from freqtrade.exchange import Exchange
from freqtrade.misc import safe_value_fallback
from freqtrade.persistence import LocalTrade, Trade
from freqtrade.util.datetime_helpers import dt_now
from freqtrade.persistence import LocalTrade, Trade, WalletHistory
from freqtrade.util import dt_floor_day, dt_now
logger = logging.getLogger(__name__)
@@ -445,3 +445,71 @@ class Wallets:
logger.debug(msg)
else:
logger.info(msg)
def record_wallet_state(self) -> None:
"""Record daily wallet totals to database"""
if self._is_backtest:
# only record in live mode.
return
timestamp = dt_floor_day(dt_now())
# Record total balances for all currencies
wallet_records = []
position_collaterals = 0.0
open_assets: dict[str, Trade] = {t.safe_base_currency: t for t in Trade.get_open_trades()}
for pos in self.get_all_positions().values():
base = self._exchange.get_pair_base_currency(pos.symbol)
rate = self._exchange.get_conversion_rate(base, self._stake_currency)
total_quote = None
leverage = pos.leverage or 1.0
if rate:
# Same formula than in rpc's _rpc_balance
total_quote = (
rate * pos.position - pos.collateral * (leverage - 1)
if pos.side == "long"
else pos.collateral * (1 + leverage) - rate * pos.position
)
position_record = WalletHistory(
timestamp=timestamp,
currency=pos.symbol,
quote_currency=self._stake_currency,
rate=rate,
balance=pos.position,
total_quote=total_quote,
total_position_value=rate * pos.position if rate else None,
collateral=pos.collateral,
leverage=leverage,
bot_managed=base in open_assets,
)
position_collaterals += pos.collateral
wallet_records.append(position_record)
for wallet in self.get_all_balances().values():
# TODO: (needs decision) exclude minimal balances?
rate = self._exchange.get_conversion_rate(wallet.currency, self._stake_currency)
is_bot_managed = (
self._stake_currency == wallet.currency or wallet.currency in open_assets
)
balance = wallet.total - (
position_collaterals if wallet.currency == self._stake_currency else 0
)
total_quote = rate * balance if rate else None
wallet_record = WalletHistory(
timestamp=timestamp,
currency=wallet.currency,
quote_currency=self._stake_currency,
rate=rate,
balance=balance,
leverage=1.0,
total_quote=total_quote,
bot_managed=is_bot_managed,
)
wallet_records.append(wallet_record)
try:
WalletHistory.session.bulk_save_objects(wallet_records)
WalletHistory.session.commit()
except Exception as e:
WalletHistory.session.rollback()
logger.error(f"Error saving wallet balance records: {e}")
+4 -2
View File
@@ -169,10 +169,12 @@ def generate_trades_history(n_rows, start_date: datetime | None = None, days=5):
return df
def generate_test_data(timeframe: str, size: int, start: str = "2020-07-05", random_seed=42):
def generate_test_data(
timeframe: str, size: int, start: str = "2020-07-05", random_seed=42, base=20
):
np.random.seed(random_seed)
base = np.random.normal(20, 2, size=size)
base = np.random.normal(base, 2, size=size)
if timeframe == "1y":
date = pd.date_range(start, periods=size, freq="1YS", tz="UTC")
elif timeframe == "1M":
+55 -12
View File
@@ -10,8 +10,9 @@ from freqtrade.configuration import TimeRange
from freqtrade.constants import LAST_BT_RESULT_FN
from freqtrade.data.btanalysis import (
BT_DATA_COLUMNS,
analyze_trade_parallelism,
extract_trades_of_period,
get_backtest_market_change,
get_backtest_wallet_change,
get_latest_backtest_filename,
get_latest_hyperopt_file,
load_backtest_data,
@@ -209,17 +210,6 @@ def test_extract_trades_of_period(testdatadir):
assert trades1.iloc[-1].close_date == datetime(2017, 11, 14, 15, 25, 0, tzinfo=UTC)
def test_analyze_trade_parallelism(testdatadir):
filename = testdatadir / "backtest_results/backtest-result.json"
bt_data = load_backtest_data(filename)
res = analyze_trade_parallelism(bt_data, "5m")
assert isinstance(res, DataFrame)
assert "open_trades" in res.columns
assert res["open_trades"].max() == 3
assert res["open_trades"].min() == 0
def test_load_trades(default_conf, mocker):
db_mock = mocker.patch(
"freqtrade.data.btanalysis.bt_fileutils.load_trades_from_db", MagicMock()
@@ -649,3 +639,56 @@ 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")
def test_get_backtest_market_change(tmp_path):
df = DataFrame(
{
"date": [dt_utc(2020, 1, 1), dt_utc(2020, 1, 2)],
"price": [100.0, 110.0],
}
)
feather_file = tmp_path / "backtest-result_market_change.feather"
df.to_feather(feather_file)
direct_df = get_backtest_market_change(feather_file)
assert isinstance(direct_df, DataFrame)
assert "__date_ts" in direct_df.columns
assert direct_df.loc[0, "__date_ts"] == int(df.loc[0, "date"].timestamp() * 1000)
no_ts_df = get_backtest_market_change(feather_file, include_ts=False)
assert "__date_ts" not in no_ts_df.columns
zip_file = tmp_path / "backtest-result.zip"
with ZipFile(zip_file, "w") as zipf:
zipf.write(feather_file, arcname=f"{zip_file.stem}_market_change.feather")
zipped_df = get_backtest_market_change(zip_file)
assert isinstance(zipped_df, DataFrame)
assert zipped_df.loc[0, "__date_ts"] == int(df.loc[0, "date"].timestamp() * 1000)
assert list(zipped_df["price"]) == [100.0, 110.0]
def test_get_backtest_wallet_change(tmp_path):
df = DataFrame(
{
"date": [dt_utc(2020, 1, 1), dt_utc(2020, 1, 2)],
"balance": [1.0, 1.1],
"rate": [1.0, 1.1],
}
)
wallet_feather = tmp_path / "backtest-result_TestStrategy_wallet.feather"
df.to_feather(wallet_feather)
zip_file = tmp_path / "backtest-result.zip"
with ZipFile(zip_file, "w") as zipf:
zipf.write(wallet_feather, arcname=wallet_feather.name)
wallet_df = get_backtest_wallet_change(zip_file, "TestStrategy")
assert isinstance(wallet_df, DataFrame)
assert "__date_ts" in wallet_df.columns
assert wallet_df.loc[0, "__date_ts"] == int(df.loc[0, "date"].timestamp() * 1000)
assert list(wallet_df["balance"]) == [1.0, 1.1]
assert get_backtest_wallet_change(tmp_path / "backtest-result.feather", "TestStrategy") is None
assert get_backtest_wallet_change(zip_file, "UnknownStrategy") is None
+210
View File
@@ -0,0 +1,210 @@
from datetime import timedelta
import pytest
from pandas import DataFrame, Timestamp
from freqtrade.data.btanalysis import (
analyze_trade_parallelism,
load_backtest_data,
)
from freqtrade.data.btanalysis.trade_parallelism import balance_distribution_over_time
from freqtrade.util import dt_utc
def test_analyze_trade_parallelism(testdatadir):
filename = testdatadir / "backtest_results/backtest-result.json"
bt_data = load_backtest_data(filename)
res = analyze_trade_parallelism(bt_data, "5m")
assert isinstance(res, DataFrame)
assert "open_trades" in res.columns
assert res["open_trades"].max() == 3
assert res["open_trades"].min() == 0
@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
assert f"{pair}_leverage" in result.columns
assert f"{pair}_is_short" in result.columns
assert f"{pair}_collateral" 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
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 long trades and < start balance for short trades
assert (final_balance > start_balance) if not is_short else (final_balance < start_balance), (
"Balance increases for long and decreases for short trades"
)
+18
View File
@@ -757,10 +757,12 @@ def test_backtest__check_trade_exit(default_conf, mocker) -> None:
def test_backtest_one(default_conf, mocker, testdatadir) -> None:
default_conf["use_exit_signal"] = False
default_conf["max_open_trades"] = 10
default_conf["runmode"] = RunMode.BACKTEST
patch_exchange(mocker)
mocker.patch(f"{EXMS}.get_min_pair_stake_amount", return_value=0.00001)
mocker.patch(f"{EXMS}.get_max_pair_stake_amount", return_value=float("inf"))
mocker.patch(f"{EXMS}.get_pair_base_currency", lambda _, x: x.split("/")[0])
backtesting = Backtesting(default_conf)
backtesting._set_strategy(backtesting.strategylist[0])
pair = "UNITTEST/BTC"
@@ -875,13 +877,23 @@ def test_backtest_one(default_conf, mocker, testdatadir) -> None:
ln1.iloc[0]["low"], 6
) < round(t["close_rate"], 6) < round(ln1.iloc[0]["high"], 6)
wallet_summary = result["wallet_summary"]
assert isinstance(wallet_summary, pd.DataFrame)
assert len(wallet_summary) == 255
unique_currencies = wallet_summary["currency"].value_counts()
assert unique_currencies["BTC"] == 200
assert unique_currencies["UNITTEST"] == 55
@pytest.mark.parametrize("use_detail", [True, False])
def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail) -> None:
default_conf_usdt["use_exit_signal"] = False
default_conf_usdt["runmode"] = RunMode.BACKTEST
patch_exchange(mocker)
mocker.patch(f"{EXMS}.get_min_pair_stake_amount", return_value=0.00001)
mocker.patch(f"{EXMS}.get_max_pair_stake_amount", return_value=float("inf"))
mocker.patch(f"{EXMS}.get_pair_base_currency", lambda _, x: x.split("/")[0])
default_conf_usdt["unfilledtimeout"] = {
"entry": 11,
"exit": 30,
@@ -968,6 +980,12 @@ def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail)
)
assert late_entry > 0
wallet_summary = result["wallet_summary"]
assert isinstance(wallet_summary, pd.DataFrame)
assert len(wallet_summary) == 591 if use_detail else 597
unique_currencies = wallet_summary["currency"].value_counts()
assert unique_currencies["USDT"] == 576
assert unique_currencies["XRP"] == 15 if use_detail else 21
@pytest.mark.parametrize(
+87 -1
View File
@@ -7,8 +7,10 @@ import logging
import time
from copy import deepcopy
from datetime import UTC, datetime, timedelta
from io import BytesIO
from pathlib import Path
from unittest.mock import ANY, MagicMock, PropertyMock, patch
from zipfile import ZipFile
import pandas as pd
import pytest
@@ -1434,6 +1436,41 @@ def test_api_stats(botclient, mocker, ticker, fee, markets, is_short):
assert "draws" in rc.json()["durations"]
@pytest.mark.parametrize("is_short", [True, False])
def test_api_historic_balance(botclient, mocker, ticker, fee, markets, is_short):
ftbot, client = botclient
patch_get_signal(ftbot, enter_long=not is_short, enter_short=is_short)
mocker.patch.multiple(
EXMS,
get_balances=MagicMock(return_value=ticker),
fetch_ticker=ticker,
get_fee=fee,
markets=PropertyMock(return_value=markets),
)
rc = client_get(client, f"{BASE_URI}/historic_balance")
assert_response(rc, 200)
resp = rc.json()
assert "columns" in resp
assert "data" in resp
assert "length" in resp
assert "capture_start_ts" in resp
assert resp["length"] == 0
ftbot.wallets.record_wallet_state()
rc = client_get(client, f"{BASE_URI}/historic_balance")
assert_response(rc, 200)
resp1 = rc.json()
assert "columns" in resp1
assert "data" in resp1
assert "length" in resp1
assert "capture_start_ts" in resp1
assert resp1["length"] == 1
assert "__date_ts" in resp1["columns"]
assert "total_quote" in resp1["columns"]
def test_api_performance(botclient, fee):
ftbot, client = botclient
patch_get_signal(ftbot)
@@ -3275,7 +3312,7 @@ def test_api_patch_backtest_history_entry(botclient, tmp_path: Path):
assert fileres[CURRENT_TEST_STRATEGY]["notes"] == "FooBar"
def test_api_patch_backtest_market_change(botclient, tmp_path: Path):
def test_api_backtest_market_change(botclient, tmp_path: Path):
ftbot, client = botclient
# Create a temporary directory and file
@@ -3313,6 +3350,55 @@ def test_api_patch_backtest_market_change(botclient, tmp_path: Path):
]
def test_api_backtest_wallets(botclient, tmp_path: Path):
ftbot, client = botclient
# Create a temporary directory and file
bt_results_base = tmp_path / "backtest_results"
bt_results_base.mkdir()
zip_file = bt_results_base / "backtest_15.zip"
with ZipFile(zip_file, "w") as zipf:
wallet_df = pd.DataFrame(
{
"date": [
"2018-01-01T00:00:00Z",
"2018-01-01T00:00:00Z",
"2018-01-01T00:05:00Z",
"2018-01-01T00:05:00Z",
],
"currency": ["ETH", "BTC", "ETH", "BTC"],
"rate": [2000, 60_000, 2001, 60_001],
"balance": [0.5, 0.25, 0.5, 0.25],
}
)
wallet_df["date"] = pd.to_datetime(wallet_df["date"])
wallet_buf = BytesIO()
wallet_df.reset_index().to_feather(wallet_buf, compression_level=9, compression="lz4")
wallet_buf.seek(0)
zipf.writestr("backtest_15_SampleStrategy_wallet.feather", wallet_buf.read())
# Wrong basedirectory
rc = client_get(client, f"{BASE_URI}/backtest/history/randomFile.json/SampleStrategy/wallet")
assert_response(rc, 503)
ftbot.config["user_data_dir"] = tmp_path
ftbot.config["runmode"] = RunMode.WEBSERVER
# Nonexisting file - fails "is_file_in_dir" check
rc = client_get(client, f"{BASE_URI}/backtest/history/randomFile.json/SampleStrategy/wallet")
assert_response(rc, 400)
rc = client_get(client, f"{BASE_URI}/backtest/history/backtest_15/SampleStrategy/wallet")
assert_response(rc, 200)
result = rc.json()
assert result["length"] == 2
assert result["columns"] == ["date", "__date_ts", "total_quote"]
assert result["data"] == [
["2018-01-01T00:00:00Z", 1514764800000, 16000.0],
["2018-01-01T00:05:00Z", 1514765100000, 16000.75],
]
def test_health(botclient):
_ftbot, client = botclient
+81 -1
View File
@@ -7,12 +7,14 @@ from sqlalchemy import select
from freqtrade.constants import UNLIMITED_STAKE_AMOUNT
from freqtrade.exceptions import DependencyException
from freqtrade.persistence import Trade
from freqtrade.persistence import Trade, WalletHistory
from freqtrade.wallets import PositionWallet, Wallet
from tests.conftest import (
EXMS,
create_mock_trades,
create_mock_trades_usdt,
get_patched_freqtradebot,
log_has_re,
patch_wallet,
)
@@ -607,3 +609,81 @@ def test_dry_run_wallet_initialization(mocker, default_conf_usdt, config, wallet
pytest.approx(freqtrade.wallets._wallets[stake_currency].free)
== wallets[stake_currency]["free"] - 100.0
)
@pytest.mark.usefixtures("init_persistence")
def test_record_wallet_state_stores_wallet_history(mocker, default_conf_usdt):
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
stake_currency = default_conf_usdt["stake_currency"]
freqtrade.wallets._wallets = {
stake_currency: Wallet(stake_currency, free=100.0, used=50, total=150),
"BTC": Wallet("BTC", free=2.0, used=1.0, total=3.0),
}
freqtrade.wallets._positions = {
"ETH/USDT:USDT": PositionWallet(
symbol="ETH/USDT:USDT",
position=0.8,
collateral=1.0,
leverage=3.0,
side="long",
)
}
conversion_rates = {stake_currency: 1.0, "BTC": 70000, "ETH": 2500.1}
mocker.patch.object(
freqtrade.exchange,
"get_conversion_rate",
side_effect=lambda currency, *args, **kwargs: conversion_rates.get(currency, 1.0),
)
mocker.patch(
"freqtrade.persistence.trade_model.Trade.get_open_trades",
return_value=[
MagicMock(pair="ETH/USDT:USDT", safe_base_currency="ETH"),
],
)
freqtrade.wallets.record_wallet_state()
wallet_entries = WalletHistory.session.query(WalletHistory).all()
assert len(wallet_entries) == 3
assert "total_quote" in repr(wallet_entries[0])
assert "WalletHistory(" in repr(wallet_entries[0])
records_by_currency = {entry.currency: entry for entry in wallet_entries}
assert records_by_currency[stake_currency].balance == 149
assert records_by_currency[stake_currency].rate == 1.0
assert records_by_currency["BTC"].rate == 70000
assert records_by_currency["BTC"].balance == 3
assert not records_by_currency["BTC"].bot_managed
assert records_by_currency["ETH/USDT:USDT"].balance == 0.8
assert records_by_currency["ETH/USDT:USDT"].rate == 2500.1
assert records_by_currency["ETH/USDT:USDT"].bot_managed is True
@pytest.mark.usefixtures("init_persistence")
def test_record_wallet_state_stores_wallet_history_error(mocker, default_conf, caplog):
freqtrade = get_patched_freqtradebot(mocker, default_conf)
stake_currency = default_conf["stake_currency"]
freqtrade.wallets._wallets = {
stake_currency: Wallet(stake_currency, free=1.0, used=0.5, total=1.5),
"ETH": Wallet("ETH", free=2.0, used=1.0, total=3.0),
}
freqtrade.wallets._positions = {
"ETH/BTC": PositionWallet(
symbol="ETH/BTC",
position=0.8,
collateral=1.0,
leverage=3.0,
side="long",
)
}
# Mock bulk_save_objects to raise an exception
mocker.patch.object(
WalletHistory.session, "bulk_save_objects", side_effect=Exception("DB Error")
)
freqtrade.wallets.record_wallet_state()
assert log_has_re(r"Error saving wallet balance records: .*", caplog)
wallet_entries = WalletHistory.session.query(WalletHistory).all()
assert len(wallet_entries) == 0
@@ -0,0 +1,500 @@
from datetime import datetime, timedelta
from unittest.mock import MagicMock
import pandas as pd
import pytest
from freqtrade.enums import CandleType
from freqtrade.persistence import KeyValueStore, Order, Trade, WalletHistory
from freqtrade.util import dt_now, dt_utc
from freqtrade.util.migrations.migrate_wallet_history import (
_migrate_wallet_history,
_prepare_balance_distribution,
migrate_wallet_history,
)
from tests.conftest import EXMS, generate_test_data, get_patched_exchange, log_has_re
def create_closed_mock_trade(fee, pair: str, open_date: datetime, close_date: datetime):
"""Create a closed trade for wallet history testing."""
trade = Trade(
pair=pair,
stake_amount=100.0,
amount=10.0,
amount_requested=10.0,
fee_open=fee.return_value,
fee_close=fee.return_value,
open_rate=10.0,
close_rate=11.0,
close_profit=0.1,
close_profit_abs=9.5,
exchange="binance",
is_open=False,
strategy="TestStrategy",
timeframe=5,
open_date=open_date,
close_date=close_date,
is_short=False,
)
order_entry = Order(
ft_order_side="buy",
ft_pair=pair,
ft_is_open=False,
ft_amount=10.0,
ft_price=10.0,
order_id=f"order_{pair}_entry",
status="closed",
symbol=pair,
order_type="limit",
side="buy",
price=10.0,
average=10.0,
amount=10.0,
filled=10.0,
remaining=0.0,
order_date=open_date,
order_filled_date=open_date,
)
order_exit = Order(
ft_order_side="sell",
ft_pair=pair,
ft_is_open=False,
ft_amount=10.0,
ft_price=11.0,
order_id=f"order_{pair}_exit",
status="closed",
symbol=pair,
order_type="limit",
side="sell",
price=11.0,
average=11.0,
amount=10.0,
filled=10.0,
remaining=0.0,
order_date=close_date,
order_filled_date=close_date,
)
trade.orders.append(order_entry)
trade.orders.append(order_exit)
return trade
@pytest.mark.usefixtures("init_persistence")
def test_migrate_wallet_history_skips_when_no_ohlcv_history(mocker, default_conf_usdt):
"""Test that migration is skipped when exchange doesn't support OHLCV history."""
exchange = MagicMock()
exchange.get_option.return_value = False # ohlcv_has_history = False
migrate_mock = mocker.patch(
"freqtrade.util.migrations.migrate_wallet_history._migrate_wallet_history"
)
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
# Should return early without setting the migration flag
assert KeyValueStore.get_int_value("wallet_history_migration") is None
assert not migrate_mock.called
@pytest.mark.usefixtures("init_persistence")
def test_migrate_wallet_history_skips_when_already_migrated(mocker, default_conf_usdt):
"""Test that migration is skipped if already completed."""
exchange = MagicMock()
exchange.get_option.return_value = True
migrate_mock = mocker.patch(
"freqtrade.util.migrations.migrate_wallet_history._migrate_wallet_history"
)
# Set migration as already completed
KeyValueStore.store_value("wallet_history_migration", 1)
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
# Should not call any migration logic
assert KeyValueStore.get_int_value("wallet_history_migration") == 1
assert not migrate_mock.called
@pytest.mark.usefixtures("init_persistence")
def test_migrate_wallet_history_no_trades(default_conf_usdt):
"""Test migration with no trades in database."""
exchange = MagicMock()
exchange.get_option.return_value = True
# Set bot_start_time
KeyValueStore.store_value("bot_start_time", dt_now() - timedelta(days=5))
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
# Should complete migration (flag set) but no wallet entries
assert KeyValueStore.get_int_value("wallet_history_migration") == 1
assert WalletHistory.session.query(WalletHistory).count() == 0
@pytest.mark.usefixtures("init_persistence")
def test_migrate_wallet_history_with_trades(default_conf_usdt, fee, time_machine, markets):
"""Test migration with trades creates wallet history entries."""
start_time = dt_utc(2024, 1, 10, 12, 0, 0)
time_machine.move_to(start_time, tick=False)
# Bot started 10 days ago
bot_start = start_time - timedelta(days=10)
KeyValueStore.store_value("bot_start_time", bot_start)
# Create mock trades with dates within the range
trade_open = start_time - timedelta(days=5)
trade_close = start_time - timedelta(days=3)
trade1 = create_closed_mock_trade(
fee,
"ETH/USDT",
open_date=trade_open,
close_date=trade_close,
)
Trade.session.add(trade1)
Trade.commit()
# Generate mock OHLCV data starting from bot_start
candle_type = default_conf_usdt.get("candle_type_def", CandleType.SPOT)
ohlcv_df = generate_test_data("1d", size=15, start=bot_start.strftime("%Y-%m-%d"))
ohlcv_data = {("ETH/USDT", "1d", candle_type): ohlcv_df}
exchange = MagicMock()
exchange.get_option.return_value = True
exchange.markets = markets
exchange.refresh_latest_ohlcv.return_value = ohlcv_data
exchange.get_pair_base_currency = MagicMock(side_effect=lambda pair: markets.get(pair)["base"])
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
# Should complete migration
assert KeyValueStore.get_int_value("wallet_history_migration") == 1
# Should have created wallet history entries
wallet_entries = WalletHistory.session.query(WalletHistory).all()
assert len(wallet_entries) > 0
@pytest.mark.usefixtures("init_persistence")
def test_migrate_wallet_history_with_multiple_pairs(default_conf_usdt, fee, time_machine, markets):
"""Test migration with multiple trading pairs."""
start_time = dt_utc(2024, 1, 15, 12, 0, 0)
time_machine.move_to(start_time, tick=False)
# Bot started 15 days ago
bot_start = start_time - timedelta(days=15)
KeyValueStore.store_value("bot_start_time", bot_start)
# Create mock trades for multiple pairs within the date range
trade1 = create_closed_mock_trade(
fee,
"ETH/USDT",
open_date=start_time - timedelta(days=10),
close_date=start_time - timedelta(days=6),
)
trade2 = create_closed_mock_trade(
fee,
"BTC/USDT",
open_date=start_time - timedelta(days=7),
close_date=start_time - timedelta(days=5),
)
Trade.session.add(trade1)
Trade.session.add(trade2)
Trade.commit()
# Generate mock OHLCV data for both pairs starting from bot_start
candle_type = default_conf_usdt.get("candle_type_def", CandleType.SPOT)
ohlcv_data = {}
ohlcv_data[("ETH/USDT", "1d", candle_type)] = generate_test_data(
"1d", size=20, start=bot_start.strftime("%Y-%m-%d"), base=1500
)
ohlcv_data[("BTC/USDT", "1d", candle_type)] = generate_test_data(
"1d", size=20, start=bot_start.strftime("%Y-%m-%d"), base=30000
)
exchange = MagicMock()
exchange.get_option.return_value = True
exchange.markets = markets
exchange.refresh_latest_ohlcv.return_value = ohlcv_data
exchange.get_pair_base_currency = MagicMock(side_effect=lambda pair: markets.get(pair)["base"])
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
# Should complete migration
assert KeyValueStore.get_int_value("wallet_history_migration") == 1
# Should have wallet history entries
wallet_entries = WalletHistory.session.query(WalletHistory).all()
assert len(wallet_entries) > 0
# Check that stake currency (USDT) entries exist
usdt_entries = [e for e in wallet_entries if e.currency == "USDT"]
assert len(usdt_entries) > 0
assert len(wallet_entries) > len(usdt_entries)
# Stake currency should have price = 1.0
for entry in usdt_entries:
assert entry.rate == 1.0
eth_entries = [e for e in wallet_entries if e.currency == "ETH"]
btc_entries = [e for e in wallet_entries if e.currency == "BTC"]
assert len(eth_entries) == 4
assert len(btc_entries) == 2
assert all(entry.rate and entry.rate > 1400 and entry.rate < 1600 for entry in eth_entries)
assert all(entry.rate and entry.rate > 29000 and entry.rate < 31000 for entry in btc_entries)
assert all(entry.balance == 10 for entry in btc_entries)
@pytest.mark.usefixtures("init_persistence")
def test_migrate_wallet_history_pair_not_in_markets(
default_conf_usdt, caplog, fee, time_machine, markets
):
"""Test migration handles pairs that are not in exchange markets."""
start_time = dt_utc(2024, 1, 10, 12, 0, 0)
time_machine.move_to(start_time, tick=False)
# Bot started 10 days ago
bot_start = start_time - timedelta(days=10)
KeyValueStore.store_value("bot_start_time", bot_start)
# Create a trade with a pair that won't be in markets
trade1 = create_closed_mock_trade(
fee,
"UNKNOWN/USDT",
open_date=start_time - timedelta(days=5),
close_date=start_time - timedelta(days=3),
)
Trade.session.add(trade1)
Trade.commit()
exchange = MagicMock()
exchange.get_option.return_value = True
exchange.markets = markets
exchange.refresh_latest_ohlcv.return_value = {}
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
assert log_has_re("No OHLCV data available for .*", caplog)
@pytest.mark.usefixtures("init_persistence")
def test_migrate_wallet_history_stores_migration_date(
default_conf_usdt, fee, time_machine, markets
):
"""Test that migration stores the migration date."""
start_time = dt_utc(2024, 1, 10, 12, 0, 0)
time_machine.move_to(start_time, tick=False)
# Bot started 10 days ago
bot_start = start_time - timedelta(days=10)
KeyValueStore.store_value("bot_start_time", bot_start)
# Create a trade
trade1 = create_closed_mock_trade(
fee,
"ETH/USDT",
open_date=start_time - timedelta(days=5),
close_date=start_time - timedelta(days=3),
)
Trade.session.add(trade1)
Trade.commit()
candle_type = default_conf_usdt.get("candle_type_def", CandleType.SPOT)
ohlcv_data = {
("ETH/USDT", "1d", candle_type): generate_test_data(
"1d", size=15, start=bot_start.strftime("%Y-%m-%d")
)
}
exchange = MagicMock()
exchange.get_option.return_value = True
exchange.markets = markets
exchange.refresh_latest_ohlcv.return_value = ohlcv_data
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
# Check migration date is stored
migration_date = KeyValueStore.get_datetime_value("wallet_history_migration_date")
assert migration_date is not None
@pytest.mark.usefixtures("init_persistence")
def test_internal_migrate_wallet_history_empty_trades(default_conf_usdt, time_machine):
"""Test _migrate_wallet_history returns early when no trades exist."""
start_time = dt_utc(2024, 1, 1, 12, 0, 0)
time_machine.move_to(start_time, tick=False)
# Set bot_start_time
KeyValueStore.store_value("bot_start_time", start_time - timedelta(days=5))
exchange = MagicMock()
exchange.get_option.return_value = True
exchange.markets = {}
exchange.refresh_latest_ohlcv.return_value = {}
# Call internal function directly with no trades
_migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
# refresh_latest_ohlcv should not be called when there are no trades
exchange.refresh_latest_ohlcv.assert_not_called()
@pytest.mark.usefixtures("init_persistence")
def test_migrate_wallet_history_with_patched_exchange(mocker, default_conf_usdt, fee, time_machine):
"""Test migration using get_patched_exchange helper."""
start_time = dt_utc(2024, 1, 10, 12, 0, 0)
time_machine.move_to(start_time, tick=False)
# Bot started 10 days ago
bot_start = start_time - timedelta(days=10)
KeyValueStore.store_value("bot_start_time", bot_start)
# Create a trade
trade1 = create_closed_mock_trade(
fee,
"ETH/USDT",
open_date=start_time - timedelta(days=5),
close_date=start_time - timedelta(days=3),
)
Trade.session.add(trade1)
Trade.commit()
# Generate mock OHLCV data starting from bot_start
candle_type = default_conf_usdt.get("candle_type_def", CandleType.SPOT)
ohlcv_df = generate_test_data("1d", size=15, start=bot_start.strftime("%Y-%m-%d"))
ohlcv_data = {("ETH/USDT", "1d", candle_type): ohlcv_df}
# Mock exchange methods
mocker.patch.multiple(
EXMS,
get_option=MagicMock(return_value=True),
refresh_latest_ohlcv=MagicMock(return_value=ohlcv_data),
)
exchange = get_patched_exchange(mocker, default_conf_usdt)
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
# Should complete migration
assert KeyValueStore.get_int_value("wallet_history_migration") == 1
@pytest.mark.usefixtures("init_persistence")
def test_migrate_wallet_history_db_error_handling(
mocker, default_conf_usdt, fee, time_machine, markets
):
"""Test that database errors are handled gracefully."""
start_time = dt_utc(2024, 1, 10, 12, 0, 0)
time_machine.move_to(start_time, tick=False)
# Bot started 10 days ago
bot_start = start_time - timedelta(days=10)
KeyValueStore.store_value("bot_start_time", bot_start)
# Create a trade
trade1 = create_closed_mock_trade(
fee,
"ETH/USDT",
open_date=start_time - timedelta(days=5),
close_date=start_time - timedelta(days=3),
)
Trade.session.add(trade1)
Trade.commit()
candle_type = default_conf_usdt.get("candle_type_def", CandleType.SPOT)
ohlcv_data = {
("ETH/USDT", "1d", candle_type): generate_test_data(
"1d", size=15, start=bot_start.strftime("%Y-%m-%d")
)
}
exchange = MagicMock()
exchange.get_option.return_value = True
exchange.markets = markets
exchange.refresh_latest_ohlcv.return_value = ohlcv_data
# Mock bulk_save_objects to raise an exception
mocker.patch.object(
WalletHistory.session, "bulk_save_objects", side_effect=Exception("DB Error")
)
# Should not raise exception, but handle error gracefully
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
# Migration flag should still be set even after error in _migrate
assert KeyValueStore.get_int_value("wallet_history_migration") == 1
@pytest.mark.usefixtures("init_persistence")
def test__prepare_balance_distribution(default_conf_usdt, fee, time_machine, markets):
"""Test migration with multiple trading pairs."""
start_time = dt_utc(2024, 1, 15, 12, 0, 0)
time_machine.move_to(start_time, tick=False)
# Bot started 15 days ago
bot_start = start_time - timedelta(days=15)
KeyValueStore.store_value("bot_start_time", bot_start)
# Create mock trades for multiple pairs within the date range
trade1 = create_closed_mock_trade(
fee,
"ETH/USDT",
open_date=start_time - timedelta(days=10),
close_date=start_time - timedelta(days=6),
)
trade2 = create_closed_mock_trade(
fee,
"BTC/USDT",
open_date=start_time - timedelta(days=7),
close_date=start_time - timedelta(days=5),
)
Trade.session.add(trade1)
Trade.session.add(trade2)
Trade.commit()
# Generate mock OHLCV data for both pairs starting from bot_start
candle_type = default_conf_usdt.get("candle_type_def", CandleType.SPOT)
ohlcv_data = {}
ohlcv_data[("ETH/USDT", "1d", candle_type)] = generate_test_data(
"1d", size=20, start=bot_start.strftime("%Y-%m-%d"), base=1500
)
ohlcv_data[("BTC/USDT", "1d", candle_type)] = generate_test_data(
"1d", size=20, start=bot_start.strftime("%Y-%m-%d"), base=30000
)
exchange = MagicMock()
exchange.get_option.return_value = True
exchange.markets = markets
exchange.refresh_latest_ohlcv.return_value = ohlcv_data
balance_dist, pairlist_valid = _prepare_balance_distribution(
default_conf_usdt, exchange, 1000.0
)
assert not balance_dist.empty
assert len(pairlist_valid) == 2
assert "ETH/USDT" in pairlist_valid
assert "BTC/USDT" in pairlist_valid
assert len(balance_dist) == 16 # 16 days from bot_start to now
assert balance_dist["USDT"].iloc[0] == 1000.0
assert pd.isna(balance_dist["USDT"]).sum() == 0
assert all(
col in balance_dist.columns
for col in [
"USDT",
"ETH/USDT",
"ETH/USDT_collateral",
"ETH/USDT_leverage",
"BTC/USDT",
"BTC/USDT_collateral",
"BTC/USDT_leverage",
"ETH/USDT_open",
"BTC/USDT_open",
"ETH/USDT_value",
"BTC/USDT_value",
"total_value",
]
)