Merge pull request #13019 from freqtrade/feat/capture_wallets
Capture wallet balance
This commit is contained in:
@@ -283,6 +283,10 @@
|
|||||||
"month"
|
"month"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"skip_wallet_history_migration": {
|
||||||
|
"description": "Disable wallet history migration.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"hyperopt_path": {
|
"hyperopt_path": {
|
||||||
"description": "Specify additional lookup path for Hyperopt Loss functions.",
|
"description": "Specify additional lookup path for Hyperopt Loss functions.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
+109
-101
@@ -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 │
|
│ TOTAL │ │ 77 │ 0.22 │ 54.774 │ 5.48 │ 22:12:00 │ 67 0 10 87.0 │
|
||||||
└───────────┴─────────────┴────────┴──────────────┴─────────────────┴──────────────┴─────────────────┴────────────────────────┘
|
└───────────┴─────────────┴────────┴──────────────┴─────────────────┴──────────────┴─────────────────┴────────────────────────┘
|
||||||
SUMMARY METRICS
|
SUMMARY METRICS
|
||||||
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||||
┃ Metric ┃ Value ┃
|
┃ Metric ┃ Value ┃
|
||||||
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||||
│ Backtesting from │ 2025-07-01 00:00:00 │
|
│ Backtesting from │ 2025-07-01 00:00:00 │
|
||||||
│ Backtesting to │ 2025-08-01 00:00:00 │
|
│ Backtesting to │ 2025-08-01 00:00:00 │
|
||||||
│ Trading Mode │ Isolated Futures │
|
│ Trading Mode │ Isolated Futures │
|
||||||
│ Max open trades │ 3 │
|
│ Max open trades │ 3 │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ Total/Daily Avg Trades │ 77 / 2.48 │
|
│ Total/Daily Avg Trades │ 77 / 2.48 │
|
||||||
│ Starting balance │ 1000 USDT │
|
│ Starting balance │ 1000 USDT │
|
||||||
│ Final balance │ 1054.774 USDT │
|
│ Final balance │ 1054.669 USDT │
|
||||||
│ Absolute profit │ 54.774 USDT │
|
│ Absolute profit │ 54.669 USDT │
|
||||||
│ Total profit % │ 5.48% │
|
│ Total profit % │ 5.47% │
|
||||||
│ CAGR % │ 87.36% │
|
│ CAGR % │ 87.14% │
|
||||||
│ Sortino │ 2.48 │
|
│ Sortino │ 2.46 │
|
||||||
│ Sharpe │ 3.75 │
|
│ Sharpe │ 3.73 │
|
||||||
│ Calmar │ 40.99 │
|
│ Calmar │ 40.81 │
|
||||||
│ SQN │ 0.69 │
|
│ SQN │ 0.69 │
|
||||||
│ Profit factor │ 1.29 │
|
│ Profit factor │ 1.29 │
|
||||||
│ Expectancy (Ratio) │ 0.71 (0.04) │
|
│ Expectancy (Ratio) │ 0.71 (0.04) │
|
||||||
│ Avg. daily profit │ 1.767 USDT │
|
│ Avg. daily profit │ 1.764 USDT │
|
||||||
│ Avg. stake amount │ 345.016 USDT │
|
│ Avg. stake amount │ 345.251 USDT │
|
||||||
│ Total trade volume │ 53316.954 USDT │
|
│ Total trade volume │ 53352.96 USDT │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ Long / Short trades │ 67 / 10 │
|
│ Long / Short trades │ 67 / 10 │
|
||||||
│ Long / Short profit % │ 8.94% / -3.47% │
|
│ Long / Short profit % │ 8.93% / -3.46% │
|
||||||
│ Long / Short profit USDT │ 89.425 / -34.651 │
|
│ Long / Short profit USDT │ 89.262 / -34.593 │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ Best Pair │ LTC/USDT:USDT 5.62% │
|
│ Best Pair │ LTC/USDT:USDT 5.62% │
|
||||||
│ Worst Pair │ ADA/USDT:USDT -5.21% │
|
│ Worst Pair │ ADA/USDT:USDT -5.21% │
|
||||||
│ Best trade │ ETC/USDT:USDT 2.00% │
|
│ Best trade │ ETC/USDT:USDT 2.00% │
|
||||||
│ Worst trade │ ADA/USDT:USDT -10.17% │
|
│ Worst trade │ ADA/USDT:USDT -10.17% │
|
||||||
│ Best day │ 26.91 USDT │
|
│ Best day │ 26.931 USDT │
|
||||||
│ Worst day │ -47.741 USDT │
|
│ Worst day │ -47.741 USDT │
|
||||||
│ Days win/draw/lose │ 20 / 6 / 5 │
|
│ 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 Winners │ 0d 00:35 / 5d 18:15 / 0d 15:49 │
|
||||||
│ Min/Max/Avg. Duration Losers │ 0d 10:40 / 17d 08:00 / 2d 17:00 │
|
│ Min/Max/Avg. Duration Losers │ 0d 10:40 / 17d 08:00 / 2d 17:00 │
|
||||||
│ Max Consecutive Wins / Loss │ 36 / 3 │
|
│ Max Consecutive Wins / Loss │ 36 / 3 │
|
||||||
│ Rejected Entry signals │ 258 │
|
│ Rejected Entry signals │ 258 │
|
||||||
│ Entry/Exit Timeouts │ 0 / 0 │
|
│ Entry/Exit Timeouts │ 0 / 0 │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ Min balance │ 1003.168 USDT │
|
│ Min/Max balance realized │ 1003.168 USDT / 1149.577 USDT │
|
||||||
│ Max balance │ 1149.421 USDT │
|
│ Min/Max balance unrealized │ 1000 USDT / 1149.577 USDT │
|
||||||
│ Max % of account underwater │ 8.23% │
|
│ Min/Max balance dates │ 2025-07-01 00:05:00 / 2025-07-22 15:15:00 │
|
||||||
│ Absolute drawdown │ 94.647 USDT (8.23%) │
|
│ Max % of account underwater │ 8.26% │
|
||||||
│ Drawdown duration │ 9 days 08:50:00 │
|
│ Absolute drawdown │ 94.908 USDT (8.26%) │
|
||||||
│ Profit at drawdown start │ 149.421 USDT │
|
│ Drawdown duration │ 9 days 08:50:00 │
|
||||||
│ Profit at drawdown end │ 54.774 USDT │
|
│ Profit at drawdown start │ 149.577 USDT │
|
||||||
│ Drawdown start │ 2025-07-22 15:10:00 │
|
│ Profit at drawdown end │ 54.669 USDT │
|
||||||
│ Drawdown end │ 2025-08-01 00:00:00 │
|
│ Drawdown start │ 2025-07-22 15:10:00 │
|
||||||
│ Market change │ 30.51% │
|
│ 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
|
Backtested 2025-07-01 00:00:00 -> 2025-08-01 00:00:00 | Max open trades : 3
|
||||||
STRATEGY SUMMARY
|
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.
|
It contains key metrics about the performance of your strategy on backtesting data.
|
||||||
|
|
||||||
```
|
```
|
||||||
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||||
┃ Metric ┃ Value ┃
|
┃ Metric ┃ Value ┃
|
||||||
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||||
│ Backtesting from │ 2025-07-01 00:00:00 │
|
│ Backtesting from │ 2025-07-01 00:00:00 │
|
||||||
│ Backtesting to │ 2025-08-01 00:00:00 │
|
│ Backtesting to │ 2025-08-01 00:00:00 │
|
||||||
│ Trading Mode │ Isolated Futures │
|
│ Trading Mode │ Isolated Futures │
|
||||||
│ Max open trades │ 3 │
|
│ Max open trades │ 3 │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ Total/Daily Avg Trades │ 72 / 2.32 │
|
│ Total/Daily Avg Trades │ 77 / 2.48 │
|
||||||
│ Starting balance │ 1000 USDT │
|
│ Starting balance │ 1000 USDT │
|
||||||
│ Final balance │ 1106.734 USDT │
|
│ Final balance │ 1054.669 USDT │
|
||||||
│ Absolute profit │ 106.734 USDT │
|
│ Absolute profit │ 54.669 USDT │
|
||||||
│ Total profit % │ 10.67% │
|
│ Total profit % │ 5.47% │
|
||||||
│ CAGR % │ 230.04% │
|
│ CAGR % │ 87.14% │
|
||||||
│ Sortino │ 4.99 │
|
│ Sortino │ 2.46 │
|
||||||
│ Sharpe │ 8.00 │
|
│ Sharpe │ 3.73 │
|
||||||
│ Calmar │ 77.76 │
|
│ Calmar │ 40.81 │
|
||||||
│ SQN │ 1.52 │
|
│ SQN │ 0.69 │
|
||||||
│ Profit factor │ 1.79 │
|
│ Profit factor │ 1.29 │
|
||||||
│ Expectancy (Ratio) │ 1.48 (0.07) │
|
│ Expectancy (Ratio) │ 0.71 (0.04) │
|
||||||
│ Avg. daily profit │ 3.443 USDT │
|
│ Avg. daily profit │ 1.764 USDT │
|
||||||
│ Avg. stake amount │ 363.133 USDT │
|
│ Avg. stake amount │ 345.251 USDT │
|
||||||
│ Total trade volume │ 52466.174 USDT │
|
│ Total trade volume │ 53352.96 USDT │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ Best Pair │ LTC/USDT:USDT 4.48% │
|
│ Long / Short trades │ 67 / 10 │
|
||||||
│ Worst Pair │ ADA/USDT:USDT -1.78% │
|
│ Long / Short profit % │ 8.93% / -3.46% │
|
||||||
│ Best trade │ ETC/USDT:USDT 2.00% │
|
│ Long / Short profit USDT │ 89.262 / -34.593 │
|
||||||
│ Worst trade │ ADA/USDT:USDT -10.17% │
|
│ │ │
|
||||||
│ Best day │ 23.535 USDT │
|
│ Best Pair │ LTC/USDT:USDT 5.62% │
|
||||||
│ Worst day │ -49.813 USDT │
|
│ Worst Pair │ ADA/USDT:USDT -5.21% │
|
||||||
│ Days win/draw/lose │ 21 / 6 / 4 │
|
│ Best trade │ ETC/USDT:USDT 2.00% │
|
||||||
│ Min/Max/Avg. Duration Winners │ 0d 00:35 / 5d 18:15 / 0d 15:30 │
|
│ Worst trade │ ADA/USDT:USDT -10.17% │
|
||||||
│ Min/Max/Avg. Duration Losers │ 0d 12:00 / 17d 08:00 / 3d 23:28 │
|
│ Best day │ 26.931 USDT │
|
||||||
│ Max Consecutive Wins / Loss │ 58 / 4 │
|
│ Worst day │ -47.741 USDT │
|
||||||
│ Rejected Entry signals │ 254 │
|
│ Days win/draw/lose │ 20 / 6 / 5 │
|
||||||
│ Entry/Exit Timeouts │ 0 / 0 │
|
│ 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 │
|
||||||
│ Min balance │ 1003.168 USDT │
|
│ Max Consecutive Wins / Loss │ 36 / 3 │
|
||||||
│ Max balance │ 1209 USDT │
|
│ Rejected Entry signals │ 258 │
|
||||||
│ Max % of account underwater │ 8.46% │
|
│ Entry/Exit Timeouts │ 0 / 0 │
|
||||||
│ Absolute drawdown │ 102.266 USDT (8.46%) │
|
│ │ │
|
||||||
│ Drawdown duration │ 9 days 08:50:00 │
|
│ Min/Max balance realized │ 1003.168 USDT / 1149.577 USDT │
|
||||||
│ Profit at drawdown start │ 209 USDT │
|
│ Min/Max balance unrealized │ 1000 USDT / 1149.577 USDT │
|
||||||
│ Profit at drawdown end │ 106.734 USDT │
|
│ Min/Max balance dates │ 2025-07-01 00:05:00 / 2025-07-22 15:15:00 │
|
||||||
│ Drawdown start │ 2025-07-22 15:10:00 │
|
│ Max % of account underwater │ 8.26% │
|
||||||
│ Drawdown end │ 2025-08-01 00:00:00 │
|
│ Absolute drawdown │ 94.908 USDT (8.26%) │
|
||||||
│ Market change │ 30.51% │
|
│ 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).
|
- `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.
|
- `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.
|
- `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).
|
- `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used).
|
||||||
- `Min balance` / `Max balance`: Lowest and Highest Wallet balance during the backtest period.
|
- `Min/Max balance realized`: Lowest and Highest Wallet balance during the backtest period based on closed trades trades.
|
||||||
|
- `Min/Max balance unrealized`: Lowest and Highest Wallet balance during the backtest period - including capital tied in open trades.
|
||||||
|
- `Min/Max balance dates`: Dates when the minimum and maximum unrealized balance occurred.
|
||||||
- `Max % of account underwater`: Maximum percentage your account has decreased from the top since the simulation started. Calculated as the maximum of `(Max Balance - Current Balance) / (Max Balance)`.
|
- `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)`..
|
- `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.
|
- `Drawdown duration`: Duration of the largest drawdown period.
|
||||||
|
|||||||
@@ -46,6 +46,23 @@ On this page, you can also interact with the bot by starting and stopping it and
|
|||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
The dashboard view provides an overview of the bot's performance and status.
|
||||||
|
If multiple bots are connected, the dashboard will show an overview of all connected bots, allowing you to easily switch between them or show just a subset of available bots.
|
||||||
|
|
||||||
|
#### Wallet Balance
|
||||||
|
|
||||||
|
New in freqtrade 2026.4: This shows the balance of the bot over time.
|
||||||
|
|
||||||
|
Compared to the "cumulative Profit" chart, this chart will show the actual balance of the bot over time, including unrealized profit and losses, as well as deposits and withdrawals.
|
||||||
|
|
||||||
|
Historic data has re-populated based on available exchange data - however is assumed to be best-effort and may not be 100% accurate.
|
||||||
|
More specifically, it won't cover deposits and withdrawals, and will assume a starting balance of current balance - profit/losses.
|
||||||
|
|
||||||
|
For clarity - a "Capture start" marker line is shown on the chart, which indicates the point at which the migration to the new wallet balance tracking system happened.
|
||||||
|
Only beyond this point, the wallet balance is expected to be accurate.
|
||||||
|
|
||||||
### Plot Configurator
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -236,6 +236,10 @@ CONF_SCHEMA = {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": BACKTEST_CACHE_AGE,
|
"enum": BACKTEST_CACHE_AGE,
|
||||||
},
|
},
|
||||||
|
"skip_wallet_history_migration": {
|
||||||
|
"description": "Disable wallet history migration.",
|
||||||
|
"type": "boolean",
|
||||||
|
},
|
||||||
# Hyperopt
|
# Hyperopt
|
||||||
"hyperopt_path": {
|
"hyperopt_path": {
|
||||||
"description": "Specify additional lookup path for Hyperopt Loss functions.",
|
"description": "Specify additional lookup path for Hyperopt Loss functions.",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from .bt_fileutils import (
|
|||||||
get_backtest_market_change,
|
get_backtest_market_change,
|
||||||
get_backtest_result,
|
get_backtest_result,
|
||||||
get_backtest_resultlist,
|
get_backtest_resultlist,
|
||||||
|
get_backtest_wallet_change,
|
||||||
get_latest_backtest_filename,
|
get_latest_backtest_filename,
|
||||||
get_latest_hyperopt_file,
|
get_latest_hyperopt_file,
|
||||||
get_latest_hyperopt_filename,
|
get_latest_hyperopt_filename,
|
||||||
|
|||||||
@@ -312,6 +312,27 @@ def get_backtest_market_change(filename: Path, include_ts: bool = True) -> pd.Da
|
|||||||
return df
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def get_backtest_wallet_change(filename: Path, strategy_name: str) -> pd.DataFrame | None:
|
||||||
|
"""
|
||||||
|
Read backtest wallet change file.
|
||||||
|
:param filename: Path to the backtest result zip file
|
||||||
|
:param strategy_name: Name of the strategy to load
|
||||||
|
:return: DataFrame with wallet change data
|
||||||
|
"""
|
||||||
|
if filename.suffix != ".zip":
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = load_file_from_zip(filename, f"{filename.stem}_{strategy_name}_wallet.feather")
|
||||||
|
df = pd.read_feather(BytesIO(data))
|
||||||
|
|
||||||
|
df.loc[:, "__date_ts"] = df.loc[:, "date"].astype(np.int64) // 1000 // 1000
|
||||||
|
return df
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_existing_backtest_stats(
|
def find_existing_backtest_stats(
|
||||||
dirname: Path | str, run_ids: dict[str, str], min_backtest_date: datetime | None = None
|
dirname: Path | str, run_ids: dict[str, str], min_backtest_date: datetime | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -503,13 +524,16 @@ def load_backtest_analysis_data(
|
|||||||
return None
|
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
|
Convert list of Trade objects to pandas Dataframe
|
||||||
:param trades: List of trade objects
|
:param trades: List of trade objects
|
||||||
|
:param minified: Whether to use minified version of trade JSON
|
||||||
:return: Dataframe with BT_DATA_COLUMNS
|
: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:
|
if len(df) > 0:
|
||||||
df["close_date"] = pd.to_datetime(df["close_timestamp"], unit="ms", utc=True)
|
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)
|
df["open_date"] = pd.to_datetime(df["open_timestamp"], unit="ms", utc=True)
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from freqtrade.constants import IntOrInf
|
from freqtrade.constants import IntOrInf
|
||||||
|
from freqtrade.exchange import (
|
||||||
|
timeframe_to_prev_date,
|
||||||
|
timeframe_to_resample_freq,
|
||||||
|
)
|
||||||
|
from freqtrade.util import dt_from_ts
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -58,3 +64,95 @@ def evaluate_result_multi(
|
|||||||
"""
|
"""
|
||||||
df_final = analyze_trade_parallelism(trades, timeframe)
|
df_final = analyze_trade_parallelism(trades, timeframe)
|
||||||
return df_final[df_final["open_trades"] > max_open_trades]
|
return df_final[df_final["open_trades"] > max_open_trades]
|
||||||
|
|
||||||
|
|
||||||
|
def balance_distribution_over_time(
|
||||||
|
trades: pd.DataFrame,
|
||||||
|
min_date: datetime,
|
||||||
|
max_date: datetime,
|
||||||
|
timeframe: str,
|
||||||
|
stake_currency: str,
|
||||||
|
start_balance: float,
|
||||||
|
pairlist: list[str],
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Return a dataframe with stake_currency and the pairlist as columns
|
||||||
|
Each column will contain the amount of the currency at the given time
|
||||||
|
Columns added are:
|
||||||
|
- stake_currency: amount of stake currency
|
||||||
|
- <pair>: amount of base currency in the pair
|
||||||
|
- <pair>_leverage: leverage used for the pair at the time (NaN if no open trade)
|
||||||
|
- <pair>_is_short: 1 if the open trade is short, 0 if long (NaN if no open trade)
|
||||||
|
- <pair>_collateral: amount of stake currency used as collateral for open trades
|
||||||
|
:param trades: Trades Dataframe - can be loaded from backtest, or created
|
||||||
|
via trade_list_to_dataframe
|
||||||
|
:param timeframe: Frequency to use for the resulting dataframe
|
||||||
|
:param min_date: start date
|
||||||
|
:param max_date: End date (will be rounded down to timeframe)
|
||||||
|
:param stake_currency: The stake currency
|
||||||
|
:param start_balance: Starting balance in stake currency
|
||||||
|
:param pairlist: List of trading pairs to include in the dataframe
|
||||||
|
Can be obtained via trade_df["pair"].unique()
|
||||||
|
For pairs without trades, the column will be all zeros
|
||||||
|
:return: Dataframe with balance distribution over time
|
||||||
|
"""
|
||||||
|
min_date_res = timeframe_to_prev_date(timeframe, min_date)
|
||||||
|
max_date_res = timeframe_to_prev_date(timeframe, max_date)
|
||||||
|
index = pd.date_range(min_date_res, max_date_res, freq=timeframe_to_resample_freq(timeframe))
|
||||||
|
pairs_lev = [f"{pair}_leverage" for pair in pairlist]
|
||||||
|
pairs_is_short = [f"{pair}_is_short" for pair in pairlist]
|
||||||
|
pairs_collateral = [f"{pair}_collateral" for pair in pairlist]
|
||||||
|
pairs_lev += pairs_is_short
|
||||||
|
|
||||||
|
df = pd.DataFrame(
|
||||||
|
index=index, columns=[stake_currency] + pairlist + pairs_lev + pairs_collateral, dtype=float
|
||||||
|
)
|
||||||
|
# Initialize variables to starting values
|
||||||
|
df[stake_currency] = float(start_balance)
|
||||||
|
df[pairlist + pairs_collateral] = 0.0
|
||||||
|
df[pairs_lev] = np.nan
|
||||||
|
|
||||||
|
for trade in trades.sort_values(by=["open_date"]).itertuples():
|
||||||
|
pair = trade.pair
|
||||||
|
end_date = trade.close_date if trade.close_date is not pd.NaT else None
|
||||||
|
# Exclude open orders - these won't have order_filled_timestamp set.
|
||||||
|
df.loc[trade.open_date : end_date, f"{pair}_leverage"] = trade.leverage
|
||||||
|
df.loc[trade.open_date : end_date, f"{pair}_is_short"] = 1 if trade.is_short else 0
|
||||||
|
orders = [o for o in trade.orders if o["order_filled_timestamp"]]
|
||||||
|
current_position = 0
|
||||||
|
current_collateral = 0
|
||||||
|
for order in sorted(orders, key=lambda x: x["order_filled_timestamp"]):
|
||||||
|
filled_at = pd.Timestamp(dt_from_ts(order["order_filled_timestamp"]))
|
||||||
|
real_amount = order.get("filled", order["amount"])
|
||||||
|
stake = order["safe_price"] * real_amount
|
||||||
|
stake_no_lev = stake / trade.leverage
|
||||||
|
if order["ft_is_entry"]:
|
||||||
|
# Entry order: lock collateral and pay fee
|
||||||
|
# For both long and short: balance decreases by collateral + fee
|
||||||
|
fee_open = stake * trade.fee_open
|
||||||
|
current_position += real_amount
|
||||||
|
current_collateral += stake_no_lev
|
||||||
|
df.loc[filled_at:end_date, pair] += real_amount
|
||||||
|
df.loc[filled_at:end_date, f"{pair}_collateral"] += stake_no_lev
|
||||||
|
df.loc[filled_at:, stake_currency] -= stake_no_lev + fee_open
|
||||||
|
else:
|
||||||
|
# Exit order: release collateral and realize profit/loss
|
||||||
|
fee_close = stake * trade.fee_close
|
||||||
|
if trade.is_short:
|
||||||
|
# For SHORT
|
||||||
|
df.loc[filled_at:, stake_currency] += (
|
||||||
|
current_collateral * (1 + trade.leverage) - stake
|
||||||
|
) - fee_close
|
||||||
|
else:
|
||||||
|
# For LONG
|
||||||
|
df.loc[filled_at:, stake_currency] += (
|
||||||
|
stake - current_collateral * (trade.leverage - 1) - fee_close
|
||||||
|
)
|
||||||
|
df.loc[filled_at:end_date, pair] -= real_amount
|
||||||
|
df.loc[filled_at:end_date, f"{pair}_collateral"] -= stake_no_lev
|
||||||
|
current_position -= real_amount
|
||||||
|
current_collateral -= stake_no_lev
|
||||||
|
|
||||||
|
# Round to avoid floating point issues
|
||||||
|
df = df.round(14)
|
||||||
|
return df
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
self._schedule.every().day.at(t).do(update)
|
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: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()
|
self.strategy.ft_bot_start()
|
||||||
# Initialize protections AFTER bot start - otherwise parameters are not loaded.
|
# Initialize protections AFTER bot start - otherwise parameters are not loaded.
|
||||||
@@ -238,7 +239,7 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
Called on startup and after reloading the bot - triggers notifications and
|
Called on startup and after reloading the bot - triggers notifications and
|
||||||
performs startup tasks
|
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()
|
set_startup_time()
|
||||||
|
|
||||||
self.rpc.startup_messages(self.config, self.pairlists, self.protections)
|
self.rpc.startup_messages(self.config, self.pairlists, self.protections)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class BacktestContentTypeIcomplete(TypedDict, total=False):
|
|||||||
backtest_start_time: int
|
backtest_start_time: int
|
||||||
backtest_end_time: int
|
backtest_end_time: int
|
||||||
run_id: str
|
run_id: str
|
||||||
|
wallet_summary: DataFrame
|
||||||
|
|
||||||
|
|
||||||
class BacktestContentType(BacktestContentTypeIcomplete, total=True):
|
class BacktestContentType(BacktestContentTypeIcomplete, total=True):
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ from freqtrade.mixins import LoggingMixin
|
|||||||
from freqtrade.optimize.backtest_caching import get_strategy_run_id
|
from freqtrade.optimize.backtest_caching import get_strategy_run_id
|
||||||
from freqtrade.optimize.bt_progress import BTProgress
|
from freqtrade.optimize.bt_progress import BTProgress
|
||||||
from freqtrade.optimize.optimize_reports import (
|
from freqtrade.optimize.optimize_reports import (
|
||||||
|
convert_bt_wallet_collection,
|
||||||
generate_backtest_stats,
|
generate_backtest_stats,
|
||||||
generate_rejected_signals,
|
generate_rejected_signals,
|
||||||
generate_trade_signal_candles,
|
generate_trade_signal_candles,
|
||||||
@@ -137,6 +138,7 @@ class Backtesting:
|
|||||||
}
|
}
|
||||||
self.rejected_dict: dict[str, list] = {}
|
self.rejected_dict: dict[str, list] = {}
|
||||||
self.starting_balance: float = 0.0
|
self.starting_balance: float = 0.0
|
||||||
|
self.wallet_captures: list = []
|
||||||
|
|
||||||
self._exchange_name = self.config["exchange"]["name"]
|
self._exchange_name = self.config["exchange"]["name"]
|
||||||
self.__initial_backtest = exchange is None
|
self.__initial_backtest = exchange is None
|
||||||
@@ -451,6 +453,7 @@ class Backtesting:
|
|||||||
self.replaced_entry_orders = 0
|
self.replaced_entry_orders = 0
|
||||||
self.canceled_exit_orders = 0
|
self.canceled_exit_orders = 0
|
||||||
self.replaced_exit_orders = 0
|
self.replaced_exit_orders = 0
|
||||||
|
self.wallet_captures = []
|
||||||
self.dataprovider.clear_cache()
|
self.dataprovider.clear_cache()
|
||||||
if enable_protections:
|
if enable_protections:
|
||||||
self._load_protections(self.strategy)
|
self._load_protections(self.strategy)
|
||||||
@@ -1603,6 +1606,7 @@ class Backtesting:
|
|||||||
pair_detail_cache: dict[str, list[tuple]] = {}
|
pair_detail_cache: dict[str, list[tuple]] = {}
|
||||||
pair_tradedir_cache: dict[str, LongShort | None] = {}
|
pair_tradedir_cache: dict[str, LongShort | None] = {}
|
||||||
pairs_with_open_trades = [t.pair for t in LocalTrade.bt_trades_open]
|
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(
|
for current_time_det, is_first, has_detail, idx, pair in self._time_pair_generator_det(
|
||||||
current_time, pairs
|
current_time, pairs
|
||||||
@@ -1627,6 +1631,7 @@ class Backtesting:
|
|||||||
)
|
)
|
||||||
trade_dir = self.check_for_trade_entry(row)
|
trade_dir = self.check_for_trade_entry(row)
|
||||||
pair_tradedir_cache[pair] = trade_dir
|
pair_tradedir_cache[pair] = trade_dir
|
||||||
|
self._capture_wallet(current_time, pair.split("/")[0], row[OPEN_IDX])
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Detail candle - from cache.
|
# Detail candle - from cache.
|
||||||
@@ -1680,6 +1685,15 @@ class Backtesting:
|
|||||||
yield current_time_det, pair, row, is_last_row, trade_dir
|
yield current_time_det, pair, row, is_last_row, trade_dir
|
||||||
self.progress.increment()
|
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(
|
def backtest(
|
||||||
self, processed: dict, start_date: datetime, end_date: datetime
|
self, processed: dict, start_date: datetime, end_date: datetime
|
||||||
) -> BacktestContentTypeIcomplete:
|
) -> BacktestContentTypeIcomplete:
|
||||||
@@ -1739,6 +1753,7 @@ class Backtesting:
|
|||||||
"canceled_entry_orders": self.canceled_entry_orders,
|
"canceled_entry_orders": self.canceled_entry_orders,
|
||||||
"replaced_entry_orders": self.replaced_entry_orders,
|
"replaced_entry_orders": self.replaced_entry_orders,
|
||||||
"final_balance": self.wallets.get_total(self.strategy.config["stake_currency"]),
|
"final_balance": self.wallets.get_total(self.strategy.config["stake_currency"]),
|
||||||
|
"wallet_summary": convert_bt_wallet_collection(self.wallet_captures),
|
||||||
}
|
}
|
||||||
|
|
||||||
def backtest_one_strategy(
|
def backtest_one_strategy(
|
||||||
@@ -1867,6 +1882,11 @@ class Backtesting:
|
|||||||
dt_appendix,
|
dt_appendix,
|
||||||
market_change_data=combined_res,
|
market_change_data=combined_res,
|
||||||
analysis_results=self.analysis_results,
|
analysis_results=self.analysis_results,
|
||||||
|
wallet_summary={
|
||||||
|
s: x["wallet_summary"]
|
||||||
|
for s, x in self.all_bt_content.items()
|
||||||
|
if "wallet_summary" in x
|
||||||
|
},
|
||||||
strategy_files={s.get_strategy_name(): s.__file__ for s in self.strategylist},
|
strategy_files={s.get_strategy_name(): s.__file__ for s in self.strategylist},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from freqtrade.optimize.optimize_reports.bt_output import (
|
|||||||
)
|
)
|
||||||
from freqtrade.optimize.optimize_reports.bt_storage import store_backtest_results
|
from freqtrade.optimize.optimize_reports.bt_storage import store_backtest_results
|
||||||
from freqtrade.optimize.optimize_reports.optimize_reports import (
|
from freqtrade.optimize.optimize_reports.optimize_reports import (
|
||||||
|
convert_bt_wallet_collection,
|
||||||
generate_all_periodic_breakdown_stats,
|
generate_all_periodic_breakdown_stats,
|
||||||
generate_backtest_stats,
|
generate_backtest_stats,
|
||||||
generate_daily_stats,
|
generate_daily_stats,
|
||||||
|
|||||||
@@ -287,6 +287,24 @@ def text_table_add_metrics(strat_results: dict) -> None:
|
|||||||
if "trading_mode" in strat_results
|
if "trading_mode" in strat_results
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
|
wallet_metrics: list[tuple[str, str]] = []
|
||||||
|
if wallet_stats := strat_results.get("wallet_stats"):
|
||||||
|
wallet_metrics = [
|
||||||
|
(
|
||||||
|
"Min/Max balance realized",
|
||||||
|
f"{fmt_coin(strat_results['csum_min'], stake)} / "
|
||||||
|
f"{fmt_coin(strat_results['csum_max'], stake)}",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Min/Max balance unrealized",
|
||||||
|
f"{fmt_coin(wallet_stats['low_balance'], stake)} / "
|
||||||
|
f"{fmt_coin(wallet_stats['high_balance'], stake)}",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Min/Max balance dates",
|
||||||
|
f"{wallet_stats['low_date']} / {wallet_stats['high_date']}",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
# Newly added fields should be ignored if they are missing in strat_results. hyperopt-show
|
# 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
|
# command stores these results and newer version of freqtrade must be able to handle old
|
||||||
@@ -408,8 +426,7 @@ def text_table_add_metrics(strat_results: dict) -> None:
|
|||||||
),
|
),
|
||||||
*entry_adjustment_metrics,
|
*entry_adjustment_metrics,
|
||||||
("", ""), # Empty line to improve readability
|
("", ""), # Empty line to improve readability
|
||||||
("Min balance", fmt_coin(strat_results["csum_min"], stake)),
|
*wallet_metrics,
|
||||||
("Max balance", fmt_coin(strat_results["csum_max"], stake)),
|
|
||||||
*drawdown_metrics,
|
*drawdown_metrics,
|
||||||
("Market change", f"{strat_results['market_change']:.2%}"),
|
("Market change", f"{strat_results['market_change']:.2%}"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ def store_backtest_results(
|
|||||||
dtappendix: str,
|
dtappendix: str,
|
||||||
*,
|
*,
|
||||||
market_change_data: DataFrame | None = None,
|
market_change_data: DataFrame | None = None,
|
||||||
|
wallet_summary: dict[str, DataFrame] | None = None,
|
||||||
analysis_results: dict[str, dict[str, DataFrame]] | None = None,
|
analysis_results: dict[str, dict[str, DataFrame]] | None = None,
|
||||||
strategy_files: dict[str, str] | None = None,
|
strategy_files: dict[str, str] | None = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
@@ -123,6 +124,15 @@ def store_backtest_results(
|
|||||||
market_change_buf.seek(0)
|
market_change_buf.seek(0)
|
||||||
zipf.writestr(market_change_name, market_change_buf.getvalue())
|
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
|
# Add analysis results if present and running in backtest mode
|
||||||
if (
|
if (
|
||||||
config.get("export", "none") == "signals"
|
config.get("export", "none") == "signals"
|
||||||
|
|||||||
@@ -29,6 +29,48 @@ from freqtrade.util import decimals_per_coin, fmt_coin, format_duration, get_dry
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_bt_wallet_collection(wallet_captures: list[tuple]) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Convert the wallet capture list to a DataFrame.
|
||||||
|
Assumes the wallet_captures list contains tuples with the following structure:
|
||||||
|
(date, currency, price, balance).
|
||||||
|
"""
|
||||||
|
if len(wallet_captures) == 0:
|
||||||
|
return DataFrame()
|
||||||
|
return DataFrame(
|
||||||
|
wallet_captures,
|
||||||
|
columns=["date", "currency", "rate", "balance"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_wallet_stats(wallet_df: DataFrame, stake_currency: str) -> dict[str, Any]:
|
||||||
|
"""Generate wallet statistics from the wallet DataFrame."""
|
||||||
|
if wallet_df is None or wallet_df.empty:
|
||||||
|
return {}
|
||||||
|
wallet_df.loc[:, "total_quote"] = wallet_df["rate"] * wallet_df["balance"]
|
||||||
|
# Group by date to get total wallet value at each timestamp
|
||||||
|
wallet = wallet_df.groupby("date")["total_quote"].sum().reset_index()
|
||||||
|
total_quote = wallet["total_quote"]
|
||||||
|
low_idx = total_quote.idxmin()
|
||||||
|
high_idx = total_quote.idxmax()
|
||||||
|
start_balance = wallet.iloc[0]["total_quote"]
|
||||||
|
end_balance = wallet.iloc[-1]["total_quote"]
|
||||||
|
high_balance = total_quote.loc[high_idx]
|
||||||
|
low_balance = total_quote.loc[low_idx]
|
||||||
|
low_date = wallet.loc[low_idx, "date"]
|
||||||
|
high_date = wallet.loc[high_idx, "date"]
|
||||||
|
return {
|
||||||
|
"start_balance": start_balance,
|
||||||
|
"end_balance": end_balance,
|
||||||
|
"high_balance": high_balance,
|
||||||
|
"low_balance": low_balance,
|
||||||
|
"low_date": low_date.strftime(DATETIME_PRINT_FORMAT),
|
||||||
|
"low_ts": int(low_date.timestamp() * 1000),
|
||||||
|
"high_date": high_date.strftime(DATETIME_PRINT_FORMAT),
|
||||||
|
"high_ts": int(high_date.timestamp() * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def generate_trade_signal_candles(
|
def generate_trade_signal_candles(
|
||||||
preprocessed_df: dict[str, DataFrame], bt_results: BacktestContentType, date_col: str
|
preprocessed_df: dict[str, DataFrame], bt_results: BacktestContentType, date_col: str
|
||||||
) -> dict[str, DataFrame]:
|
) -> dict[str, DataFrame]:
|
||||||
@@ -592,6 +634,7 @@ def generate_strategy_stats(
|
|||||||
"sharpe": calculate_sharpe(results, min_date, max_date, start_balance),
|
"sharpe": calculate_sharpe(results, min_date, max_date, start_balance),
|
||||||
"calmar": calculate_calmar(results, min_date, max_date, start_balance),
|
"calmar": calculate_calmar(results, min_date, max_date, start_balance),
|
||||||
"sqn": calculate_sqn(results, start_balance),
|
"sqn": calculate_sqn(results, start_balance),
|
||||||
|
"wallet_stats": generate_wallet_stats(content.get("wallet_summary"), stake_currency),
|
||||||
"profit_factor": profit_factor,
|
"profit_factor": profit_factor,
|
||||||
"backtest_start": min_date.strftime(DATETIME_PRINT_FORMAT),
|
"backtest_start": min_date.strftime(DATETIME_PRINT_FORMAT),
|
||||||
"backtest_start_ts": int(min_date.timestamp() * 1000),
|
"backtest_start_ts": int(min_date.timestamp() * 1000),
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ from freqtrade.persistence.usedb_context import (
|
|||||||
disable_database_use,
|
disable_database_use,
|
||||||
enable_database_use,
|
enable_database_use,
|
||||||
)
|
)
|
||||||
|
from freqtrade.persistence.wallet_history import WalletHistory
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ KeyStoreKeys = Literal[
|
|||||||
"bot_start_time",
|
"bot_start_time",
|
||||||
"startup_time",
|
"startup_time",
|
||||||
"binance_migration",
|
"binance_migration",
|
||||||
|
"wallet_history_migration",
|
||||||
|
"wallet_history_migration_date",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from freqtrade.persistence.key_value_store import _KeyValueStoreModel
|
|||||||
from freqtrade.persistence.migrations import check_migrate
|
from freqtrade.persistence.migrations import check_migrate
|
||||||
from freqtrade.persistence.pairlock import PairLock
|
from freqtrade.persistence.pairlock import PairLock
|
||||||
from freqtrade.persistence.trade_model import Order, Trade
|
from freqtrade.persistence.trade_model import Order, Trade
|
||||||
|
from freqtrade.persistence.wallet_history import WalletHistory
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -91,6 +92,7 @@ def init_db(db_url: str) -> None:
|
|||||||
_CustomData.session = scoped_session(
|
_CustomData.session = scoped_session(
|
||||||
sessionmaker(bind=engine, autoflush=True), scopefunc=get_request_or_thread_id
|
sessionmaker(bind=engine, autoflush=True), scopefunc=get_request_or_thread_id
|
||||||
)
|
)
|
||||||
|
WalletHistory.session = Trade.session
|
||||||
|
|
||||||
previous_tables = inspect(engine).get_table_names()
|
previous_tables = inspect(engine).get_table_names()
|
||||||
ModelBase.metadata.create_all(engine)
|
ModelBase.metadata.create_all(engine)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from freqtrade.persistence.base import ModelBase, SessionType
|
||||||
|
|
||||||
|
|
||||||
|
class WalletHistory(ModelBase):
|
||||||
|
"""
|
||||||
|
Daily wallet state tracking with minimal fields
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "wallet_history"
|
||||||
|
session: ClassVar[SessionType]
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||||
|
currency: Mapped[str] = mapped_column(String(25), nullable=False)
|
||||||
|
# Rate: price of 1 unit of `currency` quoted in `quote_currency`.
|
||||||
|
# e.g., USDT/ETH -> USDT per ETH
|
||||||
|
rate: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
|
# Quote currency for rate/total fields (e.g., 'USDT')
|
||||||
|
quote_currency: Mapped[str] = mapped_column(String(25), nullable=False)
|
||||||
|
|
||||||
|
# Balance in `currency` units
|
||||||
|
balance: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
|
||||||
|
# Canonical total wallet equity/value denominated in `quote_currency` (if available)
|
||||||
|
# For futures positions, collateral + PnL is used to compute this value.
|
||||||
|
total_quote: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
|
# Total position value in `quote_currency` - including leverage
|
||||||
|
total_position_value: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
|
collateral: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
|
leverage: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
|
||||||
|
|
||||||
|
bot_managed: Mapped[bool] = mapped_column(nullable=False, default=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# Ensure one record per currency per day
|
||||||
|
UniqueConstraint("timestamp", "currency", name="unique_wallet_daily"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"WalletHistory(timestamp={self.timestamp}, currency={self.currency}, "
|
||||||
|
f"rate={self.rate}, total_quote={self.total_quote}, "
|
||||||
|
f"balance={self.balance}, leverage={self.leverage})"
|
||||||
|
)
|
||||||
@@ -16,10 +16,11 @@ from freqtrade.data.btanalysis import (
|
|||||||
get_backtest_market_change,
|
get_backtest_market_change,
|
||||||
get_backtest_result,
|
get_backtest_result,
|
||||||
get_backtest_resultlist,
|
get_backtest_resultlist,
|
||||||
|
get_backtest_wallet_change,
|
||||||
load_and_merge_backtest_result,
|
load_and_merge_backtest_result,
|
||||||
update_backtest_metadata,
|
update_backtest_metadata,
|
||||||
)
|
)
|
||||||
from freqtrade.enums import BacktestState
|
from freqtrade.enums import BacktestState, RunMode
|
||||||
from freqtrade.exceptions import ConfigurationError, DependencyException, OperationalException
|
from freqtrade.exceptions import ConfigurationError, DependencyException, OperationalException
|
||||||
from freqtrade.ft_types import get_BacktestResultType_default
|
from freqtrade.ft_types import get_BacktestResultType_default
|
||||||
from freqtrade.misc import deep_merge_dicts, is_file_in_dir
|
from freqtrade.misc import deep_merge_dicts, is_file_in_dir
|
||||||
@@ -29,6 +30,7 @@ from freqtrade.rpc.api_server.api_schemas import (
|
|||||||
BacktestMetadataUpdate,
|
BacktestMetadataUpdate,
|
||||||
BacktestRequest,
|
BacktestRequest,
|
||||||
BacktestResponse,
|
BacktestResponse,
|
||||||
|
WalletHistoryResponse,
|
||||||
)
|
)
|
||||||
from freqtrade.rpc.api_server.deps import get_config, verify_strategy
|
from freqtrade.rpc.api_server.deps import get_config, verify_strategy
|
||||||
from freqtrade.rpc.api_server.webserver_bgwork import ApiBG
|
from freqtrade.rpc.api_server.webserver_bgwork import ApiBG
|
||||||
@@ -106,6 +108,11 @@ def __run_backtest_bg(btconfig: Config):
|
|||||||
ApiBG.bt["bt"].results,
|
ApiBG.bt["bt"].results,
|
||||||
datetime.now().strftime("%Y-%m-%d_%H-%M-%S"),
|
datetime.now().strftime("%Y-%m-%d_%H-%M-%S"),
|
||||||
market_change_data=combined_res,
|
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={
|
strategy_files={
|
||||||
s.get_strategy_name(): s.__file__ for s in ApiBG.bt["bt"].strategylist
|
s.get_strategy_name(): s.__file__ for s in ApiBG.bt["bt"].strategylist
|
||||||
},
|
},
|
||||||
@@ -137,6 +144,7 @@ async def api_start_backtest(
|
|||||||
verify_strategy(bt_settings.strategy)
|
verify_strategy(bt_settings.strategy)
|
||||||
|
|
||||||
btconfig = deepcopy(config)
|
btconfig = deepcopy(config)
|
||||||
|
btconfig["runmode"] = RunMode.BACKTEST
|
||||||
remove_exchange_credentials(btconfig["exchange"], True)
|
remove_exchange_credentials(btconfig["exchange"], True)
|
||||||
settings = dict(bt_settings)
|
settings = dict(bt_settings)
|
||||||
if settings.get("freqai", None) is not None:
|
if settings.get("freqai", None) is not None:
|
||||||
@@ -354,3 +362,29 @@ def api_get_backtest_market_change(file: str, config=Depends(get_config)):
|
|||||||
"data": df.values.tolist(),
|
"data": df.values.tolist(),
|
||||||
"length": len(df),
|
"length": len(df),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/backtest/history/{file}/{strategy}/wallet",
|
||||||
|
response_model=WalletHistoryResponse,
|
||||||
|
tags=["webserver", "backtest"],
|
||||||
|
)
|
||||||
|
def api_get_backtest_wallet(file: str, strategy: str, config=Depends(get_config)):
|
||||||
|
bt_results_base: Path = config["user_data_dir"] / "backtest_results"
|
||||||
|
file_abs = (bt_results_base / file).with_suffix(".zip")
|
||||||
|
# Ensure file is in backtest_results directory
|
||||||
|
if not is_file_in_dir(file_abs, bt_results_base):
|
||||||
|
raise HTTPException(status_code=400, detail="Unable to retrieve wallet history.")
|
||||||
|
|
||||||
|
results = get_backtest_wallet_change(file_abs, strategy)
|
||||||
|
if results is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unable to retrieve wallet history.")
|
||||||
|
# Consolidate the wallet to the base currency
|
||||||
|
results.loc[:, "total_quote"] = results["rate"] * results["balance"]
|
||||||
|
results = results.groupby(["date", "__date_ts"]).agg({"total_quote": "sum"}).reset_index()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"columns": results.columns.tolist(),
|
||||||
|
"data": results.values.tolist(),
|
||||||
|
"length": len(results),
|
||||||
|
}
|
||||||
|
|||||||
@@ -679,6 +679,15 @@ class BacktestMarketChange(BaseModel):
|
|||||||
data: list[list[Any]]
|
data: list[list[Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class WalletHistoryResponse(BaseModel):
|
||||||
|
columns: list[str]
|
||||||
|
length: int
|
||||||
|
data: list[list[Any]]
|
||||||
|
# start date of the effectively captured data
|
||||||
|
# Before this date, it's based on a reconstructed wallet history
|
||||||
|
capture_start_ts: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class MarketRequest(ExchangeModePayloadMixin, BaseModel):
|
class MarketRequest(ExchangeModePayloadMixin, BaseModel):
|
||||||
base: str | None = None
|
base: str | None = None
|
||||||
quote: str | None = None
|
quote: str | None = None
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from freqtrade.rpc.api_server.api_schemas import (
|
|||||||
ResultMsg,
|
ResultMsg,
|
||||||
Stats,
|
Stats,
|
||||||
StatusMsg,
|
StatusMsg,
|
||||||
|
WalletHistoryResponse,
|
||||||
WhitelistResponse,
|
WhitelistResponse,
|
||||||
)
|
)
|
||||||
from freqtrade.rpc.api_server.deps import get_config, get_rpc
|
from freqtrade.rpc.api_server.deps import get_config, get_rpc
|
||||||
@@ -104,6 +105,22 @@ def stats(rpc: RPC = Depends(get_rpc)):
|
|||||||
return rpc._rpc_stats()
|
return rpc._rpc_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/historic_balance",
|
||||||
|
response_model=WalletHistoryResponse,
|
||||||
|
tags=["info"],
|
||||||
|
)
|
||||||
|
def api_get_wallet_history(rpc: RPC = Depends(get_rpc)):
|
||||||
|
results, capture_date_ts = rpc._rpc_get_historic_balance()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"columns": results.columns.tolist(),
|
||||||
|
"data": results.values.tolist(),
|
||||||
|
"length": len(results),
|
||||||
|
"capture_start_ts": capture_date_ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/daily", response_model=DailyWeeklyMonthly, tags=["Trading-info"])
|
@router.get("/daily", response_model=DailyWeeklyMonthly, tags=["Trading-info"])
|
||||||
def daily(
|
def daily(
|
||||||
timescale: int = Query(7, ge=1, description="Number of days to fetch data for"),
|
timescale: int = Query(7, ge=1, description="Number of days to fetch data for"),
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ logger = logging.getLogger(__name__)
|
|||||||
# 2.45: Add price to forceexit endpoint
|
# 2.45: Add price to forceexit endpoint
|
||||||
# 2.46: Add prepend_data to download-data endpoint
|
# 2.46: Add prepend_data to download-data endpoint
|
||||||
# 2.47: Add Strategy parameters
|
# 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.
|
# Public API, requires no auth.
|
||||||
router_public = APIRouter()
|
router_public = APIRouter()
|
||||||
|
|||||||
+21
-1
@@ -12,7 +12,7 @@ import psutil
|
|||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from dateutil.tz import tzlocal
|
from dateutil.tz import tzlocal
|
||||||
from numpy import inf, int64, isnan, mean, nan
|
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 sqlalchemy import func, select
|
||||||
|
|
||||||
from freqtrade import __version__
|
from freqtrade import __version__
|
||||||
@@ -785,6 +785,26 @@ class RPC:
|
|||||||
"bot_start_date": format_date(bot_start),
|
"bot_start_date": format_date(bot_start),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _rpc_get_historic_balance(self) -> tuple[DataFrame, int]:
|
||||||
|
"""
|
||||||
|
Returns the historic balance of the bot
|
||||||
|
:return: DataFrame with the balance history and the timestamp of the migration
|
||||||
|
"""
|
||||||
|
results = read_sql("wallet_history", con=Trade.session.bind, parse_dates=["timestamp"])
|
||||||
|
|
||||||
|
results = results.rename({"timestamp": "date"}, axis=1)
|
||||||
|
results.loc[:, "__date_ts"] = results.loc[:, "date"].astype("int64") // 1000 // 1000
|
||||||
|
# Exclude non-bot managed for now
|
||||||
|
results_filtered = results.loc[results["bot_managed"]]
|
||||||
|
|
||||||
|
results_final = (
|
||||||
|
results_filtered.groupby(["date", "__date_ts"])
|
||||||
|
.agg({"total_quote": "sum"})
|
||||||
|
.reset_index()
|
||||||
|
)
|
||||||
|
hist = KeyValueStore.get_datetime_value("wallet_history_migration_date")
|
||||||
|
return results_final, dt_ts_def(hist, 0)
|
||||||
|
|
||||||
def __balance_get_est_stake(
|
def __balance_get_est_stake(
|
||||||
self, coin: str, stake_currency: str, amount: float, balance: Wallet
|
self, coin: str, stake_currency: str, amount: float, balance: Wallet
|
||||||
) -> tuple[float, float]:
|
) -> tuple[float, float]:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
from freqtrade.constants import Config
|
||||||
from freqtrade.exchange import Exchange
|
from freqtrade.exchange import Exchange
|
||||||
from freqtrade.util.migrations.funding_rate_mig import migrate_funding_fee_timeframe
|
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
|
Migrate persisted data from old formats to new formats
|
||||||
"""
|
"""
|
||||||
@@ -10,10 +12,9 @@ def migrate_data(config, exchange: Exchange | None = None) -> None:
|
|||||||
migrate_funding_fee_timeframe(config, exchange)
|
migrate_funding_fee_timeframe(config, exchange)
|
||||||
|
|
||||||
|
|
||||||
def migrate_live_content(config, exchange: Exchange | None = None) -> None:
|
def migrate_live_content(config: Config, exchange: Exchange, starting_balance: float) -> None:
|
||||||
"""
|
"""
|
||||||
Migrate database content from old formats to new formats
|
Migrate database content from old formats to new formats
|
||||||
Used for dry/live mode.
|
Used for dry/live mode.
|
||||||
"""
|
"""
|
||||||
# Currently not used
|
migrate_wallet_history(config, exchange, starting_balance)
|
||||||
pass
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from freqtrade.constants import Config
|
||||||
|
from freqtrade.data.btanalysis.bt_fileutils import trade_list_to_dataframe
|
||||||
|
from freqtrade.data.btanalysis.trade_parallelism import balance_distribution_over_time
|
||||||
|
from freqtrade.exchange import Exchange
|
||||||
|
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_prev_date
|
||||||
|
from freqtrade.persistence import KeyValueStore, Trade, WalletHistory
|
||||||
|
from freqtrade.util import dt_now, dt_ts
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: float):
|
||||||
|
if config.get("skip_wallet_history_migration") or not exchange.get_option(
|
||||||
|
"ohlcv_has_history", True
|
||||||
|
):
|
||||||
|
# we can't fill up wallet history without ohlcv history
|
||||||
|
return
|
||||||
|
if KeyValueStore.get_int_value("wallet_history_migration"):
|
||||||
|
logger.debug("Wallet history migration already completed.")
|
||||||
|
return
|
||||||
|
logger.info("Starting wallet history migration...")
|
||||||
|
_migrate_wallet_history(config, exchange, starting_balance)
|
||||||
|
logger.info("Wallet history migration completed.")
|
||||||
|
KeyValueStore.store_value("wallet_history_migration", 1)
|
||||||
|
KeyValueStore.store_value("wallet_history_migration_date", dt_now())
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_wallet_history(config: Config, exchange: Exchange, starting_balance: float):
|
||||||
|
# Prepare balance distribution data with OHLCV rates
|
||||||
|
balance_dist, pairlist_valid = _prepare_balance_distribution(config, exchange, starting_balance)
|
||||||
|
if not balance_dist.empty and pairlist_valid:
|
||||||
|
_create_wallet_history_entries(
|
||||||
|
config, exchange, balance_dist, pairlist_valid, config["stake_currency"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_balance_distribution(
|
||||||
|
config: Config, exchange: Exchange, starting_balance: float
|
||||||
|
) -> tuple[pd.DataFrame, list[str]]:
|
||||||
|
trade_df = trade_list_to_dataframe(Trade.get_trades_proxy(), minified=False)
|
||||||
|
if trade_df.empty:
|
||||||
|
# no trades, nothing to do
|
||||||
|
return pd.DataFrame(), []
|
||||||
|
pairlist = list(trade_df["pair"].unique())
|
||||||
|
timeframe = "1d"
|
||||||
|
stake_currency = config["stake_currency"]
|
||||||
|
min_date = timeframe_to_prev_date(timeframe, KeyValueStore.get_datetime_value("bot_start_time"))
|
||||||
|
balance_dist = balance_distribution_over_time(
|
||||||
|
trade_df,
|
||||||
|
min_date=min_date,
|
||||||
|
max_date=dt_now(),
|
||||||
|
start_balance=starting_balance,
|
||||||
|
stake_currency=stake_currency,
|
||||||
|
timeframe=timeframe,
|
||||||
|
pairlist=pairlist,
|
||||||
|
)
|
||||||
|
pairlist_valid = [p for p in pairlist if p in exchange.markets]
|
||||||
|
pairlist_invalid = set(pairlist) - set(pairlist_valid)
|
||||||
|
if pairlist_invalid:
|
||||||
|
logger.warning(
|
||||||
|
f"The following trading pairs from the trade history are not available on the exchange "
|
||||||
|
f"and will be skipped during wallet history migration: {', '.join(pairlist_invalid)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Wallet History migration: Fetching OHLCV data ...")
|
||||||
|
data = exchange.refresh_latest_ohlcv(
|
||||||
|
[(p, timeframe, config["candle_type_def"]) for p in pairlist_valid],
|
||||||
|
since_ms=dt_ts(min_date),
|
||||||
|
cache=False,
|
||||||
|
drop_incomplete=False,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Wallet History migration: Done fetching OHLCV data for wallet history migration..."
|
||||||
|
)
|
||||||
|
|
||||||
|
dfs = []
|
||||||
|
# Combine all dataframes into one using the open rate
|
||||||
|
for p, x in data.items():
|
||||||
|
x = x.set_index("date", drop=True)
|
||||||
|
col = f"{p[0]}_open"
|
||||||
|
x[col] = x["open"]
|
||||||
|
dfs.append(x[[col]])
|
||||||
|
|
||||||
|
if not dfs:
|
||||||
|
logger.warning(
|
||||||
|
"No OHLCV data available for the trading pairs; skipping wallet history migration."
|
||||||
|
)
|
||||||
|
return pd.DataFrame(), []
|
||||||
|
merged = pd.concat(dfs, axis=1)
|
||||||
|
|
||||||
|
balance_dist = balance_dist.join(merged, how="left")
|
||||||
|
df_value = pd.DataFrame(
|
||||||
|
index=balance_dist.index, columns=[f"{p}_value" for p in pairlist_valid], dtype=float
|
||||||
|
)
|
||||||
|
for p in pairlist_valid:
|
||||||
|
# df_value[f"{p}_value"] = balance_dist[f"{p}_open"] * balance_dist[p]
|
||||||
|
# Identical calculation to rpc and wallets.py
|
||||||
|
df_value[f"{p}_value"] = np.where(
|
||||||
|
balance_dist[f"{p}_is_short"] == 0,
|
||||||
|
(balance_dist[f"{p}_open"] * balance_dist[p])
|
||||||
|
- balance_dist[f"{p}_collateral"] * (balance_dist[f"{p}_leverage"] - 1),
|
||||||
|
(
|
||||||
|
balance_dist[f"{p}_collateral"] * (1 + balance_dist[f"{p}_leverage"])
|
||||||
|
- balance_dist[f"{p}_open"] * balance_dist[p]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
balance_dist = pd.concat([balance_dist, df_value], axis=1)
|
||||||
|
|
||||||
|
# Aggregate total value at each point in time
|
||||||
|
balance_dist["total_value"] = balance_dist[
|
||||||
|
[f"{p}_value" for p in pairlist_valid] + [stake_currency]
|
||||||
|
].sum(axis=1)
|
||||||
|
|
||||||
|
return balance_dist, pairlist_valid
|
||||||
|
|
||||||
|
|
||||||
|
def _create_wallet_history_entries(
|
||||||
|
config: Config,
|
||||||
|
exchange: Exchange,
|
||||||
|
balance_dist: pd.DataFrame,
|
||||||
|
pairlist_valid: list[str],
|
||||||
|
stake_currency: str,
|
||||||
|
):
|
||||||
|
is_futures = config["trading_mode"] == "futures"
|
||||||
|
# Precompute column indices for faster tuple-based iteration
|
||||||
|
# Assume the first column is the index (date)
|
||||||
|
stake_idx = balance_dist.columns.get_loc(stake_currency)
|
||||||
|
pair_balance_idx = {pair: balance_dist.columns.get_loc(pair) + 1 for pair in pairlist_valid}
|
||||||
|
pair_leverage_idx = {
|
||||||
|
pair: balance_dist.columns.get_loc(f"{pair}_leverage") + 1 for pair in pairlist_valid
|
||||||
|
}
|
||||||
|
pair_collateral_idx = {
|
||||||
|
pair: balance_dist.columns.get_loc(f"{pair}_collateral") + 1 for pair in pairlist_valid
|
||||||
|
}
|
||||||
|
pair_is_short_idx = {
|
||||||
|
pair: balance_dist.columns.get_loc(f"{pair}_is_short") + 1 for pair in pairlist_valid
|
||||||
|
}
|
||||||
|
pair_rate_idx = {
|
||||||
|
pair: balance_dist.columns.get_loc(f"{pair}_open") + 1 for pair in pairlist_valid
|
||||||
|
}
|
||||||
|
# Convert balance_dist to WalletHistory entries
|
||||||
|
wallet_entries = []
|
||||||
|
for row in balance_dist.itertuples(index=True, name=None):
|
||||||
|
date = row[0]
|
||||||
|
|
||||||
|
# Add stake currency entry
|
||||||
|
stake_balance = row[stake_idx + 1]
|
||||||
|
if not pd.isna(stake_balance):
|
||||||
|
wallet_entries.append(
|
||||||
|
WalletHistory(
|
||||||
|
timestamp=date,
|
||||||
|
currency=stake_currency,
|
||||||
|
rate=1.0, # Stake currency price is always 1.0
|
||||||
|
balance=stake_balance,
|
||||||
|
total_quote=stake_balance,
|
||||||
|
quote_currency=stake_currency,
|
||||||
|
leverage=1.0,
|
||||||
|
bot_managed=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add entries for each trading pair
|
||||||
|
for pair in pairlist_valid:
|
||||||
|
base_currency = exchange.get_pair_base_currency(pair)
|
||||||
|
balance = row[pair_balance_idx[pair]]
|
||||||
|
leverage = row[pair_leverage_idx[pair]]
|
||||||
|
# Only add entry if balance is not empty/NaN
|
||||||
|
if not pd.isna(balance) and balance > 0:
|
||||||
|
rate_value = row[pair_rate_idx[pair]]
|
||||||
|
rate = rate_value if not pd.isna(rate_value) else None
|
||||||
|
|
||||||
|
total_quote = balance * rate if rate else None
|
||||||
|
collateral: float | None = None
|
||||||
|
if is_futures:
|
||||||
|
collateral = row[pair_collateral_idx[pair]]
|
||||||
|
is_short = row[pair_is_short_idx[pair]]
|
||||||
|
if collateral is not None and not pd.isna(collateral):
|
||||||
|
# Same formula than in rpc's _rpc_balance
|
||||||
|
total_quote = (
|
||||||
|
(rate * balance - collateral * (leverage - 1))
|
||||||
|
if is_short == 0
|
||||||
|
else (collateral * (1 + leverage) - rate * balance)
|
||||||
|
)
|
||||||
|
wallet_entries.append(
|
||||||
|
WalletHistory(
|
||||||
|
timestamp=date,
|
||||||
|
currency=base_currency,
|
||||||
|
quote_currency=stake_currency,
|
||||||
|
rate=rate,
|
||||||
|
balance=balance,
|
||||||
|
total_quote=total_quote,
|
||||||
|
leverage=leverage if not pd.isna(leverage) else 1.0,
|
||||||
|
bot_managed=True,
|
||||||
|
total_position_value=balance * rate if is_futures and rate else None,
|
||||||
|
# collateral=collateral,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save entries to database
|
||||||
|
if wallet_entries:
|
||||||
|
try:
|
||||||
|
# Use bulk_save_objects for better performance
|
||||||
|
WalletHistory.session.bulk_save_objects(wallet_entries)
|
||||||
|
WalletHistory.session.commit()
|
||||||
|
logger.info(f"Successfully created {len(wallet_entries)} wallet balance records")
|
||||||
|
except Exception as e:
|
||||||
|
WalletHistory.session.rollback()
|
||||||
|
logger.error(f"Error saving wallet balance records: {e}")
|
||||||
+70
-2
@@ -10,8 +10,8 @@ from freqtrade.enums import RunMode, TradingMode
|
|||||||
from freqtrade.exceptions import DependencyException
|
from freqtrade.exceptions import DependencyException
|
||||||
from freqtrade.exchange import Exchange
|
from freqtrade.exchange import Exchange
|
||||||
from freqtrade.misc import safe_value_fallback
|
from freqtrade.misc import safe_value_fallback
|
||||||
from freqtrade.persistence import LocalTrade, Trade
|
from freqtrade.persistence import LocalTrade, Trade, WalletHistory
|
||||||
from freqtrade.util.datetime_helpers import dt_now
|
from freqtrade.util import dt_floor_day, dt_now
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -445,3 +445,71 @@ class Wallets:
|
|||||||
logger.debug(msg)
|
logger.debug(msg)
|
||||||
else:
|
else:
|
||||||
logger.info(msg)
|
logger.info(msg)
|
||||||
|
|
||||||
|
def record_wallet_state(self) -> None:
|
||||||
|
"""Record daily wallet totals to database"""
|
||||||
|
if self._is_backtest:
|
||||||
|
# only record in live mode.
|
||||||
|
return
|
||||||
|
timestamp = dt_floor_day(dt_now())
|
||||||
|
|
||||||
|
# Record total balances for all currencies
|
||||||
|
wallet_records = []
|
||||||
|
position_collaterals = 0.0
|
||||||
|
open_assets: dict[str, Trade] = {t.safe_base_currency: t for t in Trade.get_open_trades()}
|
||||||
|
for pos in self.get_all_positions().values():
|
||||||
|
base = self._exchange.get_pair_base_currency(pos.symbol)
|
||||||
|
rate = self._exchange.get_conversion_rate(base, self._stake_currency)
|
||||||
|
total_quote = None
|
||||||
|
leverage = pos.leverage or 1.0
|
||||||
|
if rate:
|
||||||
|
# Same formula than in rpc's _rpc_balance
|
||||||
|
total_quote = (
|
||||||
|
rate * pos.position - pos.collateral * (leverage - 1)
|
||||||
|
if pos.side == "long"
|
||||||
|
else pos.collateral * (1 + leverage) - rate * pos.position
|
||||||
|
)
|
||||||
|
|
||||||
|
position_record = WalletHistory(
|
||||||
|
timestamp=timestamp,
|
||||||
|
currency=pos.symbol,
|
||||||
|
quote_currency=self._stake_currency,
|
||||||
|
rate=rate,
|
||||||
|
balance=pos.position,
|
||||||
|
total_quote=total_quote,
|
||||||
|
total_position_value=rate * pos.position if rate else None,
|
||||||
|
collateral=pos.collateral,
|
||||||
|
leverage=leverage,
|
||||||
|
bot_managed=base in open_assets,
|
||||||
|
)
|
||||||
|
position_collaterals += pos.collateral
|
||||||
|
wallet_records.append(position_record)
|
||||||
|
|
||||||
|
for wallet in self.get_all_balances().values():
|
||||||
|
# TODO: (needs decision) exclude minimal balances?
|
||||||
|
rate = self._exchange.get_conversion_rate(wallet.currency, self._stake_currency)
|
||||||
|
is_bot_managed = (
|
||||||
|
self._stake_currency == wallet.currency or wallet.currency in open_assets
|
||||||
|
)
|
||||||
|
balance = wallet.total - (
|
||||||
|
position_collaterals if wallet.currency == self._stake_currency else 0
|
||||||
|
)
|
||||||
|
total_quote = rate * balance if rate else None
|
||||||
|
|
||||||
|
wallet_record = WalletHistory(
|
||||||
|
timestamp=timestamp,
|
||||||
|
currency=wallet.currency,
|
||||||
|
quote_currency=self._stake_currency,
|
||||||
|
rate=rate,
|
||||||
|
balance=balance,
|
||||||
|
leverage=1.0,
|
||||||
|
total_quote=total_quote,
|
||||||
|
bot_managed=is_bot_managed,
|
||||||
|
)
|
||||||
|
wallet_records.append(wallet_record)
|
||||||
|
try:
|
||||||
|
WalletHistory.session.bulk_save_objects(wallet_records)
|
||||||
|
WalletHistory.session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
WalletHistory.session.rollback()
|
||||||
|
logger.error(f"Error saving wallet balance records: {e}")
|
||||||
|
|||||||
+4
-2
@@ -169,10 +169,12 @@ def generate_trades_history(n_rows, start_date: datetime | None = None, days=5):
|
|||||||
return df
|
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)
|
np.random.seed(random_seed)
|
||||||
|
|
||||||
base = np.random.normal(20, 2, size=size)
|
base = np.random.normal(base, 2, size=size)
|
||||||
if timeframe == "1y":
|
if timeframe == "1y":
|
||||||
date = pd.date_range(start, periods=size, freq="1YS", tz="UTC")
|
date = pd.date_range(start, periods=size, freq="1YS", tz="UTC")
|
||||||
elif timeframe == "1M":
|
elif timeframe == "1M":
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ from freqtrade.configuration import TimeRange
|
|||||||
from freqtrade.constants import LAST_BT_RESULT_FN
|
from freqtrade.constants import LAST_BT_RESULT_FN
|
||||||
from freqtrade.data.btanalysis import (
|
from freqtrade.data.btanalysis import (
|
||||||
BT_DATA_COLUMNS,
|
BT_DATA_COLUMNS,
|
||||||
analyze_trade_parallelism,
|
|
||||||
extract_trades_of_period,
|
extract_trades_of_period,
|
||||||
|
get_backtest_market_change,
|
||||||
|
get_backtest_wallet_change,
|
||||||
get_latest_backtest_filename,
|
get_latest_backtest_filename,
|
||||||
get_latest_hyperopt_file,
|
get_latest_hyperopt_file,
|
||||||
load_backtest_data,
|
load_backtest_data,
|
||||||
@@ -209,17 +210,6 @@ def test_extract_trades_of_period(testdatadir):
|
|||||||
assert trades1.iloc[-1].close_date == datetime(2017, 11, 14, 15, 25, 0, tzinfo=UTC)
|
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):
|
def test_load_trades(default_conf, mocker):
|
||||||
db_mock = mocker.patch(
|
db_mock = mocker.patch(
|
||||||
"freqtrade.data.btanalysis.bt_fileutils.load_trades_from_db", MagicMock()
|
"freqtrade.data.btanalysis.bt_fileutils.load_trades_from_db", MagicMock()
|
||||||
@@ -649,3 +639,56 @@ def test_load_file_from_zip(tmp_path):
|
|||||||
|
|
||||||
with pytest.raises(ValueError, match=r"File .* not found in zip.*"):
|
with pytest.raises(ValueError, match=r"File .* not found in zip.*"):
|
||||||
load_file_from_zip(zip_file, "testfile55.txt")
|
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
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pandas import DataFrame, Timestamp
|
||||||
|
|
||||||
|
from freqtrade.data.btanalysis import (
|
||||||
|
analyze_trade_parallelism,
|
||||||
|
load_backtest_data,
|
||||||
|
)
|
||||||
|
from freqtrade.data.btanalysis.trade_parallelism import balance_distribution_over_time
|
||||||
|
from freqtrade.util import dt_utc
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_trade_parallelism(testdatadir):
|
||||||
|
filename = testdatadir / "backtest_results/backtest-result.json"
|
||||||
|
bt_data = load_backtest_data(filename)
|
||||||
|
|
||||||
|
res = analyze_trade_parallelism(bt_data, "5m")
|
||||||
|
assert isinstance(res, DataFrame)
|
||||||
|
assert "open_trades" in res.columns
|
||||||
|
assert res["open_trades"].max() == 3
|
||||||
|
assert res["open_trades"].min() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("is_short", [False, True])
|
||||||
|
def test_balance_distribution_over_time(is_short):
|
||||||
|
"""
|
||||||
|
Test balance_distribution_over_time for both long and short trades.
|
||||||
|
"""
|
||||||
|
# Create a minimal trades DataFrame with 4 trades over time
|
||||||
|
# Base dates for trades
|
||||||
|
start_date = dt_utc(2023, 1, 1)
|
||||||
|
base_date = start_date + timedelta(hours=15)
|
||||||
|
stake_currency = "USDT"
|
||||||
|
start_balance = 1000.0
|
||||||
|
fee = 0.001 # 0.1% fee
|
||||||
|
|
||||||
|
# Create trades spanning different time periods
|
||||||
|
trades_data = {
|
||||||
|
"pair": ["BTC/USDT", "ETH/USDT", "XRP/USDT", "LTC/USDT"],
|
||||||
|
"stake_amount": [100.0, 150.0, 80.0, 120.0],
|
||||||
|
"open_date": [
|
||||||
|
base_date,
|
||||||
|
base_date + timedelta(hours=2),
|
||||||
|
base_date + timedelta(hours=5),
|
||||||
|
base_date + timedelta(hours=8),
|
||||||
|
],
|
||||||
|
"close_date": [
|
||||||
|
base_date + timedelta(hours=3),
|
||||||
|
base_date + timedelta(hours=6),
|
||||||
|
base_date + timedelta(hours=9),
|
||||||
|
base_date + timedelta(hours=12),
|
||||||
|
],
|
||||||
|
"open_rate": [40000.0, 2000.0, 0.5, 100.0],
|
||||||
|
"close_rate": [41000.0, 2100.0, 0.52, 105.0],
|
||||||
|
"fee_open": [fee, fee, fee, fee],
|
||||||
|
"fee_close": [fee, fee, fee, fee],
|
||||||
|
"is_short": [is_short, is_short, is_short, is_short],
|
||||||
|
"leverage": [1.0, 1.0, 1.0, 1.0],
|
||||||
|
"orders": [
|
||||||
|
# Trade 1: BTC/USDT - entry at 40000, exit at 41000
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"amount": 0.0025, # 100 / 40000
|
||||||
|
"filled": 0.0025,
|
||||||
|
"safe_price": 40000.0,
|
||||||
|
"ft_order_side": "sell" if is_short else "buy",
|
||||||
|
"order_filled_timestamp": int(base_date.timestamp() * 1000),
|
||||||
|
"ft_is_entry": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": 0.0025,
|
||||||
|
"filled": 0.0025,
|
||||||
|
"safe_price": 41000.0,
|
||||||
|
"ft_order_side": "buy" if is_short else "sell",
|
||||||
|
"order_filled_timestamp": int(
|
||||||
|
(base_date + timedelta(hours=3)).timestamp() * 1000
|
||||||
|
),
|
||||||
|
"ft_is_entry": False,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
# Trade 2: ETH/USDT - entry at 2000, exit at 2100
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"amount": 0.075, # 150 / 2000
|
||||||
|
"filled": 0.075,
|
||||||
|
"safe_price": 2000.0,
|
||||||
|
"ft_order_side": "sell" if is_short else "buy",
|
||||||
|
"order_filled_timestamp": int(
|
||||||
|
(base_date + timedelta(hours=2)).timestamp() * 1000
|
||||||
|
),
|
||||||
|
"ft_is_entry": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": 0.075,
|
||||||
|
"filled": 0.075,
|
||||||
|
"safe_price": 2100.0,
|
||||||
|
"ft_order_side": "buy" if is_short else "sell",
|
||||||
|
"order_filled_timestamp": int(
|
||||||
|
(base_date + timedelta(hours=6)).timestamp() * 1000
|
||||||
|
),
|
||||||
|
"ft_is_entry": False,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
# Trade 3: XRP/USDT - entry at 0.5, exit at 0.52
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"amount": 160.0, # 80 / 0.5
|
||||||
|
"filled": 160.0,
|
||||||
|
"safe_price": 0.5,
|
||||||
|
"ft_order_side": "sell" if is_short else "buy",
|
||||||
|
"order_filled_timestamp": int(
|
||||||
|
(base_date + timedelta(hours=5)).timestamp() * 1000
|
||||||
|
),
|
||||||
|
"ft_is_entry": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": 160.0,
|
||||||
|
"filled": 160.0,
|
||||||
|
"safe_price": 0.52,
|
||||||
|
"ft_order_side": "buy" if is_short else "sell",
|
||||||
|
"order_filled_timestamp": int(
|
||||||
|
(base_date + timedelta(hours=9)).timestamp() * 1000
|
||||||
|
),
|
||||||
|
"ft_is_entry": False,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
# Trade 4: LTC/USDT - entry at 100, exit at 105
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"amount": 1.2, # 120 / 100
|
||||||
|
"filled": 1.2,
|
||||||
|
"safe_price": 100.0,
|
||||||
|
"ft_order_side": "sell" if is_short else "buy",
|
||||||
|
"order_filled_timestamp": int(
|
||||||
|
(base_date + timedelta(hours=8)).timestamp() * 1000
|
||||||
|
),
|
||||||
|
"ft_is_entry": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": 1.2,
|
||||||
|
"filled": 1.2,
|
||||||
|
"safe_price": 105.0,
|
||||||
|
"ft_order_side": "buy" if is_short else "sell",
|
||||||
|
"order_filled_timestamp": int(
|
||||||
|
(base_date + timedelta(hours=12)).timestamp() * 1000
|
||||||
|
),
|
||||||
|
"ft_is_entry": False,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
trades_df = DataFrame(trades_data)
|
||||||
|
pairlist = ["BTC/USDT", "ETH/USDT", "XRP/USDT", "LTC/USDT"]
|
||||||
|
|
||||||
|
min_date = start_date
|
||||||
|
max_date = start_date + timedelta(hours=35)
|
||||||
|
|
||||||
|
result = balance_distribution_over_time(
|
||||||
|
trades=trades_df,
|
||||||
|
min_date=min_date,
|
||||||
|
max_date=max_date,
|
||||||
|
timeframe="1h",
|
||||||
|
stake_currency=stake_currency,
|
||||||
|
start_balance=start_balance,
|
||||||
|
pairlist=pairlist,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify basic structure
|
||||||
|
assert isinstance(result, DataFrame)
|
||||||
|
assert stake_currency in result.columns
|
||||||
|
for pair in pairlist:
|
||||||
|
assert pair in result.columns
|
||||||
|
assert f"{pair}_leverage" in result.columns
|
||||||
|
assert f"{pair}_is_short" in result.columns
|
||||||
|
assert f"{pair}_collateral" in result.columns
|
||||||
|
|
||||||
|
# Verify the index is a DatetimeIndex
|
||||||
|
assert isinstance(result.index, Timestamp.__class__.__bases__[0])
|
||||||
|
|
||||||
|
# Verify we have entries over the full time period (36h)
|
||||||
|
assert len(result) == 36
|
||||||
|
|
||||||
|
# First trade opens 15h after the start date
|
||||||
|
assert result.iloc[0][stake_currency] == 1000
|
||||||
|
expected_first_balance = start_balance - (100.0 + 100.0 * fee)
|
||||||
|
assert result.iloc[15][stake_currency] == pytest.approx(expected_first_balance)
|
||||||
|
|
||||||
|
# Check that pair columns have non-zero values during trade periods
|
||||||
|
# Trade 1 (BTC/USDT) is open from hour 15 to hour 18
|
||||||
|
# At hour 16, BTC/USDT should have position
|
||||||
|
btc_during_trade = result.loc[base_date + timedelta(hours=1), "BTC/USDT"]
|
||||||
|
assert btc_during_trade > 0, "Trade should have positive position during open period"
|
||||||
|
|
||||||
|
# After Trade 1 closes at hour 3, BTC/USDT position should be 0
|
||||||
|
btc_after_close = result.loc[base_date + timedelta(hours=4) :, "BTC/USDT"]
|
||||||
|
assert all(btc_after_close == 0), "Position should be 0 after trade closes"
|
||||||
|
|
||||||
|
# Final stake currency should reflect all trades' cash flows minus fees
|
||||||
|
final_balance = result.iloc[-1][stake_currency]
|
||||||
|
|
||||||
|
# Verify the balance changed (trades had effect)
|
||||||
|
assert final_balance != start_balance, "Balance should change after trading"
|
||||||
|
|
||||||
|
# Since all exit prices > entry prices, exits return more cash than entries spent
|
||||||
|
# This means final balance > start balance for long trades and < start balance for short trades
|
||||||
|
assert (final_balance > start_balance) if not is_short else (final_balance < start_balance), (
|
||||||
|
"Balance increases for long and decreases for short trades"
|
||||||
|
)
|
||||||
@@ -757,10 +757,12 @@ def test_backtest__check_trade_exit(default_conf, mocker) -> None:
|
|||||||
def test_backtest_one(default_conf, mocker, testdatadir) -> None:
|
def test_backtest_one(default_conf, mocker, testdatadir) -> None:
|
||||||
default_conf["use_exit_signal"] = False
|
default_conf["use_exit_signal"] = False
|
||||||
default_conf["max_open_trades"] = 10
|
default_conf["max_open_trades"] = 10
|
||||||
|
default_conf["runmode"] = RunMode.BACKTEST
|
||||||
|
|
||||||
patch_exchange(mocker)
|
patch_exchange(mocker)
|
||||||
mocker.patch(f"{EXMS}.get_min_pair_stake_amount", return_value=0.00001)
|
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_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 = Backtesting(default_conf)
|
||||||
backtesting._set_strategy(backtesting.strategylist[0])
|
backtesting._set_strategy(backtesting.strategylist[0])
|
||||||
pair = "UNITTEST/BTC"
|
pair = "UNITTEST/BTC"
|
||||||
@@ -875,13 +877,23 @@ def test_backtest_one(default_conf, mocker, testdatadir) -> None:
|
|||||||
ln1.iloc[0]["low"], 6
|
ln1.iloc[0]["low"], 6
|
||||||
) < round(t["close_rate"], 6) < round(ln1.iloc[0]["high"], 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])
|
@pytest.mark.parametrize("use_detail", [True, False])
|
||||||
def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail) -> None:
|
def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail) -> None:
|
||||||
default_conf_usdt["use_exit_signal"] = False
|
default_conf_usdt["use_exit_signal"] = False
|
||||||
|
default_conf_usdt["runmode"] = RunMode.BACKTEST
|
||||||
patch_exchange(mocker)
|
patch_exchange(mocker)
|
||||||
mocker.patch(f"{EXMS}.get_min_pair_stake_amount", return_value=0.00001)
|
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_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"] = {
|
default_conf_usdt["unfilledtimeout"] = {
|
||||||
"entry": 11,
|
"entry": 11,
|
||||||
"exit": 30,
|
"exit": 30,
|
||||||
@@ -968,6 +980,12 @@ def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail)
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert late_entry > 0
|
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(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import ANY, MagicMock, PropertyMock, patch
|
from unittest.mock import ANY, MagicMock, PropertyMock, patch
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
@@ -1434,6 +1436,41 @@ def test_api_stats(botclient, mocker, ticker, fee, markets, is_short):
|
|||||||
assert "draws" in rc.json()["durations"]
|
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):
|
def test_api_performance(botclient, fee):
|
||||||
ftbot, client = botclient
|
ftbot, client = botclient
|
||||||
patch_get_signal(ftbot)
|
patch_get_signal(ftbot)
|
||||||
@@ -3275,7 +3312,7 @@ def test_api_patch_backtest_history_entry(botclient, tmp_path: Path):
|
|||||||
assert fileres[CURRENT_TEST_STRATEGY]["notes"] == "FooBar"
|
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
|
ftbot, client = botclient
|
||||||
|
|
||||||
# Create a temporary directory and file
|
# Create a temporary directory and file
|
||||||
@@ -3313,6 +3350,55 @@ def test_api_patch_backtest_market_change(botclient, tmp_path: Path):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_backtest_wallets(botclient, tmp_path: Path):
|
||||||
|
ftbot, client = botclient
|
||||||
|
|
||||||
|
# Create a temporary directory and file
|
||||||
|
bt_results_base = tmp_path / "backtest_results"
|
||||||
|
bt_results_base.mkdir()
|
||||||
|
zip_file = bt_results_base / "backtest_15.zip"
|
||||||
|
with ZipFile(zip_file, "w") as zipf:
|
||||||
|
wallet_df = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"date": [
|
||||||
|
"2018-01-01T00:00:00Z",
|
||||||
|
"2018-01-01T00:00:00Z",
|
||||||
|
"2018-01-01T00:05:00Z",
|
||||||
|
"2018-01-01T00:05:00Z",
|
||||||
|
],
|
||||||
|
"currency": ["ETH", "BTC", "ETH", "BTC"],
|
||||||
|
"rate": [2000, 60_000, 2001, 60_001],
|
||||||
|
"balance": [0.5, 0.25, 0.5, 0.25],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
wallet_df["date"] = pd.to_datetime(wallet_df["date"])
|
||||||
|
wallet_buf = BytesIO()
|
||||||
|
wallet_df.reset_index().to_feather(wallet_buf, compression_level=9, compression="lz4")
|
||||||
|
wallet_buf.seek(0)
|
||||||
|
zipf.writestr("backtest_15_SampleStrategy_wallet.feather", wallet_buf.read())
|
||||||
|
|
||||||
|
# Wrong basedirectory
|
||||||
|
rc = client_get(client, f"{BASE_URI}/backtest/history/randomFile.json/SampleStrategy/wallet")
|
||||||
|
assert_response(rc, 503)
|
||||||
|
|
||||||
|
ftbot.config["user_data_dir"] = tmp_path
|
||||||
|
ftbot.config["runmode"] = RunMode.WEBSERVER
|
||||||
|
|
||||||
|
# Nonexisting file - fails "is_file_in_dir" check
|
||||||
|
rc = client_get(client, f"{BASE_URI}/backtest/history/randomFile.json/SampleStrategy/wallet")
|
||||||
|
assert_response(rc, 400)
|
||||||
|
|
||||||
|
rc = client_get(client, f"{BASE_URI}/backtest/history/backtest_15/SampleStrategy/wallet")
|
||||||
|
assert_response(rc, 200)
|
||||||
|
result = rc.json()
|
||||||
|
assert result["length"] == 2
|
||||||
|
assert result["columns"] == ["date", "__date_ts", "total_quote"]
|
||||||
|
assert result["data"] == [
|
||||||
|
["2018-01-01T00:00:00Z", 1514764800000, 16000.0],
|
||||||
|
["2018-01-01T00:05:00Z", 1514765100000, 16000.75],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_health(botclient):
|
def test_health(botclient):
|
||||||
_ftbot, client = botclient
|
_ftbot, client = botclient
|
||||||
|
|
||||||
|
|||||||
+81
-1
@@ -7,12 +7,14 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from freqtrade.constants import UNLIMITED_STAKE_AMOUNT
|
from freqtrade.constants import UNLIMITED_STAKE_AMOUNT
|
||||||
from freqtrade.exceptions import DependencyException
|
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 (
|
from tests.conftest import (
|
||||||
EXMS,
|
EXMS,
|
||||||
create_mock_trades,
|
create_mock_trades,
|
||||||
create_mock_trades_usdt,
|
create_mock_trades_usdt,
|
||||||
get_patched_freqtradebot,
|
get_patched_freqtradebot,
|
||||||
|
log_has_re,
|
||||||
patch_wallet,
|
patch_wallet,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -607,3 +609,81 @@ def test_dry_run_wallet_initialization(mocker, default_conf_usdt, config, wallet
|
|||||||
pytest.approx(freqtrade.wallets._wallets[stake_currency].free)
|
pytest.approx(freqtrade.wallets._wallets[stake_currency].free)
|
||||||
== wallets[stake_currency]["free"] - 100.0
|
== wallets[stake_currency]["free"] - 100.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_record_wallet_state_stores_wallet_history(mocker, default_conf_usdt):
|
||||||
|
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
|
||||||
|
stake_currency = default_conf_usdt["stake_currency"]
|
||||||
|
freqtrade.wallets._wallets = {
|
||||||
|
stake_currency: Wallet(stake_currency, free=100.0, used=50, total=150),
|
||||||
|
"BTC": Wallet("BTC", free=2.0, used=1.0, total=3.0),
|
||||||
|
}
|
||||||
|
freqtrade.wallets._positions = {
|
||||||
|
"ETH/USDT:USDT": PositionWallet(
|
||||||
|
symbol="ETH/USDT:USDT",
|
||||||
|
position=0.8,
|
||||||
|
collateral=1.0,
|
||||||
|
leverage=3.0,
|
||||||
|
side="long",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
conversion_rates = {stake_currency: 1.0, "BTC": 70000, "ETH": 2500.1}
|
||||||
|
mocker.patch.object(
|
||||||
|
freqtrade.exchange,
|
||||||
|
"get_conversion_rate",
|
||||||
|
side_effect=lambda currency, *args, **kwargs: conversion_rates.get(currency, 1.0),
|
||||||
|
)
|
||||||
|
mocker.patch(
|
||||||
|
"freqtrade.persistence.trade_model.Trade.get_open_trades",
|
||||||
|
return_value=[
|
||||||
|
MagicMock(pair="ETH/USDT:USDT", safe_base_currency="ETH"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
freqtrade.wallets.record_wallet_state()
|
||||||
|
|
||||||
|
wallet_entries = WalletHistory.session.query(WalletHistory).all()
|
||||||
|
assert len(wallet_entries) == 3
|
||||||
|
assert "total_quote" in repr(wallet_entries[0])
|
||||||
|
assert "WalletHistory(" in repr(wallet_entries[0])
|
||||||
|
|
||||||
|
records_by_currency = {entry.currency: entry for entry in wallet_entries}
|
||||||
|
assert records_by_currency[stake_currency].balance == 149
|
||||||
|
assert records_by_currency[stake_currency].rate == 1.0
|
||||||
|
assert records_by_currency["BTC"].rate == 70000
|
||||||
|
assert records_by_currency["BTC"].balance == 3
|
||||||
|
assert not records_by_currency["BTC"].bot_managed
|
||||||
|
assert records_by_currency["ETH/USDT:USDT"].balance == 0.8
|
||||||
|
assert records_by_currency["ETH/USDT:USDT"].rate == 2500.1
|
||||||
|
assert records_by_currency["ETH/USDT:USDT"].bot_managed is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_record_wallet_state_stores_wallet_history_error(mocker, default_conf, caplog):
|
||||||
|
freqtrade = get_patched_freqtradebot(mocker, default_conf)
|
||||||
|
stake_currency = default_conf["stake_currency"]
|
||||||
|
freqtrade.wallets._wallets = {
|
||||||
|
stake_currency: Wallet(stake_currency, free=1.0, used=0.5, total=1.5),
|
||||||
|
"ETH": Wallet("ETH", free=2.0, used=1.0, total=3.0),
|
||||||
|
}
|
||||||
|
freqtrade.wallets._positions = {
|
||||||
|
"ETH/BTC": PositionWallet(
|
||||||
|
symbol="ETH/BTC",
|
||||||
|
position=0.8,
|
||||||
|
collateral=1.0,
|
||||||
|
leverage=3.0,
|
||||||
|
side="long",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock bulk_save_objects to raise an exception
|
||||||
|
mocker.patch.object(
|
||||||
|
WalletHistory.session, "bulk_save_objects", side_effect=Exception("DB Error")
|
||||||
|
)
|
||||||
|
freqtrade.wallets.record_wallet_state()
|
||||||
|
|
||||||
|
assert log_has_re(r"Error saving wallet balance records: .*", caplog)
|
||||||
|
wallet_entries = WalletHistory.session.query(WalletHistory).all()
|
||||||
|
assert len(wallet_entries) == 0
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from freqtrade.enums import CandleType
|
||||||
|
from freqtrade.persistence import KeyValueStore, Order, Trade, WalletHistory
|
||||||
|
from freqtrade.util import dt_now, dt_utc
|
||||||
|
from freqtrade.util.migrations.migrate_wallet_history import (
|
||||||
|
_migrate_wallet_history,
|
||||||
|
_prepare_balance_distribution,
|
||||||
|
migrate_wallet_history,
|
||||||
|
)
|
||||||
|
from tests.conftest import EXMS, generate_test_data, get_patched_exchange, log_has_re
|
||||||
|
|
||||||
|
|
||||||
|
def create_closed_mock_trade(fee, pair: str, open_date: datetime, close_date: datetime):
|
||||||
|
"""Create a closed trade for wallet history testing."""
|
||||||
|
trade = Trade(
|
||||||
|
pair=pair,
|
||||||
|
stake_amount=100.0,
|
||||||
|
amount=10.0,
|
||||||
|
amount_requested=10.0,
|
||||||
|
fee_open=fee.return_value,
|
||||||
|
fee_close=fee.return_value,
|
||||||
|
open_rate=10.0,
|
||||||
|
close_rate=11.0,
|
||||||
|
close_profit=0.1,
|
||||||
|
close_profit_abs=9.5,
|
||||||
|
exchange="binance",
|
||||||
|
is_open=False,
|
||||||
|
strategy="TestStrategy",
|
||||||
|
timeframe=5,
|
||||||
|
open_date=open_date,
|
||||||
|
close_date=close_date,
|
||||||
|
is_short=False,
|
||||||
|
)
|
||||||
|
order_entry = Order(
|
||||||
|
ft_order_side="buy",
|
||||||
|
ft_pair=pair,
|
||||||
|
ft_is_open=False,
|
||||||
|
ft_amount=10.0,
|
||||||
|
ft_price=10.0,
|
||||||
|
order_id=f"order_{pair}_entry",
|
||||||
|
status="closed",
|
||||||
|
symbol=pair,
|
||||||
|
order_type="limit",
|
||||||
|
side="buy",
|
||||||
|
price=10.0,
|
||||||
|
average=10.0,
|
||||||
|
amount=10.0,
|
||||||
|
filled=10.0,
|
||||||
|
remaining=0.0,
|
||||||
|
order_date=open_date,
|
||||||
|
order_filled_date=open_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
order_exit = Order(
|
||||||
|
ft_order_side="sell",
|
||||||
|
ft_pair=pair,
|
||||||
|
ft_is_open=False,
|
||||||
|
ft_amount=10.0,
|
||||||
|
ft_price=11.0,
|
||||||
|
order_id=f"order_{pair}_exit",
|
||||||
|
status="closed",
|
||||||
|
symbol=pair,
|
||||||
|
order_type="limit",
|
||||||
|
side="sell",
|
||||||
|
price=11.0,
|
||||||
|
average=11.0,
|
||||||
|
amount=10.0,
|
||||||
|
filled=10.0,
|
||||||
|
remaining=0.0,
|
||||||
|
order_date=close_date,
|
||||||
|
order_filled_date=close_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
trade.orders.append(order_entry)
|
||||||
|
trade.orders.append(order_exit)
|
||||||
|
return trade
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_migrate_wallet_history_skips_when_no_ohlcv_history(mocker, default_conf_usdt):
|
||||||
|
"""Test that migration is skipped when exchange doesn't support OHLCV history."""
|
||||||
|
exchange = MagicMock()
|
||||||
|
exchange.get_option.return_value = False # ohlcv_has_history = False
|
||||||
|
|
||||||
|
migrate_mock = mocker.patch(
|
||||||
|
"freqtrade.util.migrations.migrate_wallet_history._migrate_wallet_history"
|
||||||
|
)
|
||||||
|
|
||||||
|
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
|
||||||
|
|
||||||
|
# Should return early without setting the migration flag
|
||||||
|
assert KeyValueStore.get_int_value("wallet_history_migration") is None
|
||||||
|
assert not migrate_mock.called
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_migrate_wallet_history_skips_when_already_migrated(mocker, default_conf_usdt):
|
||||||
|
"""Test that migration is skipped if already completed."""
|
||||||
|
exchange = MagicMock()
|
||||||
|
exchange.get_option.return_value = True
|
||||||
|
|
||||||
|
migrate_mock = mocker.patch(
|
||||||
|
"freqtrade.util.migrations.migrate_wallet_history._migrate_wallet_history"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set migration as already completed
|
||||||
|
KeyValueStore.store_value("wallet_history_migration", 1)
|
||||||
|
|
||||||
|
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
|
||||||
|
# Should not call any migration logic
|
||||||
|
assert KeyValueStore.get_int_value("wallet_history_migration") == 1
|
||||||
|
assert not migrate_mock.called
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_migrate_wallet_history_no_trades(default_conf_usdt):
|
||||||
|
"""Test migration with no trades in database."""
|
||||||
|
exchange = MagicMock()
|
||||||
|
exchange.get_option.return_value = True
|
||||||
|
|
||||||
|
# Set bot_start_time
|
||||||
|
KeyValueStore.store_value("bot_start_time", dt_now() - timedelta(days=5))
|
||||||
|
|
||||||
|
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
|
||||||
|
|
||||||
|
# Should complete migration (flag set) but no wallet entries
|
||||||
|
assert KeyValueStore.get_int_value("wallet_history_migration") == 1
|
||||||
|
assert WalletHistory.session.query(WalletHistory).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_migrate_wallet_history_with_trades(default_conf_usdt, fee, time_machine, markets):
|
||||||
|
"""Test migration with trades creates wallet history entries."""
|
||||||
|
start_time = dt_utc(2024, 1, 10, 12, 0, 0)
|
||||||
|
time_machine.move_to(start_time, tick=False)
|
||||||
|
|
||||||
|
# Bot started 10 days ago
|
||||||
|
bot_start = start_time - timedelta(days=10)
|
||||||
|
KeyValueStore.store_value("bot_start_time", bot_start)
|
||||||
|
|
||||||
|
# Create mock trades with dates within the range
|
||||||
|
trade_open = start_time - timedelta(days=5)
|
||||||
|
trade_close = start_time - timedelta(days=3)
|
||||||
|
trade1 = create_closed_mock_trade(
|
||||||
|
fee,
|
||||||
|
"ETH/USDT",
|
||||||
|
open_date=trade_open,
|
||||||
|
close_date=trade_close,
|
||||||
|
)
|
||||||
|
Trade.session.add(trade1)
|
||||||
|
Trade.commit()
|
||||||
|
|
||||||
|
# Generate mock OHLCV data starting from bot_start
|
||||||
|
candle_type = default_conf_usdt.get("candle_type_def", CandleType.SPOT)
|
||||||
|
ohlcv_df = generate_test_data("1d", size=15, start=bot_start.strftime("%Y-%m-%d"))
|
||||||
|
ohlcv_data = {("ETH/USDT", "1d", candle_type): ohlcv_df}
|
||||||
|
|
||||||
|
exchange = MagicMock()
|
||||||
|
exchange.get_option.return_value = True
|
||||||
|
exchange.markets = markets
|
||||||
|
exchange.refresh_latest_ohlcv.return_value = ohlcv_data
|
||||||
|
exchange.get_pair_base_currency = MagicMock(side_effect=lambda pair: markets.get(pair)["base"])
|
||||||
|
|
||||||
|
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
|
||||||
|
|
||||||
|
# Should complete migration
|
||||||
|
assert KeyValueStore.get_int_value("wallet_history_migration") == 1
|
||||||
|
|
||||||
|
# Should have created wallet history entries
|
||||||
|
wallet_entries = WalletHistory.session.query(WalletHistory).all()
|
||||||
|
assert len(wallet_entries) > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_migrate_wallet_history_with_multiple_pairs(default_conf_usdt, fee, time_machine, markets):
|
||||||
|
"""Test migration with multiple trading pairs."""
|
||||||
|
start_time = dt_utc(2024, 1, 15, 12, 0, 0)
|
||||||
|
time_machine.move_to(start_time, tick=False)
|
||||||
|
|
||||||
|
# Bot started 15 days ago
|
||||||
|
bot_start = start_time - timedelta(days=15)
|
||||||
|
KeyValueStore.store_value("bot_start_time", bot_start)
|
||||||
|
|
||||||
|
# Create mock trades for multiple pairs within the date range
|
||||||
|
trade1 = create_closed_mock_trade(
|
||||||
|
fee,
|
||||||
|
"ETH/USDT",
|
||||||
|
open_date=start_time - timedelta(days=10),
|
||||||
|
close_date=start_time - timedelta(days=6),
|
||||||
|
)
|
||||||
|
trade2 = create_closed_mock_trade(
|
||||||
|
fee,
|
||||||
|
"BTC/USDT",
|
||||||
|
open_date=start_time - timedelta(days=7),
|
||||||
|
close_date=start_time - timedelta(days=5),
|
||||||
|
)
|
||||||
|
Trade.session.add(trade1)
|
||||||
|
Trade.session.add(trade2)
|
||||||
|
Trade.commit()
|
||||||
|
|
||||||
|
# Generate mock OHLCV data for both pairs starting from bot_start
|
||||||
|
candle_type = default_conf_usdt.get("candle_type_def", CandleType.SPOT)
|
||||||
|
ohlcv_data = {}
|
||||||
|
ohlcv_data[("ETH/USDT", "1d", candle_type)] = generate_test_data(
|
||||||
|
"1d", size=20, start=bot_start.strftime("%Y-%m-%d"), base=1500
|
||||||
|
)
|
||||||
|
|
||||||
|
ohlcv_data[("BTC/USDT", "1d", candle_type)] = generate_test_data(
|
||||||
|
"1d", size=20, start=bot_start.strftime("%Y-%m-%d"), base=30000
|
||||||
|
)
|
||||||
|
|
||||||
|
exchange = MagicMock()
|
||||||
|
exchange.get_option.return_value = True
|
||||||
|
exchange.markets = markets
|
||||||
|
exchange.refresh_latest_ohlcv.return_value = ohlcv_data
|
||||||
|
exchange.get_pair_base_currency = MagicMock(side_effect=lambda pair: markets.get(pair)["base"])
|
||||||
|
|
||||||
|
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
|
||||||
|
|
||||||
|
# Should complete migration
|
||||||
|
assert KeyValueStore.get_int_value("wallet_history_migration") == 1
|
||||||
|
|
||||||
|
# Should have wallet history entries
|
||||||
|
wallet_entries = WalletHistory.session.query(WalletHistory).all()
|
||||||
|
assert len(wallet_entries) > 0
|
||||||
|
|
||||||
|
# Check that stake currency (USDT) entries exist
|
||||||
|
usdt_entries = [e for e in wallet_entries if e.currency == "USDT"]
|
||||||
|
assert len(usdt_entries) > 0
|
||||||
|
assert len(wallet_entries) > len(usdt_entries)
|
||||||
|
|
||||||
|
# Stake currency should have price = 1.0
|
||||||
|
for entry in usdt_entries:
|
||||||
|
assert entry.rate == 1.0
|
||||||
|
|
||||||
|
eth_entries = [e for e in wallet_entries if e.currency == "ETH"]
|
||||||
|
btc_entries = [e for e in wallet_entries if e.currency == "BTC"]
|
||||||
|
assert len(eth_entries) == 4
|
||||||
|
assert len(btc_entries) == 2
|
||||||
|
assert all(entry.rate and entry.rate > 1400 and entry.rate < 1600 for entry in eth_entries)
|
||||||
|
assert all(entry.rate and entry.rate > 29000 and entry.rate < 31000 for entry in btc_entries)
|
||||||
|
assert all(entry.balance == 10 for entry in btc_entries)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_migrate_wallet_history_pair_not_in_markets(
|
||||||
|
default_conf_usdt, caplog, fee, time_machine, markets
|
||||||
|
):
|
||||||
|
"""Test migration handles pairs that are not in exchange markets."""
|
||||||
|
start_time = dt_utc(2024, 1, 10, 12, 0, 0)
|
||||||
|
time_machine.move_to(start_time, tick=False)
|
||||||
|
|
||||||
|
# Bot started 10 days ago
|
||||||
|
bot_start = start_time - timedelta(days=10)
|
||||||
|
KeyValueStore.store_value("bot_start_time", bot_start)
|
||||||
|
|
||||||
|
# Create a trade with a pair that won't be in markets
|
||||||
|
trade1 = create_closed_mock_trade(
|
||||||
|
fee,
|
||||||
|
"UNKNOWN/USDT",
|
||||||
|
open_date=start_time - timedelta(days=5),
|
||||||
|
close_date=start_time - timedelta(days=3),
|
||||||
|
)
|
||||||
|
Trade.session.add(trade1)
|
||||||
|
Trade.commit()
|
||||||
|
|
||||||
|
exchange = MagicMock()
|
||||||
|
exchange.get_option.return_value = True
|
||||||
|
exchange.markets = markets
|
||||||
|
exchange.refresh_latest_ohlcv.return_value = {}
|
||||||
|
|
||||||
|
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
|
||||||
|
assert log_has_re("No OHLCV data available for .*", caplog)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_migrate_wallet_history_stores_migration_date(
|
||||||
|
default_conf_usdt, fee, time_machine, markets
|
||||||
|
):
|
||||||
|
"""Test that migration stores the migration date."""
|
||||||
|
start_time = dt_utc(2024, 1, 10, 12, 0, 0)
|
||||||
|
time_machine.move_to(start_time, tick=False)
|
||||||
|
|
||||||
|
# Bot started 10 days ago
|
||||||
|
bot_start = start_time - timedelta(days=10)
|
||||||
|
KeyValueStore.store_value("bot_start_time", bot_start)
|
||||||
|
|
||||||
|
# Create a trade
|
||||||
|
trade1 = create_closed_mock_trade(
|
||||||
|
fee,
|
||||||
|
"ETH/USDT",
|
||||||
|
open_date=start_time - timedelta(days=5),
|
||||||
|
close_date=start_time - timedelta(days=3),
|
||||||
|
)
|
||||||
|
Trade.session.add(trade1)
|
||||||
|
Trade.commit()
|
||||||
|
|
||||||
|
candle_type = default_conf_usdt.get("candle_type_def", CandleType.SPOT)
|
||||||
|
ohlcv_data = {
|
||||||
|
("ETH/USDT", "1d", candle_type): generate_test_data(
|
||||||
|
"1d", size=15, start=bot_start.strftime("%Y-%m-%d")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
exchange = MagicMock()
|
||||||
|
exchange.get_option.return_value = True
|
||||||
|
exchange.markets = markets
|
||||||
|
exchange.refresh_latest_ohlcv.return_value = ohlcv_data
|
||||||
|
|
||||||
|
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
|
||||||
|
|
||||||
|
# Check migration date is stored
|
||||||
|
migration_date = KeyValueStore.get_datetime_value("wallet_history_migration_date")
|
||||||
|
assert migration_date is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_internal_migrate_wallet_history_empty_trades(default_conf_usdt, time_machine):
|
||||||
|
"""Test _migrate_wallet_history returns early when no trades exist."""
|
||||||
|
start_time = dt_utc(2024, 1, 1, 12, 0, 0)
|
||||||
|
time_machine.move_to(start_time, tick=False)
|
||||||
|
|
||||||
|
# Set bot_start_time
|
||||||
|
KeyValueStore.store_value("bot_start_time", start_time - timedelta(days=5))
|
||||||
|
|
||||||
|
exchange = MagicMock()
|
||||||
|
exchange.get_option.return_value = True
|
||||||
|
exchange.markets = {}
|
||||||
|
exchange.refresh_latest_ohlcv.return_value = {}
|
||||||
|
|
||||||
|
# Call internal function directly with no trades
|
||||||
|
_migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
|
||||||
|
|
||||||
|
# refresh_latest_ohlcv should not be called when there are no trades
|
||||||
|
exchange.refresh_latest_ohlcv.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_migrate_wallet_history_with_patched_exchange(mocker, default_conf_usdt, fee, time_machine):
|
||||||
|
"""Test migration using get_patched_exchange helper."""
|
||||||
|
start_time = dt_utc(2024, 1, 10, 12, 0, 0)
|
||||||
|
time_machine.move_to(start_time, tick=False)
|
||||||
|
|
||||||
|
# Bot started 10 days ago
|
||||||
|
bot_start = start_time - timedelta(days=10)
|
||||||
|
KeyValueStore.store_value("bot_start_time", bot_start)
|
||||||
|
|
||||||
|
# Create a trade
|
||||||
|
trade1 = create_closed_mock_trade(
|
||||||
|
fee,
|
||||||
|
"ETH/USDT",
|
||||||
|
open_date=start_time - timedelta(days=5),
|
||||||
|
close_date=start_time - timedelta(days=3),
|
||||||
|
)
|
||||||
|
Trade.session.add(trade1)
|
||||||
|
Trade.commit()
|
||||||
|
|
||||||
|
# Generate mock OHLCV data starting from bot_start
|
||||||
|
candle_type = default_conf_usdt.get("candle_type_def", CandleType.SPOT)
|
||||||
|
ohlcv_df = generate_test_data("1d", size=15, start=bot_start.strftime("%Y-%m-%d"))
|
||||||
|
ohlcv_data = {("ETH/USDT", "1d", candle_type): ohlcv_df}
|
||||||
|
|
||||||
|
# Mock exchange methods
|
||||||
|
mocker.patch.multiple(
|
||||||
|
EXMS,
|
||||||
|
get_option=MagicMock(return_value=True),
|
||||||
|
refresh_latest_ohlcv=MagicMock(return_value=ohlcv_data),
|
||||||
|
)
|
||||||
|
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf_usdt)
|
||||||
|
|
||||||
|
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
|
||||||
|
|
||||||
|
# Should complete migration
|
||||||
|
assert KeyValueStore.get_int_value("wallet_history_migration") == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_migrate_wallet_history_db_error_handling(
|
||||||
|
mocker, default_conf_usdt, fee, time_machine, markets
|
||||||
|
):
|
||||||
|
"""Test that database errors are handled gracefully."""
|
||||||
|
start_time = dt_utc(2024, 1, 10, 12, 0, 0)
|
||||||
|
time_machine.move_to(start_time, tick=False)
|
||||||
|
|
||||||
|
# Bot started 10 days ago
|
||||||
|
bot_start = start_time - timedelta(days=10)
|
||||||
|
KeyValueStore.store_value("bot_start_time", bot_start)
|
||||||
|
|
||||||
|
# Create a trade
|
||||||
|
trade1 = create_closed_mock_trade(
|
||||||
|
fee,
|
||||||
|
"ETH/USDT",
|
||||||
|
open_date=start_time - timedelta(days=5),
|
||||||
|
close_date=start_time - timedelta(days=3),
|
||||||
|
)
|
||||||
|
Trade.session.add(trade1)
|
||||||
|
Trade.commit()
|
||||||
|
|
||||||
|
candle_type = default_conf_usdt.get("candle_type_def", CandleType.SPOT)
|
||||||
|
ohlcv_data = {
|
||||||
|
("ETH/USDT", "1d", candle_type): generate_test_data(
|
||||||
|
"1d", size=15, start=bot_start.strftime("%Y-%m-%d")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
exchange = MagicMock()
|
||||||
|
exchange.get_option.return_value = True
|
||||||
|
exchange.markets = markets
|
||||||
|
exchange.refresh_latest_ohlcv.return_value = ohlcv_data
|
||||||
|
|
||||||
|
# Mock bulk_save_objects to raise an exception
|
||||||
|
mocker.patch.object(
|
||||||
|
WalletHistory.session, "bulk_save_objects", side_effect=Exception("DB Error")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should not raise exception, but handle error gracefully
|
||||||
|
migrate_wallet_history(default_conf_usdt, exchange, 1000.0)
|
||||||
|
|
||||||
|
# Migration flag should still be set even after error in _migrate
|
||||||
|
assert KeyValueStore.get_int_value("wallet_history_migration") == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test__prepare_balance_distribution(default_conf_usdt, fee, time_machine, markets):
|
||||||
|
"""Test migration with multiple trading pairs."""
|
||||||
|
start_time = dt_utc(2024, 1, 15, 12, 0, 0)
|
||||||
|
time_machine.move_to(start_time, tick=False)
|
||||||
|
|
||||||
|
# Bot started 15 days ago
|
||||||
|
bot_start = start_time - timedelta(days=15)
|
||||||
|
KeyValueStore.store_value("bot_start_time", bot_start)
|
||||||
|
|
||||||
|
# Create mock trades for multiple pairs within the date range
|
||||||
|
trade1 = create_closed_mock_trade(
|
||||||
|
fee,
|
||||||
|
"ETH/USDT",
|
||||||
|
open_date=start_time - timedelta(days=10),
|
||||||
|
close_date=start_time - timedelta(days=6),
|
||||||
|
)
|
||||||
|
trade2 = create_closed_mock_trade(
|
||||||
|
fee,
|
||||||
|
"BTC/USDT",
|
||||||
|
open_date=start_time - timedelta(days=7),
|
||||||
|
close_date=start_time - timedelta(days=5),
|
||||||
|
)
|
||||||
|
Trade.session.add(trade1)
|
||||||
|
Trade.session.add(trade2)
|
||||||
|
Trade.commit()
|
||||||
|
|
||||||
|
# Generate mock OHLCV data for both pairs starting from bot_start
|
||||||
|
candle_type = default_conf_usdt.get("candle_type_def", CandleType.SPOT)
|
||||||
|
ohlcv_data = {}
|
||||||
|
ohlcv_data[("ETH/USDT", "1d", candle_type)] = generate_test_data(
|
||||||
|
"1d", size=20, start=bot_start.strftime("%Y-%m-%d"), base=1500
|
||||||
|
)
|
||||||
|
|
||||||
|
ohlcv_data[("BTC/USDT", "1d", candle_type)] = generate_test_data(
|
||||||
|
"1d", size=20, start=bot_start.strftime("%Y-%m-%d"), base=30000
|
||||||
|
)
|
||||||
|
|
||||||
|
exchange = MagicMock()
|
||||||
|
exchange.get_option.return_value = True
|
||||||
|
exchange.markets = markets
|
||||||
|
exchange.refresh_latest_ohlcv.return_value = ohlcv_data
|
||||||
|
|
||||||
|
balance_dist, pairlist_valid = _prepare_balance_distribution(
|
||||||
|
default_conf_usdt, exchange, 1000.0
|
||||||
|
)
|
||||||
|
assert not balance_dist.empty
|
||||||
|
assert len(pairlist_valid) == 2
|
||||||
|
assert "ETH/USDT" in pairlist_valid
|
||||||
|
assert "BTC/USDT" in pairlist_valid
|
||||||
|
|
||||||
|
assert len(balance_dist) == 16 # 16 days from bot_start to now
|
||||||
|
assert balance_dist["USDT"].iloc[0] == 1000.0
|
||||||
|
assert pd.isna(balance_dist["USDT"]).sum() == 0
|
||||||
|
|
||||||
|
assert all(
|
||||||
|
col in balance_dist.columns
|
||||||
|
for col in [
|
||||||
|
"USDT",
|
||||||
|
"ETH/USDT",
|
||||||
|
"ETH/USDT_collateral",
|
||||||
|
"ETH/USDT_leverage",
|
||||||
|
"BTC/USDT",
|
||||||
|
"BTC/USDT_collateral",
|
||||||
|
"BTC/USDT_leverage",
|
||||||
|
"ETH/USDT_open",
|
||||||
|
"BTC/USDT_open",
|
||||||
|
"ETH/USDT_value",
|
||||||
|
"BTC/USDT_value",
|
||||||
|
"total_value",
|
||||||
|
]
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user