Merge pull request #3818 from freqtrade/rpc/candlehistory

Rpc/candlehistory
This commit is contained in:
Matthias
2020-10-04 09:34:25 +02:00
committed by GitHub
11 changed files with 579 additions and 64 deletions

View File

@@ -51,7 +51,8 @@ class IResolver:
:param object_name: Class name of the object
:param enum_failed: If True, will return None for modules which fail.
Otherwise, failing modules are skipped.
:return: generator containing matching objects
:return: generator containing tuple of matching objects
Tuple format: [Object, source]
"""
# Generate spec based on absolute path
@@ -67,14 +68,16 @@ class IResolver:
return iter([None])
valid_objects_gen = (
obj for name, obj in inspect.getmembers(module, inspect.isclass)
if ((object_name is None or object_name == name) and
issubclass(obj, cls.object_type) and obj is not cls.object_type)
(obj, inspect.getsource(module)) for
name, obj in inspect.getmembers(
module, inspect.isclass) if ((object_name is None or object_name == name)
and issubclass(obj, cls.object_type)
and obj is not cls.object_type)
)
return valid_objects_gen
@classmethod
def _search_object(cls, directory: Path, object_name: str
def _search_object(cls, directory: Path, *, object_name: str, add_source: bool = False
) -> Union[Tuple[Any, Path], Tuple[None, None]]:
"""
Search for the objectname in the given directory
@@ -93,11 +96,14 @@ class IResolver:
obj = next(cls._get_valid_object(module_path, object_name), None)
if obj:
return (obj, module_path)
obj[0].__file__ = str(entry)
if add_source:
obj[0].__source__ = obj[1]
return (obj[0], module_path)
return (None, None)
@classmethod
def _load_object(cls, paths: List[Path], object_name: str,
def _load_object(cls, paths: List[Path], *, object_name: str, add_source: bool = False,
kwargs: dict = {}) -> Optional[Any]:
"""
Try to load object from path list.
@@ -106,7 +112,8 @@ class IResolver:
for _path in paths:
try:
(module, module_path) = cls._search_object(directory=_path,
object_name=object_name)
object_name=object_name,
add_source=add_source)
if module:
logger.info(
f"Using resolved {cls.object_type.__name__.lower()[1:]} {object_name} "
@@ -118,7 +125,7 @@ class IResolver:
return None
@classmethod
def load_object(cls, object_name: str, config: dict, kwargs: dict,
def load_object(cls, object_name: str, config: dict, *, kwargs: dict,
extra_dir: Optional[str] = None) -> Any:
"""
Search and loads the specified object as configured in hte child class.
@@ -133,10 +140,10 @@ class IResolver:
user_subdir=cls.user_subdir,
extra_dir=extra_dir)
pairlist = cls._load_object(paths=abs_paths, object_name=object_name,
kwargs=kwargs)
if pairlist:
return pairlist
found_object = cls._load_object(paths=abs_paths, object_name=object_name,
kwargs=kwargs)
if found_object:
return found_object
raise OperationalException(
f"Impossible to load {cls.object_type_str} '{object_name}'. This class does not exist "
"or contains Python code errors."
@@ -164,8 +171,8 @@ class IResolver:
for obj in cls._get_valid_object(module_path, object_name=None,
enum_failed=enum_failed):
objects.append(
{'name': obj.__name__ if obj is not None else '',
'class': obj,
{'name': obj[0].__name__ if obj is not None else '',
'class': obj[0] if obj is not None else None,
'location': entry,
})
return objects

View File

@@ -174,7 +174,9 @@ class StrategyResolver(IResolver):
strategy = StrategyResolver._load_object(paths=abs_paths,
object_name=strategy_name,
kwargs={'config': config})
add_source=True,
kwargs={'config': config},
)
if strategy:
strategy._populate_fun_len = len(getfullargspec(strategy.populate_indicators).args)
strategy._buy_fun_len = len(getfullargspec(strategy.populate_buy_trend).args)

View File

@@ -1,7 +1,9 @@
import logging
import threading
from copy import deepcopy
from datetime import date, datetime
from ipaddress import IPv4Address
from pathlib import Path
from typing import Any, Callable, Dict
from arrow import Arrow
@@ -15,7 +17,8 @@ from werkzeug.security import safe_str_cmp
from werkzeug.serving import make_server
from freqtrade.__init__ import __version__
from freqtrade.constants import DATETIME_PRINT_FORMAT
from freqtrade.constants import DATETIME_PRINT_FORMAT, USERPATH_STRATEGIES
from freqtrade.exceptions import OperationalException
from freqtrade.persistence import Trade
from freqtrade.rpc.fiat_convert import CryptoToFiatConverter
from freqtrade.rpc.rpc import RPC, RPCException
@@ -26,15 +29,15 @@ logger = logging.getLogger(__name__)
BASE_URI = "/api/v1"
class ArrowJSONEncoder(JSONEncoder):
class FTJSONEncoder(JSONEncoder):
def default(self, obj):
try:
if isinstance(obj, Arrow):
return obj.for_json()
elif isinstance(obj, date):
return obj.strftime("%Y-%m-%d")
elif isinstance(obj, datetime):
return obj.strftime(DATETIME_PRINT_FORMAT)
elif isinstance(obj, date):
return obj.strftime("%Y-%m-%d")
iterable = iter(obj)
except TypeError:
pass
@@ -108,7 +111,7 @@ class ApiServer(RPC):
'jwt_secret_key', 'super-secret')
self.jwt = JWTManager(self.app)
self.app.json_encoder = ArrowJSONEncoder
self.app.json_encoder = FTJSONEncoder
self.app.teardown_appcontext(shutdown_session)
@@ -160,16 +163,12 @@ class ApiServer(RPC):
"""
pass
def rest_dump(self, return_value):
""" Helper function to jsonify object for a webserver """
return jsonify(return_value)
def rest_error(self, error_msg):
return jsonify({"error": error_msg}), 502
def rest_error(self, error_msg, error_code=502):
return jsonify({"error": error_msg}), error_code
def register_rest_rpc_urls(self):
"""
Registers flask app URLs that are calls to functonality in rpc.rpc.
Registers flask app URLs that are calls to functionality in rpc.rpc.
First two arguments passed are /URL and 'Label'
Label can be used as a shortcut when refactoring
@@ -212,6 +211,20 @@ class ApiServer(RPC):
view_func=self._trades, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/trades/<int:tradeid>', 'trades_delete',
view_func=self._trades_delete, methods=['DELETE'])
self.app.add_url_rule(f'{BASE_URI}/pair_candles', 'pair_candles',
view_func=self._analysed_candles, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/pair_history', 'pair_history',
view_func=self._analysed_history, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/plot_config', 'plot_config',
view_func=self._plot_config, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/strategies', 'strategies',
view_func=self._list_strategies, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/strategy/<string:strategy>', 'strategy',
view_func=self._get_strategy, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/available_pairs', 'pairs',
view_func=self._list_available_pairs, methods=['GET'])
# Combined actions and infos
self.app.add_url_rule(f'{BASE_URI}/blacklist', 'blacklist', view_func=self._blacklist,
methods=['GET', 'POST'])
@@ -227,7 +240,7 @@ class ApiServer(RPC):
"""
Return "404 not found", 404.
"""
return self.rest_dump({
return jsonify({
'status': 'error',
'reason': f"There's no API call for {request.base_url}.",
'code': 404
@@ -247,7 +260,7 @@ class ApiServer(RPC):
'access_token': create_access_token(identity=keystuff),
'refresh_token': create_refresh_token(identity=keystuff),
}
return self.rest_dump(ret)
return jsonify(ret)
return jsonify({"error": "Unauthorized"}), 401
@@ -262,7 +275,7 @@ class ApiServer(RPC):
new_token = create_access_token(identity=current_user, fresh=False)
ret = {'access_token': new_token}
return self.rest_dump(ret)
return jsonify(ret)
@require_login
@rpc_catch_errors
@@ -272,7 +285,7 @@ class ApiServer(RPC):
Starts TradeThread in bot if stopped.
"""
msg = self._rpc_start()
return self.rest_dump(msg)
return jsonify(msg)
@require_login
@rpc_catch_errors
@@ -282,7 +295,7 @@ class ApiServer(RPC):
Stops TradeThread in bot if running
"""
msg = self._rpc_stop()
return self.rest_dump(msg)
return jsonify(msg)
@require_login
@rpc_catch_errors
@@ -292,14 +305,14 @@ class ApiServer(RPC):
Sets max_open_trades to 0 and gracefully sells all open trades
"""
msg = self._rpc_stopbuy()
return self.rest_dump(msg)
return jsonify(msg)
@rpc_catch_errors
def _ping(self):
"""
simple poing version
simple ping version
"""
return self.rest_dump({"status": "pong"})
return jsonify({"status": "pong"})
@require_login
@rpc_catch_errors
@@ -307,7 +320,7 @@ class ApiServer(RPC):
"""
Prints the bot's version
"""
return self.rest_dump({"version": __version__})
return jsonify({"version": __version__})
@require_login
@rpc_catch_errors
@@ -315,7 +328,7 @@ class ApiServer(RPC):
"""
Prints the bot's version
"""
return self.rest_dump(self._rpc_show_config())
return jsonify(self._rpc_show_config(self._config))
@require_login
@rpc_catch_errors
@@ -325,7 +338,7 @@ class ApiServer(RPC):
Triggers a config file reload
"""
msg = self._rpc_reload_config()
return self.rest_dump(msg)
return jsonify(msg)
@require_login
@rpc_catch_errors
@@ -335,7 +348,7 @@ class ApiServer(RPC):
Returns the number of trades running
"""
msg = self._rpc_count()
return self.rest_dump(msg)
return jsonify(msg)
@require_login
@rpc_catch_errors
@@ -353,7 +366,7 @@ class ApiServer(RPC):
self._config.get('fiat_display_currency', '')
)
return self.rest_dump(stats)
return jsonify(stats)
@require_login
@rpc_catch_errors
@@ -365,7 +378,7 @@ class ApiServer(RPC):
limit: Only get a certain number of records
"""
limit = int(request.args.get('limit', 0)) or None
return self.rest_dump(self._rpc_get_logs(limit))
return jsonify(self._rpc_get_logs(limit))
@require_login
@rpc_catch_errors
@@ -376,7 +389,7 @@ class ApiServer(RPC):
"""
stats = self._rpc_edge()
return self.rest_dump(stats)
return jsonify(stats)
@require_login
@rpc_catch_errors
@@ -392,7 +405,7 @@ class ApiServer(RPC):
self._config.get('fiat_display_currency')
)
return self.rest_dump(stats)
return jsonify(stats)
@require_login
@rpc_catch_errors
@@ -405,7 +418,7 @@ class ApiServer(RPC):
"""
stats = self._rpc_performance()
return self.rest_dump(stats)
return jsonify(stats)
@require_login
@rpc_catch_errors
@@ -417,9 +430,9 @@ class ApiServer(RPC):
"""
try:
results = self._rpc_trade_status()
return self.rest_dump(results)
return jsonify(results)
except RPCException:
return self.rest_dump([])
return jsonify([])
@require_login
@rpc_catch_errors
@@ -431,7 +444,7 @@ class ApiServer(RPC):
"""
results = self._rpc_balance(self._config['stake_currency'],
self._config.get('fiat_display_currency', ''))
return self.rest_dump(results)
return jsonify(results)
@require_login
@rpc_catch_errors
@@ -443,7 +456,7 @@ class ApiServer(RPC):
"""
limit = int(request.args.get('limit', 0))
results = self._rpc_trade_history(limit)
return self.rest_dump(results)
return jsonify(results)
@require_login
@rpc_catch_errors
@@ -456,7 +469,7 @@ class ApiServer(RPC):
tradeid: Numeric trade-id assigned to the trade.
"""
result = self._rpc_delete(tradeid)
return self.rest_dump(result)
return jsonify(result)
@require_login
@rpc_catch_errors
@@ -465,7 +478,7 @@ class ApiServer(RPC):
Handler for /whitelist.
"""
results = self._rpc_whitelist()
return self.rest_dump(results)
return jsonify(results)
@require_login
@rpc_catch_errors
@@ -475,7 +488,7 @@ class ApiServer(RPC):
"""
add = request.json.get("blacklist", None) if request.method == 'POST' else None
results = self._rpc_blacklist(add)
return self.rest_dump(results)
return jsonify(results)
@require_login
@rpc_catch_errors
@@ -487,9 +500,9 @@ class ApiServer(RPC):
price = request.json.get("price", None)
trade = self._rpc_forcebuy(asset, price)
if trade:
return self.rest_dump(trade.to_json())
return jsonify(trade.to_json())
else:
return self.rest_dump({"status": f"Error buying pair {asset}."})
return jsonify({"status": f"Error buying pair {asset}."})
@require_login
@rpc_catch_errors
@@ -499,4 +512,132 @@ class ApiServer(RPC):
"""
tradeid = request.json.get("tradeid")
results = self._rpc_forcesell(tradeid)
return self.rest_dump(results)
return jsonify(results)
@require_login
@rpc_catch_errors
def _analysed_candles(self):
"""
Handler for /pair_candles.
Returns the dataframe the bot is using during live/dry operations.
Takes the following get arguments:
get:
parameters:
- pair: Pair
- timeframe: Timeframe to get data for (should be aligned to strategy.timeframe)
- limit: Limit return length to the latest X candles
"""
pair = request.args.get("pair")
timeframe = request.args.get("timeframe")
limit = request.args.get("limit", type=int)
if not pair or not timeframe:
return self.rest_error("Mandatory parameter missing.", 400)
results = self._rpc_analysed_dataframe(pair, timeframe, limit)
return jsonify(results)
@require_login
@rpc_catch_errors
def _analysed_history(self):
"""
Handler for /pair_history.
Returns the dataframe of a given timerange
Takes the following get arguments:
get:
parameters:
- pair: Pair
- timeframe: Timeframe to get data for (should be aligned to strategy.timeframe)
- strategy: Strategy to use - Must exist in configured strategy-path!
- timerange: timerange in the format YYYYMMDD-YYYYMMDD (YYYYMMDD- or (-YYYYMMDD))
are als possible. If omitted uses all available data.
"""
pair = request.args.get("pair")
timeframe = request.args.get("timeframe")
timerange = request.args.get("timerange")
strategy = request.args.get("strategy")
if not pair or not timeframe or not timerange or not strategy:
return self.rest_error("Mandatory parameter missing.", 400)
config = deepcopy(self._config)
config.update({
'strategy': strategy,
})
results = self._rpc_analysed_history_full(config, pair, timeframe, timerange)
return jsonify(results)
@require_login
@rpc_catch_errors
def _plot_config(self):
"""
Handler for /plot_config.
"""
return jsonify(self._rpc_plot_config())
@require_login
@rpc_catch_errors
def _list_strategies(self):
directory = Path(self._config.get(
'strategy_path', self._config['user_data_dir'] / USERPATH_STRATEGIES))
from freqtrade.resolvers.strategy_resolver import StrategyResolver
strategy_objs = StrategyResolver.search_all_objects(directory, False)
strategy_objs = sorted(strategy_objs, key=lambda x: x['name'])
return jsonify({'strategies': [x['name'] for x in strategy_objs]})
@require_login
@rpc_catch_errors
def _get_strategy(self, strategy: str):
"""
Get a single strategy
get:
parameters:
- strategy: Only get this strategy
"""
config = deepcopy(self._config)
from freqtrade.resolvers.strategy_resolver import StrategyResolver
try:
strategy_obj = StrategyResolver._load_strategy(strategy, config,
extra_dir=config.get('strategy_path'))
except OperationalException:
return self.rest_error("Strategy not found.", 404)
return jsonify({
'strategy': strategy_obj.get_strategy_name(),
'code': strategy_obj.__source__,
})
@require_login
@rpc_catch_errors
def _list_available_pairs(self):
"""
Handler for /available_pairs.
Returns an object, with pairs, available pair length and pair_interval combinations
Takes the following get arguments:
get:
parameters:
- stake_currency: Filter on this stake currency
- timeframe: Timeframe to get data for Filter elements to this timeframe
"""
timeframe = request.args.get("timeframe")
stake_currency = request.args.get("stake_currency")
from freqtrade.data.history import get_datahandler
dh = get_datahandler(self._config['datadir'], self._config.get('dataformat_ohlcv', None))
pair_interval = dh.ohlcv_get_available_data(self._config['datadir'])
if timeframe:
pair_interval = [pair for pair in pair_interval if pair[1] == timeframe]
if stake_currency:
pair_interval = [pair for pair in pair_interval if pair[0].endswith(stake_currency)]
pair_interval = sorted(pair_interval, key=lambda x: x[0])
pairs = list({x[0] for x in pair_interval})
result = {
'length': len(pairs),
'pairs': pairs,
'pair_interval': pair_interval,
}
return jsonify(result)

View File

@@ -9,9 +9,12 @@ from math import isnan
from typing import Any, Dict, List, Optional, Tuple, Union
import arrow
from numpy import NAN, mean
from numpy import NAN, int64, mean
from pandas import DataFrame
from freqtrade.configuration.timerange import TimeRange
from freqtrade.constants import CANCEL_REASON
from freqtrade.data.history import load_data
from freqtrade.exceptions import ExchangeError, PricingError
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs
from freqtrade.loggers import bufferHandler
@@ -90,13 +93,12 @@ class RPC:
def send_msg(self, msg: Dict[str, str]) -> None:
""" Sends a message to all registered rpc modules """
def _rpc_show_config(self) -> Dict[str, Any]:
def _rpc_show_config(self, config) -> Dict[str, Any]:
"""
Return a dict of config options.
Explicitly does NOT return the full config to avoid leakage of sensitive
information via rpc.
"""
config = self._freqtrade.config
val = {
'dry_run': config['dry_run'],
'stake_currency': config['stake_currency'],
@@ -117,7 +119,7 @@ class RPC:
'forcebuy_enabled': config.get('forcebuy_enable', False),
'ask_strategy': config.get('ask_strategy', {}),
'bid_strategy': config.get('bid_strategy', {}),
'state': str(self._freqtrade.state)
'state': str(self._freqtrade.state) if self._freqtrade else '',
}
return val
@@ -653,3 +655,80 @@ class RPC:
if not self._freqtrade.edge:
raise RPCException('Edge is not enabled.')
return self._freqtrade.edge.accepted_pairs()
def _convert_dataframe_to_dict(self, strategy: str, pair: str, timeframe: str,
dataframe: DataFrame, last_analyzed: datetime) -> Dict[str, Any]:
has_content = len(dataframe) != 0
buy_signals = 0
sell_signals = 0
if has_content:
dataframe.loc[:, '__date_ts'] = dataframe.loc[:, 'date'].astype(int64) // 1000 // 1000
# Move open to seperate column when signal for easy plotting
if 'buy' in dataframe.columns:
buy_mask = (dataframe['buy'] == 1)
buy_signals = int(buy_mask.sum())
dataframe.loc[buy_mask, '_buy_signal_open'] = dataframe.loc[buy_mask, 'open']
if 'sell' in dataframe.columns:
sell_mask = (dataframe['sell'] == 1)
sell_signals = int(sell_mask.sum())
dataframe.loc[sell_mask, '_sell_signal_open'] = dataframe.loc[sell_mask, 'open']
dataframe = dataframe.replace({NAN: None})
res = {
'pair': pair,
'timeframe': timeframe,
'timeframe_ms': timeframe_to_msecs(timeframe),
'strategy': strategy,
'columns': list(dataframe.columns),
'data': dataframe.values.tolist(),
'length': len(dataframe),
'buy_signals': buy_signals,
'sell_signals': sell_signals,
'last_analyzed': last_analyzed,
'last_analyzed_ts': int(last_analyzed.timestamp()),
'data_start': '',
'data_start_ts': 0,
'data_stop': '',
'data_stop_ts': 0,
}
if has_content:
res.update({
'data_start': str(dataframe.iloc[0]['date']),
'data_start_ts': int(dataframe.iloc[0]['__date_ts']),
'data_stop': str(dataframe.iloc[-1]['date']),
'data_stop_ts': int(dataframe.iloc[-1]['__date_ts']),
})
return res
def _rpc_analysed_dataframe(self, pair: str, timeframe: str, limit: int) -> Dict[str, Any]:
_data, last_analyzed = self._freqtrade.dataprovider.get_analyzed_dataframe(
pair, timeframe)
_data = _data.copy()
if limit:
_data = _data.iloc[-limit:]
return self._convert_dataframe_to_dict(self._freqtrade.config['strategy'],
pair, timeframe, _data, last_analyzed)
def _rpc_analysed_history_full(self, config, pair: str, timeframe: str,
timerange: str) -> Dict[str, Any]:
timerange_parsed = TimeRange.parse_timerange(timerange)
_data = load_data(
datadir=config.get("datadir"),
pairs=[pair],
timeframe=timeframe,
timerange=timerange_parsed,
data_format=config.get('dataformat_ohlcv', 'json'),
)
from freqtrade.resolvers.strategy_resolver import StrategyResolver
strategy = StrategyResolver.load_strategy(config)
df_analyzed = strategy.analyze_ticker(_data[pair], {'pair': pair})
return self._convert_dataframe_to_dict(strategy.get_strategy_name(), pair, timeframe,
df_analyzed, arrow.Arrow.utcnow().datetime)
def _rpc_plot_config(self) -> Dict[str, Any]:
return self._freqtrade.strategy.plot_config

View File

@@ -755,7 +755,7 @@ class Telegram(RPC):
:param update: message update
:return: None
"""
val = self._rpc_show_config()
val = self._rpc_show_config(self._freqtrade.config)
if val['trailing_stop']:
sl_info = (
f"*Initial Stoploss:* `{val['stoploss']}`\n"

View File

@@ -123,6 +123,8 @@ class IStrategy(ABC):
# and wallets - access to the current balance.
dp: Optional[DataProvider] = None
wallets: Optional[Wallets] = None
# container variable for strategy source code
__source__: str = ''
# Definition of plot_config. See plotting documentation for more details.
plot_config: Dict = {}