From a3e7ee989501a82db2a871dc446b4c698b8acd90 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 26 Apr 2025 08:44:32 +0200 Subject: [PATCH 001/104] feat: capture wallet state per candle in backtesting --- freqtrade/optimize/backtesting.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index a95b29005..138fcce50 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -137,6 +137,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 @@ -1603,6 +1604,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 +1629,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 +1683,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: From 11cb3ef41609e2c662fafa1f7c1d05c3a41e7266 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 26 Apr 2025 08:45:54 +0200 Subject: [PATCH 002/104] feat: reset wallet_captures list in Backtesting --- freqtrade/optimize/backtesting.py | 1 + 1 file changed, 1 insertion(+) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 138fcce50..797e47786 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -452,6 +452,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) From 10cc857c51e9b560ee414909f8f8f7ae925bbeee Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 26 Apr 2025 09:14:17 +0200 Subject: [PATCH 003/104] feat: add wallet to dataframe conversion --- freqtrade/ft_types/backtest_result_type.py | 1 + freqtrade/optimize/backtesting.py | 2 ++ freqtrade/optimize/optimize_reports/__init__.py | 1 + .../optimize/optimize_reports/optimize_reports.py | 14 ++++++++++++++ 4 files changed, 18 insertions(+) diff --git a/freqtrade/ft_types/backtest_result_type.py b/freqtrade/ft_types/backtest_result_type.py index b253231a1..768d13517 100644 --- a/freqtrade/ft_types/backtest_result_type.py +++ b/freqtrade/ft_types/backtest_result_type.py @@ -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): diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 797e47786..32c145e24 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -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, @@ -1752,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( diff --git a/freqtrade/optimize/optimize_reports/__init__.py b/freqtrade/optimize/optimize_reports/__init__.py index 5cf8e51ad..a41a8ebbf 100644 --- a/freqtrade/optimize/optimize_reports/__init__.py +++ b/freqtrade/optimize/optimize_reports/__init__.py @@ -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, diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index 57b7740d8..2f640bcbf 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -29,6 +29,20 @@ 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 [] + return DataFrame( + wallet_captures, + columns=["date", "currency", "price", "balance"], + ) + + def generate_trade_signal_candles( preprocessed_df: dict[str, DataFrame], bt_results: BacktestContentType, date_col: str ) -> dict[str, DataFrame]: From ee745551a2f31e5e4bc79aa9e1882de5ccc02051 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 26 Apr 2025 09:34:10 +0200 Subject: [PATCH 004/104] feat: store wallet_summary --- freqtrade/optimize/backtesting.py | 1 + freqtrade/optimize/optimize_reports/bt_storage.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 32c145e24..1ac5fa989 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1882,6 +1882,7 @@ 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()}, strategy_files={s.get_strategy_name(): s.__file__ for s in self.strategylist}, ) diff --git a/freqtrade/optimize/optimize_reports/bt_storage.py b/freqtrade/optimize/optimize_reports/bt_storage.py index ef73d4721..c4f20e3e4 100644 --- a/freqtrade/optimize/optimize_reports/bt_storage.py +++ b/freqtrade/optimize/optimize_reports/bt_storage.py @@ -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" From 21269b8a8d48d74f231b4adc21a2a5f175700d99 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 26 Apr 2025 10:16:00 +0200 Subject: [PATCH 005/104] feat: add backtest/.../wallets endpoint --- freqtrade/data/btanalysis/bt_fileutils.py | 14 ++++++++++++ freqtrade/rpc/api_server/api_backtest.py | 28 +++++++++++++++++++++++ freqtrade/rpc/api_server/api_schemas.py | 6 +++++ freqtrade/rpc/api_server/api_v1.py | 3 ++- 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/freqtrade/data/btanalysis/bt_fileutils.py b/freqtrade/data/btanalysis/bt_fileutils.py index e1c0ea64c..7d29ab9a2 100644 --- a/freqtrade/data/btanalysis/bt_fileutils.py +++ b/freqtrade/data/btanalysis/bt_fileutils.py @@ -312,6 +312,20 @@ 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: + """ + 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 + """ + 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 + + def find_existing_backtest_stats( dirname: Path | str, run_ids: dict[str, str], min_backtest_date: datetime | None = None ) -> dict[str, Any]: diff --git a/freqtrade/rpc/api_server/api_backtest.py b/freqtrade/rpc/api_server/api_backtest.py index 428fea1c9..92339c7d2 100644 --- a/freqtrade/rpc/api_server/api_backtest.py +++ b/freqtrade/rpc/api_server/api_backtest.py @@ -16,6 +16,7 @@ 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, ) @@ -29,6 +30,7 @@ from freqtrade.rpc.api_server.api_schemas import ( BacktestMetadataUpdate, BacktestRequest, BacktestResponse, + BacktestWalletsSummary, ) from freqtrade.rpc.api_server.deps import get_config, verify_strategy from freqtrade.rpc.api_server.webserver_bgwork import ApiBG @@ -354,3 +356,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=BacktestWalletsSummary, + 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=404, detail="File not found.") + + results = get_backtest_wallet_change(file_abs, strategy) + if results is None: + raise HTTPException(status_code=404, detail="File not found.") + # Consolidate the wallet to the base currency + results.loc[:, "total"] = results["price"] * results["balance"] + results = results.groupby(["date", "__date_ts"]).agg({"total": "sum"}).reset_index() + + return { + "columns": results.columns.tolist(), + "data": results.values.tolist(), + "length": len(results), + } diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 7952d5724..df86300e5 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -679,6 +679,12 @@ class BacktestMarketChange(BaseModel): data: list[list[Any]] +class BacktestWalletsSummary(BaseModel): + columns: list[str] + length: int + data: list[list[Any]] + + class MarketRequest(ExchangeModePayloadMixin, BaseModel): base: str | None = None quote: str | None = None diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index d25bda78e..64283898f 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -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() From ed560f995d751efe12a85e452f4e83a3ebf3d56d Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 26 Apr 2025 19:40:06 +0200 Subject: [PATCH 006/104] feat: add WalletBalance model --- freqtrade/persistence/models.py | 2 ++ freqtrade/persistence/wallet_history.py | 28 +++++++++++++++++++++ freqtrade/wallets.py | 33 +++++++++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 freqtrade/persistence/wallet_history.py diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 4d4808eeb..a9c8b8320 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -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 WalletBalance 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 ) + WalletBalance.session = Trade.session previous_tables = inspect(engine).get_table_names() ModelBase.metadata.create_all(engine) diff --git a/freqtrade/persistence/wallet_history.py b/freqtrade/persistence/wallet_history.py new file mode 100644 index 000000000..aa84a65b3 --- /dev/null +++ b/freqtrade/persistence/wallet_history.py @@ -0,0 +1,28 @@ +from datetime import datetime +from typing import ClassVar + +from sqlalchemy import DateTime, Float, Index, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from freqtrade.persistence.base import ModelBase, SessionType +from freqtrade.wallets import Wallets + + +class WalletBalance(ModelBase): + """ + Daily wallet state tracking with minimal fields + """ + + __tablename__ = "wallet_balance" + 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) + price: Mapped[float] = mapped_column(Float, nullable=True) + balance: Mapped[float] = mapped_column(Float, nullable=False) + + __table_args__ = ( + # Ensure one record per currency per day + UniqueConstraint("timestamp", "currency", name="unique_wallet_daily"), + ) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 56c32adb6..64fba9656 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -11,6 +11,7 @@ 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.persistence.wallet_history import WalletBalance from freqtrade.util.datetime_helpers import dt_now @@ -445,3 +446,35 @@ 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 = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + # Record total balances for all currencies + for wallet in self.get_all_balances().values(): + # TODO: exclude minimal balances + price = self._exchange.get_conversion_rate(wallet.currency, self._stake_currency) + wallet_record = WalletBalance( + timestamp=timestamp, + currency=wallet.currency, + price=price, + balance=wallet.total, + ) + WalletBalance.session.add(wallet_record) + + for position in self.get_all_positions().values(): + price = self._exchange.get_conversion_rate(position.symbol, self._stake_currency) + position_record = WalletBalance( + timestamp=timestamp, + currency=position.pair, + price=position.price, + balance=position.amount, + ) + WalletBalance.session.add(position_record) + WalletBalance.session.commit() From b614ec4ef9476648250124d9665b5aa942f5fff1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 27 Apr 2025 08:26:07 +0200 Subject: [PATCH 007/104] chore: schedule wallet_state capturing every night. --- freqtrade/freqtradebot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 2b4d6c8ff..40db0effe 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -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. From cba49307b6024d41aa88b6ae2eb18299ac5727b6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 27 Apr 2025 08:29:24 +0200 Subject: [PATCH 008/104] chore: improve imports --- freqtrade/persistence/__init__.py | 1 + freqtrade/persistence/wallet_history.py | 3 +-- freqtrade/wallets.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/freqtrade/persistence/__init__.py b/freqtrade/persistence/__init__.py index 3612544ee..4fa003e02 100644 --- a/freqtrade/persistence/__init__.py +++ b/freqtrade/persistence/__init__.py @@ -10,3 +10,4 @@ from freqtrade.persistence.usedb_context import ( disable_database_use, enable_database_use, ) +from freqtrade.persistence.wallet_history import WalletBalance diff --git a/freqtrade/persistence/wallet_history.py b/freqtrade/persistence/wallet_history.py index aa84a65b3..c89553937 100644 --- a/freqtrade/persistence/wallet_history.py +++ b/freqtrade/persistence/wallet_history.py @@ -1,11 +1,10 @@ from datetime import datetime from typing import ClassVar -from sqlalchemy import DateTime, Float, Index, Integer, String, UniqueConstraint +from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from freqtrade.persistence.base import ModelBase, SessionType -from freqtrade.wallets import Wallets class WalletBalance(ModelBase): diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 64fba9656..d7d3af4aa 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -10,8 +10,7 @@ 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.persistence.wallet_history import WalletBalance +from freqtrade.persistence import LocalTrade, Trade, WalletBalance from freqtrade.util.datetime_helpers import dt_now From e52276e3da1a5a0592db9feed59278bacf58a617 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 27 Apr 2025 14:37:41 +0200 Subject: [PATCH 009/104] chore: fix import error --- freqtrade/data/btanalysis/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/freqtrade/data/btanalysis/__init__.py b/freqtrade/data/btanalysis/__init__.py index 2253a45ae..bd8b55df8 100644 --- a/freqtrade/data/btanalysis/__init__.py +++ b/freqtrade/data/btanalysis/__init__.py @@ -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, From 37fae7ea71ccff887bc680c251182f5b2e099cc1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 27 Apr 2025 15:03:54 +0200 Subject: [PATCH 010/104] feat: use proper properties for record_wallet_state --- freqtrade/wallets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index d7d3af4aa..295fb1277 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -450,7 +450,7 @@ class Wallets: """ Record daily wallet totals to database """ - if self.is_backtest: + if self._is_backtest: # only record in live mode. return timestamp = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) @@ -471,9 +471,9 @@ class Wallets: price = self._exchange.get_conversion_rate(position.symbol, self._stake_currency) position_record = WalletBalance( timestamp=timestamp, - currency=position.pair, - price=position.price, - balance=position.amount, + currency=position.symbol, + price=price, + balance=position.position, ) WalletBalance.session.add(position_record) WalletBalance.session.commit() From 63869be3760aeb2af5c71c161d4db80bec8a9ec5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 27 Apr 2025 15:30:49 +0200 Subject: [PATCH 011/104] feat: initial attempt at migrating walletHistory --- .../data/btanalysis/trade_parallelism.py | 44 +++++++++ freqtrade/util/migrations/__init__.py | 6 +- .../util/migrations/migrate_wallet_history.py | 99 +++++++++++++++++++ 3 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 freqtrade/util/migrations/migrate_wallet_history.py diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index eabdcf08a..b68692cf9 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -1,9 +1,16 @@ import logging +from datetime import datetime import numpy as np import pandas as pd from freqtrade.constants import IntOrInf +from freqtrade.exchange.exchange_utils_timeframe import ( + timeframe_to_next_date, + timeframe_to_prev_date, + timeframe_to_resample_freq, +) +from freqtrade.util.datetime_helpers import dt_from_ts logger = logging.getLogger(__name__) @@ -58,3 +65,40 @@ 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 + """ + min_date_res = timeframe_to_prev_date(timeframe, min_date) + max_date_res = timeframe_to_next_date(timeframe, max_date) + index = pd.date_range(min_date_res, max_date_res, freq=timeframe_to_resample_freq(timeframe)) + df = pd.DataFrame(index=index) + df[stake_currency] = float(start_balance) + df[pairlist] = 0.0 + for trade in trades.sort_values(by=["open_date"]).itertuples(): + for order in sorted(trade.orders, key=lambda x: x["order_filled_timestamp"]): + filled_at = pd.Timestamp(dt_from_ts(order["order_filled_timestamp"])) + real_amount = order["amount"] / trade.leverage + stake = order["safe_price"] * real_amount + if order["ft_is_entry"]: + fee = stake * trade.fee_open + df.loc[filled_at:, trade.pair] += real_amount + df.loc[filled_at:, stake_currency] -= stake + fee + else: + fee = stake * trade.fee_close + df.loc[filled_at:, trade.pair] -= real_amount + df.loc[filled_at:, stake_currency] += stake - fee + + df = df.round(14) + return df diff --git a/freqtrade/util/migrations/__init__.py b/freqtrade/util/migrations/__init__.py index 20aafb04b..0ed6c97da 100644 --- a/freqtrade/util/migrations/__init__.py +++ b/freqtrade/util/migrations/__init__.py @@ -1,5 +1,6 @@ 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: @@ -10,10 +11,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, exchange: Exchange) -> None: """ Migrate database content from old formats to new formats Used for dry/live mode. """ - # Currently not used - pass + migrate_wallet_history(config, exchange) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py new file mode 100644 index 000000000..ef3596e14 --- /dev/null +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -0,0 +1,99 @@ +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.key_value_store import KeyValueStore +from freqtrade.persistence.trade_model import Trade +from freqtrade.persistence.wallet_history import WalletBalance +from freqtrade.util.datetime_helpers import dt_now, dt_ts + + +def migrate_wallet_history(config: Config, exchange: Exchange): + if not exchange.get_option("ohlcv_has_history", True): + # we can't fill up wallet history without ohlcv history + return + trade_df = trade_list_to_dataframe(Trade.get_trades_proxy()) + if trade_df.empty: + # no trades, nothing to do + return + starting_balance = 1000 # wallets.get_starting_balance() + 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, + ) + + data = exchange.refresh_latest_ohlcv( + [(p, timeframe, config["candle_type_def"]) for p in pairlist], + since_ms=dt_ts(min_date), + cache=False, + drop_incomplete=False, + ) + + 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]]) + + merged = pd.concat(dfs, axis=1) + + balance_dist = balance_dist.join(merged, how="left") + for p in pairlist: + balance_dist[f"{p}_value"] = balance_dist[f"{p}_open"] * balance_dist[p] + + balance_dist["total_value"] = balance_dist[ + [f"{p}_value" for p in pairlist] + [stake_currency] + ].sum(axis=1) + + # Convert balance_dist to WalletBalance entries + wallet_entries = [] + for date, row in balance_dist.iterrows(): + # Add stake currency entry + if not pd.isna(row[stake_currency]): + wallet_entries.append( + WalletBalance( + timestamp=date, + currency=stake_currency, + price=1.0, # Stake currency price is always 1.0 + balance=row[stake_currency], + ) + ) + + # Add entries for each trading pair + for pair in pairlist: + base_currency = pair.split("/")[0] + # Only add entry if balance is not empty/NaN + if not pd.isna(row[pair]) and row[pair] > 0: + price_col = f"{pair}_open" + price = row[price_col] if not pd.isna(row[price_col]) else None + + wallet_entries.append( + WalletBalance( + timestamp=date, currency=base_currency, price=price, balance=row[pair] + ) + ) + + # Save entries to database + if wallet_entries: + try: + # Use bulk_save_objects for better performance + WalletBalance.session.bulk_save_objects(wallet_entries) + WalletBalance.session.commit() + print(f"Successfully created {len(wallet_entries)} wallet balance records") + except Exception as e: + WalletBalance.session.rollback() + print(f"Error saving wallet balance records: {e}") From 2d2cee2c5809f60ba083487a826aeaac09123d0c Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 27 Apr 2025 15:31:21 +0200 Subject: [PATCH 012/104] refactor: rename walletsSummary schema --- freqtrade/rpc/api_server/api_backtest.py | 4 ++-- freqtrade/rpc/api_server/api_schemas.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/freqtrade/rpc/api_server/api_backtest.py b/freqtrade/rpc/api_server/api_backtest.py index 92339c7d2..9f037f106 100644 --- a/freqtrade/rpc/api_server/api_backtest.py +++ b/freqtrade/rpc/api_server/api_backtest.py @@ -30,7 +30,7 @@ from freqtrade.rpc.api_server.api_schemas import ( BacktestMetadataUpdate, BacktestRequest, BacktestResponse, - BacktestWalletsSummary, + WalletsSummary, ) from freqtrade.rpc.api_server.deps import get_config, verify_strategy from freqtrade.rpc.api_server.webserver_bgwork import ApiBG @@ -360,7 +360,7 @@ def api_get_backtest_market_change(file: str, config=Depends(get_config)): @router.get( "/backtest/history/{file}/{strategy}/wallet", - response_model=BacktestWalletsSummary, + response_model=WalletsSummary, tags=["webserver", "backtest"], ) def api_get_backtest_wallet(file: str, strategy: str, config=Depends(get_config)): diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index df86300e5..f84b830a4 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -679,7 +679,7 @@ class BacktestMarketChange(BaseModel): data: list[list[Any]] -class BacktestWalletsSummary(BaseModel): +class WalletsSummary(BaseModel): columns: list[str] length: int data: list[list[Any]] From 281b627db35b49e6e23ba9d20a25f43ef64d20b5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 27 Apr 2025 15:32:28 +0200 Subject: [PATCH 013/104] feat: add historic_balance api endpoint --- freqtrade/rpc/api_server/api_trading.py | 16 ++++++++++++++++ freqtrade/rpc/rpc.py | 15 ++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/freqtrade/rpc/api_server/api_trading.py b/freqtrade/rpc/api_server/api_trading.py index 3ec7a08b3..085116d0a 100644 --- a/freqtrade/rpc/api_server/api_trading.py +++ b/freqtrade/rpc/api_server/api_trading.py @@ -31,6 +31,7 @@ from freqtrade.rpc.api_server.api_schemas import ( ResultMsg, Stats, StatusMsg, + WalletsSummary, WhitelistResponse, ) from freqtrade.rpc.api_server.deps import get_config, get_rpc @@ -104,6 +105,21 @@ def stats(rpc: RPC = Depends(get_rpc)): return rpc._rpc_stats() +@router.get( + "/historic_balance", + response_model=WalletsSummary, + tags=["info"], +) +def api_get_backtest_wallet(rpc: RPC = Depends(get_rpc)): + results = rpc._rpc_get_historic_balance() + + return { + "columns": results.columns.tolist(), + "data": results.values.tolist(), + "length": len(results), + } + + @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"), diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 37b8dfa6d..7ad480989 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -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,19 @@ class RPC: "bot_start_date": format_date(bot_start), } + def _rpc_get_historic_balance(self) -> DataFrame: + """ + Returns the historic balance of the bot + :return: DataFrame with the balance history + """ + results = read_sql("wallet_balance", con=Trade.session.bind, parse_dates=["timestamp"]) + results.loc[:, "total"] = results["price"] * results["balance"] + results = results.rename({"timestamp": "date"}, axis=1) + results.loc[:, "__date_ts"] = results.loc[:, "date"].astype("int64") // 1000 // 1000 + + results = results.groupby(["date", "__date_ts"]).agg({"total": "sum"}).reset_index() + return results + def __balance_get_est_stake( self, coin: str, stake_currency: str, amount: float, balance: Wallet ) -> tuple[float, float]: From 1f15d28eebe89ab5432126332ef05011c0681e1a Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 27 Apr 2025 16:35:53 +0200 Subject: [PATCH 014/104] feat: prevent duplicate wallet migrations --- freqtrade/persistence/key_value_store.py | 1 + .../util/migrations/migrate_wallet_history.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/freqtrade/persistence/key_value_store.py b/freqtrade/persistence/key_value_store.py index 310e82b4b..6abc31889 100644 --- a/freqtrade/persistence/key_value_store.py +++ b/freqtrade/persistence/key_value_store.py @@ -22,6 +22,7 @@ KeyStoreKeys = Literal[ "bot_start_time", "startup_time", "binance_migration", + "wallet_history_migration", ] diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index ef3596e14..3f4aa29b8 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -1,3 +1,5 @@ +import logging + import pandas as pd from freqtrade.constants import Config @@ -11,10 +13,22 @@ from freqtrade.persistence.wallet_history import WalletBalance from freqtrade.util.datetime_helpers import dt_now, dt_ts +logger = logging.getLogger(__name__) + + def migrate_wallet_history(config: Config, exchange: Exchange): if 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 + + _migrate_wallet_history(config, exchange) + KeyValueStore.store_value("wallet_history_migration", 1) + + +def _migrate_wallet_history(config: Config, exchange: Exchange): trade_df = trade_list_to_dataframe(Trade.get_trades_proxy()) if trade_df.empty: # no trades, nothing to do From 51cf051fce74cf1bf738405001302dba2d8aec17 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Apr 2025 20:27:01 +0200 Subject: [PATCH 015/104] test: add test for backtest/wallets endpoint --- tests/rpc/test_rpc_apiserver.py | 53 ++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 1247d4c02..226abec1c 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -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 @@ -3275,7 +3277,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 +3315,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"], + "price": [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 + rc = client_get(client, f"{BASE_URI}/backtest/history/randomFile.json/SampleStrategy/wallet") + assert_response(rc, 404) + + 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"] + 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 From c7878130f12272217319434689fb452ba48f797b Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 10 May 2025 16:17:44 +0200 Subject: [PATCH 016/104] feat: capture wallet_history_date --- freqtrade/persistence/key_value_store.py | 1 + freqtrade/util/migrations/migrate_wallet_history.py | 1 + 2 files changed, 2 insertions(+) diff --git a/freqtrade/persistence/key_value_store.py b/freqtrade/persistence/key_value_store.py index 6abc31889..ac3cedcd1 100644 --- a/freqtrade/persistence/key_value_store.py +++ b/freqtrade/persistence/key_value_store.py @@ -23,6 +23,7 @@ KeyStoreKeys = Literal[ "startup_time", "binance_migration", "wallet_history_migration", + "wallet_history_migration_date", ] diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 3f4aa29b8..7250d59e6 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -107,6 +107,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange): # Use bulk_save_objects for better performance WalletBalance.session.bulk_save_objects(wallet_entries) WalletBalance.session.commit() + KeyValueStore.store_value("wallet_history_migration_date", dt_now()) print(f"Successfully created {len(wallet_entries)} wallet balance records") except Exception as e: WalletBalance.session.rollback() From d6a0a4ec6c438cd0b5e6332b2c7bbaffa7b86401 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 15 Jun 2025 06:56:04 +0200 Subject: [PATCH 017/104] chore: rename WalletHistory model to better match it's intend --- freqtrade/persistence/__init__.py | 2 +- freqtrade/persistence/models.py | 4 ++-- freqtrade/persistence/wallet_history.py | 4 ++-- freqtrade/rpc/rpc.py | 2 +- .../util/migrations/migrate_wallet_history.py | 14 +++++++------- freqtrade/wallets.py | 12 ++++++------ 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/freqtrade/persistence/__init__.py b/freqtrade/persistence/__init__.py index 4fa003e02..4966c0b83 100644 --- a/freqtrade/persistence/__init__.py +++ b/freqtrade/persistence/__init__.py @@ -10,4 +10,4 @@ from freqtrade.persistence.usedb_context import ( disable_database_use, enable_database_use, ) -from freqtrade.persistence.wallet_history import WalletBalance +from freqtrade.persistence.wallet_history import WalletHistory diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index a9c8b8320..05905abfe 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -20,7 +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 WalletBalance +from freqtrade.persistence.wallet_history import WalletHistory logger = logging.getLogger(__name__) @@ -92,7 +92,7 @@ def init_db(db_url: str) -> None: _CustomData.session = scoped_session( sessionmaker(bind=engine, autoflush=True), scopefunc=get_request_or_thread_id ) - WalletBalance.session = Trade.session + WalletHistory.session = Trade.session previous_tables = inspect(engine).get_table_names() ModelBase.metadata.create_all(engine) diff --git a/freqtrade/persistence/wallet_history.py b/freqtrade/persistence/wallet_history.py index c89553937..8fbd81661 100644 --- a/freqtrade/persistence/wallet_history.py +++ b/freqtrade/persistence/wallet_history.py @@ -7,12 +7,12 @@ from sqlalchemy.orm import Mapped, mapped_column from freqtrade.persistence.base import ModelBase, SessionType -class WalletBalance(ModelBase): +class WalletHistory(ModelBase): """ Daily wallet state tracking with minimal fields """ - __tablename__ = "wallet_balance" + __tablename__ = "wallet_history" session: ClassVar[SessionType] id: Mapped[int] = mapped_column(Integer, primary_key=True) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 7ad480989..22c6ec865 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -790,7 +790,7 @@ class RPC: Returns the historic balance of the bot :return: DataFrame with the balance history """ - results = read_sql("wallet_balance", con=Trade.session.bind, parse_dates=["timestamp"]) + results = read_sql("wallet_history", con=Trade.session.bind, parse_dates=["timestamp"]) results.loc[:, "total"] = results["price"] * results["balance"] results = results.rename({"timestamp": "date"}, axis=1) results.loc[:, "__date_ts"] = results.loc[:, "date"].astype("int64") // 1000 // 1000 diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 7250d59e6..c43d20ef2 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -9,7 +9,7 @@ from freqtrade.exchange import Exchange from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_prev_date from freqtrade.persistence.key_value_store import KeyValueStore from freqtrade.persistence.trade_model import Trade -from freqtrade.persistence.wallet_history import WalletBalance +from freqtrade.persistence.wallet_history import WalletHistory from freqtrade.util.datetime_helpers import dt_now, dt_ts @@ -73,13 +73,13 @@ def _migrate_wallet_history(config: Config, exchange: Exchange): [f"{p}_value" for p in pairlist] + [stake_currency] ].sum(axis=1) - # Convert balance_dist to WalletBalance entries + # Convert balance_dist to WalletHistory entries wallet_entries = [] for date, row in balance_dist.iterrows(): # Add stake currency entry if not pd.isna(row[stake_currency]): wallet_entries.append( - WalletBalance( + WalletHistory( timestamp=date, currency=stake_currency, price=1.0, # Stake currency price is always 1.0 @@ -96,7 +96,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange): price = row[price_col] if not pd.isna(row[price_col]) else None wallet_entries.append( - WalletBalance( + WalletHistory( timestamp=date, currency=base_currency, price=price, balance=row[pair] ) ) @@ -105,10 +105,10 @@ def _migrate_wallet_history(config: Config, exchange: Exchange): if wallet_entries: try: # Use bulk_save_objects for better performance - WalletBalance.session.bulk_save_objects(wallet_entries) - WalletBalance.session.commit() + WalletHistory.session.bulk_save_objects(wallet_entries) + WalletHistory.session.commit() KeyValueStore.store_value("wallet_history_migration_date", dt_now()) print(f"Successfully created {len(wallet_entries)} wallet balance records") except Exception as e: - WalletBalance.session.rollback() + WalletHistory.session.rollback() print(f"Error saving wallet balance records: {e}") diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 295fb1277..0cb707a84 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -10,7 +10,7 @@ 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, WalletBalance +from freqtrade.persistence import LocalTrade, Trade, WalletHistory from freqtrade.util.datetime_helpers import dt_now @@ -459,21 +459,21 @@ class Wallets: for wallet in self.get_all_balances().values(): # TODO: exclude minimal balances price = self._exchange.get_conversion_rate(wallet.currency, self._stake_currency) - wallet_record = WalletBalance( + wallet_record = WalletHistory( timestamp=timestamp, currency=wallet.currency, price=price, balance=wallet.total, ) - WalletBalance.session.add(wallet_record) + WalletHistory.session.add(wallet_record) for position in self.get_all_positions().values(): price = self._exchange.get_conversion_rate(position.symbol, self._stake_currency) - position_record = WalletBalance( + position_record = WalletHistory( timestamp=timestamp, currency=position.symbol, price=price, balance=position.position, ) - WalletBalance.session.add(position_record) - WalletBalance.session.commit() + WalletHistory.session.add(position_record) + WalletHistory.session.commit() From a8295de2b919b7428f7b54272e35595a4b995338 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 15 Jun 2025 07:05:01 +0200 Subject: [PATCH 018/104] chore: fix endpoint naming --- freqtrade/rpc/api_server/api_trading.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/rpc/api_server/api_trading.py b/freqtrade/rpc/api_server/api_trading.py index 085116d0a..35f48a616 100644 --- a/freqtrade/rpc/api_server/api_trading.py +++ b/freqtrade/rpc/api_server/api_trading.py @@ -110,7 +110,7 @@ def stats(rpc: RPC = Depends(get_rpc)): response_model=WalletsSummary, tags=["info"], ) -def api_get_backtest_wallet(rpc: RPC = Depends(get_rpc)): +def api_get_wallet_history(rpc: RPC = Depends(get_rpc)): results = rpc._rpc_get_historic_balance() return { From 6c249255220bbd495d15cb4c2fd93236d0f61d4f Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 15 Jun 2025 07:21:41 +0200 Subject: [PATCH 019/104] fix: make sure wallet_summary exists --- freqtrade/optimize/backtesting.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 1ac5fa989..e9edb2569 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1882,7 +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()}, + 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}, ) From 15be0510fc24f1d489b240e4a693582059eef8fa Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 15 Jun 2025 07:41:41 +0200 Subject: [PATCH 020/104] feat: return "capture_start_ts" as part of API response --- freqtrade/rpc/api_server/api_schemas.py | 3 +++ freqtrade/rpc/api_server/api_trading.py | 3 ++- freqtrade/rpc/rpc.py | 7 ++++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index f84b830a4..e44e0c612 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -683,6 +683,9 @@ class WalletsSummary(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): diff --git a/freqtrade/rpc/api_server/api_trading.py b/freqtrade/rpc/api_server/api_trading.py index 35f48a616..5f61bd91a 100644 --- a/freqtrade/rpc/api_server/api_trading.py +++ b/freqtrade/rpc/api_server/api_trading.py @@ -111,12 +111,13 @@ def stats(rpc: RPC = Depends(get_rpc)): tags=["info"], ) def api_get_wallet_history(rpc: RPC = Depends(get_rpc)): - results = rpc._rpc_get_historic_balance() + 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, } diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 22c6ec865..147861dca 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -785,10 +785,10 @@ class RPC: "bot_start_date": format_date(bot_start), } - def _rpc_get_historic_balance(self) -> DataFrame: + def _rpc_get_historic_balance(self) -> tuple[DataFrame, int]: """ Returns the historic balance of the bot - :return: DataFrame with the balance history + :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.loc[:, "total"] = results["price"] * results["balance"] @@ -796,7 +796,8 @@ class RPC: results.loc[:, "__date_ts"] = results.loc[:, "date"].astype("int64") // 1000 // 1000 results = results.groupby(["date", "__date_ts"]).agg({"total": "sum"}).reset_index() - return results + hist = KeyValueStore.get_datetime_value("wallet_history_migration_date", None) + return results, dt_ts_def(hist, 0) def __balance_get_est_stake( self, coin: str, stake_currency: str, amount: float, balance: Wallet From d105770ff6f5056252636848053db39d30bbfaed Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 17 Jun 2025 06:54:35 +0200 Subject: [PATCH 021/104] chore: Improve response model naming --- freqtrade/rpc/api_server/api_backtest.py | 4 ++-- freqtrade/rpc/api_server/api_schemas.py | 2 +- freqtrade/rpc/api_server/api_trading.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/freqtrade/rpc/api_server/api_backtest.py b/freqtrade/rpc/api_server/api_backtest.py index 9f037f106..db01a8d2d 100644 --- a/freqtrade/rpc/api_server/api_backtest.py +++ b/freqtrade/rpc/api_server/api_backtest.py @@ -30,7 +30,7 @@ from freqtrade.rpc.api_server.api_schemas import ( BacktestMetadataUpdate, BacktestRequest, BacktestResponse, - WalletsSummary, + WalletHistory, ) from freqtrade.rpc.api_server.deps import get_config, verify_strategy from freqtrade.rpc.api_server.webserver_bgwork import ApiBG @@ -360,7 +360,7 @@ def api_get_backtest_market_change(file: str, config=Depends(get_config)): @router.get( "/backtest/history/{file}/{strategy}/wallet", - response_model=WalletsSummary, + response_model=WalletHistory, tags=["webserver", "backtest"], ) def api_get_backtest_wallet(file: str, strategy: str, config=Depends(get_config)): diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index e44e0c612..3837afb43 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -679,7 +679,7 @@ class BacktestMarketChange(BaseModel): data: list[list[Any]] -class WalletsSummary(BaseModel): +class WalletHistory(BaseModel): columns: list[str] length: int data: list[list[Any]] diff --git a/freqtrade/rpc/api_server/api_trading.py b/freqtrade/rpc/api_server/api_trading.py index 5f61bd91a..368de9f79 100644 --- a/freqtrade/rpc/api_server/api_trading.py +++ b/freqtrade/rpc/api_server/api_trading.py @@ -31,7 +31,7 @@ from freqtrade.rpc.api_server.api_schemas import ( ResultMsg, Stats, StatusMsg, - WalletsSummary, + WalletHistory, WhitelistResponse, ) from freqtrade.rpc.api_server.deps import get_config, get_rpc @@ -107,7 +107,7 @@ def stats(rpc: RPC = Depends(get_rpc)): @router.get( "/historic_balance", - response_model=WalletsSummary, + response_model=WalletHistory, tags=["info"], ) def api_get_wallet_history(rpc: RPC = Depends(get_rpc)): From 1f32dbef9a48b4f6b47c16738720afbaa3fb1652 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 17 Jun 2025 06:54:54 +0200 Subject: [PATCH 022/104] feat: enable wallet-capture in webserver mode --- freqtrade/rpc/api_server/api_backtest.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/freqtrade/rpc/api_server/api_backtest.py b/freqtrade/rpc/api_server/api_backtest.py index db01a8d2d..ae50db978 100644 --- a/freqtrade/rpc/api_server/api_backtest.py +++ b/freqtrade/rpc/api_server/api_backtest.py @@ -20,7 +20,7 @@ from freqtrade.data.btanalysis import ( 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 @@ -108,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 }, @@ -139,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: From 235c46ae125a3737821bc50dd550077f0008543d Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 20 Jun 2025 06:49:44 +0200 Subject: [PATCH 023/104] chore: add wallet migration timing log --- freqtrade/util/migrations/migrate_wallet_history.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index c43d20ef2..781517c26 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -23,8 +23,9 @@ def migrate_wallet_history(config: Config, exchange: Exchange): 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) + logger.info("Wallet history migration completed.") KeyValueStore.store_value("wallet_history_migration", 1) From 8a284060d1db94469d8e72381e8a6d4d8b43b58a Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 20 Jun 2025 15:49:14 +0200 Subject: [PATCH 024/104] fix: wrong usage of getdatetimevalue --- freqtrade/rpc/rpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 147861dca..f9939c604 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -796,7 +796,7 @@ class RPC: results.loc[:, "__date_ts"] = results.loc[:, "date"].astype("int64") // 1000 // 1000 results = results.groupby(["date", "__date_ts"]).agg({"total": "sum"}).reset_index() - hist = KeyValueStore.get_datetime_value("wallet_history_migration_date", None) + hist = KeyValueStore.get_datetime_value("wallet_history_migration_date") return results, dt_ts_def(hist, 0) def __balance_get_est_stake( From 418acb7034c74dc01fa6d4d2d2772ae805a8f8ba Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 20 Jun 2025 15:50:19 +0200 Subject: [PATCH 025/104] chore: exclude open orders from balance calculation --- freqtrade/data/btanalysis/trade_parallelism.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index b68692cf9..b8f119786 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -87,7 +87,9 @@ def balance_distribution_over_time( df[stake_currency] = float(start_balance) df[pairlist] = 0.0 for trade in trades.sort_values(by=["open_date"]).itertuples(): - for order in sorted(trade.orders, key=lambda x: x["order_filled_timestamp"]): + # Exclude open orders - these won't have order_filled_timestamp set. + orders = [o for o in trade.orders if o["order_filled_timestamp"]] + 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["amount"] / trade.leverage stake = order["safe_price"] * real_amount From db2309dfd2be0284ee99e75ceaaa2cd97c502263 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 20 Jun 2025 15:50:39 +0200 Subject: [PATCH 026/104] fix: avoid errors for delisted pairs --- freqtrade/util/migrations/migrate_wallet_history.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 781517c26..ef1094920 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -48,9 +48,10 @@ def _migrate_wallet_history(config: Config, exchange: Exchange): timeframe=timeframe, pairlist=pairlist, ) + pairlist_valid = [p for p in pairlist if p in exchange.markets] data = exchange.refresh_latest_ohlcv( - [(p, timeframe, config["candle_type_def"]) for p in pairlist], + [(p, timeframe, config["candle_type_def"]) for p in pairlist_valid], since_ms=dt_ts(min_date), cache=False, drop_incomplete=False, @@ -67,11 +68,11 @@ def _migrate_wallet_history(config: Config, exchange: Exchange): merged = pd.concat(dfs, axis=1) balance_dist = balance_dist.join(merged, how="left") - for p in pairlist: + for p in pairlist_valid: balance_dist[f"{p}_value"] = balance_dist[f"{p}_open"] * balance_dist[p] balance_dist["total_value"] = balance_dist[ - [f"{p}_value" for p in pairlist] + [stake_currency] + [f"{p}_value" for p in pairlist_valid] + [stake_currency] ].sum(axis=1) # Convert balance_dist to WalletHistory entries @@ -89,7 +90,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange): ) # Add entries for each trading pair - for pair in pairlist: + for pair in pairlist_valid: base_currency = pair.split("/")[0] # Only add entry if balance is not empty/NaN if not pd.isna(row[pair]) and row[pair] > 0: From b5f31bf82d1f1f537310785a7c4980c36b6324aa Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 20 Jun 2025 16:05:03 +0200 Subject: [PATCH 027/104] chore: use proper starting balance --- freqtrade/util/migrations/migrate_wallet_history.py | 1 + 1 file changed, 1 insertion(+) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index ef1094920..86db525ac 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -34,6 +34,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange): if trade_df.empty: # no trades, nothing to do return + # TODO: use a proper starting balance. starting_balance = 1000 # wallets.get_starting_balance() pairlist = list(trade_df["pair"].unique()) timeframe = "1d" From 9ddabbd849490851be35e282e5fb85a8bdf3a3c4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 14 Sep 2025 11:42:16 +0200 Subject: [PATCH 028/104] feat: use proper starting balance --- freqtrade/freqtradebot.py | 2 +- freqtrade/util/migrations/__init__.py | 7 ++++--- freqtrade/util/migrations/migrate_wallet_history.py | 8 +++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 40db0effe..1646cfa9b 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -237,7 +237,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) diff --git a/freqtrade/util/migrations/__init__.py b/freqtrade/util/migrations/__init__.py index 0ed6c97da..90f866075 100644 --- a/freqtrade/util/migrations/__init__.py +++ b/freqtrade/util/migrations/__init__.py @@ -1,9 +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 """ @@ -11,9 +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: +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. """ - migrate_wallet_history(config, exchange) + migrate_wallet_history(config, exchange, starting_balance) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 86db525ac..316e74ab1 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -16,7 +16,7 @@ from freqtrade.util.datetime_helpers import dt_now, dt_ts logger = logging.getLogger(__name__) -def migrate_wallet_history(config: Config, exchange: Exchange): +def migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: float): if not exchange.get_option("ohlcv_has_history", True): # we can't fill up wallet history without ohlcv history return @@ -24,18 +24,16 @@ def migrate_wallet_history(config: Config, exchange: Exchange): logger.debug("Wallet history migration already completed.") return logger.info("Starting wallet history migration...") - _migrate_wallet_history(config, exchange) + _migrate_wallet_history(config, exchange, starting_balance) logger.info("Wallet history migration completed.") KeyValueStore.store_value("wallet_history_migration", 1) -def _migrate_wallet_history(config: Config, exchange: Exchange): +def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: float): trade_df = trade_list_to_dataframe(Trade.get_trades_proxy()) if trade_df.empty: # no trades, nothing to do return - # TODO: use a proper starting balance. - starting_balance = 1000 # wallets.get_starting_balance() pairlist = list(trade_df["pair"].unique()) timeframe = "1d" stake_currency = config["stake_currency"] From ba1092b72659bcb4bfa123362b9893009440624a Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 14 Sep 2025 12:05:25 +0200 Subject: [PATCH 029/104] chore: use builtin helpers --- freqtrade/wallets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 0cb707a84..42187eac9 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -11,7 +11,7 @@ from freqtrade.exceptions import DependencyException from freqtrade.exchange import Exchange from freqtrade.misc import safe_value_fallback from freqtrade.persistence import LocalTrade, Trade, WalletHistory -from freqtrade.util.datetime_helpers import dt_now +from freqtrade.util.datetime_helpers import dt_floor_day, dt_now logger = logging.getLogger(__name__) @@ -453,7 +453,7 @@ class Wallets: if self._is_backtest: # only record in live mode. return - timestamp = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + timestamp = dt_floor_day(datetime.now()) # Record total balances for all currencies for wallet in self.get_all_balances().values(): From 680aeb89c31d7ef3628ad6474bbcf9c7b745ed75 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Nov 2025 10:11:06 +0100 Subject: [PATCH 030/104] feat: store wallet stats --- .../optimize_reports/optimize_reports.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index 2f640bcbf..53a40b519 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -43,6 +43,31 @@ def convert_bt_wallet_collection(wallet_captures: list[tuple]) -> DataFrame: ) +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"] = wallet_df["price"] * wallet_df["balance"] + # Group by date to get total wallet value at each timestamp + wallet = wallet_df.groupby("date")["total"].sum().reset_index() + start_balance = wallet.iloc[0]["total"] + end_balance = wallet.iloc[-1]["total"] + high_balance = wallet["total"].max() + low_balance = wallet["total"].min() + low_date = wallet.iloc[wallet["total"].idxmin()]["date"] + high_date = wallet.iloc[wallet["total"].idxmax()]["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]: @@ -606,6 +631,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), From 1ede18648433ab786ee845664437b8726b361ef7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Nov 2025 10:11:56 +0100 Subject: [PATCH 031/104] feat: display min/max balance --- .../optimize/optimize_reports/bt_output.py | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/freqtrade/optimize/optimize_reports/bt_output.py b/freqtrade/optimize/optimize_reports/bt_output.py index 338fe5ca5..026052ae6 100644 --- a/freqtrade/optimize/optimize_reports/bt_output.py +++ b/freqtrade/optimize/optimize_reports/bt_output.py @@ -288,6 +288,24 @@ def text_table_add_metrics(strat_results: dict) -> None: else [] ) + 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 # results with missing new fields. @@ -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%}"), ] From c66adf2bf1399aefac9de6b23c5ba67d11d7e0e5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Nov 2025 10:27:34 +0100 Subject: [PATCH 032/104] docs: update backtesting docs with new output --- docs/backtesting.md | 210 +++++++++++++++++++++++--------------------- 1 file changed, 109 insertions(+), 101 deletions(-) diff --git a/docs/backtesting.md b/docs/backtesting.md index 6b86d9635..5f42a6bcd 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -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 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 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 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% │ +└───────────────────────────────┴───────────────────────────────────────────┘ 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 trade │ ADA/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 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 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 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. From c877d267c72b8d3390e6fddce92df89c7243fb17 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Nov 2025 14:17:10 +0100 Subject: [PATCH 033/104] feat: expose minfied when converting trade list to dataframe --- freqtrade/data/btanalysis/bt_fileutils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/freqtrade/data/btanalysis/bt_fileutils.py b/freqtrade/data/btanalysis/bt_fileutils.py index 7d29ab9a2..103abae5b 100644 --- a/freqtrade/data/btanalysis/bt_fileutils.py +++ b/freqtrade/data/btanalysis/bt_fileutils.py @@ -517,13 +517,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) From bf5ec9891811a66d17dd2951b2f3f7bdf9097567 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Nov 2025 14:17:34 +0100 Subject: [PATCH 034/104] chore: use "filled" over amount for balance distribution --- freqtrade/data/btanalysis/trade_parallelism.py | 2 +- freqtrade/util/migrations/migrate_wallet_history.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index b8f119786..0337ef09a 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -91,7 +91,7 @@ def balance_distribution_over_time( orders = [o for o in trade.orders if o["order_filled_timestamp"]] 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["amount"] / trade.leverage + real_amount = order.get("filled", order["amount"]) / trade.leverage stake = order["safe_price"] * real_amount if order["ft_is_entry"]: fee = stake * trade.fee_open diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 316e74ab1..ec6095aee 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -30,7 +30,7 @@ def migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: float): - trade_df = trade_list_to_dataframe(Trade.get_trades_proxy()) + trade_df = trade_list_to_dataframe(Trade.get_trades_proxy(), minified=False) if trade_df.empty: # no trades, nothing to do return From 921cb4dad8d8bacc6ca48b2a231a5a02c3cb3420 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Nov 2025 16:21:40 +0100 Subject: [PATCH 035/104] feat: Only ffill until the end of the trade --- freqtrade/data/btanalysis/trade_parallelism.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index 0337ef09a..70b1ea7da 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -87,6 +87,7 @@ def balance_distribution_over_time( df[stake_currency] = float(start_balance) df[pairlist] = 0.0 for trade in trades.sort_values(by=["open_date"]).itertuples(): + 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. orders = [o for o in trade.orders if o["order_filled_timestamp"]] for order in sorted(orders, key=lambda x: x["order_filled_timestamp"]): @@ -95,11 +96,11 @@ def balance_distribution_over_time( stake = order["safe_price"] * real_amount if order["ft_is_entry"]: fee = stake * trade.fee_open - df.loc[filled_at:, trade.pair] += real_amount + df.loc[filled_at:end_date, trade.pair] += real_amount df.loc[filled_at:, stake_currency] -= stake + fee else: fee = stake * trade.fee_close - df.loc[filled_at:, trade.pair] -= real_amount + df.loc[filled_at:end_date, trade.pair] -= real_amount df.loc[filled_at:, stake_currency] += stake - fee df = df.round(14) From cbd1a4c06072f3c3dd2d962882eb172527efb0c0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 12:25:01 +0100 Subject: [PATCH 036/104] chore: minor improvements --- freqtrade/data/btanalysis/trade_parallelism.py | 1 + freqtrade/optimize/optimize_reports/optimize_reports.py | 2 +- freqtrade/util/migrations/migrate_wallet_history.py | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index 70b1ea7da..aed8aaf4b 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -103,5 +103,6 @@ def balance_distribution_over_time( df.loc[filled_at:end_date, trade.pair] -= real_amount df.loc[filled_at:, stake_currency] += stake - fee + # Round to avoid floating point issues df = df.round(14) return df diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index 53a40b519..d260916c1 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -36,7 +36,7 @@ def convert_bt_wallet_collection(wallet_captures: list[tuple]) -> DataFrame: (date, currency, price, balance). """ if len(wallet_captures) == 0: - return [] + return DataFrame() return DataFrame( wallet_captures, columns=["date", "currency", "price", "balance"], diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index ec6095aee..371493ea7 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -109,7 +109,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance WalletHistory.session.bulk_save_objects(wallet_entries) WalletHistory.session.commit() KeyValueStore.store_value("wallet_history_migration_date", dt_now()) - print(f"Successfully created {len(wallet_entries)} wallet balance records") + logger.info(f"Successfully created {len(wallet_entries)} wallet balance records") except Exception as e: WalletHistory.session.rollback() - print(f"Error saving wallet balance records: {e}") + logger.error(f"Error saving wallet balance records: {e}") From 7b49dc8c115190ca5f8335240c5adfa59cbc19d9 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 2 Jan 2026 18:23:00 +0100 Subject: [PATCH 037/104] chore: improve optimize reports stability --- freqtrade/optimize/optimize_reports/bt_output.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/freqtrade/optimize/optimize_reports/bt_output.py b/freqtrade/optimize/optimize_reports/bt_output.py index 026052ae6..754ed6b9f 100644 --- a/freqtrade/optimize/optimize_reports/bt_output.py +++ b/freqtrade/optimize/optimize_reports/bt_output.py @@ -287,9 +287,9 @@ 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 = ( + wallet_metrics = [ ( "Min/Max balance realized", f"{fmt_coin(strat_results['csum_min'], stake)} / " @@ -304,7 +304,7 @@ def text_table_add_metrics(strat_results: dict) -> None: "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 From 6e3c8508072391d2e1cd19c36aced2f35b737863 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 4 Jan 2026 14:10:43 +0100 Subject: [PATCH 038/104] chore: minor nitpick changes --- freqtrade/rpc/api_server/api_backtest.py | 4 ++-- freqtrade/wallets.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/freqtrade/rpc/api_server/api_backtest.py b/freqtrade/rpc/api_server/api_backtest.py index ae50db978..ab451cfbd 100644 --- a/freqtrade/rpc/api_server/api_backtest.py +++ b/freqtrade/rpc/api_server/api_backtest.py @@ -374,11 +374,11 @@ def api_get_backtest_wallet(file: str, strategy: str, config=Depends(get_config) 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=404, detail="File not found.") + 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="File not found.") + raise HTTPException(status_code=404, detail="Unable to retrieve wallet history.") # Consolidate the wallet to the base currency results.loc[:, "total"] = results["price"] * results["balance"] results = results.groupby(["date", "__date_ts"]).agg({"total": "sum"}).reset_index() diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 42187eac9..66c86e63b 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -453,7 +453,7 @@ class Wallets: if self._is_backtest: # only record in live mode. return - timestamp = dt_floor_day(datetime.now()) + timestamp = dt_floor_day(dt_now()) # Record total balances for all currencies for wallet in self.get_all_balances().values(): From 97e7939a30f92fb2ccb580ea9b76bae187d44e23 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 4 Jan 2026 14:15:26 +0100 Subject: [PATCH 039/104] chore: improve wallet capturing performance --- freqtrade/wallets.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 66c86e63b..7f858735b 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -456,6 +456,7 @@ class Wallets: timestamp = dt_floor_day(dt_now()) # Record total balances for all currencies + wallet_records = [] for wallet in self.get_all_balances().values(): # TODO: exclude minimal balances price = self._exchange.get_conversion_rate(wallet.currency, self._stake_currency) @@ -465,7 +466,7 @@ class Wallets: price=price, balance=wallet.total, ) - WalletHistory.session.add(wallet_record) + wallet_records.append(wallet_record) for position in self.get_all_positions().values(): price = self._exchange.get_conversion_rate(position.symbol, self._stake_currency) @@ -475,5 +476,10 @@ class Wallets: price=price, balance=position.position, ) - WalletHistory.session.add(position_record) - WalletHistory.session.commit() + wallet_records.append(position_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}") From 0f9cab4231740e00081861acdd65bd423eec3008 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 4 Jan 2026 14:35:16 +0100 Subject: [PATCH 040/104] chore: fix edge-case bug for empty pairlist --- freqtrade/util/migrations/migrate_wallet_history.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 371493ea7..502a3b887 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -64,6 +64,11 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance 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 merged = pd.concat(dfs, axis=1) balance_dist = balance_dist.join(merged, how="left") From c884fd20ea07ac4bfd6ff8e6f6dfc4489b98f158 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 5 Jan 2026 09:48:32 +0100 Subject: [PATCH 041/104] chore: add repr output for wallet_history --- freqtrade/persistence/wallet_history.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/freqtrade/persistence/wallet_history.py b/freqtrade/persistence/wallet_history.py index 8fbd81661..aa72ea3ef 100644 --- a/freqtrade/persistence/wallet_history.py +++ b/freqtrade/persistence/wallet_history.py @@ -25,3 +25,9 @@ class WalletHistory(ModelBase): # 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"price={self.price}, balance={self.balance})" + ) From c12c2177cd62dfe28043b2353db4ccecc7569162 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 5 Jan 2026 10:04:37 +0100 Subject: [PATCH 042/104] test: add tests for wallets_migration --- tests/util/test_historic_wallets_migration.py | 425 ++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 tests/util/test_historic_wallets_migration.py diff --git a/tests/util/test_historic_wallets_migration.py b/tests/util/test_historic_wallets_migration.py new file mode 100644 index 000000000..5c0b17876 --- /dev/null +++ b/tests/util/test_historic_wallets_migration.py @@ -0,0 +1,425 @@ +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +import pytest + +from freqtrade.enums import CandleType +from freqtrade.persistence import Trade +from freqtrade.persistence.key_value_store import KeyValueStore +from freqtrade.persistence.trade_model import Order +from freqtrade.persistence.wallet_history import WalletHistory +from freqtrade.util.datetime_helpers import dt_now, dt_utc +from freqtrade.util.migrations.migrate_wallet_history import ( + _migrate_wallet_history, + migrate_wallet_history, +) +from tests.conftest import EXMS, generate_test_data, get_patched_exchange, log_has_re + + +def create_mock_trade_for_wallet(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_mock_trade_for_wallet( + 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 + + 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_mock_trade_for_wallet( + fee, + "ETH/USDT", + open_date=start_time - timedelta(days=10), + close_date=start_time - timedelta(days=6), + ) + trade2 = create_mock_trade_for_wallet( + 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") + ) + + ohlcv_data[("BTC/USDT", "1d", candle_type)] = generate_test_data( + "1d", size=20, 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) + + # 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.price == 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.price and entry.price != 1.0 for entry in eth_entries) + assert all(entry.price and entry.price != 1.0 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_mock_trade_for_wallet( + 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_mock_trade_for_wallet( + 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_mock_trade_for_wallet( + 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_mock_trade_for_wallet( + 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 From 03fd0575ce4ae0c54dec9c1c7ebc42a01b50e8f2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 5 Jan 2026 10:08:54 +0100 Subject: [PATCH 043/104] test: simplify imports --- tests/util/test_historic_wallets_migration.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/util/test_historic_wallets_migration.py b/tests/util/test_historic_wallets_migration.py index 5c0b17876..eacdb8889 100644 --- a/tests/util/test_historic_wallets_migration.py +++ b/tests/util/test_historic_wallets_migration.py @@ -4,11 +4,8 @@ from unittest.mock import MagicMock import pytest from freqtrade.enums import CandleType -from freqtrade.persistence import Trade -from freqtrade.persistence.key_value_store import KeyValueStore -from freqtrade.persistence.trade_model import Order -from freqtrade.persistence.wallet_history import WalletHistory -from freqtrade.util.datetime_helpers import dt_now, dt_utc +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, migrate_wallet_history, From 323f42fc5329a707b6fa1f7ff55c6471e20f23fc Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 5 Jan 2026 11:11:45 +0100 Subject: [PATCH 044/104] fix: Don't round date up to next date it'll cause a record in the future eventually. --- freqtrade/data/btanalysis/trade_parallelism.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index aed8aaf4b..dc242587e 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -6,7 +6,6 @@ import pandas as pd from freqtrade.constants import IntOrInf from freqtrade.exchange.exchange_utils_timeframe import ( - timeframe_to_next_date, timeframe_to_prev_date, timeframe_to_resample_freq, ) @@ -81,7 +80,7 @@ def balance_distribution_over_time( Each column will contain the amount of the currency at the given time """ min_date_res = timeframe_to_prev_date(timeframe, min_date) - max_date_res = timeframe_to_next_date(timeframe, max_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)) df = pd.DataFrame(index=index) df[stake_currency] = float(start_balance) From 1d7b1cd4ea1601a86262ccfa434d4eb600f1129b Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 5 Jan 2026 11:50:39 +0100 Subject: [PATCH 045/104] test: improve test to make it more realistic --- tests/conftest.py | 6 ++++-- tests/util/test_historic_wallets_migration.py | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index abd15a6a1..93d34fe18 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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": diff --git a/tests/util/test_historic_wallets_migration.py b/tests/util/test_historic_wallets_migration.py index eacdb8889..e5f40bb8f 100644 --- a/tests/util/test_historic_wallets_migration.py +++ b/tests/util/test_historic_wallets_migration.py @@ -204,11 +204,11 @@ def test_migrate_wallet_history_with_multiple_pairs(default_conf_usdt, fee, time 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") + "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") + "1d", size=20, start=bot_start.strftime("%Y-%m-%d"), base=30000 ) exchange = MagicMock() @@ -238,8 +238,8 @@ def test_migrate_wallet_history_with_multiple_pairs(default_conf_usdt, fee, time 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.price and entry.price != 1.0 for entry in eth_entries) - assert all(entry.price and entry.price != 1.0 for entry in btc_entries) + assert all(entry.price and entry.price > 1400 and entry.price < 1600 for entry in eth_entries) + assert all(entry.price and entry.price > 29000 and entry.price < 31000 for entry in btc_entries) assert all(entry.balance == 10 for entry in btc_entries) From 175e77794c23dd58a30014f2789e29300f5a6e4a Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 5 Jan 2026 11:52:01 +0100 Subject: [PATCH 046/104] chore: improve wallet migration code --- .../util/migrations/migrate_wallet_history.py | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 502a3b887..9693dfd7d 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -7,10 +7,8 @@ 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.key_value_store import KeyValueStore -from freqtrade.persistence.trade_model import Trade -from freqtrade.persistence.wallet_history import WalletHistory -from freqtrade.util.datetime_helpers import dt_now, dt_ts +from freqtrade.persistence import KeyValueStore, Trade, WalletHistory +from freqtrade.util import dt_now, dt_ts logger = logging.getLogger(__name__) @@ -79,31 +77,46 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance [f"{p}_value" for p in pairlist_valid] + [stake_currency] ].sum(axis=1) + # 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_price_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 date, row in balance_dist.iterrows(): + for row in balance_dist.itertuples(index=True, name=None): + date = row[0] + # Add stake currency entry - if not pd.isna(row[stake_currency]): + stake_balance = row[stake_idx + 1] + if not pd.isna(stake_balance): wallet_entries.append( WalletHistory( timestamp=date, currency=stake_currency, price=1.0, # Stake currency price is always 1.0 - balance=row[stake_currency], + balance=stake_balance, ) ) # Add entries for each trading pair for pair in pairlist_valid: base_currency = pair.split("/")[0] + balance_value = row[pair_balance_idx[pair]] # Only add entry if balance is not empty/NaN - if not pd.isna(row[pair]) and row[pair] > 0: - price_col = f"{pair}_open" - price = row[price_col] if not pd.isna(row[price_col]) else None + if not pd.isna(balance_value) and balance_value > 0: + price_value = row[pair_price_idx[pair]] + price = price_value if not pd.isna(price_value) else None wallet_entries.append( WalletHistory( - timestamp=date, currency=base_currency, price=price, balance=row[pair] + timestamp=date, + currency=base_currency, + price=price, + balance=balance_value, ) ) From 9f9e13cec25aeb3354d09ac7c02e9fc3423f6c36 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 5 Jan 2026 11:57:06 +0100 Subject: [PATCH 047/104] chore: add better docstring --- freqtrade/data/btanalysis/trade_parallelism.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index dc242587e..f6c756094 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -78,6 +78,17 @@ def balance_distribution_over_time( """ Return a dataframe with stake_currency and the pairlist as columns Each column will contain the amount of the currency at the given time + :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) From 27c7a375310adb8c34c50ba45dfb03c5f59056d8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 5 Jan 2026 13:05:00 +0100 Subject: [PATCH 048/104] test: add test for balance_distribution_over_time --- tests/data/test_btanalysis.py | 187 ++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) 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" From 4904c7b9fd0d974d3262e25ff6676eac4d51ef54 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 5 Jan 2026 13:06:53 +0100 Subject: [PATCH 049/104] refactor: split trade_parallelism trades into their own testfile --- tests/data/test_btanalysis.py | 199 ------------------------- tests/data/test_trade_parallelism.py | 208 +++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 199 deletions(-) create mode 100644 tests/data/test_trade_parallelism.py diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index 117efd956..318918044 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -10,7 +10,6 @@ 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_latest_backtest_filename, get_latest_hyperopt_file, @@ -20,7 +19,6 @@ 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, @@ -210,17 +208,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() @@ -650,189 +637,3 @@ 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" diff --git a/tests/data/test_trade_parallelism.py b/tests/data/test_trade_parallelism.py new file mode 100644 index 000000000..1aadf99f7 --- /dev/null +++ b/tests/data/test_trade_parallelism.py @@ -0,0 +1,208 @@ +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 + + # 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" From 3a9160aace2a0a9256cf53640554d3925cc39bea Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 5 Jan 2026 13:07:02 +0100 Subject: [PATCH 050/104] chore: simplify date import --- freqtrade/data/btanalysis/trade_parallelism.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index f6c756094..1ffa14af2 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -9,7 +9,7 @@ from freqtrade.exchange.exchange_utils_timeframe import ( timeframe_to_prev_date, timeframe_to_resample_freq, ) -from freqtrade.util.datetime_helpers import dt_from_ts +from freqtrade.util import dt_from_ts logger = logging.getLogger(__name__) From 7dfcf846d01396f3d194c45fcad19d13ff42bd96 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 5 Jan 2026 19:55:32 +0100 Subject: [PATCH 051/104] test: add asserts for backtest wallet capturing --- tests/optimize/test_backtesting.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index e051e6b3c..70b591d54 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -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( From cb8d68f395202dfae14434dfdd7762db24b3fbb3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 6 Jan 2026 10:33:34 +0100 Subject: [PATCH 052/104] test: add test for record_wallet_state --- tests/test_wallets.py | 71 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/tests/test_wallets.py b/tests/test_wallets.py index a7f83ebf0..3e8c5bc45 100644 --- a/tests/test_wallets.py +++ b/tests/test_wallets.py @@ -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,70 @@ 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): + 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", + ) + } + + conversion_rates = {stake_currency: 1.0, "ETH": 0.5, "ETH/BTC": 2500.0} + mocker.patch.object( + freqtrade.exchange, + "get_conversion_rate", + side_effect=lambda currency, _: conversion_rates.get(currency, 1.0), + ) + + freqtrade.wallets.record_wallet_state() + + wallet_entries = WalletHistory.session.query(WalletHistory).all() + assert len(wallet_entries) == 3 + + records_by_currency = {entry.currency: entry for entry in wallet_entries} + assert records_by_currency[stake_currency].balance == 1.5 + assert records_by_currency[stake_currency].price == 1.0 + assert records_by_currency["ETH"].price == 0.5 + assert records_by_currency["ETH/BTC"].balance == 0.8 + assert records_by_currency["ETH/BTC"].price == 2500.0 + + +@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 From a88dc8839df22822116ac25c0f5167099cd975e2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 6 Jan 2026 10:46:21 +0100 Subject: [PATCH 053/104] chore: simplify imports --- freqtrade/data/btanalysis/trade_parallelism.py | 2 +- freqtrade/wallets.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index 1ffa14af2..82cc043b1 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -5,7 +5,7 @@ import numpy as np import pandas as pd from freqtrade.constants import IntOrInf -from freqtrade.exchange.exchange_utils_timeframe import ( +from freqtrade.exchange import ( timeframe_to_prev_date, timeframe_to_resample_freq, ) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 7f858735b..37d7efd99 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -11,7 +11,7 @@ from freqtrade.exceptions import DependencyException from freqtrade.exchange import Exchange from freqtrade.misc import safe_value_fallback from freqtrade.persistence import LocalTrade, Trade, WalletHistory -from freqtrade.util.datetime_helpers import dt_floor_day, dt_now +from freqtrade.util import dt_floor_day, dt_now logger = logging.getLogger(__name__) From 00d39bbb80621af789739b743cef7b763471ce18 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 6 Jan 2026 11:35:06 +0100 Subject: [PATCH 054/104] feat: add leverage column to wallet history --- freqtrade/persistence/wallet_history.py | 3 ++- freqtrade/wallets.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/freqtrade/persistence/wallet_history.py b/freqtrade/persistence/wallet_history.py index aa72ea3ef..0e384730c 100644 --- a/freqtrade/persistence/wallet_history.py +++ b/freqtrade/persistence/wallet_history.py @@ -20,6 +20,7 @@ class WalletHistory(ModelBase): currency: Mapped[str] = mapped_column(String(25), nullable=False) price: Mapped[float] = mapped_column(Float, nullable=True) balance: Mapped[float] = mapped_column(Float, nullable=False) + leverage: Mapped[float] = mapped_column(Float, nullable=False, default=1.0) __table_args__ = ( # Ensure one record per currency per day @@ -29,5 +30,5 @@ class WalletHistory(ModelBase): def __repr__(self) -> str: return ( f"WalletHistory(timestamp={self.timestamp}, currency={self.currency}, " - f"price={self.price}, balance={self.balance})" + f"price={self.price}, balance={self.balance}, leverage={self.leverage})" ) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 37d7efd99..25dd27fb8 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -465,16 +465,19 @@ class Wallets: currency=wallet.currency, price=price, balance=wallet.total, + leverage=1.0, ) wallet_records.append(wallet_record) for position in self.get_all_positions().values(): - price = self._exchange.get_conversion_rate(position.symbol, self._stake_currency) + base = self._exchange.get_pair_base_currency(position.symbol) + price = self._exchange.get_conversion_rate(base, self._stake_currency) position_record = WalletHistory( timestamp=timestamp, currency=position.symbol, price=price, balance=position.position, + leverage=position.leverage or 1.0, ) wallet_records.append(position_record) try: From b69a042f5be2ab847de479d3860559c7dcb3ada7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 6 Jan 2026 11:35:43 +0100 Subject: [PATCH 055/104] feat: update wallet migration to keep leverage --- freqtrade/data/btanalysis/trade_parallelism.py | 5 ++++- freqtrade/util/migrations/migrate_wallet_history.py | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index 82cc043b1..f69cb4e80 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -93,13 +93,16 @@ def 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)) - df = pd.DataFrame(index=index) + pairs_lev = [f"{pair}_leverage" for pair in pairlist] + df = pd.DataFrame(index=index, columns=[stake_currency] + pairlist + pairs_lev, dtype=float) df[stake_currency] = float(start_balance) df[pairlist] = 0.0 + df[pairs_lev] = np.nan for trade in trades.sort_values(by=["open_date"]).itertuples(): 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. orders = [o for o in trade.orders if o["order_filled_timestamp"]] + df.loc[trade.open_date : end_date, f"{trade.pair}_leverage"] = trade.leverage 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"]) / trade.leverage diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 9693dfd7d..a03ac8c70 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -81,6 +81,9 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance # 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_price_idx = { pair: balance_dist.columns.get_loc(f"{pair}_open") + 1 for pair in pairlist_valid } @@ -106,6 +109,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance for pair in pairlist_valid: base_currency = pair.split("/")[0] balance_value = row[pair_balance_idx[pair]] + leverage_value = row[pair_leverage_idx[pair]] # Only add entry if balance is not empty/NaN if not pd.isna(balance_value) and balance_value > 0: price_value = row[pair_price_idx[pair]] @@ -117,6 +121,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance currency=base_currency, price=price, balance=balance_value, + leverage=leverage_value if not pd.isna(leverage_value) else 1.0, ) ) From 0835318b8faa9d6d510704f261b3dc17a60a2237 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 6 Jan 2026 11:50:56 +0100 Subject: [PATCH 056/104] fix: capture correct balance for futures --- freqtrade/wallets.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 25dd27fb8..a520f98a7 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -457,18 +457,7 @@ class Wallets: # Record total balances for all currencies wallet_records = [] - for wallet in self.get_all_balances().values(): - # TODO: exclude minimal balances - price = self._exchange.get_conversion_rate(wallet.currency, self._stake_currency) - wallet_record = WalletHistory( - timestamp=timestamp, - currency=wallet.currency, - price=price, - balance=wallet.total, - leverage=1.0, - ) - wallet_records.append(wallet_record) - + position_collaterals = 0.0 for position in self.get_all_positions().values(): base = self._exchange.get_pair_base_currency(position.symbol) price = self._exchange.get_conversion_rate(base, self._stake_currency) @@ -479,7 +468,21 @@ class Wallets: balance=position.position, leverage=position.leverage or 1.0, ) + position_collaterals += position.collateral wallet_records.append(position_record) + + for wallet in self.get_all_balances().values(): + # TODO: exclude minimal balances? + price = self._exchange.get_conversion_rate(wallet.currency, self._stake_currency) + wallet_record = WalletHistory( + timestamp=timestamp, + currency=wallet.currency, + price=price, + balance=wallet.total + - (position_collaterals if wallet.currency == self._stake_currency else 0), + leverage=1.0, + ) + wallet_records.append(wallet_record) try: WalletHistory.session.bulk_save_objects(wallet_records) WalletHistory.session.commit() From 98493dc9ed63aa0de3ba917ded9cf7d5bc51d107 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 6 Jan 2026 15:51:06 +0100 Subject: [PATCH 057/104] feat: add "bot_managed" to wallet_history --- freqtrade/persistence/wallet_history.py | 1 + freqtrade/wallets.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/freqtrade/persistence/wallet_history.py b/freqtrade/persistence/wallet_history.py index 0e384730c..383e42a1c 100644 --- a/freqtrade/persistence/wallet_history.py +++ b/freqtrade/persistence/wallet_history.py @@ -21,6 +21,7 @@ class WalletHistory(ModelBase): price: Mapped[float] = mapped_column(Float, nullable=True) balance: Mapped[float] = mapped_column(Float, nullable=False) 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 diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index a520f98a7..b0bdc37a3 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -458,6 +458,7 @@ class Wallets: # 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 position in self.get_all_positions().values(): base = self._exchange.get_pair_base_currency(position.symbol) price = self._exchange.get_conversion_rate(base, self._stake_currency) @@ -467,6 +468,7 @@ class Wallets: price=price, balance=position.position, leverage=position.leverage or 1.0, + bot_managed=base in open_assets, ) position_collaterals += position.collateral wallet_records.append(position_record) @@ -474,6 +476,10 @@ class Wallets: for wallet in self.get_all_balances().values(): # TODO: exclude minimal balances? price = self._exchange.get_conversion_rate(wallet.currency, self._stake_currency) + is_bot_managed = ( + self._stake_currency == wallet.currency or wallet.currency in open_assets + ) + wallet_record = WalletHistory( timestamp=timestamp, currency=wallet.currency, @@ -481,6 +487,7 @@ class Wallets: balance=wallet.total - (position_collaterals if wallet.currency == self._stake_currency else 0), leverage=1.0, + bot_managed=is_bot_managed, ) wallet_records.append(wallet_record) try: From 61a5ab8e1c68397ecd865ef49bb6fb0393989731 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 6 Jan 2026 15:51:41 +0100 Subject: [PATCH 058/104] feat: add bot_managed to wallet-history migration --- freqtrade/util/migrations/migrate_wallet_history.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index a03ac8c70..7278c320d 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -102,6 +102,8 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance currency=stake_currency, price=1.0, # Stake currency price is always 1.0 balance=stake_balance, + leverage=1.0, + bot_managed=True, ) ) @@ -122,6 +124,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance price=price, balance=balance_value, leverage=leverage_value if not pd.isna(leverage_value) else 1.0, + bot_managed=True, ) ) From 7b223f3d380d8cce3f02222015a670dc13615195 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 6 Jan 2026 16:07:44 +0100 Subject: [PATCH 059/104] feat: improve balance_history response --- freqtrade/rpc/rpc.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index f9939c604..77faa2e62 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -791,13 +791,15 @@ class RPC: :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.loc[:, "total"] = results["price"] * results["balance"] + results.loc[:, "total"] = results["price"] * results["balance"] / results["leverage"] 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 = results.loc[results["bot_managed"]] - results = results.groupby(["date", "__date_ts"]).agg({"total": "sum"}).reset_index() + results_final = results.groupby(["date", "__date_ts"]).agg({"total": "sum"}).reset_index() hist = KeyValueStore.get_datetime_value("wallet_history_migration_date") - return results, dt_ts_def(hist, 0) + return results_final, dt_ts_def(hist, 0) def __balance_get_est_stake( self, coin: str, stake_currency: str, amount: float, balance: Wallet From 9891b8332b0170fe80bfd26df54d03ab5cc3d3cd Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 6 Jan 2026 16:10:38 +0100 Subject: [PATCH 060/104] chore: set wallet_migration_date in the correct space --- freqtrade/util/migrations/migrate_wallet_history.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 7278c320d..1eb8968eb 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -25,6 +25,7 @@ def migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: _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): @@ -134,7 +135,6 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance # Use bulk_save_objects for better performance WalletHistory.session.bulk_save_objects(wallet_entries) WalletHistory.session.commit() - KeyValueStore.store_value("wallet_history_migration_date", dt_now()) logger.info(f"Successfully created {len(wallet_entries)} wallet balance records") except Exception as e: WalletHistory.session.rollback() From a09036cd6e7ee483e827c220103428471f68c35d Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 6 Jan 2026 16:30:18 +0100 Subject: [PATCH 061/104] chore: fix Model naming collision --- freqtrade/rpc/api_server/api_backtest.py | 4 ++-- freqtrade/rpc/api_server/api_schemas.py | 2 +- freqtrade/rpc/api_server/api_trading.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/freqtrade/rpc/api_server/api_backtest.py b/freqtrade/rpc/api_server/api_backtest.py index ab451cfbd..772b33f99 100644 --- a/freqtrade/rpc/api_server/api_backtest.py +++ b/freqtrade/rpc/api_server/api_backtest.py @@ -30,7 +30,7 @@ from freqtrade.rpc.api_server.api_schemas import ( BacktestMetadataUpdate, BacktestRequest, BacktestResponse, - WalletHistory, + WalletHistoryResponse, ) from freqtrade.rpc.api_server.deps import get_config, verify_strategy from freqtrade.rpc.api_server.webserver_bgwork import ApiBG @@ -366,7 +366,7 @@ def api_get_backtest_market_change(file: str, config=Depends(get_config)): @router.get( "/backtest/history/{file}/{strategy}/wallet", - response_model=WalletHistory, + response_model=WalletHistoryResponse, tags=["webserver", "backtest"], ) def api_get_backtest_wallet(file: str, strategy: str, config=Depends(get_config)): diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 3837afb43..722438ae3 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -679,7 +679,7 @@ class BacktestMarketChange(BaseModel): data: list[list[Any]] -class WalletHistory(BaseModel): +class WalletHistoryResponse(BaseModel): columns: list[str] length: int data: list[list[Any]] diff --git a/freqtrade/rpc/api_server/api_trading.py b/freqtrade/rpc/api_server/api_trading.py index 368de9f79..0bae0eefb 100644 --- a/freqtrade/rpc/api_server/api_trading.py +++ b/freqtrade/rpc/api_server/api_trading.py @@ -31,7 +31,7 @@ from freqtrade.rpc.api_server.api_schemas import ( ResultMsg, Stats, StatusMsg, - WalletHistory, + WalletHistoryResponse, WhitelistResponse, ) from freqtrade.rpc.api_server.deps import get_config, get_rpc @@ -107,7 +107,7 @@ def stats(rpc: RPC = Depends(get_rpc)): @router.get( "/historic_balance", - response_model=WalletHistory, + response_model=WalletHistoryResponse, tags=["info"], ) def api_get_wallet_history(rpc: RPC = Depends(get_rpc)): From 1237bb798cc909702ee16524963e4472e9f64223 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 12 Jan 2026 20:11:57 +0100 Subject: [PATCH 062/104] test: fix backtest api wallets test --- tests/rpc/test_rpc_apiserver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 226abec1c..af5f5e5bf 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -3349,9 +3349,9 @@ def test_api_backtest_wallets(botclient, tmp_path: Path): ftbot.config["user_data_dir"] = tmp_path ftbot.config["runmode"] = RunMode.WEBSERVER - # Nonexisting file + # Nonexisting file - fails "is_file_in_dir" check rc = client_get(client, f"{BASE_URI}/backtest/history/randomFile.json/SampleStrategy/wallet") - assert_response(rc, 404) + assert_response(rc, 400) rc = client_get(client, f"{BASE_URI}/backtest/history/backtest_15/SampleStrategy/wallet") assert_response(rc, 200) From 623991c772e79469e4ae8ed7b02fcdf35fc704b7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 12 Jan 2026 20:18:51 +0100 Subject: [PATCH 063/104] chore: rename variable to price --- freqtrade/wallets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index b0bdc37a3..817e98edf 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -461,11 +461,11 @@ class Wallets: open_assets: dict[str, Trade] = {t.safe_base_currency: t for t in Trade.get_open_trades()} for position in self.get_all_positions().values(): base = self._exchange.get_pair_base_currency(position.symbol) - price = self._exchange.get_conversion_rate(base, self._stake_currency) + rate = self._exchange.get_conversion_rate(base, self._stake_currency) position_record = WalletHistory( timestamp=timestamp, currency=position.symbol, - price=price, + price=rate, balance=position.position, leverage=position.leverage or 1.0, bot_managed=base in open_assets, @@ -475,7 +475,7 @@ class Wallets: for wallet in self.get_all_balances().values(): # TODO: exclude minimal balances? - price = self._exchange.get_conversion_rate(wallet.currency, self._stake_currency) + 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 ) @@ -483,7 +483,7 @@ class Wallets: wallet_record = WalletHistory( timestamp=timestamp, currency=wallet.currency, - price=price, + price=rate, balance=wallet.total - (position_collaterals if wallet.currency == self._stake_currency else 0), leverage=1.0, From f7ddf46b3271ea11d41680219e9cb445ac38aac1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 12 Jan 2026 20:28:47 +0100 Subject: [PATCH 064/104] refactor: rename WalletHistory fieldname from price to rate --- .../optimize/optimize_reports/optimize_reports.py | 4 ++-- freqtrade/persistence/wallet_history.py | 4 ++-- freqtrade/rpc/rpc.py | 2 +- freqtrade/util/migrations/migrate_wallet_history.py | 10 +++++----- freqtrade/wallets.py | 4 ++-- tests/test_wallets.py | 6 +++--- tests/util/test_historic_wallets_migration.py | 6 +++--- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index d260916c1..ba0b4304a 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -39,7 +39,7 @@ def convert_bt_wallet_collection(wallet_captures: list[tuple]) -> DataFrame: return DataFrame() return DataFrame( wallet_captures, - columns=["date", "currency", "price", "balance"], + columns=["date", "currency", "rate", "balance"], ) @@ -47,7 +47,7 @@ def generate_wallet_stats(wallet_df: DataFrame, stake_currency: str) -> dict[str """Generate wallet statistics from the wallet DataFrame.""" if wallet_df is None or wallet_df.empty: return {} - wallet_df.loc[:, "total"] = wallet_df["price"] * wallet_df["balance"] + wallet_df.loc[:, "total"] = wallet_df["rate"] * wallet_df["balance"] # Group by date to get total wallet value at each timestamp wallet = wallet_df.groupby("date")["total"].sum().reset_index() start_balance = wallet.iloc[0]["total"] diff --git a/freqtrade/persistence/wallet_history.py b/freqtrade/persistence/wallet_history.py index 383e42a1c..de9ccd986 100644 --- a/freqtrade/persistence/wallet_history.py +++ b/freqtrade/persistence/wallet_history.py @@ -18,7 +18,7 @@ class WalletHistory(ModelBase): 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) - price: Mapped[float] = mapped_column(Float, nullable=True) + rate: Mapped[float] = mapped_column(Float, nullable=True) balance: Mapped[float] = mapped_column(Float, nullable=False) leverage: Mapped[float] = mapped_column(Float, nullable=False, default=1.0) bot_managed: Mapped[bool] = mapped_column(nullable=False, default=True) @@ -31,5 +31,5 @@ class WalletHistory(ModelBase): def __repr__(self) -> str: return ( f"WalletHistory(timestamp={self.timestamp}, currency={self.currency}, " - f"price={self.price}, balance={self.balance}, leverage={self.leverage})" + f"rate={self.rate}, balance={self.balance}, leverage={self.leverage})" ) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 77faa2e62..b9f783005 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -791,7 +791,7 @@ class RPC: :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.loc[:, "total"] = results["price"] * results["balance"] / results["leverage"] + results.loc[:, "total"] = results["rate"] * results["balance"] / results["leverage"] results = results.rename({"timestamp": "date"}, axis=1) results.loc[:, "__date_ts"] = results.loc[:, "date"].astype("int64") // 1000 // 1000 # Exclude non-bot managed for now diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 1eb8968eb..45d4dd9b3 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -85,7 +85,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance pair_leverage_idx = { pair: balance_dist.columns.get_loc(f"{pair}_leverage") + 1 for pair in pairlist_valid } - pair_price_idx = { + pair_rate_idx = { pair: balance_dist.columns.get_loc(f"{pair}_open") + 1 for pair in pairlist_valid } @@ -101,7 +101,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance WalletHistory( timestamp=date, currency=stake_currency, - price=1.0, # Stake currency price is always 1.0 + rate=1.0, # Stake currency price is always 1.0 balance=stake_balance, leverage=1.0, bot_managed=True, @@ -115,14 +115,14 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance leverage_value = row[pair_leverage_idx[pair]] # Only add entry if balance is not empty/NaN if not pd.isna(balance_value) and balance_value > 0: - price_value = row[pair_price_idx[pair]] - price = price_value if not pd.isna(price_value) else None + rate_value = row[pair_rate_idx[pair]] + rate = rate_value if not pd.isna(rate_value) else None wallet_entries.append( WalletHistory( timestamp=date, currency=base_currency, - price=price, + rate=rate, balance=balance_value, leverage=leverage_value if not pd.isna(leverage_value) else 1.0, bot_managed=True, diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 817e98edf..b37348baf 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -465,7 +465,7 @@ class Wallets: position_record = WalletHistory( timestamp=timestamp, currency=position.symbol, - price=rate, + rate=rate, balance=position.position, leverage=position.leverage or 1.0, bot_managed=base in open_assets, @@ -483,7 +483,7 @@ class Wallets: wallet_record = WalletHistory( timestamp=timestamp, currency=wallet.currency, - price=rate, + rate=rate, balance=wallet.total - (position_collaterals if wallet.currency == self._stake_currency else 0), leverage=1.0, diff --git a/tests/test_wallets.py b/tests/test_wallets.py index 3e8c5bc45..357c67baf 100644 --- a/tests/test_wallets.py +++ b/tests/test_wallets.py @@ -643,10 +643,10 @@ def test_record_wallet_state_stores_wallet_history(mocker, default_conf): records_by_currency = {entry.currency: entry for entry in wallet_entries} assert records_by_currency[stake_currency].balance == 1.5 - assert records_by_currency[stake_currency].price == 1.0 - assert records_by_currency["ETH"].price == 0.5 + assert records_by_currency[stake_currency].rate == 1.0 + assert records_by_currency["ETH"].rate == 0.5 assert records_by_currency["ETH/BTC"].balance == 0.8 - assert records_by_currency["ETH/BTC"].price == 2500.0 + assert records_by_currency["ETH/BTC"].rate == 2500.0 @pytest.mark.usefixtures("init_persistence") diff --git a/tests/util/test_historic_wallets_migration.py b/tests/util/test_historic_wallets_migration.py index e5f40bb8f..2de03a7a3 100644 --- a/tests/util/test_historic_wallets_migration.py +++ b/tests/util/test_historic_wallets_migration.py @@ -232,14 +232,14 @@ def test_migrate_wallet_history_with_multiple_pairs(default_conf_usdt, fee, time # Stake currency should have price = 1.0 for entry in usdt_entries: - assert entry.price == 1.0 + 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.price and entry.price > 1400 and entry.price < 1600 for entry in eth_entries) - assert all(entry.price and entry.price > 29000 and entry.price < 31000 for entry in btc_entries) + 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) From f12534958785be05dd8d00ba09479816eeb6b9c9 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 13 Jan 2026 21:13:12 +0100 Subject: [PATCH 065/104] feat: add additional columns to better cover futures --- freqtrade/persistence/wallet_history.py | 17 ++++++++++++++++- freqtrade/rpc/rpc.py | 6 ++++-- freqtrade/wallets.py | 24 ++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/freqtrade/persistence/wallet_history.py b/freqtrade/persistence/wallet_history.py index de9ccd986..2aef7000d 100644 --- a/freqtrade/persistence/wallet_history.py +++ b/freqtrade/persistence/wallet_history.py @@ -18,9 +18,23 @@ class WalletHistory(ModelBase): 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__ = ( @@ -31,5 +45,6 @@ class WalletHistory(ModelBase): def __repr__(self) -> str: return ( f"WalletHistory(timestamp={self.timestamp}, currency={self.currency}, " - f"rate={self.rate}, balance={self.balance}, leverage={self.leverage})" + f"rate={self.rate}, total_quote={self.total_quote}, " + f"balance={self.balance}, leverage={self.leverage})" ) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index b9f783005..ecb17128a 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -791,13 +791,15 @@ class RPC: :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.loc[:, "total"] = results["rate"] * results["balance"] / results["leverage"] + 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 = results.loc[results["bot_managed"]] - results_final = results.groupby(["date", "__date_ts"]).agg({"total": "sum"}).reset_index() + results_final = ( + results.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) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index b37348baf..f58baebe7 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -462,11 +462,26 @@ class Wallets: for position in self.get_all_positions().values(): base = self._exchange.get_pair_base_currency(position.symbol) rate = self._exchange.get_conversion_rate(base, self._stake_currency) + total_quote = None + if rate: + total_quote = ( + rate * position.position - position.collateral * (position.leverage - 1) + if position.side == "long" + else ( + position.collateral + - (rate * position.position - position.collateral * position.leverage) + ) + ) + position_record = WalletHistory( timestamp=timestamp, currency=position.symbol, + quote_currency=self._stake_currency, rate=rate, balance=position.position, + total_quote=total_quote, + total_position_value=rate * position.position if rate else None, + collateral=position.collateral, leverage=position.leverage or 1.0, bot_managed=base in open_assets, ) @@ -479,14 +494,19 @@ class Wallets: 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=wallet.total - - (position_collaterals if wallet.currency == self._stake_currency else 0), + balance=balance, leverage=1.0, + total_quote=total_quote, bot_managed=is_bot_managed, ) wallet_records.append(wallet_record) From 21ac7d196901f27a1c47f5208cc5633ebb1fc66c Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 16 Jan 2026 06:14:04 +0100 Subject: [PATCH 066/104] refactor: use shorter variable name --- freqtrade/wallets.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index f58baebe7..231282437 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -459,33 +459,30 @@ class Wallets: wallet_records = [] position_collaterals = 0.0 open_assets: dict[str, Trade] = {t.safe_base_currency: t for t in Trade.get_open_trades()} - for position in self.get_all_positions().values(): - base = self._exchange.get_pair_base_currency(position.symbol) + 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 if rate: total_quote = ( - rate * position.position - position.collateral * (position.leverage - 1) - if position.side == "long" - else ( - position.collateral - - (rate * position.position - position.collateral * position.leverage) - ) + rate * pos.position - pos.collateral * (pos.leverage - 1) + if pos.side == "long" + else (pos.collateral - (rate * pos.position - pos.collateral * pos.leverage)) ) position_record = WalletHistory( timestamp=timestamp, - currency=position.symbol, + currency=pos.symbol, quote_currency=self._stake_currency, rate=rate, - balance=position.position, + balance=pos.position, total_quote=total_quote, - total_position_value=rate * position.position if rate else None, - collateral=position.collateral, - leverage=position.leverage or 1.0, + total_position_value=rate * pos.position if rate else None, + collateral=pos.collateral, + leverage=pos.leverage or 1.0, bot_managed=base in open_assets, ) - position_collaterals += position.collateral + position_collaterals += pos.collateral wallet_records.append(position_record) for wallet in self.get_all_balances().values(): From 05bbc84bf2c72b296c95806552e967e2c9d65677 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 16 Jan 2026 06:16:40 +0100 Subject: [PATCH 067/104] fix: use correct formula for wallet capture --- freqtrade/wallets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 231282437..6cd8f9e0a 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -464,10 +464,11 @@ class Wallets: rate = self._exchange.get_conversion_rate(base, self._stake_currency) total_quote = None if rate: + # Same formula than in rpc's _rpc_balance total_quote = ( rate * pos.position - pos.collateral * (pos.leverage - 1) if pos.side == "long" - else (pos.collateral - (rate * pos.position - pos.collateral * pos.leverage)) + else (pos.collateral + (rate * pos.position - pos.collateral * pos.leverage)) ) position_record = WalletHistory( From 4cac7247092c4c11e8e3f770a0041faa9a670f4b Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 16 Jan 2026 06:28:34 +0100 Subject: [PATCH 068/104] test: update wallet capture test --- tests/test_wallets.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/tests/test_wallets.py b/tests/test_wallets.py index 357c67baf..b14a5e602 100644 --- a/tests/test_wallets.py +++ b/tests/test_wallets.py @@ -612,16 +612,16 @@ def test_dry_run_wallet_initialization(mocker, default_conf_usdt, config, wallet @pytest.mark.usefixtures("init_persistence") -def test_record_wallet_state_stores_wallet_history(mocker, default_conf): - freqtrade = get_patched_freqtradebot(mocker, default_conf) - stake_currency = default_conf["stake_currency"] +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=1.0, used=0.5, total=1.5), - "ETH": Wallet("ETH", free=2.0, used=1.0, total=3.0), + 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/BTC": PositionWallet( - symbol="ETH/BTC", + "ETH/USDT:USDT": PositionWallet( + symbol="ETH/USDT:USDT", position=0.8, collateral=1.0, leverage=3.0, @@ -629,12 +629,18 @@ def test_record_wallet_state_stores_wallet_history(mocker, default_conf): ) } - conversion_rates = {stake_currency: 1.0, "ETH": 0.5, "ETH/BTC": 2500.0} + conversion_rates = {stake_currency: 1.0, "BTC": 70000, "ETH": 2500.1} mocker.patch.object( freqtrade.exchange, "get_conversion_rate", side_effect=lambda currency, _: 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() @@ -642,11 +648,14 @@ def test_record_wallet_state_stores_wallet_history(mocker, default_conf): assert len(wallet_entries) == 3 records_by_currency = {entry.currency: entry for entry in wallet_entries} - assert records_by_currency[stake_currency].balance == 1.5 + assert records_by_currency[stake_currency].balance == 149 assert records_by_currency[stake_currency].rate == 1.0 - assert records_by_currency["ETH"].rate == 0.5 - assert records_by_currency["ETH/BTC"].balance == 0.8 - assert records_by_currency["ETH/BTC"].rate == 2500.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") From b4961a2cb7d63cd5423bacfbf4ce4abcff6c5abd Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 16 Jan 2026 06:29:59 +0100 Subject: [PATCH 069/104] fix: add quote_currency to wallet migration --- freqtrade/util/migrations/migrate_wallet_history.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 45d4dd9b3..68caaf6ec 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -103,6 +103,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance currency=stake_currency, rate=1.0, # Stake currency price is always 1.0 balance=stake_balance, + quote_currency=stake_currency, leverage=1.0, bot_managed=True, ) @@ -123,6 +124,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance timestamp=date, currency=base_currency, rate=rate, + quote_currency=stake_currency, balance=balance_value, leverage=leverage_value if not pd.isna(leverage_value) else 1.0, bot_managed=True, From 50fd6d152ed388cc955db85ee1f3890b5e0dc37a Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 17 Jan 2026 16:53:10 +0100 Subject: [PATCH 070/104] fix: use correct formula for shorts --- freqtrade/wallets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 6cd8f9e0a..41ce229c6 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -468,7 +468,7 @@ class Wallets: total_quote = ( rate * pos.position - pos.collateral * (pos.leverage - 1) if pos.side == "long" - else (pos.collateral + (rate * pos.position - pos.collateral * pos.leverage)) + else pos.collateral * (1 + pos.leverage) - rate * pos.position ) position_record = WalletHistory( From 5a847665ff082fbc5ec7112c763f6fc066a4d940 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Jan 2026 14:08:08 +0100 Subject: [PATCH 071/104] fix: add total_quote to migration --- freqtrade/util/migrations/migrate_wallet_history.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 68caaf6ec..48cf9765e 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -103,6 +103,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance 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, @@ -126,8 +127,11 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance rate=rate, quote_currency=stake_currency, balance=balance_value, + total_quote=balance_value * rate if rate else None, leverage=leverage_value if not pd.isna(leverage_value) else 1.0, bot_managed=True, + # total_position_value=total_position_value, + # collateral=collateral, ) ) From 09ddef37167f7d46809c9d4e7089a972c2981a20 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Jan 2026 14:28:22 +0100 Subject: [PATCH 072/104] chore: improved variable naming --- freqtrade/util/migrations/migrate_wallet_history.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 48cf9765e..75a7ff629 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -113,10 +113,10 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance # Add entries for each trading pair for pair in pairlist_valid: base_currency = pair.split("/")[0] - balance_value = row[pair_balance_idx[pair]] - leverage_value = row[pair_leverage_idx[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_value) and balance_value > 0: + 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 @@ -126,9 +126,9 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance currency=base_currency, rate=rate, quote_currency=stake_currency, - balance=balance_value, - total_quote=balance_value * rate if rate else None, - leverage=leverage_value if not pd.isna(leverage_value) else 1.0, + balance=balance, + total_quote=balance * rate if rate else None, + leverage=leverage if not pd.isna(leverage) else 1.0, bot_managed=True, # total_position_value=total_position_value, # collateral=collateral, From 803b4cae788f99a97cd3b3dce0775e9f0e25f25e Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Jan 2026 14:32:37 +0100 Subject: [PATCH 073/104] chore: improved docstring --- freqtrade/data/btanalysis/trade_parallelism.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index f69cb4e80..15d6d2138 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -78,6 +78,10 @@ def balance_distribution_over_time( """ 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 + - : amount of base currency in the pair + - _leverage: leverage used for the pair at the time (NaN if no open trade) :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 From e4eee1aa1b6ff85ee4d8ad65ba002380f41fdac5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 25 Jan 2026 20:17:15 +0100 Subject: [PATCH 074/104] feat: add short fields to balance_distribution --- .../data/btanalysis/trade_parallelism.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index 15d6d2138..9e023a6b5 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -82,6 +82,8 @@ def balance_distribution_over_time( - stake_currency: amount of stake currency - : amount of base currency in the pair - _leverage: leverage used for the pair at the time (NaN if no open trade) + - _is_short: 1 if the open trade is short, 0 if long (NaN if no open trade) + - _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 @@ -98,26 +100,38 @@ def balance_distribution_over_time( 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] - df = pd.DataFrame(index=index, columns=[stake_currency] + pairlist + pairs_lev, dtype=float) + 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] = 0.0 + 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. orders = [o for o in trade.orders if o["order_filled_timestamp"]] - df.loc[trade.open_date : end_date, f"{trade.pair}_leverage"] = trade.leverage + 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 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"]) / trade.leverage + real_amount = order.get("filled", order["amount"]) stake = order["safe_price"] * real_amount if order["ft_is_entry"]: fee = stake * trade.fee_open - df.loc[filled_at:end_date, trade.pair] += real_amount + df.loc[filled_at:end_date, pair] += real_amount + df.loc[filled_at:end_date, f"{pair}_collateral"] += stake / trade.leverage df.loc[filled_at:, stake_currency] -= stake + fee else: fee = stake * trade.fee_close - df.loc[filled_at:end_date, trade.pair] -= real_amount + df.loc[filled_at:end_date, pair] -= real_amount + df.loc[filled_at:end_date, f"{pair}_collateral"] -= stake / trade.leverage df.loc[filled_at:, stake_currency] += stake - fee # Round to avoid floating point issues From 5d2a7d218771a706100c23b4f203906d151d0910 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 25 Jan 2026 20:22:33 +0100 Subject: [PATCH 075/104] feat: wallet-migration for futures trades --- .../util/migrations/migrate_wallet_history.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 75a7ff629..82fb2ab91 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -35,6 +35,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance return pairlist = list(trade_df["pair"].unique()) timeframe = "1d" + is_futures = config["trading_mode"] == "futures" 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( @@ -85,6 +86,12 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance 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 } @@ -120,17 +127,29 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance 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, - rate=rate, quote_currency=stake_currency, + rate=rate, balance=balance, - total_quote=balance * rate if rate else None, + total_quote=total_quote, leverage=leverage if not pd.isna(leverage) else 1.0, bot_managed=True, - # total_position_value=total_position_value, + total_position_value=balance * rate if is_futures and rate else None, # collateral=collateral, ) ) From fa48910e962a386b9d574d631ce2eb9192a3b5d4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 27 Jan 2026 07:17:51 +0100 Subject: [PATCH 076/104] feat: improved dataframe handling --- freqtrade/util/migrations/migrate_wallet_history.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 82fb2ab91..ec0776e4d 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -72,9 +72,14 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance 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: - balance_dist[f"{p}_value"] = balance_dist[f"{p}_open"] * balance_dist[p] + df_value[f"{p}_value"] = 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) From ab2e0e6d6f1dbd9fb775d6c1ea151b42b7ad290b Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 27 Jan 2026 07:19:45 +0100 Subject: [PATCH 077/104] chore: improved logging for clarity on startup wait --- freqtrade/util/migrations/migrate_wallet_history.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index ec0776e4d..b8cc5cfda 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -49,12 +49,16 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance ) pairlist_valid = [p for p in pairlist if p in exchange.markets] + 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 From 5d3776309c1b4c5c4d43cdc9165f21bcd355fee8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 27 Jan 2026 07:23:19 +0100 Subject: [PATCH 078/104] refactor: improve migration code structure --- .../util/migrations/migrate_wallet_history.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index b8cc5cfda..ca9dbae88 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -29,13 +29,19 @@ def migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: 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) + + _create_wallet_history_entries(config, balance_dist, pairlist_valid, config["stake_currency"]) + + +def _prepare_balance_distribution(config: Config, exchange: Exchange, starting_balance: float): trade_df = trade_list_to_dataframe(Trade.get_trades_proxy(), minified=False) if trade_df.empty: # no trades, nothing to do return pairlist = list(trade_df["pair"].unique()) timeframe = "1d" - is_futures = config["trading_mode"] == "futures" 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( @@ -88,6 +94,16 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance [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, + 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) @@ -104,7 +120,6 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance 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): From 518092a0ad5b39fa16962ecc06fadf27115243a7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 27 Jan 2026 19:11:16 +0100 Subject: [PATCH 079/104] fix: handle error-cases gracefully --- .../util/migrations/migrate_wallet_history.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index ca9dbae88..7c91a9975 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -31,15 +31,19 @@ def migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: 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) - - _create_wallet_history_entries(config, balance_dist, pairlist_valid, config["stake_currency"]) + if not balance_dist.empty and pairlist_valid: + _create_wallet_history_entries( + config, balance_dist, pairlist_valid, config["stake_currency"] + ) -def _prepare_balance_distribution(config: Config, exchange: Exchange, starting_balance: float): +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 + return pd.DataFrame(), [] pairlist = list(trade_df["pair"].unique()) timeframe = "1d" stake_currency = config["stake_currency"] @@ -78,7 +82,7 @@ def _prepare_balance_distribution(config: Config, exchange: Exchange, starting_b logger.warning( "No OHLCV data available for the trading pairs; skipping wallet history migration." ) - return + return pd.DataFrame(), [] merged = pd.concat(dfs, axis=1) balance_dist = balance_dist.join(merged, how="left") From 138b70a2bf9aa69bf3dbae377adb78b833f011e7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 27 Jan 2026 19:56:37 +0100 Subject: [PATCH 080/104] test: add explicit test for prepare_balance_distribution --- tests/util/test_historic_wallets_migration.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/util/test_historic_wallets_migration.py b/tests/util/test_historic_wallets_migration.py index 2de03a7a3..2048d588b 100644 --- a/tests/util/test_historic_wallets_migration.py +++ b/tests/util/test_historic_wallets_migration.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta from unittest.mock import MagicMock +import pandas as pd import pytest from freqtrade.enums import CandleType @@ -8,6 +9,7 @@ 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 @@ -420,3 +422,77 @@ def test_migrate_wallet_history_db_error_handling( # 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_mock_trade_for_wallet( + fee, + "ETH/USDT", + open_date=start_time - timedelta(days=10), + close_date=start_time - timedelta(days=6), + ) + trade2 = create_mock_trade_for_wallet( + 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", + ] + ) From f9db85fcafc33e35424639a4a851ee7bff8e767f Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 29 Jan 2026 07:11:28 +0100 Subject: [PATCH 081/104] chore: slightly reorder parallelism code --- freqtrade/data/btanalysis/trade_parallelism.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index 9e023a6b5..22a63e14d 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -116,9 +116,9 @@ def balance_distribution_over_time( 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. - orders = [o for o in trade.orders if o["order_filled_timestamp"]] 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"]] 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"]) From 5e6d3e265b216d53cb284efc09b575330204bc43 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 29 Jan 2026 07:11:52 +0100 Subject: [PATCH 082/104] tests: rename mock helper function --- tests/util/test_historic_wallets_migration.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/util/test_historic_wallets_migration.py b/tests/util/test_historic_wallets_migration.py index 2048d588b..6eecae762 100644 --- a/tests/util/test_historic_wallets_migration.py +++ b/tests/util/test_historic_wallets_migration.py @@ -15,7 +15,7 @@ from freqtrade.util.migrations.migrate_wallet_history import ( from tests.conftest import EXMS, generate_test_data, get_patched_exchange, log_has_re -def create_mock_trade_for_wallet(fee, pair: str, open_date: datetime, close_date: datetime): +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, @@ -146,7 +146,7 @@ def test_migrate_wallet_history_with_trades(default_conf_usdt, fee, time_machine # Create mock trades with dates within the range trade_open = start_time - timedelta(days=5) trade_close = start_time - timedelta(days=3) - trade1 = create_mock_trade_for_wallet( + trade1 = create_closed_mock_trade( fee, "ETH/USDT", open_date=trade_open, @@ -186,13 +186,13 @@ def test_migrate_wallet_history_with_multiple_pairs(default_conf_usdt, fee, time KeyValueStore.store_value("bot_start_time", bot_start) # Create mock trades for multiple pairs within the date range - trade1 = create_mock_trade_for_wallet( + trade1 = create_closed_mock_trade( fee, "ETH/USDT", open_date=start_time - timedelta(days=10), close_date=start_time - timedelta(days=6), ) - trade2 = create_mock_trade_for_wallet( + trade2 = create_closed_mock_trade( fee, "BTC/USDT", open_date=start_time - timedelta(days=7), @@ -258,7 +258,7 @@ def test_migrate_wallet_history_pair_not_in_markets( KeyValueStore.store_value("bot_start_time", bot_start) # Create a trade with a pair that won't be in markets - trade1 = create_mock_trade_for_wallet( + trade1 = create_closed_mock_trade( fee, "UNKNOWN/USDT", open_date=start_time - timedelta(days=5), @@ -289,7 +289,7 @@ def test_migrate_wallet_history_stores_migration_date( KeyValueStore.store_value("bot_start_time", bot_start) # Create a trade - trade1 = create_mock_trade_for_wallet( + trade1 = create_closed_mock_trade( fee, "ETH/USDT", open_date=start_time - timedelta(days=5), @@ -349,7 +349,7 @@ def test_migrate_wallet_history_with_patched_exchange(mocker, default_conf_usdt, KeyValueStore.store_value("bot_start_time", bot_start) # Create a trade - trade1 = create_mock_trade_for_wallet( + trade1 = create_closed_mock_trade( fee, "ETH/USDT", open_date=start_time - timedelta(days=5), @@ -391,7 +391,7 @@ def test_migrate_wallet_history_db_error_handling( KeyValueStore.store_value("bot_start_time", bot_start) # Create a trade - trade1 = create_mock_trade_for_wallet( + trade1 = create_closed_mock_trade( fee, "ETH/USDT", open_date=start_time - timedelta(days=5), @@ -435,13 +435,13 @@ def test__prepare_balance_distribution(default_conf_usdt, fee, time_machine, mar KeyValueStore.store_value("bot_start_time", bot_start) # Create mock trades for multiple pairs within the date range - trade1 = create_mock_trade_for_wallet( + trade1 = create_closed_mock_trade( fee, "ETH/USDT", open_date=start_time - timedelta(days=10), close_date=start_time - timedelta(days=6), ) - trade2 = create_mock_trade_for_wallet( + trade2 = create_closed_mock_trade( fee, "BTC/USDT", open_date=start_time - timedelta(days=7), From a9bbc45ba5a76432213d260d42191a8f24a68cc6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 3 Feb 2026 07:16:41 +0100 Subject: [PATCH 083/104] fix(migration): stake should be non-leveraged. --- freqtrade/data/btanalysis/trade_parallelism.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index 22a63e14d..d03c8eb48 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -123,16 +123,17 @@ def balance_distribution_over_time( 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"]: fee = stake * trade.fee_open df.loc[filled_at:end_date, pair] += real_amount - df.loc[filled_at:end_date, f"{pair}_collateral"] += stake / trade.leverage - df.loc[filled_at:, stake_currency] -= stake + fee + df.loc[filled_at:end_date, f"{pair}_collateral"] += stake_no_lev + df.loc[filled_at:, stake_currency] -= stake_no_lev + fee else: fee = stake * trade.fee_close df.loc[filled_at:end_date, pair] -= real_amount - df.loc[filled_at:end_date, f"{pair}_collateral"] -= stake / trade.leverage - df.loc[filled_at:, stake_currency] += stake - fee + df.loc[filled_at:end_date, f"{pair}_collateral"] -= stake_no_lev + df.loc[filled_at:, stake_currency] += stake_no_lev - fee # Round to avoid floating point issues df = df.round(14) From 35806c26bf8fa9b48f1cc3df38ed6f69e76d2f2f Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 3 Feb 2026 20:33:16 +0100 Subject: [PATCH 084/104] fix: Improved migration for short trades --- .../data/btanalysis/trade_parallelism.py | 21 ++++++++++++++++++- .../util/migrations/migrate_wallet_history.py | 13 +++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index d03c8eb48..70b418b49 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -119,21 +119,40 @@ def balance_distribution_over_time( 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 = 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 else: + # Exit order: release collateral and realize profit/loss fee = stake * trade.fee_close + if trade.is_short: + # For SHORT + df.loc[filled_at:, stake_currency] += ( + current_collateral * (1 + trade.leverage) - stake + ) + current_collateral * (1 + trade.leverage) - stake + else: + # For LONG + df.loc[filled_at:, stake_currency] += stake - current_collateral * ( + trade.leverage - 1 + ) 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 + current_position -= real_amount + current_collateral -= stake_no_lev # Round to avoid floating point issues df = df.round(14) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 7c91a9975..eb24c5922 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -1,5 +1,6 @@ import logging +import numpy as np import pandas as pd from freqtrade.constants import Config @@ -90,7 +91,17 @@ def _prepare_balance_distribution( 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] + # 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 From c3b2a73eefb34375745cc1b295093c6ad6140ffa Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 3 Feb 2026 20:33:28 +0100 Subject: [PATCH 085/104] feat: warn for pairs without history --- freqtrade/util/migrations/migrate_wallet_history.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index eb24c5922..30a31b8e7 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -59,6 +59,12 @@ def _prepare_balance_distribution( 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( From 425ebeeedbc9c9f9370b688a7891dbb6e5ea1eea Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 22 Mar 2026 10:40:35 +0100 Subject: [PATCH 086/104] fix: include fees on both trade sides --- freqtrade/data/btanalysis/trade_parallelism.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/freqtrade/data/btanalysis/trade_parallelism.py b/freqtrade/data/btanalysis/trade_parallelism.py index 70b418b49..8ed9702e0 100644 --- a/freqtrade/data/btanalysis/trade_parallelism.py +++ b/freqtrade/data/btanalysis/trade_parallelism.py @@ -129,25 +129,24 @@ def balance_distribution_over_time( if order["ft_is_entry"]: # Entry order: lock collateral and pay fee # For both long and short: balance decreases by collateral + fee - fee = stake * trade.fee_open + 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 + df.loc[filled_at:, stake_currency] -= stake_no_lev + fee_open else: # Exit order: release collateral and realize profit/loss - fee = stake * trade.fee_close + fee_close = stake * trade.fee_close if trade.is_short: # For SHORT df.loc[filled_at:, stake_currency] += ( current_collateral * (1 + trade.leverage) - stake - ) - current_collateral * (1 + trade.leverage) - stake + ) - fee_close else: # For LONG - df.loc[filled_at:, stake_currency] += stake - current_collateral * ( - trade.leverage - 1 + 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 From e28da4b0874a6cce8085dabcf07ef3a94b391a61 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 22 Mar 2026 19:20:02 +0100 Subject: [PATCH 087/104] test: Fix and improve balance distribution test --- tests/data/test_trade_parallelism.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/data/test_trade_parallelism.py b/tests/data/test_trade_parallelism.py index 1aadf99f7..266c59446 100644 --- a/tests/data/test_trade_parallelism.py +++ b/tests/data/test_trade_parallelism.py @@ -172,6 +172,9 @@ def test_balance_distribution_over_time(is_short): 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]) @@ -195,14 +198,13 @@ def test_balance_distribution_over_time(is_short): 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" + # 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" + ) From 78ccb68929e4a7e869f776bb3551145099845497 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 11:50:47 +0200 Subject: [PATCH 088/104] fix: adjust backtest-logic to new capture method --- freqtrade/rpc/api_server/api_backtest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/rpc/api_server/api_backtest.py b/freqtrade/rpc/api_server/api_backtest.py index 772b33f99..afbd44101 100644 --- a/freqtrade/rpc/api_server/api_backtest.py +++ b/freqtrade/rpc/api_server/api_backtest.py @@ -380,8 +380,8 @@ def api_get_backtest_wallet(file: str, strategy: str, config=Depends(get_config) 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"] = results["price"] * results["balance"] - results = results.groupby(["date", "__date_ts"]).agg({"total": "sum"}).reset_index() + 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(), From f99ccc5e3d05d0da40d32a62880ab15ed5424c7d Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 11:52:46 +0200 Subject: [PATCH 089/104] docs: improved backtesting doc wording --- docs/backtesting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/backtesting.md b/docs/backtesting.md index 5f42a6bcd..12455b367 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -417,7 +417,7 @@ It contains key metrics about the performance of your strategy on backtesting da - `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used). - `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 balance occurred. +- `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. From dec3c3e13b9daf850b0e495657d435e21f3ce17f Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 12:12:11 +0200 Subject: [PATCH 090/104] test: fix a couple tsts ... --- tests/rpc/test_rpc_apiserver.py | 4 ++-- tests/test_wallets.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index af5f5e5bf..95cb9512a 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -3332,7 +3332,7 @@ def test_api_backtest_wallets(botclient, tmp_path: Path): "2018-01-01T00:05:00Z", ], "currency": ["ETH", "BTC", "ETH", "BTC"], - "price": [2000, 60_000, 2001, 60_001], + "rate": [2000, 60_000, 2001, 60_001], "balance": [0.5, 0.25, 0.5, 0.25], } ) @@ -3357,7 +3357,7 @@ def test_api_backtest_wallets(botclient, tmp_path: Path): assert_response(rc, 200) result = rc.json() assert result["length"] == 2 - assert result["columns"] == ["date", "__date_ts", "total"] + 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], diff --git a/tests/test_wallets.py b/tests/test_wallets.py index b14a5e602..2f37bf5a2 100644 --- a/tests/test_wallets.py +++ b/tests/test_wallets.py @@ -633,7 +633,7 @@ def test_record_wallet_state_stores_wallet_history(mocker, default_conf_usdt): mocker.patch.object( freqtrade.exchange, "get_conversion_rate", - side_effect=lambda currency, _: conversion_rates.get(currency, 1.0), + side_effect=lambda currency, *args, **kwargs: conversion_rates.get(currency, 1.0), ) mocker.patch( "freqtrade.persistence.trade_model.Trade.get_open_trades", From d001d9164008e696d00d004d2334a34a609a2752 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 12:50:10 +0200 Subject: [PATCH 091/104] chore: rename temporary column for clarity --- .../optimize_reports/optimize_reports.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index ba0b4304a..a8eefbc7b 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -47,15 +47,15 @@ def generate_wallet_stats(wallet_df: DataFrame, stake_currency: str) -> dict[str """Generate wallet statistics from the wallet DataFrame.""" if wallet_df is None or wallet_df.empty: return {} - wallet_df.loc[:, "total"] = wallet_df["rate"] * wallet_df["balance"] + 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"].sum().reset_index() - start_balance = wallet.iloc[0]["total"] - end_balance = wallet.iloc[-1]["total"] - high_balance = wallet["total"].max() - low_balance = wallet["total"].min() - low_date = wallet.iloc[wallet["total"].idxmin()]["date"] - high_date = wallet.iloc[wallet["total"].idxmax()]["date"] + wallet = wallet_df.groupby("date")["total_quote"].sum().reset_index() + start_balance = wallet.iloc[0]["total_quote"] + end_balance = wallet.iloc[-1]["total_quote"] + high_balance = wallet["total_quote"].max() + low_balance = wallet["total_quote"].min() + low_date = wallet.iloc[wallet["total_quote"].idxmin()]["date"] + high_date = wallet.iloc[wallet["total_quote"].idxmax()]["date"] return { "start_balance": start_balance, "end_balance": end_balance, From 66eb7f019901552ee3b35e0d690f5d6a8011003f Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 13:05:57 +0200 Subject: [PATCH 092/104] test: add test for historic_balance endpoint --- tests/rpc/test_rpc_apiserver.py | 35 +++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 95cb9512a..63a1d6efd 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -1436,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) From a40de0a59be2b8c8aa855eb1a609a03852231234 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 13:10:35 +0200 Subject: [PATCH 093/104] chore: rename variable for better debuggability --- freqtrade/rpc/rpc.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index ecb17128a..0c5f46f01 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -795,10 +795,12 @@ class RPC: 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 = results.loc[results["bot_managed"]] + results_filtered = results.loc[results["bot_managed"]] results_final = ( - results.groupby(["date", "__date_ts"]).agg({"total_quote": "sum"}).reset_index() + 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) From 75bfa87f747d1d85ba4247df888838dcb617ea07 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 13:12:03 +0200 Subject: [PATCH 094/104] chore: minor code cleanup --- freqtrade/wallets.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 41ce229c6..e4fcdd269 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -447,9 +447,7 @@ class Wallets: logger.info(msg) def record_wallet_state(self) -> None: - """ - Record daily wallet totals to database - """ + """Record daily wallet totals to database""" if self._is_backtest: # only record in live mode. return @@ -487,7 +485,7 @@ class Wallets: wallet_records.append(position_record) for wallet in self.get_all_balances().values(): - # TODO: exclude minimal balances? + # 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 From 7af322a197fd2d41668d7e3fcefd0bfa3ad39020 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 13:17:48 +0200 Subject: [PATCH 095/104] fix: impove behavior when loading old backtest results --- freqtrade/data/btanalysis/bt_fileutils.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/freqtrade/data/btanalysis/bt_fileutils.py b/freqtrade/data/btanalysis/bt_fileutils.py index 103abae5b..a97d5bef3 100644 --- a/freqtrade/data/btanalysis/bt_fileutils.py +++ b/freqtrade/data/btanalysis/bt_fileutils.py @@ -312,18 +312,25 @@ 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: +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 """ - data = load_file_from_zip(filename, f"{filename.stem}_{strategy_name}_wallet.feather") - df = pd.read_feather(BytesIO(data)) + if filename.suffix != ".zip": + return None - df.loc[:, "__date_ts"] = df.loc[:, "date"].astype(np.int64) // 1000 // 1000 - return df + 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( From b45e086b50d315ea7e73424addf5cb8a56381077 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 13:38:35 +0200 Subject: [PATCH 096/104] test: add tests for get_backtest_wallet_change (and market change) --- tests/data/test_btanalysis.py | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index 318918044..82ff56c3a 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -11,6 +11,8 @@ from freqtrade.constants import LAST_BT_RESULT_FN from freqtrade.data.btanalysis import ( BT_DATA_COLUMNS, extract_trades_of_period, + get_backtest_market_change, + get_backtest_wallet_change, get_latest_backtest_filename, get_latest_hyperopt_file, load_backtest_data, @@ -637,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 From 754f24c8a684ade59dfccdcfa1e2e1a0aca67fd5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 13:48:54 +0200 Subject: [PATCH 097/104] feat: allow skipping of the wallet migration fallback method in case of problems --- build_helpers/schema.json | 4 ++++ freqtrade/config_schema/config_schema.py | 4 ++++ freqtrade/util/migrations/migrate_wallet_history.py | 4 +++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/build_helpers/schema.json b/build_helpers/schema.json index 8bf56b2a1..5d07b4ad1 100644 --- a/build_helpers/schema.json +++ b/build_helpers/schema.json @@ -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" diff --git a/freqtrade/config_schema/config_schema.py b/freqtrade/config_schema/config_schema.py index fc2c42441..7fc885662 100644 --- a/freqtrade/config_schema/config_schema.py +++ b/freqtrade/config_schema/config_schema.py @@ -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.", diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 30a31b8e7..82af820d3 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -16,7 +16,9 @@ logger = logging.getLogger(__name__) def migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: float): - if not exchange.get_option("ohlcv_has_history", True): + 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"): From af815a3c766ca4da2f51224d5a12a0e0c5ec5c42 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 13:51:11 +0200 Subject: [PATCH 098/104] chore: unify treatment of pos.leverage fallbacks --- freqtrade/wallets.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index e4fcdd269..e3ac1288b 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -461,12 +461,13 @@ class Wallets: 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 * (pos.leverage - 1) + rate * pos.position - pos.collateral * (leverage - 1) if pos.side == "long" - else pos.collateral * (1 + pos.leverage) - rate * pos.position + else pos.collateral * (1 + leverage) - rate * pos.position ) position_record = WalletHistory( @@ -478,7 +479,7 @@ class Wallets: total_quote=total_quote, total_position_value=rate * pos.position if rate else None, collateral=pos.collateral, - leverage=pos.leverage or 1.0, + leverage=leverage, bot_managed=base in open_assets, ) position_collaterals += pos.collateral From b6b9ae5eb0e48dcc2e03c9a3c84ba6394c74c786 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 14:09:36 +0200 Subject: [PATCH 099/104] test: assert repr for walletHistory --- tests/test_wallets.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_wallets.py b/tests/test_wallets.py index 2f37bf5a2..20861108a 100644 --- a/tests/test_wallets.py +++ b/tests/test_wallets.py @@ -646,6 +646,8 @@ def test_record_wallet_state_stores_wallet_history(mocker, default_conf_usdt): 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 From 51478baa475e8b2a84ba51c46aaec2b38875fbe5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 19:46:46 +0200 Subject: [PATCH 100/104] docs: Add Dashboard section to freqUI docs --- docs/freq-ui.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/freq-ui.md b/docs/freq-ui.md index ff9758888..efdea7ff0 100644 --- a/docs/freq-ui.md +++ b/docs/freq-ui.md @@ -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 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. From 8768fe90b33c3c09ae42637dc78154e018e3f442 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 5 Apr 2026 10:08:50 +0200 Subject: [PATCH 101/104] refactor: slightly improve generate_wallet_stats --- .../optimize/optimize_reports/optimize_reports.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index a8eefbc7b..bea047202 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -50,12 +50,15 @@ def generate_wallet_stats(wallet_df: DataFrame, stake_currency: str) -> dict[str 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 = wallet["total_quote"].max() - low_balance = wallet["total_quote"].min() - low_date = wallet.iloc[wallet["total_quote"].idxmin()]["date"] - high_date = wallet.iloc[wallet["total_quote"].idxmax()]["date"] + 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, From aaadb01a6ae6d15376b923a5d3af79a14421fe7f Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 5 Apr 2026 10:08:56 +0200 Subject: [PATCH 102/104] docs: improve doc wording --- docs/freq-ui.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/freq-ui.md b/docs/freq-ui.md index efdea7ff0..d561810f7 100644 --- a/docs/freq-ui.md +++ b/docs/freq-ui.md @@ -53,7 +53,7 @@ If multiple bots are connected, the dashboard will show an overview of all conne #### Wallet Balance -New in freqtrade 2026.4 shows the balance of the bot over time. +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. From 4ba01b5c12607952eb25ad00cb5ae8417a9d6c5e Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 5 Apr 2026 10:10:06 +0200 Subject: [PATCH 103/104] fix: don't assume "/" for pair base currency --- freqtrade/util/migrations/migrate_wallet_history.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/freqtrade/util/migrations/migrate_wallet_history.py b/freqtrade/util/migrations/migrate_wallet_history.py index 82af820d3..dbc887c56 100644 --- a/freqtrade/util/migrations/migrate_wallet_history.py +++ b/freqtrade/util/migrations/migrate_wallet_history.py @@ -36,7 +36,7 @@ def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance balance_dist, pairlist_valid = _prepare_balance_distribution(config, exchange, starting_balance) if not balance_dist.empty and pairlist_valid: _create_wallet_history_entries( - config, balance_dist, pairlist_valid, config["stake_currency"] + config, exchange, balance_dist, pairlist_valid, config["stake_currency"] ) @@ -122,6 +122,7 @@ def _prepare_balance_distribution( def _create_wallet_history_entries( config: Config, + exchange: Exchange, balance_dist: pd.DataFrame, pairlist_valid: list[str], stake_currency: str, @@ -166,7 +167,7 @@ def _create_wallet_history_entries( # Add entries for each trading pair for pair in pairlist_valid: - base_currency = pair.split("/")[0] + 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 From 3d68d6aefc5564162fc0c0b1f909b1886d8fc317 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 5 Apr 2026 17:06:53 +0200 Subject: [PATCH 104/104] test: Add exchange mock for get_pair_base_currency --- tests/util/test_historic_wallets_migration.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/util/test_historic_wallets_migration.py b/tests/util/test_historic_wallets_migration.py index 6eecae762..e4a078201 100644 --- a/tests/util/test_historic_wallets_migration.py +++ b/tests/util/test_historic_wallets_migration.py @@ -164,6 +164,7 @@ def test_migrate_wallet_history_with_trades(default_conf_usdt, fee, time_machine 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) @@ -217,6 +218,7 @@ def test_migrate_wallet_history_with_multiple_pairs(default_conf_usdt, fee, time 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)