Merge pull request #9013 from freqtrade/feat/backtest_notes

Add backtest notes capability
This commit is contained in:
Matthias
2023-08-03 20:15:08 +02:00
committed by GitHub
7 changed files with 159 additions and 12 deletions

View File

@@ -12,7 +12,7 @@ import pandas as pd
from freqtrade.constants import LAST_BT_RESULT_FN, IntOrInf from freqtrade.constants import LAST_BT_RESULT_FN, IntOrInf
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.misc import json_load from freqtrade.misc import file_dump_json, json_load
from freqtrade.optimize.backtest_caching import get_backtest_metadata_filename from freqtrade.optimize.backtest_caching import get_backtest_metadata_filename
from freqtrade.persistence import LocalTrade, Trade, init_db from freqtrade.persistence import LocalTrade, Trade, init_db
from freqtrade.types import BacktestHistoryEntryType, BacktestResultType from freqtrade.types import BacktestHistoryEntryType, BacktestResultType
@@ -175,6 +175,21 @@ def _get_backtest_files(dirname: Path) -> List[Path]:
return list(reversed(sorted(dirname.glob('backtest-result-*-[0-9][0-9].json')))) return list(reversed(sorted(dirname.glob('backtest-result-*-[0-9][0-9].json'))))
def get_backtest_result(filename: Path) -> List[BacktestHistoryEntryType]:
"""
Get backtest result read from metadata file
"""
return [
{
'filename': filename.stem,
'strategy': s,
'notes': v.get('notes', ''),
'run_id': v['run_id'],
'backtest_start_time': v['backtest_start_time'],
} for s, v in load_backtest_metadata(filename).items()
]
def get_backtest_resultlist(dirname: Path) -> List[BacktestHistoryEntryType]: def get_backtest_resultlist(dirname: Path) -> List[BacktestHistoryEntryType]:
""" """
Get list of backtest results read from metadata files Get list of backtest results read from metadata files
@@ -184,6 +199,7 @@ def get_backtest_resultlist(dirname: Path) -> List[BacktestHistoryEntryType]:
'filename': filename.stem, 'filename': filename.stem,
'strategy': s, 'strategy': s,
'run_id': v['run_id'], 'run_id': v['run_id'],
'notes': v.get('notes', ''),
'backtest_start_time': v['backtest_start_time'], 'backtest_start_time': v['backtest_start_time'],
} }
for filename in _get_backtest_files(dirname) for filename in _get_backtest_files(dirname)
@@ -203,6 +219,21 @@ def delete_backtest_result(file_abs: Path):
file_abs_meta.unlink() file_abs_meta.unlink()
def update_backtest_metadata(filename: Path, strategy: str, content: Dict[str, Any]):
"""
Updates backtest metadata file with new content.
:raises: ValueError if metadata file does not exist, or strategy is not in this file.
"""
metadata = load_backtest_metadata(filename)
if not metadata:
raise ValueError("File does not exist.")
if strategy not in metadata:
raise ValueError("Strategy not in metadata.")
metadata[strategy].update(content)
# Write data again.
file_dump_json(get_backtest_metadata_filename(filename), metadata)
def find_existing_backtest_stats(dirname: Union[Path, str], run_ids: Dict[str, str], def find_existing_backtest_stats(dirname: Union[Path, str], run_ids: Dict[str, str],
min_backtest_date: Optional[datetime] = None) -> Dict[str, Any]: min_backtest_date: Optional[datetime] = None) -> Dict[str, Any]:
""" """

View File

@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
def store_backtest_stats( def store_backtest_stats(
recordfilename: Path, stats: BacktestResultType, dtappendix: str) -> None: recordfilename: Path, stats: BacktestResultType, dtappendix: str) -> Path:
""" """
Stores backtest results Stores backtest results
:param recordfilename: Path object, which can either be a filename or a directory. :param recordfilename: Path object, which can either be a filename or a directory.
@@ -41,6 +41,8 @@ def store_backtest_stats(
latest_filename = Path.joinpath(filename.parent, LAST_BT_RESULT_FN) latest_filename = Path.joinpath(filename.parent, LAST_BT_RESULT_FN)
file_dump_json(latest_filename, {'latest_backtest': str(filename.name)}) file_dump_json(latest_filename, {'latest_backtest': str(filename.name)})
return filename
def _store_backtest_analysis_data( def _store_backtest_analysis_data(
recordfilename: Path, data: Dict[str, Dict], recordfilename: Path, data: Dict[str, Dict],

View File

@@ -10,14 +10,15 @@ from fastapi.exceptions import HTTPException
from freqtrade.configuration.config_validation import validate_config_consistency from freqtrade.configuration.config_validation import validate_config_consistency
from freqtrade.constants import Config from freqtrade.constants import Config
from freqtrade.data.btanalysis import (delete_backtest_result, get_backtest_resultlist, from freqtrade.data.btanalysis import (delete_backtest_result, get_backtest_result,
load_and_merge_backtest_result) get_backtest_resultlist, load_and_merge_backtest_result,
update_backtest_metadata)
from freqtrade.enums import BacktestState from freqtrade.enums import BacktestState
from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.exceptions import DependencyException, OperationalException
from freqtrade.exchange.common import remove_exchange_credentials from freqtrade.exchange.common import remove_exchange_credentials
from freqtrade.misc import deep_merge_dicts, is_file_in_dir from freqtrade.misc import deep_merge_dicts, is_file_in_dir
from freqtrade.rpc.api_server.api_schemas import (BacktestHistoryEntry, BacktestRequest, from freqtrade.rpc.api_server.api_schemas import (BacktestHistoryEntry, BacktestMetadataUpdate,
BacktestResponse) BacktestRequest, BacktestResponse)
from freqtrade.rpc.api_server.deps import get_config from freqtrade.rpc.api_server.deps import get_config
from freqtrade.rpc.api_server.webserver_bgwork import ApiBG from freqtrade.rpc.api_server.webserver_bgwork import ApiBG
from freqtrade.rpc.rpc import RPCException from freqtrade.rpc.rpc import RPCException
@@ -74,10 +75,11 @@ def __run_backtest_bg(btconfig: Config):
ApiBG.bt['bt'].load_prior_backtest() ApiBG.bt['bt'].load_prior_backtest()
ApiBG.bt['bt'].abort = False ApiBG.bt['bt'].abort = False
strategy_name = strat.get_strategy_name()
if (ApiBG.bt['bt'].results and if (ApiBG.bt['bt'].results and
strat.get_strategy_name() in ApiBG.bt['bt'].results['strategy']): strategy_name in ApiBG.bt['bt'].results['strategy']):
# When previous result hash matches - reuse that result and skip backtesting. # When previous result hash matches - reuse that result and skip backtesting.
logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}') logger.info(f'Reusing result of previous backtest for {strategy_name}')
else: else:
min_date, max_date = ApiBG.bt['bt'].backtest_one_strategy( min_date, max_date = ApiBG.bt['bt'].backtest_one_strategy(
strat, ApiBG.bt['data'], ApiBG.bt['timerange']) strat, ApiBG.bt['data'], ApiBG.bt['timerange'])
@@ -87,10 +89,12 @@ def __run_backtest_bg(btconfig: Config):
min_date=min_date, max_date=max_date) min_date=min_date, max_date=max_date)
if btconfig.get('export', 'none') == 'trades': if btconfig.get('export', 'none') == 'trades':
store_backtest_stats( fn = store_backtest_stats(
btconfig['exportfilename'], ApiBG.bt['bt'].results, btconfig['exportfilename'], ApiBG.bt['bt'].results,
datetime.now().strftime("%Y-%m-%d_%H-%M-%S") datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
) )
ApiBG.bt['bt'].results['metadata'][strategy_name]['filename'] = str(fn.name)
ApiBG.bt['bt'].results['metadata'][strategy_name]['strategy'] = strategy_name
logger.info("Backtest finished.") logger.info("Backtest finished.")
@@ -281,3 +285,24 @@ def api_delete_backtest_history_entry(file: str, config=Depends(get_config)):
delete_backtest_result(file_abs) delete_backtest_result(file_abs)
return get_backtest_resultlist(config['user_data_dir'] / 'backtest_results') return get_backtest_resultlist(config['user_data_dir'] / 'backtest_results')
@router.patch('/backtest/history/{file}', response_model=List[BacktestHistoryEntry],
tags=['webserver', 'backtest'])
def api_update_backtest_history_entry(file: str, body: BacktestMetadataUpdate,
config=Depends(get_config)):
# Get backtest result history, read from metadata files
bt_results_base: Path = config['user_data_dir'] / 'backtest_results'
file_abs = (bt_results_base / file).with_suffix('.json')
# Ensure file is in backtest_results directory
if not is_file_in_dir(file_abs, bt_results_base):
raise HTTPException(status_code=404, detail="File not found.")
content = {
'notes': body.notes
}
try:
update_backtest_metadata(file_abs, body.strategy, content)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return get_backtest_result(file_abs)

View File

@@ -526,6 +526,12 @@ class BacktestHistoryEntry(BaseModel):
strategy: str strategy: str
run_id: str run_id: str
backtest_start_time: int backtest_start_time: int
notes: Optional[str] = ''
class BacktestMetadataUpdate(BaseModel):
strategy: str
notes: str = ''
class SysInfo(BaseModel): class SysInfo(BaseModel):

View File

@@ -50,7 +50,8 @@ logger = logging.getLogger(__name__)
# 2.29: Add /exchanges endpoint # 2.29: Add /exchanges endpoint
# 2.30: new /pairlists endpoint # 2.30: new /pairlists endpoint
# 2.31: new /backtest/history/ delete endpoint # 2.31: new /backtest/history/ delete endpoint
API_VERSION = 2.31 # 2.32: new /backtest/history/ patch endpoint
API_VERSION = 2.32
# Public API, requires no auth. # Public API, requires no auth.
router_public = APIRouter() router_public = APIRouter()

View File

@@ -25,3 +25,4 @@ def get_BacktestResultType_default() -> BacktestResultType:
class BacktestHistoryEntryType(BacktestMetadataType): class BacktestHistoryEntryType(BacktestMetadataType):
filename: str filename: str
strategy: str strategy: str
notes: str

View File

@@ -10,6 +10,7 @@ from unittest.mock import ANY, MagicMock, PropertyMock
import pandas as pd import pandas as pd
import pytest import pytest
import rapidjson
import uvicorn import uvicorn
from fastapi import FastAPI, WebSocketDisconnect from fastapi import FastAPI, WebSocketDisconnect
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
@@ -80,6 +81,16 @@ def client_post(client: TestClient, url, data={}):
}) })
def client_patch(client: TestClient, url, data={}):
return client.patch(url,
json=data,
headers={'Authorization': _basic_auth_str(_TEST_USER, _TEST_PASS),
'Origin': 'http://example.com',
'content-type': 'application/json'
})
def client_get(client: TestClient, url): def client_get(client: TestClient, url):
# Add fake Origin to ensure CORS kicks in # Add fake Origin to ensure CORS kicks in
return client.get(url, headers={'Authorization': _basic_auth_str(_TEST_USER, _TEST_PASS), return client.get(url, headers={'Authorization': _basic_auth_str(_TEST_USER, _TEST_PASS),
@@ -1763,7 +1774,7 @@ def test_api_pairlists_evaluate(botclient, tmpdir, mocker):
rc = client_get(client, f"{BASE_URI}/pairlists/evaluate/{job_id}") rc = client_get(client, f"{BASE_URI}/pairlists/evaluate/{job_id}")
assert_response(rc) assert_response(rc)
response = rc.json() response = rc.json()
assert response['result']['whitelist'] == ['ETH/BTC', 'LTC/BTC', 'XRP/BTC', 'NEO/BTC',] assert response['result']['whitelist'] == ['ETH/BTC', 'LTC/BTC', 'XRP/BTC', 'NEO/BTC']
assert response['result']['length'] == 4 assert response['result']['length'] == 4
# Restart with additional filter, reducing the list to 2 # Restart with additional filter, reducing the list to 2
@@ -2010,6 +2021,7 @@ def test_api_backtest_history(botclient, mocker, testdatadir):
assert len(result) == 3 assert len(result) == 3
fn = result[0]['filename'] fn = result[0]['filename']
assert fn == "backtest-result_multistrat" assert fn == "backtest-result_multistrat"
assert result[0]['notes'] == ''
strategy = result[0]['strategy'] strategy = result[0]['strategy']
rc = client_get(client, f"{BASE_URI}/backtest/history/result?filename={fn}&strategy={strategy}") rc = client_get(client, f"{BASE_URI}/backtest/history/result?filename={fn}&strategy={strategy}")
assert_response(rc) assert_response(rc)
@@ -2023,7 +2035,7 @@ def test_api_backtest_history(botclient, mocker, testdatadir):
assert result2['backtest_result']['strategy'][strategy] assert result2['backtest_result']['strategy'][strategy]
def test_api_delete_backtest_history_entry(botclient, mocker, tmp_path: Path): def test_api_delete_backtest_history_entry(botclient, tmp_path: Path):
ftbot, client = botclient ftbot, client = botclient
# Create a temporary directory and file # Create a temporary directory and file
@@ -2051,6 +2063,75 @@ def test_api_delete_backtest_history_entry(botclient, mocker, tmp_path: Path):
assert not meta_path.exists() assert not meta_path.exists()
def test_api_patch_backtest_history_entry(botclient, tmp_path: Path):
ftbot, client = botclient
# Create a temporary directory and file
bt_results_base = tmp_path / "backtest_results"
bt_results_base.mkdir()
file_path = bt_results_base / "test.json"
file_path.touch()
meta_path = file_path.with_suffix('.meta.json')
with meta_path.open('w') as metafile:
rapidjson.dump({
CURRENT_TEST_STRATEGY: {
"run_id": "6e542efc8d5e62cef6e5be0ffbc29be81a6e751d",
"backtest_start_time": 1690176003}
}, metafile)
def read_metadata():
with meta_path.open('r') as metafile:
return rapidjson.load(metafile)
rc = client_patch(client, f"{BASE_URI}/backtest/history/randomFile.json")
assert_response(rc, 503)
ftbot.config['user_data_dir'] = tmp_path
ftbot.config['runmode'] = RunMode.WEBSERVER
rc = client_patch(client, f"{BASE_URI}/backtest/history/randomFile.json", {
"strategy": CURRENT_TEST_STRATEGY,
})
assert rc.status_code == 404
# Nonexisting strategy
rc = client_patch(client, f"{BASE_URI}/backtest/history/{file_path.name}", {
"strategy": f"{CURRENT_TEST_STRATEGY}xxx",
})
assert rc.status_code == 400
assert rc.json()['detail'] == 'Strategy not in metadata.'
# no Notes
rc = client_patch(client, f"{BASE_URI}/backtest/history/{file_path.name}", {
"strategy": CURRENT_TEST_STRATEGY,
})
assert rc.status_code == 200
res = rc.json()
assert isinstance(res, list)
assert len(res) == 1
assert res[0]['strategy'] == CURRENT_TEST_STRATEGY
assert res[0]['notes'] == ''
fileres = read_metadata()
assert fileres[CURRENT_TEST_STRATEGY]['run_id'] == res[0]['run_id']
assert fileres[CURRENT_TEST_STRATEGY]['notes'] == ''
rc = client_patch(client, f"{BASE_URI}/backtest/history/{file_path.name}", {
"strategy": CURRENT_TEST_STRATEGY,
"notes": "FooBar",
})
assert rc.status_code == 200
res = rc.json()
assert isinstance(res, list)
assert len(res) == 1
assert res[0]['strategy'] == CURRENT_TEST_STRATEGY
assert res[0]['notes'] == 'FooBar'
fileres = read_metadata()
assert fileres[CURRENT_TEST_STRATEGY]['run_id'] == res[0]['run_id']
assert fileres[CURRENT_TEST_STRATEGY]['notes'] == 'FooBar'
def test_health(botclient): def test_health(botclient):
ftbot, client = botclient ftbot, client = botclient