From 520a597f83fc516b6ae28752593ffb6e9df29c24 Mon Sep 17 00:00:00 2001 From: Oscar Martinez Date: Thu, 29 Oct 2020 09:35:51 -0400 Subject: [PATCH] Modified files for partial trades operation --- freqtrade/freqtradebot.py | 302 ++++++++++++++++++++++++++------ freqtrade/persistence/models.py | 179 +++++++++++-------- freqtrade/strategy/interface.py | 68 ++++--- freqtrade/wallets.py | 15 +- 4 files changed, 410 insertions(+), 154 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 7416d8236..270998749 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -4,7 +4,7 @@ Freqtrade is the main module of this bot. It contains the class Freqtrade() import copy import logging import traceback -from datetime import datetime, timezone +from datetime import datetime from math import isclose from threading import Lock from typing import Any, Dict, List, Optional @@ -12,25 +12,24 @@ from typing import Any, Dict, List, Optional import arrow from cachetools import TTLCache -from freqtrade import __version__, constants +from freqtrade import __version__, constants, persistence from freqtrade.configuration import validate_config_consistency from freqtrade.data.converter import order_book_to_dataframe from freqtrade.data.dataprovider import DataProvider from freqtrade.edge import Edge from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError, InvalidOrderException, PricingError) -from freqtrade.exchange import timeframe_to_minutes +from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date from freqtrade.misc import safe_value_fallback, safe_value_fallback2 from freqtrade.pairlist.pairlistmanager import PairListManager -from freqtrade.persistence import Order, PairLocks, Trade, cleanup_db, init_db +from freqtrade.persistence import Order, Trade from freqtrade.resolvers import ExchangeResolver, StrategyResolver from freqtrade.rpc import RPCManager, RPCMessageType from freqtrade.state import State -from freqtrade.strategy.interface import IStrategy, SellType +from freqtrade.strategy.interface import IStrategy, SellType, PartialTradeTuple from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper from freqtrade.wallets import Wallets - logger = logging.getLogger(__name__) @@ -58,8 +57,8 @@ class FreqtradeBot: # Cache values for 1800 to avoid frequent polling of the exchange for prices # Caching only applies to RPC methods, so prices for open trades are still # refreshed once every iteration. - self._sell_rate_cache: TTLCache = TTLCache(maxsize=100, ttl=1800) - self._buy_rate_cache: TTLCache = TTLCache(maxsize=100, ttl=1800) + self._sell_rate_cache = TTLCache(maxsize=100, ttl=1800) + self._buy_rate_cache = TTLCache(maxsize=100, ttl=1800) self.strategy: IStrategy = StrategyResolver.load_strategy(self.config) @@ -68,12 +67,10 @@ class FreqtradeBot: self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) - init_db(self.config.get('db_url', None), clean_open_orders=self.config['dry_run']) + persistence.init(self.config.get('db_url', None), clean_open_orders=self.config['dry_run']) self.wallets = Wallets(self.config, self.exchange) - PairLocks.timeframe = self.config['timeframe'] - self.pairlists = PairListManager(self.exchange, self.config) self.dataprovider = DataProvider(self.config, self.exchange, self.pairlists) @@ -125,7 +122,7 @@ class FreqtradeBot: self.check_for_open_trades() self.rpc.cleanup() - cleanup_db() + persistence.cleanup() def startup(self) -> None: """ @@ -347,27 +344,27 @@ class FreqtradeBot: whitelist = copy.deepcopy(self.active_pair_whitelist) if not whitelist: logger.info("Active pair whitelist is empty.") - return trades_created - # Remove pairs for currently opened trades from the whitelist - for trade in Trade.get_open_trades(): - if trade.pair in whitelist: - whitelist.remove(trade.pair) - logger.debug('Ignoring %s in pair whitelist', trade.pair) + else: + '''# Remove pairs for currently opened trades from the whitelist + for trade in Trade.get_open_trades(): + if trade.pair in whitelist: + whitelist.remove(trade.pair) + logger.debug('Ignoring %s in pair whitelist', trade.pair)''' - if not whitelist: - logger.info("No currency pair in active pair whitelist, " - "but checking to sell open trades.") - return trades_created - # Create entity and execute trade for each pair from whitelist - for pair in whitelist: - try: - trades_created += self.create_trade(pair) - except DependencyException as exception: - logger.warning('Unable to create trade for %s: %s', pair, exception) + if not whitelist: + logger.info("No currency pair in active pair whitelist, " + "but checking to sell open trades.") + else: + # Create entity and execute trade for each pair from whitelist + for pair in whitelist: + try: + trades_created += self.create_trade(pair) + except DependencyException as exception: + logger.warning('Unable to create trade for %s: %s', pair, exception) - if not trades_created: - logger.debug("Found no buy signals for whitelisted currencies. " - "Trying again...") + if not trades_created: + logger.debug("Found no buy signals for whitelisted currencies. " + "Trying again...") return trades_created @@ -553,9 +550,9 @@ class FreqtradeBot: return False # running get_signal on historical data fetched - (buy, sell) = self.strategy.get_signal(pair, self.strategy.timeframe, analyzed_df) + (buy, sell, partial_buy, partial_sell) = self.strategy.get_signal(pair, self.strategy.timeframe, analyzed_df) - if buy and not sell: + if buy or partial_buy.flag and not sell and not partial_sell.flag: stake_amount = self.get_trade_stake_amount(pair) if not stake_amount: logger.debug(f"Stake amount is 0, ignoring possible trade for {pair}.") @@ -568,13 +565,21 @@ class FreqtradeBot: if ((bid_check_dom.get('enabled', False)) and (bid_check_dom.get('bids_to_ask_delta', 0) > 0)): if self._check_depth_of_market_buy(pair, bid_check_dom): - logger.info(f'Executing Buy for {pair}.') - return self.execute_buy(pair, stake_amount) + if partial_buy.flag: + logger.info(f'Executing Partial Buy for {pair}.') + return self.execute_partial_buy(pair, partial_buy.amount) + else: + logger.info(f'Executing Buy for {pair}.') + return self.execute_buy(pair, stake_amount) else: return False - logger.info(f'Executing Buy for {pair}') - return self.execute_buy(pair, stake_amount) + if partial_buy.flag: + logger.info(f'Executing Partial Buy for {pair}') + return self.execute_partial_buy(pair, partial_buy.amount) + else: + logger.info(f'Executing Buy for {pair}') + return self.execute_buy(pair, stake_amount) else: return False @@ -704,6 +709,122 @@ class FreqtradeBot: return True + def execute_partial_buy(self, pair: str, stake_amount: float, price: Optional[float] = None) -> bool: + """ + Executes a limit buy for the given pair + :param pair: pair for which we want to create a LIMIT_BUY + :return: True if a buy order is created, false if it fails. + """ + time_in_force = self.strategy.order_time_in_force['buy'] + + if price: + buy_limit_requested = price + else: + # Calculate price + buy_limit_requested = self.get_buy_rate(pair, True) + + # in case you try to buy more than available + #stake_amount = self._safe_buy_amount(pair, stake_amount) + min_stake_amount = self._get_min_pair_stake_amount(pair, buy_limit_requested) + if min_stake_amount is not None and min_stake_amount > stake_amount: + logger.warning( + f"Can't open a new trade for {pair}: stake amount " + f"is too small ({stake_amount} < {min_stake_amount})" + ) + return False + + amount = stake_amount / buy_limit_requested + order_type = self.strategy.order_types['buy'] + + if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)( + pair=pair, order_type=order_type, amount=amount, rate=buy_limit_requested, + time_in_force=time_in_force): + logger.info(f"User requested abortion of buying {pair}") + return False + + amount = self.exchange.amount_to_precision(pair, amount) + order = self.exchange.buy(pair=pair, ordertype=order_type, + amount=amount, rate=buy_limit_requested, + time_in_force=time_in_force) + order_obj = Order.parse_from_ccxt_object(order, pair, 'buy') + order_id = order['id'] + order_status = order.get('status', None) + + # we assume the order is executed at the price requested + buy_limit_filled_price = buy_limit_requested + amount_requested = amount + + if order_status == 'expired' or order_status == 'rejected': + order_tif = self.strategy.order_time_in_force['buy'] + + # return false if the order is not filled + if float(order['filled']) == 0: + logger.warning('Buy %s order with time in force %s for %s is %s by %s.' + ' zero amount is fulfilled.', + order_tif, order_type, pair, order_status, self.exchange.name) + return False + else: + # the order is partially fulfilled + # in case of IOC orders we can check immediately + # if the order is fulfilled fully or partially + logger.warning('Buy %s order with time in force %s for %s is %s by %s.' + ' %s amount fulfilled out of %s (%s remaining which is canceled).', + order_tif, order_type, pair, order_status, self.exchange.name, + order['filled'], order['amount'], order['remaining'] + ) + stake_amount = order['cost'] + amount = safe_value_fallback(order, 'filled', 'amount') + buy_limit_filled_price = safe_value_fallback(order, 'average', 'price') + + # in case of FOK the order may be filled immediately and fully + elif order_status == 'closed': + stake_amount = order['cost'] + amount = safe_value_fallback(order, 'filled', 'amount') + buy_limit_filled_price = safe_value_fallback(order, 'average', 'price') + + # Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL + fee = self.exchange.get_fee(symbol=pair, taker_or_maker='maker') + order['fee'] = fee + + + for trade in Trade.get_open_trades(): + if trade.pair == pair: + break + if len(Trade.get_open_trades()) == 0: + trade = Trade( + pair=pair, + stake_amount=stake_amount, + amount=amount, + amount_requested=amount_requested, + fee_open=fee, + fee_close=fee, + open_rate=buy_limit_filled_price, + open_rate_requested=buy_limit_requested, + open_date=datetime.utcnow(), + exchange=_bot.exchange.id, + open_order_id=order_id, + strategy=_bot.strategy.get_strategy_name(), + timeframe=timeframe_to_minutes(_bot.config['timeframe']) + ) + trade.orders.append(order_obj) + # Update fees if order is closed + if order_status == 'closed': + self.update_trade_state(trade, order_id, order) + Trade.session.add(trade) + else: + trade.open_order_id = order_id + trade.orders.append(order_obj) + self.update_trade_state(trade, order_id, order) + + Trade.session.flush() + + # Updating wallets + self.wallets.update() + + self._notify_buy(trade, order_type) + + return True + def _notify_buy(self, trade: Trade, order_type: str) -> None: """ Sends rpc notification when a buy occured. @@ -834,7 +955,8 @@ class FreqtradeBot: logger.debug('Handling %s ...', trade) - (buy, sell) = (False, False) + (buy, sell, partial_buy, partial_sell) = (False, False, PartialTradeTuple(flag=False, amount=0), + PartialTradeTuple(flag=False, amount=0)) config_ask_strategy = self.config.get('ask_strategy', {}) @@ -843,7 +965,8 @@ class FreqtradeBot: analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair, self.strategy.timeframe) - (buy, sell) = self.strategy.get_signal(trade.pair, self.strategy.timeframe, analyzed_df) + (buy, sell, partial_buy, partial_sell) = self.strategy.get_signal(trade.pair, + self.strategy.timeframe, analyzed_df) if config_ask_strategy.get('use_order_book', False): order_book_min = config_ask_strategy.get('order_book_min', 1) @@ -868,13 +991,13 @@ class FreqtradeBot: # resulting in outdated RPC messages self._sell_rate_cache[trade.pair] = sell_rate - if self._check_and_execute_sell(trade, sell_rate, buy, sell): + if self._check_and_execute_sell(trade, sell_rate, buy, sell, partial_buy, partial_sell): return True else: logger.debug('checking sell') sell_rate = self.get_sell_rate(trade.pair, True) - if self._check_and_execute_sell(trade, sell_rate, buy, sell): + if self._check_and_execute_sell(trade, sell_rate, buy, sell, partial_buy, partial_sell): return True logger.debug('Found no sell signal for %s.', trade) @@ -939,8 +1062,8 @@ class FreqtradeBot: self.update_trade_state(trade, trade.stoploss_order_id, stoploss_order, stoploss_order=True) # Lock pair for one candle to prevent immediate rebuys - self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc), - reason='Auto lock') + self.strategy.lock_pair(trade.pair, + timeframe_to_next_date(self.config['timeframe'])) self._notify_sell(trade, "stoploss") return True @@ -1004,19 +1127,25 @@ class FreqtradeBot: f"for pair {trade.pair}.") def _check_and_execute_sell(self, trade: Trade, sell_rate: float, - buy: bool, sell: bool) -> bool: + buy: bool, sell: bool, partial_buy: PartialTradeTuple, + partial_sell: PartialTradeTuple) -> bool: """ Check and execute sell """ should_sell = self.strategy.should_sell( - trade, sell_rate, datetime.utcnow(), buy, sell, + trade, sell_rate, datetime.utcnow(), buy, sell, partial_buy, partial_sell, force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0 ) if should_sell.sell_flag: - logger.info(f'Executing Sell for {trade.pair}. Reason: {should_sell.sell_type}') - self.execute_sell(trade, sell_rate, should_sell.sell_type) + if should_sell.sell_type == SellType.PARTIAL_SELL_SIGNAL: + logger.info(f'Executing Partial Sell for {trade.pair}. Reason: {should_sell.sell_type}') + self.execute_partial_sell(trade, partial_sell.amount, sell_rate, should_sell.sell_type) + else: + logger.info(f'Executing Sell for {trade.pair}. Reason: {should_sell.sell_type}') + self.execute_sell(trade, sell_rate, should_sell.sell_type) return True + return False def _check_timed_out(self, side: str, order: dict) -> bool: @@ -1266,8 +1395,79 @@ class FreqtradeBot: Trade.session.flush() # Lock pair for one candle to prevent immediate rebuys - self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc), - reason='Auto lock') + self.strategy.lock_pair(trade.pair, timeframe_to_next_date(self.config['timeframe'])) + + self._notify_sell(trade, order_type) + + return True + + def execute_partial_sell(self, trade: Trade, amount: float, limit: float, sell_reason: SellType) -> bool: + """ + Executes a limit sell for the given trade and limit + :param trade: Trade instance + :param limit: limit rate for the sell order + :param sellreason: Reason the sell was triggered + :return: True if it succeeds (supported) False (not supported) + :OSM metodo modificado para aceptar ventas parciales + """ + #TODO: Add a custom partial sell notification call + sell_type = 'sell' + + # First cancelling stoploss on exchange ... + if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id: + try: + self.exchange.cancel_stoploss_order(trade.stoploss_order_id, trade.pair) + except InvalidOrderException: + logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}") + + order_type = self.strategy.order_types[sell_type] + if sell_reason == SellType.EMERGENCY_SELL: + # Emergency sells (default to market!) + order_type = self.strategy.order_types.get("emergencysell", "market") + + if amount > trade.amount: + amount = trade.amount + amount = self._safe_sell_amount(trade.pair, amount) + time_in_force = self.strategy.order_time_in_force['sell'] + + if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)( + pair=trade.pair, trade=trade, order_type=order_type, amount=amount, rate=limit, + time_in_force=time_in_force, + sell_reason=sell_reason.value): + logger.info(f"User requested abortion of selling {trade.pair}") + return False + + try: + # Execute sell and update trade record + order = self.exchange.sell(pair=trade.pair, + ordertype=order_type, + amount=amount, rate=limit, + time_in_force=time_in_force + ) + except InsufficientFundsError as e: + logger.warning(f"Unable to place order {e}.") + # Try to figure out what went wrong + self.handle_insufficient_funds(trade) + return False + + order_obj = Order.parse_from_ccxt_object(order, trade.pair, 'sell') + + trade.orders.append(order_obj) + + trade.open_order_id = order['id'] + trade.close_rate_requested = limit + trade.sell_reason = sell_reason.value + # In case of market sell orders the order can be closed immediately + if order.get('status', 'unknown') == 'closed': + self.update_trade_state(trade, trade.open_order_id, order) + # force trade to contine opened, if amount >= trade amount close trade + if amount < trade.amount: + trade.is_open = True + + Trade.session.flush() + + #Lock pair for one candle to prevent immediate rebuys + self.strategy.lock_pair(trade.pair, timeframe_to_next_date(self.config['timeframe'])) self._notify_sell(trade, order_type) @@ -1393,7 +1593,7 @@ class FreqtradeBot: abs_tol=constants.MATH_CLOSE_PREC): order['amount'] = new_amount order.pop('filled', None) - trade.recalc_open_trade_price() + except DependencyException as exception: logger.warning("Could not update trade amount: %s", exception) @@ -1404,7 +1604,7 @@ class FreqtradeBot: trade.update(order) # Updating wallets when order is closed - if not trade.is_open: + if order['status'] == 'closed': self.wallets.update() return False diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 7e6d967c1..5d2bf6c91 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -7,8 +7,8 @@ from decimal import Decimal from typing import Any, Dict, List, Optional import arrow -from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Integer, String, - create_engine, desc, func, inspect) +from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Integer, + String, create_engine, desc, func, inspect) from sqlalchemy.exc import NoSuchModuleError from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import Query, relationship @@ -17,20 +17,17 @@ from sqlalchemy.orm.session import sessionmaker from sqlalchemy.pool import StaticPool from sqlalchemy.sql.schema import UniqueConstraint -from freqtrade.constants import DATETIME_PRINT_FORMAT from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.misc import safe_value_fallback from freqtrade.persistence.migrations import check_migrate - logger = logging.getLogger(__name__) - _DECL_BASE: Any = declarative_base() _SQL_DOCS_URL = 'http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls' -def init_db(db_url: str, clean_open_orders: bool = False) -> None: +def init(db_url: str, clean_open_orders: bool = False) -> None: """ Initializes this module with the given config, registers all known command handlers @@ -64,9 +61,6 @@ def init_db(db_url: str, clean_open_orders: bool = False) -> None: # Copy session attributes to order object too Order.session = Trade.session Order.query = Order.session.query_property() - PairLock.session = Trade.session - PairLock.query = PairLock.session.query_property() - previous_tables = inspect(engine).get_table_names() _DECL_BASE.metadata.create_all(engine) check_migrate(engine, decl_base=_DECL_BASE, previous_tables=previous_tables) @@ -76,7 +70,7 @@ def init_db(db_url: str, clean_open_orders: bool = False) -> None: clean_dry_run_db() -def cleanup_db() -> None: +def cleanup() -> None: """ Flushes all pending operations to disk. :return: None @@ -130,6 +124,8 @@ class Order(_DECL_BASE): filled = Column(Float, nullable=True) remaining = Column(Float, nullable=True) cost = Column(Float, nullable=True) + fee = Column(Float, nullable=True) # OSM + fee_cost = Column(Float, nullable=True) # OSM order_date = Column(DateTime, nullable=True, default=datetime.utcnow) order_filled_date = Column(DateTime, nullable=True) order_update_date = Column(DateTime, nullable=True) @@ -156,6 +152,7 @@ class Order(_DECL_BASE): self.filled = order.get('filled', self.filled) self.remaining = order.get('remaining', self.remaining) self.cost = order.get('cost', self.cost) + self.fee = order.get('fee', self.fee) if 'timestamp' in order and order['timestamp'] is not None: self.order_date = datetime.fromtimestamp(order['timestamp'] / 1000, tz=timezone.utc) @@ -171,12 +168,12 @@ class Order(_DECL_BASE): """ Get all non-closed orders - useful when trying to batch-update orders """ - filtered_orders = [o for o in orders if o.order_id == order.get('id')] + filtered_orders = [o for o in orders if o.order_id == order['id']] if filtered_orders: oobj = filtered_orders[0] oobj.update_from_ccxt_object(order) else: - logger.warning(f"Did not find order for {order}.") + logger.warning(f"Did not find order for {order['id']}.") @staticmethod def parse_from_ccxt_object(order: Dict[str, Any], pair: str, side: str) -> 'Order': @@ -255,7 +252,7 @@ class Trade(_DECL_BASE): self.recalc_open_trade_price() def __repr__(self): - open_since = self.open_date.strftime(DATETIME_PRINT_FORMAT) if self.is_open else 'closed' + open_since = self.open_date.strftime('%Y-%m-%d %H:%M:%S') if self.is_open else 'closed' return (f'Trade(id={self.id}, pair={self.pair}, amount={self.amount:.8f}, ' f'open_rate={self.open_rate:.8f}, open_since={open_since})') @@ -281,7 +278,7 @@ class Trade(_DECL_BASE): 'fee_close_currency': self.fee_close_currency, 'open_date_hum': arrow.get(self.open_date).humanize(), - 'open_date': self.open_date.strftime(DATETIME_PRINT_FORMAT), + 'open_date': self.open_date.strftime("%Y-%m-%d %H:%M:%S"), 'open_timestamp': int(self.open_date.replace(tzinfo=timezone.utc).timestamp() * 1000), 'open_rate': self.open_rate, 'open_rate_requested': self.open_rate_requested, @@ -289,7 +286,7 @@ class Trade(_DECL_BASE): 'close_date_hum': (arrow.get(self.close_date).humanize() if self.close_date else None), - 'close_date': (self.close_date.strftime(DATETIME_PRINT_FORMAT) + 'close_date': (self.close_date.strftime("%Y-%m-%d %H:%M:%S") if self.close_date else None), 'close_timestamp': int(self.close_date.replace( tzinfo=timezone.utc).timestamp() * 1000) if self.close_date else None, @@ -305,7 +302,7 @@ class Trade(_DECL_BASE): 'stop_loss_ratio': self.stop_loss_pct if self.stop_loss_pct else None, 'stop_loss_pct': (self.stop_loss_pct * 100) if self.stop_loss_pct else None, 'stoploss_order_id': self.stoploss_order_id, - 'stoploss_last_update': (self.stoploss_last_update.strftime(DATETIME_PRINT_FORMAT) + 'stoploss_last_update': (self.stoploss_last_update.strftime("%Y-%m-%d %H:%M:%S") if self.stoploss_last_update else None), 'stoploss_last_update_timestamp': int(self.stoploss_last_update.replace( tzinfo=timezone.utc).timestamp() * 1000) if self.stoploss_last_update else None, @@ -382,6 +379,10 @@ class Trade(_DECL_BASE): return logger.info('Updating trade (id=%s) ...', self.id) + # to be able to partially buy or sell + if order['amount'] < self.amount: + self.partial_update(order) + return if order_type in ('market', 'limit') and order['side'] == 'buy': # Update open rate and actual amount @@ -403,7 +404,45 @@ class Trade(_DECL_BASE): self.close(order['average']) else: raise ValueError(f'Unknown order type: {order_type}') - cleanup_db() + cleanup() + + def partial_update(self, order: Dict) -> None: + """ + Updates this entity with amount and actual open/close rates, + modified to support multiple orders keeping the trade opened + :param order: order retrieved by exchange.fetch_order() + :return: None + + """ + order_type = order['type'] + + if order_type in ('market', 'limit') and order['side'] == 'buy': + # Update open rate and actual amount + self.open_rate = self.average_open_rate(order['filled'], + safe_value_fallback(order, 'average', 'price'), + self.amount, self.open_rate) + self.amount = Decimal(self.amount or 0) + Decimal(order['filled']) + self.decrease_wallet(self, Decimal(order['filled']), self.open_rate) + if self.is_open and order['filled'] != 0: + logger.info(f'{order_type.upper()}_Partial BUY has been fulfilled for {self}.') + self.open_order_id = None + + elif order_type in ('market', 'limit') and order['side'] == 'sell': + self.amount = (Decimal(self.amount or 0) - Decimal(order['filled'])) + if self.is_open and order['filled'] != 0: + logger.info(f'{order_type.upper()}_Partial SELL has been fulfilled for {self}.') + self.partial_close(self, safe_value_fallback(order, 'average', 'price')) + self.increase_wallet(self, Decimal(order['filled']), order['price']) + + elif order_type in ('stop_loss_limit', 'stop-loss', 'stop'): + self.stoploss_order_id = None + self.close_rate_requested = self.stop_loss + if self.is_open: + logger.info(f'{order_type.upper()} is hit for {self}.') + self.close(order['average']) + else: + raise ValueError(f'Unknown order type: {order_type}') + cleanup() def close(self, rate: float) -> None: """ @@ -422,6 +461,17 @@ class Trade(_DECL_BASE): self ) + def partial_close(self, rate: float) -> None: + """ modified close() to keep trade opened + """ + self.is_open = True + self.sell_order_status = 'closed' + self.open_order_id = None + logger.info( + 'Updated position %s,', + self + ) + def update_fee(self, fee_cost: float, fee_currency: Optional[str], fee_rate: Optional[float], side: str) -> None: """ @@ -499,6 +549,8 @@ class Trade(_DECL_BASE): fee: Optional[float] = None) -> float: """ Calculate the absolute profit in stake currency between Close and Open trade + Modified to stop using open_tride_price and use open_rate instead, + which be actualized if partial buys. :param fee: fee to use on the close rate (optional). If rate is not set self.fee will be used :param rate: close rate to compare with (optional). @@ -509,13 +561,15 @@ class Trade(_DECL_BASE): rate=(rate or self.close_rate), fee=(fee or self.fee_close) ) - profit = close_trade_price - self.open_trade_price + profit = close_trade_price - self.open_rate * self.amount return float(f"{profit:.8f}") def calc_profit_ratio(self, rate: Optional[float] = None, fee: Optional[float] = None) -> float: """ Calculates the profit as ratio (including fee). + Modified to stop using open_tride_price and use open_rate instead, + which be actualized if partial buys. :param rate: rate to compare with (optional). If rate is not set self.close_rate will be used :param fee: fee to use on the close rate (optional). @@ -525,7 +579,7 @@ class Trade(_DECL_BASE): rate=(rate or self.close_rate), fee=(fee or self.fee_close) ) - profit_ratio = (close_trade_price / self.open_trade_price) - 1 + profit_ratio = (close_trade_price / (self.open_rate * self.amount)) - 1 return float(f"{profit_ratio:.8f}") def select_order(self, order_side: str, is_open: Optional[bool]) -> Optional[Order]: @@ -543,6 +597,32 @@ class Trade(_DECL_BASE): else: return None + def average_open_rate(self, order_amount, order_price, trade_amount, trade_open_rate): + """ + Calculates average entry price when increase an open position with a partial buy + :param order_amount: Amount in base coin to buy + :param order_price: rate at the time of order buy + :trade_amount: Actual amount of the open position. (befor adding new order amount) + :trade_open_rate: Actual open price of position. + :return: New open rate modified with the order data. + """ + #((order['amount'] * order['price']) + (self.amount * trade.open_rate)) / (order['amount'] + trade.amount) + return ((order_amount * order_price) + (trade_amount * trade_open_rate)) / (order_amount + trade_amount) + + def increase_wallet(self, amount: float, rate: float) -> None: + + sell_trade = Decimal(amount) * Decimal(rate) + fees = sell_trade * Decimal(self.fee_close) + close_trade_price = float(sell_trade - fees) + self.stake_amount = Decimal(self.stake_amount or 0) + Decimal(close_trade_price) + + def decrease_wallet(self, amount: float, rate: float) -> None: + + buy_trade = Decimal(amount * rate) + fees = buy_trade * Decimal(self.fee_open) + open_trade_price = float(buy_trade + fees) + self.stake_amount = Decimal(self.stake_amount or 0) - Decimal(open_trade_price) + @staticmethod def get_trades(trade_filter=None) -> Query: """ @@ -600,8 +680,8 @@ class Trade(_DECL_BASE): Calculates total invested amount in open trades in stake currency """ - total_open_stake_amount = Trade.session.query(func.sum(Trade.stake_amount))\ - .filter(Trade.is_open.is_(True))\ + total_open_stake_amount = Trade.session.query(func.sum(Trade.stake_amount)) \ + .filter(Trade.is_open.is_(True)) \ .scalar() return total_open_stake_amount or 0 @@ -614,7 +694,7 @@ class Trade(_DECL_BASE): Trade.pair, func.sum(Trade.close_profit).label('profit_sum'), func.count(Trade.pair).label('count') - ).filter(Trade.is_open.is_(False))\ + ).filter(Trade.is_open.is_(False)) \ .group_by(Trade.pair) \ .order_by(desc('profit_sum')) \ .all() @@ -650,7 +730,7 @@ class Trade(_DECL_BASE): # skip case if trailing-stop changed the stoploss already. if (trade.stop_loss == trade.initial_stop_loss - and trade.initial_stop_loss_pct != desired_stoploss): + and trade.initial_stop_loss_pct != desired_stoploss): # Stoploss value got changed logger.info(f"Stoploss for {trade} needs adjustment...") @@ -658,56 +738,3 @@ class Trade(_DECL_BASE): trade.stop_loss = None trade.adjust_stop_loss(trade.open_rate, desired_stoploss) logger.info(f"New stoploss: {trade.stop_loss}.") - - -class PairLock(_DECL_BASE): - """ - Pair Locks database model. - """ - __tablename__ = 'pairlocks' - - id = Column(Integer, primary_key=True) - - pair = Column(String, nullable=False, index=True) - reason = Column(String, nullable=True) - # Time the pair was locked (start time) - lock_time = Column(DateTime, nullable=False) - # Time until the pair is locked (end time) - lock_end_time = Column(DateTime, nullable=False, index=True) - - active = Column(Boolean, nullable=False, default=True, index=True) - - def __repr__(self): - lock_time = self.lock_time.strftime(DATETIME_PRINT_FORMAT) - lock_end_time = self.lock_end_time.strftime(DATETIME_PRINT_FORMAT) - return (f'PairLock(id={self.id}, pair={self.pair}, lock_time={lock_time}, ' - f'lock_end_time={lock_end_time})') - - @staticmethod - def query_pair_locks(pair: Optional[str], now: datetime) -> Query: - """ - Get all locks for this pair - :param pair: Pair to check for. Returns all current locks if pair is empty - :param now: Datetime object (generated via datetime.now(timezone.utc)). - """ - - filters = [PairLock.lock_end_time > now, - # Only active locks - PairLock.active.is_(True), ] - if pair: - filters.append(PairLock.pair == pair) - return PairLock.query.filter( - *filters - ) - - def to_json(self) -> Dict[str, Any]: - return { - 'pair': self.pair, - 'lock_time': self.lock_time.strftime(DATETIME_PRINT_FORMAT), - 'lock_timestamp': int(self.lock_time.replace(tzinfo=timezone.utc).timestamp() * 1000), - 'lock_end_time': self.lock_end_time.strftime(DATETIME_PRINT_FORMAT), - 'lock_end_timestamp': int(self.lock_end_time.replace(tzinfo=timezone.utc - ).timestamp() * 1000), - 'reason': self.reason, - 'active': self.active, - } diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 1c6aa535d..a1a4be2c3 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -17,11 +17,10 @@ from freqtrade.data.dataprovider import DataProvider from freqtrade.exceptions import OperationalException, StrategyError from freqtrade.exchange import timeframe_to_minutes from freqtrade.exchange.exchange import timeframe_to_next_date -from freqtrade.persistence import PairLocks, Trade +from freqtrade.persistence import Trade from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper from freqtrade.wallets import Wallets - logger = logging.getLogger(__name__) @@ -31,6 +30,8 @@ class SignalType(Enum): """ BUY = "buy" SELL = "sell" + PARTIAL_BUY = "partial_buy" + PARTIAL_SELL = "partial_sell" class SellType(Enum): @@ -42,6 +43,7 @@ class SellType(Enum): STOPLOSS_ON_EXCHANGE = "stoploss_on_exchange" TRAILING_STOP_LOSS = "trailing_stop_loss" SELL_SIGNAL = "sell_signal" + PARTIAL_SELL_SIGNAL = "partial_sell_signal" FORCE_SELL = "force_sell" EMERGENCY_SELL = "emergency_sell" NONE = "" @@ -58,6 +60,13 @@ class SellCheckTuple(NamedTuple): sell_flag: bool sell_type: SellType +class PartialTradeTuple(NamedTuple): + """ + NamedTuple for partial trade + amount + """ + flag: bool + amount: float + class IStrategy(ABC): """ @@ -101,6 +110,8 @@ class IStrategy(ABC): 'stoploss': 'limit', 'stoploss_on_exchange': False, 'stoploss_on_exchange_interval': 60, + 'partial_buy': 'limit', + 'partial_sell': 'limit', } # Optional time in force @@ -123,8 +134,6 @@ class IStrategy(ABC): # and wallets - access to the current balance. dp: Optional[DataProvider] = None wallets: Optional[Wallets] = None - # container variable for strategy source code - __source__: str = '' # Definition of plot_config. See plotting documentation for more details. plot_config: Dict = {} @@ -133,6 +142,7 @@ class IStrategy(ABC): self.config = config # Dict to determine if analysis is necessary self._last_candle_seen_per_pair: Dict[str, datetime] = {} + self._pair_locked_until: Dict[str, datetime] = {} @abstractmethod def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: @@ -277,7 +287,7 @@ class IStrategy(ABC): """ return self.__class__.__name__ - def lock_pair(self, pair: str, until: datetime, reason: str = None) -> None: + def lock_pair(self, pair: str, until: datetime) -> None: """ Locks pair until a given timestamp happens. Locked pairs are not analyzed, and are prevented from opening new trades. @@ -286,9 +296,9 @@ class IStrategy(ABC): :param pair: Pair to lock :param until: datetime in UTC until the pair should be blocked from opening new trades. Needs to be timezone aware `datetime.now(timezone.utc)` - :param reason: Optional string explaining why the pair was locked. """ - PairLocks.lock_pair(pair, until, reason) + if pair not in self._pair_locked_until or self._pair_locked_until[pair] < until: + self._pair_locked_until[pair] = until def unlock_pair(self, pair: str) -> None: """ @@ -297,7 +307,8 @@ class IStrategy(ABC): manually from within the strategy, to allow an easy way to unlock pairs. :param pair: Unlock pair to allow trading again """ - PairLocks.unlock_pair(pair, datetime.now(timezone.utc)) + if pair in self._pair_locked_until: + del self._pair_locked_until[pair] def is_pair_locked(self, pair: str, candle_date: datetime = None) -> bool: """ @@ -309,13 +320,15 @@ class IStrategy(ABC): :param candle_date: Date of the last candle. Optional, defaults to current date :returns: locking state of the pair in question. """ - + if pair not in self._pair_locked_until: + return False if not candle_date: - # Simple call ... - return PairLocks.is_pair_locked(pair, candle_date) + return self._pair_locked_until[pair] >= datetime.now(timezone.utc) else: + # Locking should happen until a new candle arrives lock_time = timeframe_to_next_date(self.timeframe, candle_date) - return PairLocks.is_pair_locked(pair, lock_time) + # lock_time = candle_date + timedelta(minutes=timeframe_to_minutes(self.timeframe)) + return self._pair_locked_until[pair] > lock_time def analyze_ticker(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ @@ -422,18 +435,19 @@ class IStrategy(ABC): else: raise StrategyError(f"Dataframe returned from strategy has mismatching {message}.") - def get_signal(self, pair: str, timeframe: str, dataframe: DataFrame) -> Tuple[bool, bool]: + def get_signal(self, pair: str, timeframe: str, dataframe: DataFrame) \ + -> Tuple[bool, bool, PartialTradeTuple, PartialTradeTuple]: """ - Calculates current signal based based on the buy / sell columns of the dataframe. + Calculates current signal based based on the buy / sell / partial_buy / partial_sell columns of the dataframe. Used by Bot to get the signal to buy or sell :param pair: pair in format ANT/BTC :param timeframe: timeframe to use :param dataframe: Analyzed dataframe to get signal from. - :return: (Buy, Sell) A bool-tuple indicating buy/sell signal + :return: (Buy, Sell,partial_buy, partial_sell) A bool-tuple indicating buy/sell signal """ if not isinstance(dataframe, DataFrame) or dataframe.empty: logger.warning(f'Empty candle (OHLCV) data for pair {pair}') - return False, False + return False, False, PartialTradeTuple(False,0), PartialTradeTuple(False,0) latest_date = dataframe['date'].max() latest = dataframe.loc[dataframe['date'] == latest_date].iloc[-1] @@ -448,19 +462,22 @@ class IStrategy(ABC): 'Outdated history for pair %s. Last tick is %s minutes old', pair, int((arrow.utcnow() - latest_date).total_seconds() // 60) ) - return False, False + return False, False, PartialTradeTuple(False,0), PartialTradeTuple(False,0) - (buy, sell) = latest[SignalType.BUY.value] == 1, latest[SignalType.SELL.value] == 1 - logger.debug('trigger: %s (pair=%s) buy=%s sell=%s', - latest['date'], pair, str(buy), str(sell)) - return buy, sell + (buy, sell, partial_buy, partial_sell) = \ + latest[SignalType.BUY.value] == 1, latest[SignalType.SELL.value] == 1,\ + latest[SignalType.PARTIAL_BUY] == 1, latest[SignalType.PARTIAL_SELL == 1] + logger.debug('trigger: %s (pair=%s) buy=%s sell=%s partial_buy = %s partial_sell = %s', + latest['date'], pair, str(buy), str(sell), str(partial_buy), str(partial_sell)) + return buy, sell, partial_buy, partial_sell def should_sell(self, trade: Trade, rate: float, date: datetime, buy: bool, - sell: bool, low: float = None, high: float = None, - force_stoploss: float = 0) -> SellCheckTuple: + sell: bool, partial_buy: PartialTradeTuple, partial_sell: PartialTradeTuple, + low: float = None, high: float = None, force_stoploss: float = 0) -> SellCheckTuple: """ This function evaluates if one of the conditions required to trigger a sell has been reached, which can either be a stop-loss, ROI or sell-signal. + Modified to support partial trades :param low: Only used during backtesting to simulate stoploss :param high: Only used during backtesting, to simulate ROI :param force_stoploss: Externally provided stoploss @@ -510,6 +527,11 @@ class IStrategy(ABC): f"sell_type=SellType.SELL_SIGNAL") return SellCheckTuple(sell_flag=True, sell_type=SellType.SELL_SIGNAL) + if partial_sell.flag and not partial_buy.flag and not buy and config_ask_strategy.get('use_sell_signal', True): + logger.debug(f"{trade.pair} - Partial Sell signal received. partial_sell_flag=True, " + f"sell_type=SellType.PARTIAL_SELL_SIGNAL") + return SellCheckTuple(sell_flag=True, sell_type=SellType.PARTIAL_SELL_SIGNAL) + # This one is noisy, commented out... # logger.debug(f"{trade.pair} - No sell signal. sell_flag=False") return SellCheckTuple(sell_flag=False, sell_type=SellType.NONE) diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 21a9466e1..cad75bef3 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -10,7 +10,6 @@ import arrow from freqtrade.exchange import Exchange from freqtrade.persistence import Trade - logger = logging.getLogger(__name__) @@ -56,6 +55,7 @@ class Wallets: def _update_dry(self) -> None: """ Update from database in dry-run mode + - Modified to support partial trades considering trade.stake_amount as wallet value (Beta) - Apply apply profits of closed trades on top of stake amount - Subtract currently tied up stake_amount in open trades - update balances for currencies currently in trades @@ -65,16 +65,15 @@ class Wallets: closed_trades = Trade.get_trades(Trade.is_open.is_(False)).all() open_trades = Trade.get_trades(Trade.is_open.is_(True)).all() tot_profit = sum([trade.calc_profit() for trade in closed_trades]) - tot_in_trades = sum([trade.stake_amount for trade in open_trades]) - current_stake = self.start_cap + tot_profit - tot_in_trades + + current_stake = self.start_cap + tot_profit _wallets[self._config['stake_currency']] = Wallet( self._config['stake_currency'], current_stake, 0, current_stake ) - for trade in open_trades: curr = self._exchange.get_pair_base_currency(trade.pair) _wallets[curr] = Wallet( @@ -83,8 +82,16 @@ class Wallets: 0, trade.amount ) + current_stake += trade.stake_amount + _wallets[self._config['stake_currency']] = Wallet( + self._config['stake_currency'], + current_stake, + 0, + current_stake + ) self._wallets = _wallets + def _update_live(self) -> None: balances = self._exchange.get_balances()