diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 720f8c95d..005f092bb 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -1,7 +1,7 @@ from datetime import date, datetime -from typing import Any +from typing import Annotated, Any, Literal -from pydantic import AwareDatetime, BaseModel, RootModel, SerializeAsAny, model_validator +from pydantic import AwareDatetime, BaseModel, Field, RootModel, SerializeAsAny, model_validator from freqtrade.constants import DL_DATA_TIMEFRAMES, IntOrInf from freqtrade.enums import MarginMode, OrderTypeValues, SignalDirection, TradingMode @@ -527,10 +527,59 @@ class FreqAIModelListResponse(BaseModel): freqaimodels: list[str] +class __StrategyParameter(BaseModel): + param_type: str + name: str + space: str + load: bool + optimize: bool + + +class IntParameter(__StrategyParameter): + param_type: Literal["IntParameter"] + value: int + low: int + high: int + + +class RealParameter(__StrategyParameter): + param_type: Literal["RealParameter"] + value: float + low: float + high: float + + +class DecimalParameter(__StrategyParameter): + param_type: Literal["DecimalParameter"] + value: float + low: float + high: float + decimals: int + + +class BooleanParameter(__StrategyParameter): + param_type: Literal["BooleanParameter"] + value: bool | None + opt_range: list[bool] + + +class CategoricalParameter(__StrategyParameter): + param_type: Literal["CategoricalParameter"] + value: Any + opt_range: list[Any] + + +AllParameters = Annotated[ + BooleanParameter | CategoricalParameter | DecimalParameter | IntParameter | RealParameter, + Field(discriminator="param_type"), +] + + class StrategyResponse(BaseModel): strategy: str - code: str timeframe: str | None + params: list[AllParameters] = Field(default_factory=list) + code: str class AvailablePairs(BaseModel): diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index 841acca96..29d3e3970 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -7,6 +7,7 @@ from fastapi.exceptions import HTTPException from freqtrade import __version__ from freqtrade.enums import RunMode, State +from freqtrade.exceptions import OperationalException from freqtrade.rpc import RPC from freqtrade.rpc.api_server.api_pairlists import handleExchangePayload from freqtrade.rpc.api_server.api_schemas import ( @@ -17,6 +18,7 @@ from freqtrade.rpc.api_server.api_schemas import ( Ping, PlotConfig, ShowConfig, + StrategyResponse, SysInfo, Version, ) @@ -60,7 +62,8 @@ logger = logging.getLogger(__name__) # 2.44: Add candle_types parameter to download-data endpoint # 2.45: Add price to forceexit endpoint # 2.46: Add prepend_data to download-data endpoint -API_VERSION = 2.46 +# 2.47: Add Strategy parameters +API_VERSION = 2.47 # Public API, requires no auth. router_public = APIRouter() @@ -139,6 +142,47 @@ def markets( } +@router.get("/strategy/{strategy}", response_model=StrategyResponse, tags=["Strategy"]) +def get_strategy( + strategy: str, config=Depends(get_config), rpc: RPC | None = Depends(get_rpc_optional) +): + if ":" in strategy: + raise HTTPException(status_code=422, detail="base64 encoded strategies are not allowed.") + + if not rpc or config["runmode"] == RunMode.WEBSERVER: + # webserver mode + config_ = deepcopy(config) + from freqtrade.resolvers.strategy_resolver import StrategyResolver + + try: + strategy_obj = StrategyResolver._load_strategy( + strategy, config_, extra_dir=config_.get("strategy_path") + ) + strategy_obj.ft_load_hyper_params() + except OperationalException: + raise HTTPException(status_code=404, detail="Strategy not found") + except Exception: + logger.exception("Unexpected error while loading strategy '%s'.", strategy) + raise HTTPException( + status_code=502, + detail="Unexpected error while loading strategy.", + ) + else: + # trade mode + strategy_obj = rpc._freqtrade.strategy + if strategy_obj.get_strategy_name() != strategy: + raise HTTPException( + status_code=404, + detail="Only the currently active strategy is available in trade mode", + ) + return { + "strategy": strategy_obj.get_strategy_name(), + "timeframe": getattr(strategy_obj, "timeframe", None), + "code": strategy_obj.__source__, + "params": [p for _, p in strategy_obj.enumerate_parameters()], + } + + @router.get("/sysinfo", response_model=SysInfo, tags=["Info"]) def sysinfo(): return RPC._rpc_sysinfo() diff --git a/freqtrade/rpc/api_server/api_webserver.py b/freqtrade/rpc/api_server/api_webserver.py index 7b2cce0fc..3ca02bc5a 100644 --- a/freqtrade/rpc/api_server/api_webserver.py +++ b/freqtrade/rpc/api_server/api_webserver.py @@ -1,19 +1,15 @@ import logging -from copy import deepcopy from fastapi import APIRouter, Depends -from fastapi.exceptions import HTTPException from freqtrade.data.history.datahandlers import get_datahandler from freqtrade.enums import CandleType, TradingMode -from freqtrade.exceptions import OperationalException from freqtrade.rpc.api_server.api_schemas import ( AvailablePairs, ExchangeListResponse, FreqAIModelListResponse, HyperoptLossListResponse, StrategyListResponse, - StrategyResponse, ) from freqtrade.rpc.api_server.deps import get_config @@ -36,29 +32,6 @@ def list_strategies(config=Depends(get_config)): return {"strategies": [x["name"] for x in strategies]} -@router.get("/strategy/{strategy}", response_model=StrategyResponse, tags=["Strategy"]) -def get_strategy(strategy: str, config=Depends(get_config)): - if ":" in strategy: - raise HTTPException(status_code=500, detail="base64 encoded strategies are not allowed.") - - config_ = deepcopy(config) - from freqtrade.resolvers.strategy_resolver import StrategyResolver - - try: - strategy_obj = StrategyResolver._load_strategy( - strategy, config_, extra_dir=config_.get("strategy_path") - ) - except OperationalException: - raise HTTPException(status_code=404, detail="Strategy not found") - except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) - return { - "strategy": strategy_obj.get_strategy_name(), - "code": strategy_obj.__source__, - "timeframe": getattr(strategy_obj, "timeframe", None), - } - - @router.get("/exchanges", response_model=ExchangeListResponse, tags=[]) def list_exchanges(config=Depends(get_config)): from freqtrade.exchange import list_available_exchanges diff --git a/freqtrade/strategy/parameters.py b/freqtrade/strategy/parameters.py index c6363ce99..c899f2952 100644 --- a/freqtrade/strategy/parameters.py +++ b/freqtrade/strategy/parameters.py @@ -70,6 +70,10 @@ class BaseParameter(ABC): def __repr__(self): return f"{self.__class__.__name__}({self.value})" + @property + def param_type(self) -> str: + return self.__class__.__name__ + @abstractmethod def get_space(self, name: str) -> Union["Integer", "Real", "SKDecimal", "Categorical"]: """ @@ -255,8 +259,8 @@ class DecimalParameter(NumericParameter): :param load: Load parameter value from {space}_params. :param kwargs: Extra parameters to optuna's NumericParameter. """ - self._decimals = decimals - default = round(default, self._decimals) + self.decimals = decimals + default = round(default, self.decimals) super().__init__( low=low, high=high, default=default, space=space, optimize=optimize, load=load, **kwargs @@ -268,7 +272,7 @@ class DecimalParameter(NumericParameter): @value.setter def value(self, new_value: float): - self._value = round(new_value, self._decimals) + self._value = round(new_value, self.decimals) def get_space(self, name: str) -> "SKDecimal": """ @@ -276,7 +280,7 @@ class DecimalParameter(NumericParameter): :param name: A name of parameter field. """ return SKDecimal( - low=self.low, high=self.high, decimals=self._decimals, name=name, **self._space_params + low=self.low, high=self.high, decimals=self.decimals, name=name, **self._space_params ) @property @@ -288,9 +292,9 @@ class DecimalParameter(NumericParameter): calculating 100ds of indicators. """ if self.can_optimize(): - low = int(self.low * pow(10, self._decimals)) - high = int(self.high * pow(10, self._decimals)) + 1 - return [round(n * pow(0.1, self._decimals), self._decimals) for n in range(low, high)] + low = int(self.low * pow(10, self.decimals)) + high = int(self.high * pow(10, self.decimals)) + 1 + return [round(n * pow(0.1, self.decimals), self.decimals) for n in range(low, high)] else: return [self.value] diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 57831935b..6f3adde1d 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -2559,19 +2559,50 @@ def test_api_strategy(botclient, tmp_path, mocker): rc = client_get(client, f"{BASE_URI}/strategy/{CURRENT_TEST_STRATEGY}") assert_response(rc) - assert rc.json()["strategy"] == CURRENT_TEST_STRATEGY + response = rc.json() + assert response["strategy"] == CURRENT_TEST_STRATEGY data = (Path(__file__).parents[1] / "strategy/strats/strategy_test_v3.py").read_text( encoding="utf-8" ) - assert rc.json()["code"] == data + assert response["code"] == data + assert "params" in response + assert isinstance(response["params"], list) + assert len(response["params"]) >= 6 + buy_rsi = next(p for p in response["params"] if p["name"] == "buy_rsi") + assert buy_rsi == { + "param_type": "IntParameter", + "name": "buy_rsi", + "space": "buy", + "load": True, + "optimize": True, + "value": 35, # Parameter from buy_params + "low": 0, + "high": 50, + } + + rc = client_get(client, f"{BASE_URI}/strategy/HyperoptableStrategy") + assert_response(rc) + response2 = rc.json() + assert len(response2["params"]) >= 8 + param_exitaaa = next(p for p in response2["params"] if p["name"] == "exitaaa") + assert param_exitaaa == { + "param_type": "IntParameter", + "name": "exitaaa", + "space": "exitaspace", + "load": True, + "optimize": True, + "value": 5, + "low": 0, + "high": 10, + } rc = client_get(client, f"{BASE_URI}/strategy/NoStrat") assert_response(rc, 404) # Disallow base64 strategies rc = client_get(client, f"{BASE_URI}/strategy/xx:cHJpbnQoImhlbGxvIHdvcmxkIik=") - assert_response(rc, 500) + assert_response(rc, 422) mocker.patch( "freqtrade.resolvers.strategy_resolver.StrategyResolver._load_strategy", side_effect=Exception("Test"), @@ -2581,6 +2612,40 @@ def test_api_strategy(botclient, tmp_path, mocker): assert_response(rc, 502) +def test_api_strategy_trade_mode(botclient, tmp_path, mocker): + ftbot, client = botclient + ftbot.config["user_data_dir"] = tmp_path + + rc = client_get(client, f"{BASE_URI}/strategy/{CURRENT_TEST_STRATEGY}") + + assert_response(rc) + response = rc.json() + assert response["strategy"] == CURRENT_TEST_STRATEGY + + data = (Path(__file__).parents[1] / "strategy/strats/strategy_test_v3.py").read_text( + encoding="utf-8" + ) + assert response["code"] == data + assert "params" in response + assert isinstance(response["params"], list) + assert len(response["params"]) >= 6 + buy_rsi = next(p for p in response["params"] if p["name"] == "buy_rsi") + assert buy_rsi == { + "param_type": "IntParameter", + "name": "buy_rsi", + "space": "buy", + "load": True, + "optimize": True, + "value": 35, # Parameter from buy_params + "low": 0, + "high": 50, + } + + rc = client_get(client, f"{BASE_URI}/strategy/HyperoptableStrategy") + assert_response(rc, 404) + assert rc.json()["detail"] == "Only the currently active strategy is available in trade mode" + + def test_api_exchanges(botclient): _ftbot, client = botclient _ftbot.config["runmode"] = RunMode.WEBSERVER diff --git a/tests/strategy/test_strategy_parameters.py b/tests/strategy/test_strategy_parameters.py index 4bc10c0b5..bb379c774 100644 --- a/tests/strategy/test_strategy_parameters.py +++ b/tests/strategy/test_strategy_parameters.py @@ -43,6 +43,7 @@ def test_hyperopt_int_parameter(): HyperoptStateContainer.set_state(HyperoptState.OPTIMIZE) assert len(list(intpar.range)) == 1 + assert intpar.param_type == "IntParameter" def test_hyperopt_real_parameter(): @@ -60,6 +61,7 @@ def test_hyperopt_real_parameter(): assert isinstance(fltpar.get_space(""), FloatDistribution) assert not hasattr(fltpar, "range") + assert fltpar.param_type == "RealParameter" def test_hyperopt_decimal_parameter(): @@ -94,6 +96,7 @@ def test_hyperopt_decimal_parameter(): HyperoptStateContainer.set_state(HyperoptState.OPTIMIZE) assert len(list(decimalpar.range)) == 1 + assert decimalpar.param_type == "DecimalParameter" def test_hyperopt_categorical_parameter(): @@ -133,3 +136,5 @@ def test_hyperopt_categorical_parameter(): HyperoptStateContainer.set_state(HyperoptState.OPTIMIZE) assert len(list(catpar.range)) == 1 assert len(list(boolpar.range)) == 1 + assert boolpar.param_type == "BooleanParameter" + assert catpar.param_type == "CategoricalParameter"