Refactor main.py
- Update, clean, and improve code coverage on main.py - Move bot trading logic into Freqtradebot() class - Move unit tests to test_freqtradebot, add more coverage tests
This commit is contained in:
parent
a8b8ab20b7
commit
4da033c7a2
@ -21,12 +21,14 @@ class Configuration(object):
|
|||||||
self.args = args
|
self.args = args
|
||||||
self.logger = Logger(name=__name__).get_logger()
|
self.logger = Logger(name=__name__).get_logger()
|
||||||
self.config = self._load_config()
|
self.config = self._load_config()
|
||||||
|
self.show_info()
|
||||||
|
|
||||||
def _load_config(self) -> Dict[str, Any]:
|
def _load_config(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Extract information for sys.argv and load the bot configuration
|
Extract information for sys.argv and load the bot configuration
|
||||||
:return: Configuration dictionary
|
:return: Configuration dictionary
|
||||||
"""
|
"""
|
||||||
|
self.logger.info('Using config: %s ...', self.args.config)
|
||||||
config = self._load_config_file(self.args.config)
|
config = self._load_config_file(self.args.config)
|
||||||
|
|
||||||
# Add the strategy file to use
|
# Add the strategy file to use
|
||||||
|
548
freqtrade/freqtradebot.py
Normal file
548
freqtrade/freqtradebot.py
Normal file
@ -0,0 +1,548 @@
|
|||||||
|
"""
|
||||||
|
Freqtrade is the main module of this bot. It contains the class Freqtrade()
|
||||||
|
"""
|
||||||
|
|
||||||
|
import arrow
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from cachetools import cached, TTLCache
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional, Any, Callable
|
||||||
|
from freqtrade.analyze import Analyze
|
||||||
|
from freqtrade.constants import Constants
|
||||||
|
from freqtrade.fiat_convert import CryptoToFiatConverter
|
||||||
|
from freqtrade.logger import Logger
|
||||||
|
from freqtrade.persistence import Trade
|
||||||
|
from freqtrade.rpc.rpc_manager import RPCManager
|
||||||
|
from freqtrade.state import State
|
||||||
|
from freqtrade import (DependencyException, OperationalException, exchange, persistence)
|
||||||
|
|
||||||
|
|
||||||
|
class FreqtradeBot(object):
|
||||||
|
"""
|
||||||
|
Freqtrade is the main class of the bot.
|
||||||
|
This is from here the bot start its logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any], db_url: Optional[str] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Init all variables and object the bot need to work
|
||||||
|
:param config: configuration dict, you can use the Configuration.get_config()
|
||||||
|
method to get the config dict.
|
||||||
|
:param db_url: database connector string for sqlalchemy (Optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Init the logger
|
||||||
|
self.logger = Logger(name='freqtrade').get_logger()
|
||||||
|
|
||||||
|
# Init bot states
|
||||||
|
self._state = State.STOPPED
|
||||||
|
|
||||||
|
# Init objects
|
||||||
|
self.config = config
|
||||||
|
self.analyze = None
|
||||||
|
self.fiat_converter = None
|
||||||
|
self.rpc = None
|
||||||
|
self.persistence = None
|
||||||
|
self.exchange = None
|
||||||
|
|
||||||
|
self._init_modules(db_url=db_url)
|
||||||
|
|
||||||
|
def _init_modules(self, db_url: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
Initializes all modules and updates the config
|
||||||
|
:param db_url: database connector string for sqlalchemy (Optional)
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
# Initialize all modules
|
||||||
|
self.analyze = Analyze(self.config)
|
||||||
|
self.fiat_converter = CryptoToFiatConverter()
|
||||||
|
self.rpc = RPCManager(self)
|
||||||
|
|
||||||
|
persistence.init(self.config, db_url)
|
||||||
|
exchange.init(self.config)
|
||||||
|
|
||||||
|
# Set initial application state
|
||||||
|
initial_state = self.config.get('initial_state')
|
||||||
|
|
||||||
|
if initial_state:
|
||||||
|
self.update_state(State[initial_state.upper()])
|
||||||
|
else:
|
||||||
|
self.update_state(State.STOPPED)
|
||||||
|
|
||||||
|
def clean(self) -> bool:
|
||||||
|
"""
|
||||||
|
Cleanup the application state und finish all pending tasks
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
self.rpc.send_msg('*Status:* `Stopping trader...`')
|
||||||
|
self.logger.info('Stopping trader and cleaning up modules...')
|
||||||
|
self.update_state(State.STOPPED)
|
||||||
|
self.rpc.cleanup()
|
||||||
|
persistence.cleanup()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_state(self, state: State) -> None:
|
||||||
|
"""
|
||||||
|
Updates the application state
|
||||||
|
:param state: new state
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
self._state = state
|
||||||
|
|
||||||
|
def get_state(self) -> State:
|
||||||
|
"""
|
||||||
|
Gets the current application state
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
def worker(self, old_state: None) -> State:
|
||||||
|
"""
|
||||||
|
Trading routine that must be run at each loop
|
||||||
|
:param old_state: the previous service state from the previous call
|
||||||
|
:return: current service state
|
||||||
|
"""
|
||||||
|
new_state = self.get_state()
|
||||||
|
# Log state transition
|
||||||
|
if new_state != old_state:
|
||||||
|
self.rpc.send_msg('*Status:* `{}`'.format(new_state.name.lower()))
|
||||||
|
self.logger.info('Changing state to: %s', new_state.name)
|
||||||
|
|
||||||
|
if new_state == State.STOPPED:
|
||||||
|
time.sleep(1)
|
||||||
|
elif new_state == State.RUNNING:
|
||||||
|
min_secs = self.config['internals'].get(
|
||||||
|
'process_throttle_secs',
|
||||||
|
Constants.PROCESS_THROTTLE_SECS
|
||||||
|
)
|
||||||
|
|
||||||
|
nb_assets = self.config.get(
|
||||||
|
'dynamic_whitelist',
|
||||||
|
Constants.DYNAMIC_WHITELIST
|
||||||
|
)
|
||||||
|
|
||||||
|
interval = int(
|
||||||
|
self.config.get(
|
||||||
|
'ticker_interval',
|
||||||
|
Constants.TICKER_INTERVAL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._throttle(func=self._process,
|
||||||
|
min_secs=min_secs,
|
||||||
|
nb_assets=nb_assets,
|
||||||
|
interval=interval)
|
||||||
|
return new_state
|
||||||
|
|
||||||
|
def _throttle(self, func: Callable[..., Any], min_secs: float, *args, **kwargs) -> Any:
|
||||||
|
"""
|
||||||
|
Throttles the given callable that it
|
||||||
|
takes at least `min_secs` to finish execution.
|
||||||
|
:param func: Any callable
|
||||||
|
:param min_secs: minimum execution time in seconds
|
||||||
|
:return: Any
|
||||||
|
"""
|
||||||
|
start = time.time()
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
end = time.time()
|
||||||
|
duration = max(min_secs - (end - start), 0.0)
|
||||||
|
self.logger.debug('Throttling %s for %.2f seconds', func.__name__, duration)
|
||||||
|
time.sleep(duration)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _process(self, interval: int, nb_assets: Optional[int] = 0) -> bool:
|
||||||
|
"""
|
||||||
|
Queries the persistence layer for open trades and handles them,
|
||||||
|
otherwise a new trade is created.
|
||||||
|
:param: nb_assets: the maximum number of pairs to be traded at the same time
|
||||||
|
:return: True if one or more trades has been created or closed, False otherwise
|
||||||
|
"""
|
||||||
|
state_changed = False
|
||||||
|
try:
|
||||||
|
# Refresh whitelist based on wallet maintenance
|
||||||
|
sanitized_list = self._refresh_whitelist(
|
||||||
|
self._gen_pair_whitelist(
|
||||||
|
self.config['stake_currency']
|
||||||
|
) if nb_assets else self.config['exchange']['pair_whitelist']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep only the subsets of pairs wanted (up to nb_assets)
|
||||||
|
final_list = sanitized_list[:nb_assets] if nb_assets else sanitized_list
|
||||||
|
self.config['exchange']['pair_whitelist'] = final_list
|
||||||
|
|
||||||
|
# Query trades from persistence layer
|
||||||
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
|
|
||||||
|
# First process current opened trades
|
||||||
|
for trade in trades:
|
||||||
|
state_changed |= self.process_maybe_execute_sell(trade, interval)
|
||||||
|
|
||||||
|
# Then looking for buy opportunities
|
||||||
|
if len(trades) < self.config['max_open_trades']:
|
||||||
|
state_changed = self.process_maybe_execute_buy(interval)
|
||||||
|
|
||||||
|
if 'unfilledtimeout' in self.config:
|
||||||
|
# Check and handle any timed out open orders
|
||||||
|
self.check_handle_timedout(self.config['unfilledtimeout'])
|
||||||
|
Trade.session.flush()
|
||||||
|
|
||||||
|
except (requests.exceptions.RequestException, json.JSONDecodeError) as error:
|
||||||
|
self.logger.warning(
|
||||||
|
'Got %s in _process(), retrying in 30 seconds...',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
time.sleep(Constants.RETRY_TIMEOUT)
|
||||||
|
except OperationalException:
|
||||||
|
self.rpc.send_msg(
|
||||||
|
'*Status:* Got OperationalException:\n```\n{traceback}```{hint}'
|
||||||
|
.format(
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
hint='Issue `/start` if you think it is safe to restart.'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.logger.exception('Got OperationalException. Stopping trader ...')
|
||||||
|
self.update_state(State.STOPPED)
|
||||||
|
return state_changed
|
||||||
|
|
||||||
|
@cached(TTLCache(maxsize=1, ttl=1800))
|
||||||
|
def _gen_pair_whitelist(self, base_currency: str, key: str = 'BaseVolume') -> List[str]:
|
||||||
|
"""
|
||||||
|
Updates the whitelist with with a dynamically generated list
|
||||||
|
:param base_currency: base currency as str
|
||||||
|
:param key: sort key (defaults to 'BaseVolume')
|
||||||
|
:return: List of pairs
|
||||||
|
"""
|
||||||
|
summaries = sorted(
|
||||||
|
(s for s in exchange.get_market_summaries() if
|
||||||
|
s['MarketName'].startswith(base_currency)),
|
||||||
|
key=lambda s: s.get(key) or 0.0,
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return [s['MarketName'].replace('-', '_') for s in summaries]
|
||||||
|
|
||||||
|
def _refresh_whitelist(self, whitelist: List[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Check wallet health and remove pair from whitelist if necessary
|
||||||
|
:param whitelist: the sorted list (based on BaseVolume) of pairs the user might want to
|
||||||
|
trade
|
||||||
|
:return: the list of pairs the user wants to trade without the one unavailable or
|
||||||
|
black_listed
|
||||||
|
"""
|
||||||
|
sanitized_whitelist = whitelist
|
||||||
|
health = exchange.get_wallet_health()
|
||||||
|
known_pairs = set()
|
||||||
|
for status in health:
|
||||||
|
pair = '{}_{}'.format(self.config['stake_currency'], status['Currency'])
|
||||||
|
# pair is not int the generated dynamic market, or in the blacklist ... ignore it
|
||||||
|
if pair not in whitelist or pair in self.config['exchange'].get('pair_blacklist', []):
|
||||||
|
continue
|
||||||
|
# else the pair is valid
|
||||||
|
known_pairs.add(pair)
|
||||||
|
# Market is not active
|
||||||
|
if not status['IsActive']:
|
||||||
|
sanitized_whitelist.remove(pair)
|
||||||
|
self.logger.info(
|
||||||
|
'Ignoring %s from whitelist (reason: %s).',
|
||||||
|
pair, status.get('Notice') or 'wallet is not active'
|
||||||
|
)
|
||||||
|
|
||||||
|
# We need to remove pairs that are unknown
|
||||||
|
final_list = [x for x in sanitized_whitelist if x in known_pairs]
|
||||||
|
return final_list
|
||||||
|
|
||||||
|
def get_target_bid(self, ticker: Dict[str, float]) -> float:
|
||||||
|
"""
|
||||||
|
Calculates bid target between current ask price and last price
|
||||||
|
:param ticker: Ticker to use for getting Ask and Last Price
|
||||||
|
:return: float: Price
|
||||||
|
"""
|
||||||
|
if ticker['ask'] < ticker['last']:
|
||||||
|
return ticker['ask']
|
||||||
|
balance = self.config['bid_strategy']['ask_last_balance']
|
||||||
|
return ticker['ask'] + balance * (ticker['last'] - ticker['ask'])
|
||||||
|
|
||||||
|
# TODO: Remove the two parameters and use the value already in conf['stake_amount'] and
|
||||||
|
# int(conf['ticker_interval'])
|
||||||
|
def create_trade(self, stake_amount: float, interval: int) -> bool:
|
||||||
|
"""
|
||||||
|
Checks the implemented trading indicator(s) for a randomly picked pair,
|
||||||
|
if one pair triggers the buy_signal a new trade record gets created
|
||||||
|
:param stake_amount: amount of btc to spend
|
||||||
|
:param interval: Ticker interval used for Analyze
|
||||||
|
:return: True if a trade object has been created and persisted, False otherwise
|
||||||
|
"""
|
||||||
|
self.logger.info(
|
||||||
|
'Checking buy signals to create a new trade with stake_amount: %f ...',
|
||||||
|
stake_amount
|
||||||
|
)
|
||||||
|
whitelist = copy.deepcopy(self.config['exchange']['pair_whitelist'])
|
||||||
|
# Check if stake_amount is fulfilled
|
||||||
|
if exchange.get_balance(self.config['stake_currency']) < stake_amount:
|
||||||
|
raise DependencyException(
|
||||||
|
'stake amount is not fulfilled (currency={})'.format(self.config['stake_currency'])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove currently opened and latest pairs from whitelist
|
||||||
|
for trade in Trade.query.filter(Trade.is_open.is_(True)).all():
|
||||||
|
if trade.pair in whitelist:
|
||||||
|
whitelist.remove(trade.pair)
|
||||||
|
self.logger.debug('Ignoring %s in pair whitelist', trade.pair)
|
||||||
|
|
||||||
|
if not whitelist:
|
||||||
|
raise DependencyException('No pair in whitelist')
|
||||||
|
|
||||||
|
# Pick pair based on StochRSI buy signals
|
||||||
|
for _pair in whitelist:
|
||||||
|
(buy, sell) = self.analyze.get_signal(_pair, interval)
|
||||||
|
if buy and not sell:
|
||||||
|
pair = _pair
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Calculate amount
|
||||||
|
buy_limit = self.get_target_bid(exchange.get_ticker(pair))
|
||||||
|
amount = stake_amount / buy_limit
|
||||||
|
|
||||||
|
order_id = exchange.buy(pair, buy_limit, amount)
|
||||||
|
|
||||||
|
stake_amount_fiat = self.fiat_converter.convert_amount(
|
||||||
|
stake_amount,
|
||||||
|
self.config['stake_currency'],
|
||||||
|
self.config['fiat_display_currency']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create trade entity and return
|
||||||
|
self.rpc.send_msg(
|
||||||
|
'*{}:* Buying [{}]({}) with limit `{:.8f} ({:.6f} {}, {:.3f} {})` '
|
||||||
|
.format(
|
||||||
|
exchange.get_name().upper(),
|
||||||
|
pair.replace('_', '/'),
|
||||||
|
exchange.get_pair_detail_url(pair),
|
||||||
|
buy_limit,
|
||||||
|
stake_amount,
|
||||||
|
self.config['stake_currency'],
|
||||||
|
stake_amount_fiat,
|
||||||
|
self.config['fiat_display_currency']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL
|
||||||
|
trade = Trade(
|
||||||
|
pair=pair,
|
||||||
|
stake_amount=stake_amount,
|
||||||
|
amount=amount,
|
||||||
|
fee=exchange.get_fee(),
|
||||||
|
open_rate=buy_limit,
|
||||||
|
open_date=datetime.utcnow(),
|
||||||
|
exchange=exchange.get_name().upper(),
|
||||||
|
open_order_id=order_id
|
||||||
|
)
|
||||||
|
Trade.session.add(trade)
|
||||||
|
Trade.session.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def process_maybe_execute_buy(self, interval: int) -> bool:
|
||||||
|
"""
|
||||||
|
Tries to execute a buy trade in a safe way
|
||||||
|
:return: True if executed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create entity and execute trade
|
||||||
|
if self.create_trade(float(self.config['stake_amount']), interval):
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
'Checked all whitelisted currencies. '
|
||||||
|
'Found no suitable entry positions for buying. Will keep looking ...'
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except DependencyException as exception:
|
||||||
|
self.logger.warning('Unable to create trade: %s', exception)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def process_maybe_execute_sell(self, trade: Trade, interval: int) -> bool:
|
||||||
|
"""
|
||||||
|
Tries to execute a sell trade
|
||||||
|
:return: True if executed
|
||||||
|
"""
|
||||||
|
# Get order details for actual price per unit
|
||||||
|
if trade.open_order_id:
|
||||||
|
# Update trade with order values
|
||||||
|
self.logger.info('Got open order for %s', trade)
|
||||||
|
trade.update(exchange.get_order(trade.open_order_id))
|
||||||
|
|
||||||
|
if trade.is_open and trade.open_order_id is None:
|
||||||
|
# Check if we can sell our current pair
|
||||||
|
return self.handle_trade(trade, interval)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def handle_trade(self, trade: Trade, interval: int) -> bool:
|
||||||
|
"""
|
||||||
|
Sells the current pair if the threshold is reached and updates the trade record.
|
||||||
|
:return: True if trade has been sold, False otherwise
|
||||||
|
"""
|
||||||
|
if not trade.is_open:
|
||||||
|
raise ValueError('attempt to handle closed trade: {}'.format(trade))
|
||||||
|
|
||||||
|
self.logger.debug('Handling %s ...', trade)
|
||||||
|
current_rate = exchange.get_ticker(trade.pair)['bid']
|
||||||
|
|
||||||
|
(buy, sell) = (False, False)
|
||||||
|
|
||||||
|
if self.config.get('experimental', {}).get('use_sell_signal'):
|
||||||
|
(buy, sell) = self.analyze.get_signal(trade.pair, interval)
|
||||||
|
|
||||||
|
if self.analyze.should_sell(trade, current_rate, datetime.utcnow(), buy, sell):
|
||||||
|
self.execute_sell(trade, current_rate)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_handle_timedout(self, timeoutvalue: int) -> None:
|
||||||
|
"""
|
||||||
|
Check if any orders are timed out and cancel if neccessary
|
||||||
|
:param timeoutvalue: Number of minutes until order is considered timed out
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
timeoutthreashold = arrow.utcnow().shift(minutes=-timeoutvalue).datetime
|
||||||
|
|
||||||
|
for trade in Trade.query.filter(Trade.open_order_id.isnot(None)).all():
|
||||||
|
try:
|
||||||
|
order = exchange.get_order(trade.open_order_id)
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
self.logger.info(
|
||||||
|
'Cannot query order for %s due to %s',
|
||||||
|
trade,
|
||||||
|
traceback.format_exc())
|
||||||
|
continue
|
||||||
|
ordertime = arrow.get(order['opened'])
|
||||||
|
|
||||||
|
# Check if trade is still actually open
|
||||||
|
if int(order['remaining']) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if order['type'] == "LIMIT_BUY" and ordertime < timeoutthreashold:
|
||||||
|
self.handle_timedout_limit_buy(trade, order)
|
||||||
|
elif order['type'] == "LIMIT_SELL" and ordertime < timeoutthreashold:
|
||||||
|
self.handle_timedout_limit_sell(trade, order)
|
||||||
|
|
||||||
|
# FIX: 20180110, why is cancel.order unconditionally here, whereas
|
||||||
|
# it is conditionally called in the
|
||||||
|
# handle_timedout_limit_sell()?
|
||||||
|
def handle_timedout_limit_buy(self, trade: Trade, order: Dict) -> bool:
|
||||||
|
"""Buy timeout - cancel order
|
||||||
|
:return: True if order was fully cancelled
|
||||||
|
"""
|
||||||
|
exchange.cancel_order(trade.open_order_id)
|
||||||
|
if order['remaining'] == order['amount']:
|
||||||
|
# if trade is not partially completed, just delete the trade
|
||||||
|
Trade.session.delete(trade)
|
||||||
|
# FIX? do we really need to flush, caller of
|
||||||
|
# check_handle_timedout will flush afterwards
|
||||||
|
Trade.session.flush()
|
||||||
|
self.logger.info('Buy order timeout for %s.', trade)
|
||||||
|
self.rpc.send_msg('*Timeout:* Unfilled buy order for {} cancelled'.format(
|
||||||
|
trade.pair.replace('_', '/')))
|
||||||
|
return True
|
||||||
|
|
||||||
|
# if trade is partially complete, edit the stake details for the trade
|
||||||
|
# and close the order
|
||||||
|
trade.amount = order['amount'] - order['remaining']
|
||||||
|
trade.stake_amount = trade.amount * trade.open_rate
|
||||||
|
trade.open_order_id = None
|
||||||
|
self.logger.info('Partial buy order timeout for %s.', trade)
|
||||||
|
self.rpc.send_msg('*Timeout:* Remaining buy order for {} cancelled'.format(
|
||||||
|
trade.pair.replace('_', '/')))
|
||||||
|
return False
|
||||||
|
|
||||||
|
# FIX: 20180110, should cancel_order() be cond. or unconditionally called?
|
||||||
|
def handle_timedout_limit_sell(self, trade: Trade, order: Dict) -> bool:
|
||||||
|
"""
|
||||||
|
Sell timeout - cancel order and update trade
|
||||||
|
:return: True if order was fully cancelled
|
||||||
|
"""
|
||||||
|
if order['remaining'] == order['amount']:
|
||||||
|
# if trade is not partially completed, just cancel the trade
|
||||||
|
exchange.cancel_order(trade.open_order_id)
|
||||||
|
trade.close_rate = None
|
||||||
|
trade.close_profit = None
|
||||||
|
trade.close_date = None
|
||||||
|
trade.is_open = True
|
||||||
|
trade.open_order_id = None
|
||||||
|
self.rpc.send_msg('*Timeout:* Unfilled sell order for {} cancelled'.format(
|
||||||
|
trade.pair.replace('_', '/')))
|
||||||
|
self.logger.info('Sell order timeout for %s.', trade)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# TODO: figure out how to handle partially complete sell orders
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute_sell(self, trade: Trade, limit: float) -> None:
|
||||||
|
"""
|
||||||
|
Executes a limit sell for the given trade and limit
|
||||||
|
:param trade: Trade instance
|
||||||
|
:param limit: limit rate for the sell order
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
# Execute sell and update trade record
|
||||||
|
order_id = exchange.sell(str(trade.pair), limit, trade.amount)
|
||||||
|
trade.open_order_id = order_id
|
||||||
|
|
||||||
|
fmt_exp_profit = round(trade.calc_profit_percent(rate=limit) * 100, 2)
|
||||||
|
profit_trade = trade.calc_profit(rate=limit)
|
||||||
|
current_rate = exchange.get_ticker(trade.pair, False)['bid']
|
||||||
|
profit = trade.calc_profit_percent(current_rate)
|
||||||
|
|
||||||
|
message = "*{exchange}:* Selling\n" \
|
||||||
|
"*Current Pair:* [{pair}]({pair_url})\n" \
|
||||||
|
"*Limit:* `{limit}`\n" \
|
||||||
|
"*Amount:* `{amount}`\n" \
|
||||||
|
"*Open Rate:* `{open_rate:.8f}`\n" \
|
||||||
|
"*Current Rate:* `{current_rate:.8f}`\n" \
|
||||||
|
"*Profit:* `{profit:.2f}%`" \
|
||||||
|
"".format(
|
||||||
|
exchange=trade.exchange,
|
||||||
|
pair=trade.pair,
|
||||||
|
pair_url=exchange.get_pair_detail_url(trade.pair),
|
||||||
|
limit=limit,
|
||||||
|
open_rate=trade.open_rate,
|
||||||
|
current_rate=current_rate,
|
||||||
|
amount=round(trade.amount, 8),
|
||||||
|
profit=round(profit * 100, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
# For regular case, when the configuration exists
|
||||||
|
if 'stake_currency' in self.config and 'fiat_display_currency' in self.config:
|
||||||
|
fiat_converter = CryptoToFiatConverter()
|
||||||
|
profit_fiat = fiat_converter.convert_amount(
|
||||||
|
profit_trade,
|
||||||
|
self.config['stake_currency'],
|
||||||
|
self.config['fiat_display_currency']
|
||||||
|
)
|
||||||
|
message += '` ({gain}: {profit_percent:.2f}%, {profit_coin:.8f} {coin}`' \
|
||||||
|
'` / {profit_fiat:.3f} {fiat})`' \
|
||||||
|
''.format(
|
||||||
|
gain="profit" if fmt_exp_profit > 0 else "loss",
|
||||||
|
profit_percent=fmt_exp_profit,
|
||||||
|
profit_coin=profit_trade,
|
||||||
|
coin=self.config['stake_currency'],
|
||||||
|
profit_fiat=profit_fiat,
|
||||||
|
fiat=self.config['fiat_display_currency'],
|
||||||
|
)
|
||||||
|
# Because telegram._forcesell does not have the configuration
|
||||||
|
# Ignore the FIAT value and does not show the stake_currency as well
|
||||||
|
else:
|
||||||
|
message += '` ({gain}: {profit_percent:.2f}%, {profit_coin:.8f})`'.format(
|
||||||
|
gain="profit" if fmt_exp_profit > 0 else "loss",
|
||||||
|
profit_percent=fmt_exp_profit,
|
||||||
|
profit_coin=profit_trade
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send the message
|
||||||
|
self.rpc.send_msg(message)
|
||||||
|
Trade.session.flush()
|
@ -1,570 +1,73 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import copy
|
"""
|
||||||
import json
|
Main Freqtrade bot script.
|
||||||
|
Read the documentation to know what cli arguments you need.
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import time
|
from typing import Dict
|
||||||
import traceback
|
from freqtrade.configuration import Configuration
|
||||||
from datetime import datetime
|
from freqtrade.arguments import Arguments
|
||||||
from typing import Dict, List, Optional, Any
|
from freqtrade.freqtradebot import FreqtradeBot
|
||||||
|
from freqtrade.logger import Logger
|
||||||
|
from freqtrade import (__version__)
|
||||||
|
|
||||||
import arrow
|
logger = Logger(name='freqtrade').get_logger()
|
||||||
import requests
|
|
||||||
from cachetools import cached, TTLCache
|
|
||||||
|
|
||||||
from freqtrade import (DependencyException, OperationalException, __version__,
|
|
||||||
exchange, persistence, rpc)
|
|
||||||
from freqtrade.analyze import get_signal
|
|
||||||
from freqtrade.fiat_convert import CryptoToFiatConverter
|
|
||||||
from freqtrade.misc import (State, get_state, load_config, parse_args,
|
|
||||||
throttle, update_state)
|
|
||||||
from freqtrade.persistence import Trade
|
|
||||||
from freqtrade.strategy.strategy import Strategy
|
|
||||||
|
|
||||||
logger = logging.getLogger('freqtrade')
|
|
||||||
|
|
||||||
_CONF: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def refresh_whitelist(whitelist: List[str]) -> List[str]:
|
def main(sysargv: Dict) -> None:
|
||||||
"""
|
"""
|
||||||
Check wallet health and remove pair from whitelist if necessary
|
This function will initiate the bot and start the trading loop.
|
||||||
:param whitelist: the sorted list (based on BaseVolume) of pairs the user might want to trade
|
|
||||||
:return: the list of pairs the user wants to trade without the one unavailable or black_listed
|
|
||||||
"""
|
|
||||||
sanitized_whitelist = whitelist
|
|
||||||
health = exchange.get_wallet_health()
|
|
||||||
known_pairs = set()
|
|
||||||
for status in health:
|
|
||||||
pair = '{}_{}'.format(_CONF['stake_currency'], status['Currency'])
|
|
||||||
# pair is not int the generated dynamic market, or in the blacklist ... ignore it
|
|
||||||
if pair not in whitelist or pair in _CONF['exchange'].get('pair_blacklist', []):
|
|
||||||
continue
|
|
||||||
# else the pair is valid
|
|
||||||
known_pairs.add(pair)
|
|
||||||
# Market is not active
|
|
||||||
if not status['IsActive']:
|
|
||||||
sanitized_whitelist.remove(pair)
|
|
||||||
logger.info(
|
|
||||||
'Ignoring %s from whitelist (reason: %s).',
|
|
||||||
pair, status.get('Notice') or 'wallet is not active'
|
|
||||||
)
|
|
||||||
|
|
||||||
# We need to remove pairs that are unknown
|
|
||||||
final_list = [x for x in sanitized_whitelist if x in known_pairs]
|
|
||||||
return final_list
|
|
||||||
|
|
||||||
|
|
||||||
def process_maybe_execute_buy(interval: int) -> bool:
|
|
||||||
"""
|
|
||||||
Tries to execute a buy trade in a safe way
|
|
||||||
:return: True if executed
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Create entity and execute trade
|
|
||||||
if create_trade(float(_CONF['stake_amount']), interval):
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
'Checked all whitelisted currencies. '
|
|
||||||
'Found no suitable entry positions for buying. Will keep looking ...'
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
except DependencyException as exception:
|
|
||||||
logger.warning('Unable to create trade: %s', exception)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def process_maybe_execute_sell(trade: Trade, interval: int) -> bool:
|
|
||||||
"""
|
|
||||||
Tries to execute a sell trade
|
|
||||||
:return: True if executed
|
|
||||||
"""
|
|
||||||
# Get order details for actual price per unit
|
|
||||||
if trade.open_order_id:
|
|
||||||
# Update trade with order values
|
|
||||||
logger.info('Got open order for %s', trade)
|
|
||||||
trade.update(exchange.get_order(trade.open_order_id))
|
|
||||||
|
|
||||||
if trade.is_open and trade.open_order_id is None:
|
|
||||||
# Check if we can sell our current pair
|
|
||||||
return handle_trade(trade, interval)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _process(interval: int, nb_assets: Optional[int] = 0) -> bool:
|
|
||||||
"""
|
|
||||||
Queries the persistence layer for open trades and handles them,
|
|
||||||
otherwise a new trade is created.
|
|
||||||
:param: nb_assets: the maximum number of pairs to be traded at the same time
|
|
||||||
:return: True if one or more trades has been created or closed, False otherwise
|
|
||||||
"""
|
|
||||||
state_changed = False
|
|
||||||
try:
|
|
||||||
# Refresh whitelist based on wallet maintenance
|
|
||||||
sanitized_list = refresh_whitelist(
|
|
||||||
gen_pair_whitelist(
|
|
||||||
_CONF['stake_currency']
|
|
||||||
) if nb_assets else _CONF['exchange']['pair_whitelist']
|
|
||||||
)
|
|
||||||
|
|
||||||
# Keep only the subsets of pairs wanted (up to nb_assets)
|
|
||||||
final_list = sanitized_list[:nb_assets] if nb_assets else sanitized_list
|
|
||||||
_CONF['exchange']['pair_whitelist'] = final_list
|
|
||||||
|
|
||||||
# Query trades from persistence layer
|
|
||||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
|
||||||
|
|
||||||
# First process current opened trades
|
|
||||||
for trade in trades:
|
|
||||||
state_changed |= process_maybe_execute_sell(trade, interval)
|
|
||||||
|
|
||||||
# Then looking for buy opportunities
|
|
||||||
if len(trades) < _CONF['max_open_trades']:
|
|
||||||
state_changed = process_maybe_execute_buy(interval)
|
|
||||||
|
|
||||||
if 'unfilledtimeout' in _CONF:
|
|
||||||
# Check and handle any timed out open orders
|
|
||||||
check_handle_timedout(_CONF['unfilledtimeout'])
|
|
||||||
Trade.session.flush()
|
|
||||||
|
|
||||||
except (requests.exceptions.RequestException, json.JSONDecodeError) as error:
|
|
||||||
logger.warning(
|
|
||||||
'Got %s in _process(), retrying in 30 seconds...',
|
|
||||||
error
|
|
||||||
)
|
|
||||||
time.sleep(30)
|
|
||||||
except OperationalException:
|
|
||||||
rpc.send_msg('*Status:* Got OperationalException:\n```\n{traceback}```{hint}'.format(
|
|
||||||
traceback=traceback.format_exc(),
|
|
||||||
hint='Issue `/start` if you think it is safe to restart.'
|
|
||||||
))
|
|
||||||
logger.exception('Got OperationalException. Stopping trader ...')
|
|
||||||
update_state(State.STOPPED)
|
|
||||||
return state_changed
|
|
||||||
|
|
||||||
|
|
||||||
# FIX: 20180110, why is cancel.order unconditionally here, whereas
|
|
||||||
# it is conditionally called in the
|
|
||||||
# handle_timedout_limit_sell()?
|
|
||||||
def handle_timedout_limit_buy(trade: Trade, order: Dict) -> bool:
|
|
||||||
"""Buy timeout - cancel order
|
|
||||||
:return: True if order was fully cancelled
|
|
||||||
"""
|
|
||||||
exchange.cancel_order(trade.open_order_id)
|
|
||||||
if order['remaining'] == order['amount']:
|
|
||||||
# if trade is not partially completed, just delete the trade
|
|
||||||
Trade.session.delete(trade)
|
|
||||||
# FIX? do we really need to flush, caller of
|
|
||||||
# check_handle_timedout will flush afterwards
|
|
||||||
Trade.session.flush()
|
|
||||||
logger.info('Buy order timeout for %s.', trade)
|
|
||||||
rpc.send_msg('*Timeout:* Unfilled buy order for {} cancelled'.format(
|
|
||||||
trade.pair.replace('_', '/')))
|
|
||||||
return True
|
|
||||||
|
|
||||||
# if trade is partially complete, edit the stake details for the trade
|
|
||||||
# and close the order
|
|
||||||
trade.amount = order['amount'] - order['remaining']
|
|
||||||
trade.stake_amount = trade.amount * trade.open_rate
|
|
||||||
trade.open_order_id = None
|
|
||||||
logger.info('Partial buy order timeout for %s.', trade)
|
|
||||||
rpc.send_msg('*Timeout:* Remaining buy order for {} cancelled'.format(
|
|
||||||
trade.pair.replace('_', '/')))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# FIX: 20180110, should cancel_order() be cond. or unconditionally called?
|
|
||||||
def handle_timedout_limit_sell(trade: Trade, order: Dict) -> bool:
|
|
||||||
"""
|
|
||||||
Sell timeout - cancel order and update trade
|
|
||||||
:return: True if order was fully cancelled
|
|
||||||
"""
|
|
||||||
if order['remaining'] == order['amount']:
|
|
||||||
# if trade is not partially completed, just cancel the trade
|
|
||||||
exchange.cancel_order(trade.open_order_id)
|
|
||||||
trade.close_rate = None
|
|
||||||
trade.close_profit = None
|
|
||||||
trade.close_date = None
|
|
||||||
trade.is_open = True
|
|
||||||
trade.open_order_id = None
|
|
||||||
rpc.send_msg('*Timeout:* Unfilled sell order for {} cancelled'.format(
|
|
||||||
trade.pair.replace('_', '/')))
|
|
||||||
logger.info('Sell order timeout for %s.', trade)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# TODO: figure out how to handle partially complete sell orders
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def check_handle_timedout(timeoutvalue: int) -> None:
|
|
||||||
"""
|
|
||||||
Check if any orders are timed out and cancel if neccessary
|
|
||||||
:param timeoutvalue: Number of minutes until order is considered timed out
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
timeoutthreashold = arrow.utcnow().shift(minutes=-timeoutvalue).datetime
|
arguments = Arguments(
|
||||||
|
sysargv,
|
||||||
for trade in Trade.query.filter(Trade.open_order_id.isnot(None)).all():
|
'Simple High Frequency Trading Bot for crypto currencies'
|
||||||
try:
|
|
||||||
order = exchange.get_order(trade.open_order_id)
|
|
||||||
except requests.exceptions.RequestException:
|
|
||||||
logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc())
|
|
||||||
continue
|
|
||||||
ordertime = arrow.get(order['opened'])
|
|
||||||
|
|
||||||
# Check if trade is still actually open
|
|
||||||
if int(order['remaining']) == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if order['type'] == "LIMIT_BUY" and ordertime < timeoutthreashold:
|
|
||||||
handle_timedout_limit_buy(trade, order)
|
|
||||||
elif order['type'] == "LIMIT_SELL" and ordertime < timeoutthreashold:
|
|
||||||
handle_timedout_limit_sell(trade, order)
|
|
||||||
|
|
||||||
|
|
||||||
def execute_sell(trade: Trade, limit: float) -> None:
|
|
||||||
"""
|
|
||||||
Executes a limit sell for the given trade and limit
|
|
||||||
:param trade: Trade instance
|
|
||||||
:param limit: limit rate for the sell order
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
# Execute sell and update trade record
|
|
||||||
order_id = exchange.sell(str(trade.pair), limit, trade.amount)
|
|
||||||
trade.open_order_id = order_id
|
|
||||||
|
|
||||||
fmt_exp_profit = round(trade.calc_profit_percent(rate=limit) * 100, 2)
|
|
||||||
profit_trade = trade.calc_profit(rate=limit)
|
|
||||||
current_rate = exchange.get_ticker(trade.pair, False)['bid']
|
|
||||||
profit = trade.calc_profit_percent(current_rate)
|
|
||||||
|
|
||||||
message = """*{exchange}:* Selling
|
|
||||||
*Current Pair:* [{pair}]({pair_url})
|
|
||||||
*Limit:* `{limit}`
|
|
||||||
*Amount:* `{amount}`
|
|
||||||
*Open Rate:* `{open_rate:.8f}`
|
|
||||||
*Current Rate:* `{current_rate:.8f}`
|
|
||||||
*Profit:* `{profit:.2f}%`
|
|
||||||
""".format(
|
|
||||||
exchange=trade.exchange,
|
|
||||||
pair=trade.pair,
|
|
||||||
pair_url=exchange.get_pair_detail_url(trade.pair),
|
|
||||||
limit=limit,
|
|
||||||
open_rate=trade.open_rate,
|
|
||||||
current_rate=current_rate,
|
|
||||||
amount=round(trade.amount, 8),
|
|
||||||
profit=round(profit * 100, 2),
|
|
||||||
)
|
)
|
||||||
|
args = arguments.get_parsed_arg()
|
||||||
|
|
||||||
# For regular case, when the configuration exists
|
# A subcommand has been issued.
|
||||||
if 'stake_currency' in _CONF and 'fiat_display_currency' in _CONF:
|
# Means if Backtesting or Hyperopt have been called we exit the bot
|
||||||
fiat_converter = CryptoToFiatConverter()
|
|
||||||
profit_fiat = fiat_converter.convert_amount(
|
|
||||||
profit_trade,
|
|
||||||
_CONF['stake_currency'],
|
|
||||||
_CONF['fiat_display_currency']
|
|
||||||
)
|
|
||||||
message += '` ({gain}: {profit_percent:.2f}%, {profit_coin:.8f} {coin}`' \
|
|
||||||
'` / {profit_fiat:.3f} {fiat})`'.format(
|
|
||||||
gain="profit" if fmt_exp_profit > 0 else "loss",
|
|
||||||
profit_percent=fmt_exp_profit,
|
|
||||||
profit_coin=profit_trade,
|
|
||||||
coin=_CONF['stake_currency'],
|
|
||||||
profit_fiat=profit_fiat,
|
|
||||||
fiat=_CONF['fiat_display_currency'],
|
|
||||||
)
|
|
||||||
# Because telegram._forcesell does not have the configuration
|
|
||||||
# Ignore the FIAT value and does not show the stake_currency as well
|
|
||||||
else:
|
|
||||||
message += '` ({gain}: {profit_percent:.2f}%, {profit_coin:.8f})`'.format(
|
|
||||||
gain="profit" if fmt_exp_profit > 0 else "loss",
|
|
||||||
profit_percent=fmt_exp_profit,
|
|
||||||
profit_coin=profit_trade
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send the message
|
|
||||||
rpc.send_msg(message)
|
|
||||||
Trade.session.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def min_roi_reached(trade: Trade, current_rate: float, current_time: datetime) -> bool:
|
|
||||||
"""
|
|
||||||
Based an earlier trade and current price and ROI configuration, decides whether bot should sell
|
|
||||||
:return True if bot should sell at current rate
|
|
||||||
"""
|
|
||||||
strategy = Strategy()
|
|
||||||
|
|
||||||
current_profit = trade.calc_profit_percent(current_rate)
|
|
||||||
if strategy.stoploss is not None and current_profit < float(strategy.stoploss):
|
|
||||||
logger.debug('Stop loss hit.')
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check if time matches and current rate is above threshold
|
|
||||||
time_diff = (current_time - trade.open_date).total_seconds() / 60
|
|
||||||
for duration, threshold in sorted(strategy.minimal_roi.items()):
|
|
||||||
if time_diff > float(duration) and current_profit > threshold:
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.debug('Threshold not reached. (cur_profit: %1.2f%%)', float(current_profit) * 100.0)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def should_sell(trade: Trade, rate: float, date: datetime, buy: bool, sell: bool) -> bool:
|
|
||||||
"""
|
|
||||||
This function evaluate if on the condition required to trigger a sell has been reached
|
|
||||||
if the threshold is reached and updates the trade record.
|
|
||||||
:return: True if trade should be sold, False otherwise
|
|
||||||
"""
|
|
||||||
# Check if minimal roi has been reached and no longer in buy conditions (avoiding a fee)
|
|
||||||
if min_roi_reached(trade, rate, date):
|
|
||||||
logger.debug('Executing sell due to ROI ...')
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Experimental: Check if the trade is profitable before selling it (avoid selling at loss)
|
|
||||||
if _CONF.get('experimental', {}).get('sell_profit_only', False):
|
|
||||||
logger.debug('Checking if trade is profitable ...')
|
|
||||||
if trade.calc_profit(rate=rate) <= 0:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if sell and not buy and _CONF.get('experimental', {}).get('use_sell_signal', False):
|
|
||||||
logger.debug('Executing sell due to sell signal ...')
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def handle_trade(trade: Trade, interval: int) -> bool:
|
|
||||||
"""
|
|
||||||
Sells the current pair if the threshold is reached and updates the trade record.
|
|
||||||
:return: True if trade has been sold, False otherwise
|
|
||||||
"""
|
|
||||||
if not trade.is_open:
|
|
||||||
raise ValueError('attempt to handle closed trade: {}'.format(trade))
|
|
||||||
|
|
||||||
logger.debug('Handling %s ...', trade)
|
|
||||||
current_rate = exchange.get_ticker(trade.pair)['bid']
|
|
||||||
|
|
||||||
(buy, sell) = (False, False)
|
|
||||||
|
|
||||||
if _CONF.get('experimental', {}).get('use_sell_signal'):
|
|
||||||
(buy, sell) = get_signal(trade.pair, interval)
|
|
||||||
|
|
||||||
if should_sell(trade, current_rate, datetime.utcnow(), buy, sell):
|
|
||||||
execute_sell(trade, current_rate)
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_target_bid(ticker: Dict[str, float]) -> float:
|
|
||||||
""" Calculates bid target between current ask price and last price """
|
|
||||||
if ticker['ask'] < ticker['last']:
|
|
||||||
return ticker['ask']
|
|
||||||
balance = _CONF['bid_strategy']['ask_last_balance']
|
|
||||||
return ticker['ask'] + balance * (ticker['last'] - ticker['ask'])
|
|
||||||
|
|
||||||
|
|
||||||
def create_trade(stake_amount: float, interval: int) -> bool:
|
|
||||||
"""
|
|
||||||
Checks the implemented trading indicator(s) for a randomly picked pair,
|
|
||||||
if one pair triggers the buy_signal a new trade record gets created
|
|
||||||
:param stake_amount: amount of btc to spend
|
|
||||||
:return: True if a trade object has been created and persisted, False otherwise
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
'Checking buy signals to create a new trade with stake_amount: %f ...',
|
|
||||||
stake_amount
|
|
||||||
)
|
|
||||||
whitelist = copy.deepcopy(_CONF['exchange']['pair_whitelist'])
|
|
||||||
# Check if stake_amount is fulfilled
|
|
||||||
if exchange.get_balance(_CONF['stake_currency']) < stake_amount:
|
|
||||||
raise DependencyException(
|
|
||||||
'stake amount is not fulfilled (currency={})'.format(_CONF['stake_currency'])
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove currently opened and latest pairs from whitelist
|
|
||||||
for trade in Trade.query.filter(Trade.is_open.is_(True)).all():
|
|
||||||
if trade.pair in whitelist:
|
|
||||||
whitelist.remove(trade.pair)
|
|
||||||
logger.debug('Ignoring %s in pair whitelist', trade.pair)
|
|
||||||
if not whitelist:
|
|
||||||
raise DependencyException('No pair in whitelist')
|
|
||||||
|
|
||||||
# Pick pair based on StochRSI buy signals
|
|
||||||
for _pair in whitelist:
|
|
||||||
(buy, sell) = get_signal(_pair, interval)
|
|
||||||
if buy and not sell:
|
|
||||||
pair = _pair
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Calculate amount
|
|
||||||
buy_limit = get_target_bid(exchange.get_ticker(pair))
|
|
||||||
amount = stake_amount / buy_limit
|
|
||||||
|
|
||||||
order_id = exchange.buy(pair, buy_limit, amount)
|
|
||||||
|
|
||||||
fiat_converter = CryptoToFiatConverter()
|
|
||||||
stake_amount_fiat = fiat_converter.convert_amount(
|
|
||||||
stake_amount,
|
|
||||||
_CONF['stake_currency'],
|
|
||||||
_CONF['fiat_display_currency']
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create trade entity and return
|
|
||||||
rpc.send_msg('*{}:* Buying [{}]({}) with limit `{:.8f} ({:.6f} {}, {:.3f} {})` '.format(
|
|
||||||
exchange.get_name().upper(),
|
|
||||||
pair.replace('_', '/'),
|
|
||||||
exchange.get_pair_detail_url(pair),
|
|
||||||
buy_limit, stake_amount, _CONF['stake_currency'],
|
|
||||||
stake_amount_fiat, _CONF['fiat_display_currency']
|
|
||||||
))
|
|
||||||
# Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL
|
|
||||||
trade = Trade(
|
|
||||||
pair=pair,
|
|
||||||
stake_amount=stake_amount,
|
|
||||||
amount=amount,
|
|
||||||
fee=exchange.get_fee(),
|
|
||||||
open_rate=buy_limit,
|
|
||||||
open_date=datetime.utcnow(),
|
|
||||||
exchange=exchange.get_name().upper(),
|
|
||||||
open_order_id=order_id
|
|
||||||
)
|
|
||||||
Trade.session.add(trade)
|
|
||||||
Trade.session.flush()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def init(config: dict, db_url: Optional[str] = None) -> None:
|
|
||||||
"""
|
|
||||||
Initializes all modules and updates the config
|
|
||||||
:param config: config as dict
|
|
||||||
:param db_url: database connector string for sqlalchemy (Optional)
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
# Initialize all modules
|
|
||||||
rpc.init(config)
|
|
||||||
persistence.init(config, db_url)
|
|
||||||
exchange.init(config)
|
|
||||||
|
|
||||||
strategy = Strategy()
|
|
||||||
strategy.init(config)
|
|
||||||
|
|
||||||
# Set initial application state
|
|
||||||
initial_state = config.get('initial_state')
|
|
||||||
if initial_state:
|
|
||||||
update_state(State[initial_state.upper()])
|
|
||||||
else:
|
|
||||||
update_state(State.STOPPED)
|
|
||||||
|
|
||||||
|
|
||||||
@cached(TTLCache(maxsize=1, ttl=1800))
|
|
||||||
def gen_pair_whitelist(base_currency: str, key: str = 'BaseVolume') -> List[str]:
|
|
||||||
"""
|
|
||||||
Updates the whitelist with with a dynamically generated list
|
|
||||||
:param base_currency: base currency as str
|
|
||||||
:param key: sort key (defaults to 'BaseVolume')
|
|
||||||
:return: List of pairs
|
|
||||||
"""
|
|
||||||
summaries = sorted(
|
|
||||||
(s for s in exchange.get_market_summaries() if s['MarketName'].startswith(base_currency)),
|
|
||||||
key=lambda s: s.get(key) or 0.0,
|
|
||||||
reverse=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return [s['MarketName'].replace('-', '_') for s in summaries]
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup() -> None:
|
|
||||||
"""
|
|
||||||
Cleanup the application state und finish all pending tasks
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
rpc.send_msg('*Status:* `Stopping trader...`')
|
|
||||||
logger.info('Stopping trader and cleaning up modules...')
|
|
||||||
update_state(State.STOPPED)
|
|
||||||
persistence.cleanup()
|
|
||||||
rpc.cleanup()
|
|
||||||
exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def main(sysargv=sys.argv[1:]) -> int:
|
|
||||||
"""
|
|
||||||
Loads and validates the config and handles the main loop
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
global _CONF
|
|
||||||
args = parse_args(sysargv,
|
|
||||||
'Simple High Frequency Trading Bot for crypto currencies')
|
|
||||||
|
|
||||||
# A subcommand has been issued
|
|
||||||
if hasattr(args, 'func'):
|
if hasattr(args, 'func'):
|
||||||
args.func(args)
|
args.func(args)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Initialize logger
|
|
||||||
logging.basicConfig(
|
|
||||||
level=args.loglevel,
|
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'Starting freqtrade %s (loglevel=%s)',
|
'Starting freqtrade %s (loglevel=%s)',
|
||||||
__version__,
|
__version__,
|
||||||
logging.getLevelName(args.loglevel)
|
logging.getLevelName(args.loglevel)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load and validate configuration
|
|
||||||
_CONF = load_config(args.config)
|
|
||||||
|
|
||||||
# Add the strategy file to use
|
|
||||||
_CONF.update({'strategy': args.strategy})
|
|
||||||
|
|
||||||
# Initialize all modules and start main loop
|
|
||||||
if args.dynamic_whitelist:
|
|
||||||
logger.info('Using dynamically generated whitelist. (--dynamic-whitelist detected)')
|
|
||||||
|
|
||||||
# If the user ask for Dry run with a local DB instead of memory
|
|
||||||
if args.dry_run_db:
|
|
||||||
if _CONF.get('dry_run', False):
|
|
||||||
_CONF.update({'dry_run_db': True})
|
|
||||||
logger.info(
|
|
||||||
'Dry_run will use the DB file: "tradesv3.dry_run.sqlite". (--dry_run_db detected)'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info('Dry run is disabled. (--dry_run_db ignored)')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
init(_CONF)
|
# Load and validate configuration
|
||||||
old_state = None
|
configuration = Configuration(args)
|
||||||
|
|
||||||
while True:
|
# Init the bot
|
||||||
new_state = get_state()
|
freqtrade = FreqtradeBot(configuration.get_config())
|
||||||
# Log state transition
|
|
||||||
if new_state != old_state:
|
state = None
|
||||||
rpc.send_msg('*Status:* `{}`'.format(new_state.name.lower()))
|
while 1:
|
||||||
logger.info('Changing state to: %s', new_state.name)
|
state = freqtrade.worker(old_state=state)
|
||||||
|
|
||||||
if new_state == State.STOPPED:
|
|
||||||
time.sleep(1)
|
|
||||||
elif new_state == State.RUNNING:
|
|
||||||
throttle(
|
|
||||||
_process,
|
|
||||||
min_secs=_CONF['internals'].get('process_throttle_secs', 10),
|
|
||||||
nb_assets=args.dynamic_whitelist,
|
|
||||||
interval=int(_CONF.get('ticker_interval', 5))
|
|
||||||
)
|
|
||||||
old_state = new_state
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info('Got SIGINT, aborting ...')
|
logger.info('Got SIGINT, aborting ...')
|
||||||
except BaseException:
|
except BaseException:
|
||||||
logger.exception('Got fatal exception!')
|
logger.exception('Got fatal exception!')
|
||||||
finally:
|
finally:
|
||||||
cleanup()
|
freqtrade.clean()
|
||||||
return 0
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def set_loggers() -> None:
|
||||||
|
"""
|
||||||
|
Set the logger level for Third party libs
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
logging.getLogger('requests.packages.urllib3').setLevel(logging.INFO)
|
||||||
|
logging.getLogger('telegram').setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
set_loggers()
|
||||||
main(sys.argv[1:])
|
main(sys.argv[1:])
|
||||||
|
@ -281,3 +281,134 @@ def default_strategy():
|
|||||||
# that inserts a trade of some type and open-status
|
# that inserts a trade of some type and open-status
|
||||||
# return the open-order-id
|
# return the open-order-id
|
||||||
# See tests in rpc/main that could use this
|
# See tests in rpc/main that could use this
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def get_market_summaries_data():
|
||||||
|
"""
|
||||||
|
This fixture is a real result from exchange.get_market_summaries() but reduced to only
|
||||||
|
8 entries. 4 BTC, 4 USTD
|
||||||
|
:return: JSON market summaries
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'Ask': 1.316e-05,
|
||||||
|
'BaseVolume': 5.72599471,
|
||||||
|
'Bid': 1.3e-05,
|
||||||
|
'Created': '2014-04-14T00:00:00',
|
||||||
|
'High': 1.414e-05,
|
||||||
|
'Last': 1.298e-05,
|
||||||
|
'Low': 1.282e-05,
|
||||||
|
'MarketName': 'BTC-XWC',
|
||||||
|
'OpenBuyOrders': 2000,
|
||||||
|
'OpenSellOrders': 1484,
|
||||||
|
'PrevDay': 1.376e-05,
|
||||||
|
'TimeStamp': '2018-02-05T01:32:40.493',
|
||||||
|
'Volume': 424041.21418375
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Ask': 0.00627051,
|
||||||
|
'BaseVolume': 93.23302388,
|
||||||
|
'Bid': 0.00618192,
|
||||||
|
'Created': '2016-10-20T04:48:30.387',
|
||||||
|
'High': 0.00669897,
|
||||||
|
'Last': 0.00618192,
|
||||||
|
'Low': 0.006,
|
||||||
|
'MarketName': 'BTC-XZC',
|
||||||
|
'OpenBuyOrders': 343,
|
||||||
|
'OpenSellOrders': 2037,
|
||||||
|
'PrevDay': 0.00668229,
|
||||||
|
'TimeStamp': '2018-02-05T01:32:43.383',
|
||||||
|
'Volume': 14863.60730702
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Ask': 0.01137247,
|
||||||
|
'BaseVolume': 383.55922657,
|
||||||
|
'Bid': 0.01136006,
|
||||||
|
'Created': '2016-11-15T20:29:59.73',
|
||||||
|
'High': 0.012,
|
||||||
|
'Last': 0.01137247,
|
||||||
|
'Low': 0.01119883,
|
||||||
|
'MarketName': 'BTC-ZCL',
|
||||||
|
'OpenBuyOrders': 1332,
|
||||||
|
'OpenSellOrders': 5317,
|
||||||
|
'PrevDay': 0.01179603,
|
||||||
|
'TimeStamp': '2018-02-05T01:32:42.773',
|
||||||
|
'Volume': 33308.07358285
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Ask': 0.04155821,
|
||||||
|
'BaseVolume': 274.75369074,
|
||||||
|
'Bid': 0.04130002,
|
||||||
|
'Created': '2016-10-28T17:13:10.833',
|
||||||
|
'High': 0.04354429,
|
||||||
|
'Last': 0.041585,
|
||||||
|
'Low': 0.0413,
|
||||||
|
'MarketName': 'BTC-ZEC',
|
||||||
|
'OpenBuyOrders': 863,
|
||||||
|
'OpenSellOrders': 5579,
|
||||||
|
'PrevDay': 0.0429,
|
||||||
|
'TimeStamp': '2018-02-05T01:32:43.21',
|
||||||
|
'Volume': 6479.84033259
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Ask': 210.99999999,
|
||||||
|
'BaseVolume': 615132.70989532,
|
||||||
|
'Bid': 210.05503736,
|
||||||
|
'Created': '2017-07-21T01:08:49.397',
|
||||||
|
'High': 257.396,
|
||||||
|
'Last': 211.0,
|
||||||
|
'Low': 209.05333589,
|
||||||
|
'MarketName': 'USDT-XMR',
|
||||||
|
'OpenBuyOrders': 180,
|
||||||
|
'OpenSellOrders': 1203,
|
||||||
|
'PrevDay': 247.93528899,
|
||||||
|
'TimeStamp': '2018-02-05T01:32:43.117',
|
||||||
|
'Volume': 2688.17410793
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Ask': 0.79589979,
|
||||||
|
'BaseVolume': 9349557.01853031,
|
||||||
|
'Bid': 0.789226,
|
||||||
|
'Created': '2017-07-14T17:10:10.737',
|
||||||
|
'High': 0.977,
|
||||||
|
'Last': 0.79589979,
|
||||||
|
'Low': 0.781,
|
||||||
|
'MarketName': 'USDT-XRP',
|
||||||
|
'OpenBuyOrders': 1075,
|
||||||
|
'OpenSellOrders': 6508,
|
||||||
|
'PrevDay': 0.93300218,
|
||||||
|
'TimeStamp': '2018-02-05T01:32:42.383',
|
||||||
|
'Volume': 10801663.00788851
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Ask': 0.05154982,
|
||||||
|
'BaseVolume': 2311087.71232136,
|
||||||
|
'Bid': 0.05040107,
|
||||||
|
'Created': '2017-12-29T19:29:18.357',
|
||||||
|
'High': 0.06668561,
|
||||||
|
'Last': 0.0508,
|
||||||
|
'Low': 0.05006731,
|
||||||
|
'MarketName': 'USDT-XVG',
|
||||||
|
'OpenBuyOrders': 655,
|
||||||
|
'OpenSellOrders': 5544,
|
||||||
|
'PrevDay': 0.0627,
|
||||||
|
'TimeStamp': '2018-02-05T01:32:41.507',
|
||||||
|
'Volume': 40031424.2152716
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Ask': 332.65500022,
|
||||||
|
'BaseVolume': 562911.87455665,
|
||||||
|
'Bid': 330.00000001,
|
||||||
|
'Created': '2017-07-14T17:10:10.673',
|
||||||
|
'High': 401.59999999,
|
||||||
|
'Last': 332.65500019,
|
||||||
|
'Low': 330.0,
|
||||||
|
'MarketName': 'USDT-ZEC',
|
||||||
|
'OpenBuyOrders': 161,
|
||||||
|
'OpenSellOrders': 1731,
|
||||||
|
'PrevDay': 391.42,
|
||||||
|
'TimeStamp': '2018-02-05T01:32:42.947',
|
||||||
|
'Volume': 1571.09647946
|
||||||
|
}
|
||||||
|
]
|
||||||
|
1213
freqtrade/tests/test_freqtradebot.py
Normal file
1213
freqtrade/tests/test_freqtradebot.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,30 +1,22 @@
|
|||||||
# pragma pylint: disable=missing-docstring, C0103
|
"""
|
||||||
import copy
|
Unit test file for main.py
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import arrow
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
|
|
||||||
import freqtrade.main as main
|
from freqtrade.main import main, set_loggers
|
||||||
import freqtrade.tests.conftest as tt # test tools
|
import freqtrade.tests.conftest as tt # test tools
|
||||||
from freqtrade import DependencyException, OperationalException
|
|
||||||
from freqtrade.exchange import Exchanges
|
|
||||||
from freqtrade.main import (_process, check_handle_timedout, create_trade,
|
|
||||||
execute_sell, get_target_bid, handle_trade, init)
|
|
||||||
from freqtrade.misc import State, get_state
|
|
||||||
from freqtrade.persistence import Trade
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_args_backtesting(mocker):
|
def test_parse_args_backtesting(mocker) -> None:
|
||||||
""" Test that main() can start backtesting or hyperopt.
|
"""
|
||||||
and also ensure we can pass some specific arguments
|
Test that main() can start backtesting and also ensure we can pass some specific arguments
|
||||||
further argument parsing is done in test_misc.py """
|
further argument parsing is done in test_arguments.py
|
||||||
backtesting_mock = mocker.patch(
|
"""
|
||||||
'freqtrade.optimize.backtesting.start', MagicMock())
|
backtesting_mock = mocker.patch('freqtrade.optimize.backtesting.start', MagicMock())
|
||||||
main.main(['backtesting'])
|
main(['backtesting'])
|
||||||
assert backtesting_mock.call_count == 1
|
assert backtesting_mock.call_count == 1
|
||||||
call_args = backtesting_mock.call_args[0][0]
|
call_args = backtesting_mock.call_args[0][0]
|
||||||
assert call_args.config == 'config.json'
|
assert call_args.config == 'config.json'
|
||||||
@ -35,10 +27,12 @@ def test_parse_args_backtesting(mocker):
|
|||||||
assert call_args.ticker_interval is None
|
assert call_args.ticker_interval is None
|
||||||
|
|
||||||
|
|
||||||
def test_main_start_hyperopt(mocker):
|
def test_main_start_hyperopt(mocker) -> None:
|
||||||
hyperopt_mock = mocker.patch(
|
"""
|
||||||
'freqtrade.optimize.hyperopt.start', MagicMock())
|
Test that main() can start hyperopt.
|
||||||
main.main(['hyperopt'])
|
"""
|
||||||
|
hyperopt_mock = mocker.patch('freqtrade.optimize.hyperopt.start', MagicMock())
|
||||||
|
main(['hyperopt'])
|
||||||
assert hyperopt_mock.call_count == 1
|
assert hyperopt_mock.call_count == 1
|
||||||
call_args = hyperopt_mock.call_args[0][0]
|
call_args = hyperopt_mock.call_args[0][0]
|
||||||
assert call_args.config == 'config.json'
|
assert call_args.config == 'config.json'
|
||||||
@ -47,795 +41,51 @@ def test_main_start_hyperopt(mocker):
|
|||||||
assert call_args.func is not None
|
assert call_args.func is not None
|
||||||
|
|
||||||
|
|
||||||
def test_process_maybe_execute_buy(default_conf, mocker):
|
def test_set_loggers() -> None:
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
"""
|
||||||
mocker.patch('freqtrade.main.create_trade', return_value=True)
|
Test set_loggers() update the logger level for third-party libraries
|
||||||
assert main.process_maybe_execute_buy(int(default_conf['ticker_interval']))
|
"""
|
||||||
mocker.patch('freqtrade.main.create_trade', return_value=False)
|
previous_value1 = logging.getLogger('requests.packages.urllib3').level
|
||||||
assert not main.process_maybe_execute_buy(int(default_conf['ticker_interval']))
|
previous_value2 = logging.getLogger('telegram').level
|
||||||
|
|
||||||
|
set_loggers()
|
||||||
def test_process_maybe_execute_sell(default_conf, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
value1 = logging.getLogger('requests.packages.urllib3').level
|
||||||
mocker.patch('freqtrade.main.handle_trade', return_value=True)
|
assert previous_value1 is not value1
|
||||||
mocker.patch('freqtrade.exchange.get_order', return_value=1)
|
assert value1 is logging.INFO
|
||||||
trade = MagicMock()
|
|
||||||
trade.open_order_id = '123'
|
value2 = logging.getLogger('telegram').level
|
||||||
assert not main.process_maybe_execute_sell(trade, int(default_conf['ticker_interval']))
|
assert previous_value2 is not value2
|
||||||
trade.is_open = True
|
assert value2 is logging.INFO
|
||||||
trade.open_order_id = None
|
|
||||||
# Assert we call handle_trade() if trade is feasible for execution
|
|
||||||
assert main.process_maybe_execute_sell(trade, int(default_conf['ticker_interval']))
|
def test_main(mocker, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test main() function.
|
||||||
def test_process_maybe_execute_buy_exception(default_conf, mocker, caplog):
|
In this test we are skipping the while True loop by throwing an exception.
|
||||||
caplog.set_level(logging.INFO)
|
"""
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
mocker.patch.multiple(
|
||||||
mocker.patch('freqtrade.main.create_trade', MagicMock(side_effect=DependencyException))
|
'freqtrade.freqtradebot.FreqtradeBot',
|
||||||
main.process_maybe_execute_buy(int(default_conf['ticker_interval']))
|
_init_modules=MagicMock(),
|
||||||
tt.log_has('Unable to create trade:', caplog.record_tuples)
|
worker=MagicMock(
|
||||||
|
side_effect=KeyboardInterrupt
|
||||||
|
),
|
||||||
def test_process_trade_creation(default_conf, ticker, limit_buy_order, health, mocker):
|
clean=MagicMock(),
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
get_wallet_health=health,
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'),
|
|
||||||
get_order=MagicMock(return_value=limit_buy_order))
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
|
|
||||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
|
||||||
assert not trades
|
|
||||||
|
|
||||||
result = _process(interval=int(default_conf['ticker_interval']))
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
|
||||||
assert len(trades) == 1
|
|
||||||
trade = trades[0]
|
|
||||||
assert trade is not None
|
|
||||||
assert trade.stake_amount == default_conf['stake_amount']
|
|
||||||
assert trade.is_open
|
|
||||||
assert trade.open_date is not None
|
|
||||||
assert trade.exchange == Exchanges.BITTREX.name
|
|
||||||
assert trade.open_rate == 0.00001099
|
|
||||||
assert trade.amount == 90.99181073703367
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_exchange_failures(default_conf, ticker, health, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
sleep_mock = mocker.patch('time.sleep', side_effect=lambda _: None)
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
get_wallet_health=health,
|
|
||||||
buy=MagicMock(side_effect=requests.exceptions.RequestException))
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
result = _process(interval=int(default_conf['ticker_interval']))
|
|
||||||
assert result is False
|
|
||||||
assert sleep_mock.has_calls()
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_operational_exception(default_conf, ticker, health, mocker):
|
|
||||||
msg_mock = MagicMock()
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=msg_mock)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
get_wallet_health=health,
|
|
||||||
buy=MagicMock(side_effect=OperationalException))
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
assert get_state() == State.RUNNING
|
|
||||||
|
|
||||||
result = _process(interval=int(default_conf['ticker_interval']))
|
|
||||||
assert result is False
|
|
||||||
assert get_state() == State.STOPPED
|
|
||||||
assert 'OperationalException' in msg_mock.call_args_list[-1][0][0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_trade_handling(default_conf, ticker, limit_buy_order, health, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
get_wallet_health=health,
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'),
|
|
||||||
get_order=MagicMock(return_value=limit_buy_order))
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
|
|
||||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
|
||||||
assert not trades
|
|
||||||
result = _process(interval=int(default_conf['ticker_interval']))
|
|
||||||
assert result is True
|
|
||||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
|
||||||
assert len(trades) == 1
|
|
||||||
|
|
||||||
result = _process(interval=int(default_conf['ticker_interval']))
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_trade(default_conf, ticker, limit_buy_order, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'))
|
|
||||||
# Save state of current whitelist
|
|
||||||
whitelist = copy.deepcopy(default_conf['exchange']['pair_whitelist'])
|
|
||||||
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
trade = Trade.query.first()
|
|
||||||
assert trade is not None
|
|
||||||
assert trade.stake_amount == 0.001
|
|
||||||
assert trade.is_open
|
|
||||||
assert trade.open_date is not None
|
|
||||||
assert trade.exchange == Exchanges.BITTREX.name
|
|
||||||
|
|
||||||
# Simulate fulfilled LIMIT_BUY order for trade
|
|
||||||
trade.update(limit_buy_order)
|
|
||||||
|
|
||||||
assert trade.open_rate == 0.00001099
|
|
||||||
assert trade.amount == 90.99181073
|
|
||||||
|
|
||||||
assert whitelist == default_conf['exchange']['pair_whitelist']
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_trade_minimal_amount(default_conf, ticker, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
buy_mock = mocker.patch(
|
|
||||||
'freqtrade.main.exchange.buy', MagicMock(return_value='mocked_limit_buy')
|
|
||||||
)
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker)
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
min_stake_amount = 0.0005
|
|
||||||
create_trade(min_stake_amount, int(default_conf['ticker_interval']))
|
|
||||||
rate, amount = buy_mock.call_args[0][1], buy_mock.call_args[0][2]
|
|
||||||
assert rate * amount >= min_stake_amount
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_trade_no_stake_amount(default_conf, ticker, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'),
|
|
||||||
get_balance=MagicMock(return_value=default_conf['stake_amount'] * 0.5))
|
|
||||||
with pytest.raises(DependencyException, match=r'.*stake amount.*'):
|
|
||||||
create_trade(default_conf['stake_amount'], int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_trade_no_pairs(default_conf, ticker, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'))
|
|
||||||
|
|
||||||
with pytest.raises(DependencyException, match=r'.*No pair in whitelist.*'):
|
|
||||||
conf = copy.deepcopy(default_conf)
|
|
||||||
conf['exchange']['pair_whitelist'] = []
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
|
||||||
create_trade(default_conf['stake_amount'], int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_trade_no_pairs_after_blacklist(default_conf, ticker, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'))
|
|
||||||
|
|
||||||
with pytest.raises(DependencyException, match=r'.*No pair in whitelist.*'):
|
|
||||||
conf = copy.deepcopy(default_conf)
|
|
||||||
conf['exchange']['pair_whitelist'] = ["BTC_ETH"]
|
|
||||||
conf['exchange']['pair_blacklist'] = ["BTC_ETH"]
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
|
||||||
create_trade(default_conf['stake_amount'], int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_trade_no_signal(default_conf, mocker):
|
|
||||||
default_conf['dry_run'] = True
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', MagicMock(return_value=(False, False)))
|
|
||||||
mocker.patch.multiple('freqtrade.exchange',
|
|
||||||
get_ticker_history=MagicMock(return_value=20))
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
get_balance=MagicMock(return_value=20))
|
|
||||||
stake_amount = 10
|
|
||||||
Trade.query = MagicMock()
|
|
||||||
Trade.query.filter = MagicMock()
|
|
||||||
assert not create_trade(stake_amount, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_trade(default_conf, limit_buy_order, limit_sell_order, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=MagicMock(return_value={
|
|
||||||
'bid': 0.00001172,
|
|
||||||
'ask': 0.00001173,
|
|
||||||
'last': 0.00001172
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'),
|
|
||||||
sell=MagicMock(return_value='mocked_limit_sell'))
|
|
||||||
mocker.patch.multiple('freqtrade.fiat_convert.Pymarketcap',
|
|
||||||
ticker=MagicMock(return_value={'price_usd': 15000.0}),
|
|
||||||
_cache_symbols=MagicMock(return_value={'BTC': 1}))
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
trade = Trade.query.first()
|
|
||||||
assert trade
|
|
||||||
|
|
||||||
trade.update(limit_buy_order)
|
|
||||||
assert trade.is_open is True
|
|
||||||
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True))
|
|
||||||
assert handle_trade(trade, int(default_conf['ticker_interval'])) is True
|
|
||||||
assert trade.open_order_id == 'mocked_limit_sell'
|
|
||||||
|
|
||||||
# Simulate fulfilled LIMIT_SELL order for trade
|
|
||||||
trade.update(limit_sell_order)
|
|
||||||
|
|
||||||
assert trade.close_rate == 0.00001173
|
|
||||||
assert trade.close_profit == 0.06201057
|
|
||||||
assert trade.calc_profit() == 0.00006217
|
|
||||||
assert trade.close_date is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_overlpapping_signals(default_conf, ticker, mocker):
|
|
||||||
default_conf.update({'experimental': {'use_sell_signal': True}})
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, True))
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'))
|
|
||||||
mocker.patch('freqtrade.main.min_roi_reached', return_value=False)
|
|
||||||
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
# Buy and Sell triggering, so doing nothing ...
|
|
||||||
trades = Trade.query.all()
|
|
||||||
nb_trades = len(trades)
|
|
||||||
assert nb_trades == 0
|
|
||||||
|
|
||||||
# Buy is triggering, so buying ...
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
trades = Trade.query.all()
|
|
||||||
nb_trades = len(trades)
|
|
||||||
assert nb_trades == 1
|
|
||||||
assert trades[0].is_open is True
|
|
||||||
|
|
||||||
# Buy and Sell are not triggering, so doing nothing ...
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, False))
|
|
||||||
assert handle_trade(trades[0], int(default_conf['ticker_interval'])) is False
|
|
||||||
trades = Trade.query.all()
|
|
||||||
nb_trades = len(trades)
|
|
||||||
assert nb_trades == 1
|
|
||||||
assert trades[0].is_open is True
|
|
||||||
|
|
||||||
# Buy and Sell are triggering, so doing nothing ...
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, True))
|
|
||||||
assert handle_trade(trades[0], int(default_conf['ticker_interval'])) is False
|
|
||||||
trades = Trade.query.all()
|
|
||||||
nb_trades = len(trades)
|
|
||||||
assert nb_trades == 1
|
|
||||||
assert trades[0].is_open is True
|
|
||||||
|
|
||||||
# Sell is triggering, guess what : we are Selling!
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True))
|
|
||||||
trades = Trade.query.all()
|
|
||||||
assert handle_trade(trades[0], int(default_conf['ticker_interval'])) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_trade_roi(default_conf, ticker, mocker, caplog):
|
|
||||||
caplog.set_level(logging.DEBUG)
|
|
||||||
default_conf.update({'experimental': {'use_sell_signal': True}})
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'))
|
|
||||||
mocker.patch('freqtrade.main.min_roi_reached', return_value=True)
|
|
||||||
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
trade = Trade.query.first()
|
|
||||||
trade.is_open = True
|
|
||||||
|
|
||||||
# FIX: sniffing logs, suggest handle_trade should not execute_sell
|
|
||||||
# instead that responsibility should be moved out of handle_trade(),
|
|
||||||
# we might just want to check if we are in a sell condition without
|
|
||||||
# executing
|
|
||||||
# if ROI is reached we must sell
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True))
|
|
||||||
assert handle_trade(trade, interval=int(default_conf['ticker_interval']))
|
|
||||||
assert ('freqtrade', logging.DEBUG, 'Executing sell due to ROI ...') in caplog.record_tuples
|
|
||||||
# if ROI is reached we must sell even if sell-signal is not signalled
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True))
|
|
||||||
assert handle_trade(trade, interval=int(default_conf['ticker_interval']))
|
|
||||||
assert ('freqtrade', logging.DEBUG, 'Executing sell due to ROI ...') in caplog.record_tuples
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_trade_experimental(default_conf, ticker, mocker, caplog):
|
|
||||||
caplog.set_level(logging.DEBUG)
|
|
||||||
default_conf.update({'experimental': {'use_sell_signal': True}})
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'))
|
|
||||||
mocker.patch('freqtrade.main.min_roi_reached', return_value=False)
|
|
||||||
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
trade = Trade.query.first()
|
|
||||||
trade.is_open = True
|
|
||||||
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, False))
|
|
||||||
value_returned = handle_trade(trade, int(default_conf['ticker_interval']))
|
|
||||||
assert value_returned is False
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True))
|
|
||||||
assert handle_trade(trade, int(default_conf['ticker_interval']))
|
|
||||||
s = 'Executing sell due to sell signal ...'
|
|
||||||
assert ('freqtrade', logging.DEBUG, s) in caplog.record_tuples
|
|
||||||
|
|
||||||
|
|
||||||
def test_close_trade(default_conf, ticker, limit_buy_order, limit_sell_order, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'))
|
|
||||||
|
|
||||||
# Create trade and sell it
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
trade = Trade.query.first()
|
|
||||||
assert trade
|
|
||||||
|
|
||||||
trade.update(limit_buy_order)
|
|
||||||
trade.update(limit_sell_order)
|
|
||||||
assert trade.is_open is False
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match=r'.*closed trade.*'):
|
|
||||||
handle_trade(trade, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_handle_timedout_buy(default_conf, ticker, limit_buy_order_old, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
cancel_order_mock = MagicMock()
|
|
||||||
mocker.patch('freqtrade.rpc.init', MagicMock())
|
|
||||||
rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
get_order=MagicMock(return_value=limit_buy_order_old),
|
|
||||||
cancel_order=cancel_order_mock)
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
|
|
||||||
trade_buy = Trade(
|
|
||||||
pair='BTC_ETH',
|
|
||||||
open_rate=0.00001099,
|
|
||||||
exchange='BITTREX',
|
|
||||||
open_order_id='123456789',
|
|
||||||
amount=90.99181073,
|
|
||||||
fee=0.0,
|
|
||||||
stake_amount=1,
|
|
||||||
open_date=arrow.utcnow().shift(minutes=-601).datetime,
|
|
||||||
is_open=True
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Trade.session.add(trade_buy)
|
# Test Main + the KeyboardInterrupt exception
|
||||||
|
with pytest.raises(SystemExit) as pytest_wrapped_e:
|
||||||
|
main([])
|
||||||
|
tt.log_has('Starting freqtrade', caplog.record_tuples)
|
||||||
|
tt.log_has('Got SIGINT, aborting ...', caplog.record_tuples)
|
||||||
|
assert pytest_wrapped_e.type == SystemExit
|
||||||
|
assert pytest_wrapped_e.value.code == 42
|
||||||
|
|
||||||
# check it does cancel buy orders over the time limit
|
# Test the BaseException case
|
||||||
check_handle_timedout(600)
|
mocker.patch(
|
||||||
assert cancel_order_mock.call_count == 1
|
'freqtrade.freqtradebot.FreqtradeBot.worker',
|
||||||
assert rpc_mock.call_count == 1
|
MagicMock(side_effect=BaseException)
|
||||||
trades = Trade.query.filter(Trade.open_order_id.is_(trade_buy.open_order_id)).all()
|
|
||||||
nb_trades = len(trades)
|
|
||||||
assert nb_trades == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_timedout_limit_buy(mocker):
|
|
||||||
cancel_order = MagicMock()
|
|
||||||
mocker.patch('freqtrade.exchange.cancel_order', cancel_order)
|
|
||||||
Trade.session = MagicMock()
|
|
||||||
trade = MagicMock()
|
|
||||||
order = {'remaining': 1,
|
|
||||||
'amount': 1}
|
|
||||||
assert main.handle_timedout_limit_buy(trade, order)
|
|
||||||
assert cancel_order.call_count == 1
|
|
||||||
order['amount'] = 2
|
|
||||||
assert not main.handle_timedout_limit_buy(trade, order)
|
|
||||||
assert cancel_order.call_count == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_handle_timedout_sell(default_conf, ticker, limit_sell_order_old, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
cancel_order_mock = MagicMock()
|
|
||||||
mocker.patch('freqtrade.rpc.init', MagicMock())
|
|
||||||
rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
get_order=MagicMock(return_value=limit_sell_order_old),
|
|
||||||
cancel_order=cancel_order_mock)
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
|
|
||||||
trade_sell = Trade(
|
|
||||||
pair='BTC_ETH',
|
|
||||||
open_rate=0.00001099,
|
|
||||||
exchange='BITTREX',
|
|
||||||
open_order_id='123456789',
|
|
||||||
amount=90.99181073,
|
|
||||||
fee=0.0,
|
|
||||||
stake_amount=1,
|
|
||||||
open_date=arrow.utcnow().shift(hours=-5).datetime,
|
|
||||||
close_date=arrow.utcnow().shift(minutes=-601).datetime,
|
|
||||||
is_open=False
|
|
||||||
)
|
)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
Trade.session.add(trade_sell)
|
main([])
|
||||||
|
tt.log_has('Got fatal exception!', caplog.record_tuples)
|
||||||
# check it does cancel sell orders over the time limit
|
|
||||||
check_handle_timedout(600)
|
|
||||||
assert cancel_order_mock.call_count == 1
|
|
||||||
assert rpc_mock.call_count == 1
|
|
||||||
assert trade_sell.is_open is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_timedout_limit_sell(mocker):
|
|
||||||
cancel_order = MagicMock()
|
|
||||||
mocker.patch('freqtrade.exchange.cancel_order', cancel_order)
|
|
||||||
trade = MagicMock()
|
|
||||||
order = {'remaining': 1,
|
|
||||||
'amount': 1}
|
|
||||||
assert main.handle_timedout_limit_sell(trade, order)
|
|
||||||
assert cancel_order.call_count == 1
|
|
||||||
order['amount'] = 2
|
|
||||||
assert not main.handle_timedout_limit_sell(trade, order)
|
|
||||||
# Assert cancel_order was not called (callcount remains unchanged)
|
|
||||||
assert cancel_order.call_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_handle_timedout_partial(default_conf, ticker, limit_buy_order_old_partial,
|
|
||||||
mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
cancel_order_mock = MagicMock()
|
|
||||||
mocker.patch('freqtrade.rpc.init', MagicMock())
|
|
||||||
rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker,
|
|
||||||
get_order=MagicMock(return_value=limit_buy_order_old_partial),
|
|
||||||
cancel_order=cancel_order_mock)
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
|
|
||||||
trade_buy = Trade(
|
|
||||||
pair='BTC_ETH',
|
|
||||||
open_rate=0.00001099,
|
|
||||||
exchange='BITTREX',
|
|
||||||
open_order_id='123456789',
|
|
||||||
amount=90.99181073,
|
|
||||||
fee=0.0,
|
|
||||||
stake_amount=1,
|
|
||||||
open_date=arrow.utcnow().shift(minutes=-601).datetime,
|
|
||||||
is_open=True
|
|
||||||
)
|
|
||||||
|
|
||||||
Trade.session.add(trade_buy)
|
|
||||||
|
|
||||||
# check it does cancel buy orders over the time limit
|
|
||||||
# note this is for a partially-complete buy order
|
|
||||||
check_handle_timedout(600)
|
|
||||||
assert cancel_order_mock.call_count == 1
|
|
||||||
assert rpc_mock.call_count == 1
|
|
||||||
trades = Trade.query.filter(Trade.open_order_id.is_(trade_buy.open_order_id)).all()
|
|
||||||
assert len(trades) == 1
|
|
||||||
assert trades[0].amount == 23.0
|
|
||||||
assert trades[0].stake_amount == trade_buy.open_rate * trades[0].amount
|
|
||||||
|
|
||||||
|
|
||||||
def test_balance_fully_ask_side(mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 0.0}})
|
|
||||||
assert get_target_bid({'ask': 20, 'last': 10}) == 20
|
|
||||||
|
|
||||||
|
|
||||||
def test_balance_fully_last_side(mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}})
|
|
||||||
assert get_target_bid({'ask': 20, 'last': 10}) == 10
|
|
||||||
|
|
||||||
|
|
||||||
def test_balance_bigger_last_ask(mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}})
|
|
||||||
assert get_target_bid({'ask': 5, 'last': 10}) == 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_execute_sell_up(default_conf, ticker, ticker_sell_up, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch('freqtrade.rpc.init', MagicMock())
|
|
||||||
rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker)
|
|
||||||
mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0)
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
|
|
||||||
# Create some test data
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
trade = Trade.query.first()
|
|
||||||
assert trade
|
|
||||||
|
|
||||||
# Increase the price and sell it
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker_sell_up)
|
|
||||||
|
|
||||||
execute_sell(trade=trade, limit=ticker_sell_up()['bid'])
|
|
||||||
|
|
||||||
assert rpc_mock.call_count == 2
|
|
||||||
assert 'Selling' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert '[BTC_ETH]' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert 'Amount' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert 'Profit' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert '0.00001172' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert 'profit: 6.11%, 0.00006126' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert '0.919 USD' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_execute_sell_down(default_conf, ticker, ticker_sell_down, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch('freqtrade.rpc.init', MagicMock())
|
|
||||||
rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.rpc.telegram',
|
|
||||||
_CONF=default_conf,
|
|
||||||
init=MagicMock(),
|
|
||||||
send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker)
|
|
||||||
mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0)
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
|
|
||||||
# Create some test data
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
trade = Trade.query.first()
|
|
||||||
assert trade
|
|
||||||
|
|
||||||
# Decrease the price and sell it
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker_sell_down)
|
|
||||||
|
|
||||||
execute_sell(trade=trade, limit=ticker_sell_down()['bid'])
|
|
||||||
|
|
||||||
assert rpc_mock.call_count == 2
|
|
||||||
assert 'Selling' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert '[BTC_ETH]' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert 'Amount' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert '0.00001044' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert 'loss: -5.48%, -0.00005492' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert '-0.824 USD' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_execute_sell_without_conf_sell_down(default_conf, ticker, ticker_sell_down, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch('freqtrade.rpc.init', MagicMock())
|
|
||||||
rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker)
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
|
|
||||||
# Create some test data
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
trade = Trade.query.first()
|
|
||||||
assert trade
|
|
||||||
|
|
||||||
# Decrease the price and sell it
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker_sell_down)
|
|
||||||
mocker.patch('freqtrade.main._CONF', {})
|
|
||||||
|
|
||||||
execute_sell(trade=trade, limit=ticker_sell_down()['bid'])
|
|
||||||
|
|
||||||
assert rpc_mock.call_count == 2
|
|
||||||
assert 'Selling' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert '[BTC_ETH]' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert '0.00001044' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert 'loss: -5.48%, -0.00005492' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_execute_sell_without_conf_sell_up(default_conf, ticker, ticker_sell_up, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch('freqtrade.rpc.init', MagicMock())
|
|
||||||
rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker)
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
|
|
||||||
# Create some test data
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
trade = Trade.query.first()
|
|
||||||
assert trade
|
|
||||||
|
|
||||||
# Increase the price and sell it
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=ticker_sell_up)
|
|
||||||
mocker.patch('freqtrade.main._CONF', {})
|
|
||||||
|
|
||||||
execute_sell(trade=trade, limit=ticker_sell_up()['bid'])
|
|
||||||
|
|
||||||
assert rpc_mock.call_count == 2
|
|
||||||
assert 'Selling' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert '[BTC_ETH]' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert 'Amount' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert '0.00001172' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert '(profit: 6.11%, 0.00006126)' in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
assert 'USD' not in rpc_mock.call_args_list[-1][0][0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_sell_profit_only_enable_profit(default_conf, limit_buy_order, mocker):
|
|
||||||
default_conf['experimental'] = {
|
|
||||||
'use_sell_signal': True,
|
|
||||||
'sell_profit_only': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.min_roi_reached', return_value=False)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=MagicMock(return_value={
|
|
||||||
'bid': 0.00002172,
|
|
||||||
'ask': 0.00002173,
|
|
||||||
'last': 0.00002172
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'))
|
|
||||||
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
trade = Trade.query.first()
|
|
||||||
trade.update(limit_buy_order)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True))
|
|
||||||
assert handle_trade(trade, int(default_conf['ticker_interval'])) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_sell_profit_only_disable_profit(default_conf, limit_buy_order, mocker):
|
|
||||||
default_conf['experimental'] = {
|
|
||||||
'use_sell_signal': True,
|
|
||||||
'sell_profit_only': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.min_roi_reached', return_value=False)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=MagicMock(return_value={
|
|
||||||
'bid': 0.00002172,
|
|
||||||
'ask': 0.00002173,
|
|
||||||
'last': 0.00002172
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'))
|
|
||||||
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
trade = Trade.query.first()
|
|
||||||
trade.update(limit_buy_order)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True))
|
|
||||||
assert handle_trade(trade, int(default_conf['ticker_interval'])) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_sell_profit_only_enable_loss(default_conf, limit_buy_order, mocker):
|
|
||||||
default_conf['experimental'] = {
|
|
||||||
'use_sell_signal': True,
|
|
||||||
'sell_profit_only': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.min_roi_reached', return_value=False)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=MagicMock(return_value={
|
|
||||||
'bid': 0.00000172,
|
|
||||||
'ask': 0.00000173,
|
|
||||||
'last': 0.00000172
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'))
|
|
||||||
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
trade = Trade.query.first()
|
|
||||||
trade.update(limit_buy_order)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True))
|
|
||||||
assert handle_trade(trade, int(default_conf['ticker_interval'])) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_sell_profit_only_disable_loss(default_conf, limit_buy_order, mocker):
|
|
||||||
default_conf['experimental'] = {
|
|
||||||
'use_sell_signal': True,
|
|
||||||
'sell_profit_only': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
|
||||||
mocker.patch('freqtrade.main.min_roi_reached', return_value=False)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False))
|
|
||||||
mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=MagicMock(return_value={
|
|
||||||
'bid': 0.00000172,
|
|
||||||
'ask': 0.00000173,
|
|
||||||
'last': 0.00000172
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_limit_buy'))
|
|
||||||
|
|
||||||
init(default_conf, create_engine('sqlite://'))
|
|
||||||
create_trade(0.001, int(default_conf['ticker_interval']))
|
|
||||||
|
|
||||||
trade = Trade.query.first()
|
|
||||||
trade.update(limit_buy_order)
|
|
||||||
mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True))
|
|
||||||
assert handle_trade(trade, int(default_conf['ticker_interval'])) is True
|
|
||||||
|
@ -39,7 +39,7 @@ def test_datesarray_to_datetimearray(ticker_history):
|
|||||||
assert date_len == 3
|
assert date_len == 3
|
||||||
|
|
||||||
|
|
||||||
def test_file_dump_json(mocker):
|
def test_file_dump_json(mocker) -> None:
|
||||||
"""
|
"""
|
||||||
Test file_dump_json()
|
Test file_dump_json()
|
||||||
:return: None
|
:return: None
|
||||||
|
Loading…
Reference in New Issue
Block a user