stable/freqtrade/persistence.py

326 lines
12 KiB
Python
Raw Normal View History

2018-03-02 15:22:00 +00:00
"""
This module contains the class to persist trades into SQLite
"""
import logging
2017-05-12 17:11:56 +00:00
from datetime import datetime
2017-11-01 02:27:08 +00:00
from decimal import Decimal, getcontext
2018-01-10 07:51:36 +00:00
from typing import Dict, Optional
2017-05-12 17:11:56 +00:00
import arrow
2018-01-10 07:51:36 +00:00
from sqlalchemy import (Boolean, Column, DateTime, Float, Integer, String,
create_engine)
2017-11-09 22:45:22 +00:00
from sqlalchemy.engine import Engine
2017-05-12 17:11:56 +00:00
from sqlalchemy.ext.declarative import declarative_base
2017-09-03 06:50:48 +00:00
from sqlalchemy.orm.scoping import scoped_session
from sqlalchemy.orm.session import sessionmaker
2017-11-09 22:45:22 +00:00
from sqlalchemy.pool import StaticPool
2018-05-06 07:09:53 +00:00
from sqlalchemy import inspect
2017-05-12 17:11:56 +00:00
logger = logging.getLogger(__name__)
2017-09-08 21:10:22 +00:00
_CONF = {}
2017-11-07 19:13:36 +00:00
_DECL_BASE = declarative_base()
2017-05-12 17:11:56 +00:00
2017-11-09 22:45:22 +00:00
def init(config: dict, engine: Optional[Engine] = None) -> None:
"""
Initializes this module with the given config,
registers all known command handlers
and starts polling for message updates
:param config: config to use
2017-11-09 22:45:22 +00:00
:param engine: database engine for sqlalchemy (Optional)
:return: None
"""
2017-09-08 21:10:22 +00:00
_CONF.update(config)
2017-11-09 22:45:22 +00:00
if not engine:
2017-09-08 21:10:22 +00:00
if _CONF.get('dry_run', False):
# the user wants dry run to use a DB
if _CONF.get('dry_run_db', False):
engine = create_engine('sqlite:///tradesv3.dry_run.sqlite')
# Otherwise dry run will store in memory
else:
engine = create_engine('sqlite://',
connect_args={'check_same_thread': False},
poolclass=StaticPool,
echo=False)
2017-09-08 19:39:31 +00:00
else:
2017-11-09 22:45:22 +00:00
engine = create_engine('sqlite:///tradesv3.sqlite')
session = scoped_session(sessionmaker(bind=engine, autoflush=True, autocommit=True))
Trade.session = session()
Trade.query = session.query_property()
2017-11-07 19:13:36 +00:00
_DECL_BASE.metadata.create_all(engine)
2018-05-06 07:09:53 +00:00
check_migrate(engine)
# Clean dry_run DB
if _CONF.get('dry_run', False) and _CONF.get('dry_run_db', False):
clean_dry_run_db()
2018-05-06 07:09:53 +00:00
def has_column(columns, searchname: str) -> bool:
return len(list(filter(lambda x: x["name"] == searchname, columns))) == 1
def check_migrate(engine) -> None:
"""
Checks if migration is necessary and migrates if necessary
"""
inspector = inspect(engine)
cols = inspector.get_columns('trades')
if not has_column(cols, 'fee_open'):
# Schema migration necessary
2018-05-12 08:29:10 +00:00
engine.execute("alter table trades rename to trades_bak")
2018-05-06 07:09:53 +00:00
# let SQLAlchemy create the schema as required
_DECL_BASE.metadata.create_all(engine)
# Copy data back - following the correct schema
engine.execute("""insert into trades
(id, exchange, pair, is_open, fee_open, fee_close, open_rate,
open_rate_requested, close_rate, close_rate_requested, close_profit,
stake_amount, amount, open_date, close_date, open_order_id)
2018-05-12 11:39:16 +00:00
select id, lower(exchange),
2018-05-12 11:37:42 +00:00
case
when instr(pair, '_') != 0 then
substr(pair, instr(pair, '_') + 1) || '/' ||
substr(pair, 1, instr(pair, '_') - 1)
else pair
end
pair,
is_open, fee fee_open, fee fee_close,
2018-05-06 07:09:53 +00:00
open_rate, null open_rate_requested, close_rate,
null close_rate_requested, close_profit,
stake_amount, amount, open_date, close_date, open_order_id
from trades_bak
""")
2018-05-12 08:04:41 +00:00
# Reread columns - the above recreated the table!
inspector = inspect(engine)
cols = inspector.get_columns('trades')
2018-05-06 07:09:53 +00:00
if not has_column(cols, 'open_rate_requested'):
engine.execute("alter table trades add open_rate_requested float")
if not has_column(cols, 'close_rate_requested'):
engine.execute("alter table trades add close_rate_requested float")
def cleanup() -> None:
"""
Flushes all pending operations to disk.
:return: None
"""
Trade.session.flush()
def clean_dry_run_db() -> None:
"""
Remove open_order_id from a Dry_run DB
:return: None
"""
for trade in Trade.query.filter(Trade.open_order_id.isnot(None)).all():
# Check we are updating only a dry_run order not a prod one
if 'dry_run' in trade.open_order_id:
trade.open_order_id = None
2017-11-07 19:13:36 +00:00
class Trade(_DECL_BASE):
2018-03-02 15:22:00 +00:00
"""
Class used to define a trade structure
"""
2017-05-12 17:11:56 +00:00
__tablename__ = 'trades'
id = Column(Integer, primary_key=True)
2017-10-06 10:22:04 +00:00
exchange = Column(String, nullable=False)
2017-05-12 17:11:56 +00:00
pair = Column(String, nullable=False)
is_open = Column(Boolean, nullable=False, default=True)
fee_open = Column(Float, nullable=False, default=0.0)
fee_close = Column(Float, nullable=False, default=0.0)
open_rate = Column(Float)
open_rate_requested = Column(Float)
2017-05-12 17:11:56 +00:00
close_rate = Column(Float)
close_rate_requested = Column(Float)
2017-05-12 17:11:56 +00:00
close_profit = Column(Float)
stake_amount = Column(Float, nullable=False)
amount = Column(Float)
2017-05-12 17:11:56 +00:00
open_date = Column(DateTime, nullable=False, default=datetime.utcnow)
close_date = Column(DateTime)
2017-05-14 12:14:16 +00:00
open_order_id = Column(String)
# absolute value of the stop loss
stop_loss = Column(Float, nullable=False, default=0.0)
# absolute value of the initial stop loss
initial_stop_loss = Column(Float, nullable=False, default=0.0)
# absolute value of the highest reached price
max_rate = Column(Float, nullable=False, default=0.0)
2017-05-12 17:11:56 +00:00
def __repr__(self):
return 'Trade(id={}, pair={}, amount={:.8f}, open_rate={:.8f}, open_since={})'.format(
2017-05-12 17:11:56 +00:00
self.id,
self.pair,
self.amount,
self.open_rate,
arrow.get(self.open_date).humanize() if self.is_open else 'closed'
2017-05-12 17:11:56 +00:00
)
def adjust_stop_loss(self, current_price, stoploss):
"""
this adjusts the stop loss to it's most recently observed
setting
:param current_price:
:param stoploss:
:return:
"""
new_loss = Decimal(current_price * (1 - abs(stoploss)))
2018-05-10 23:50:04 +00:00
# keeping track of the highest observed rate for this trade
if self.max_rate is None:
self.max_rate = current_price
else:
if current_price > self.max_rate:
self.max_rate = current_price
# no stop loss assigned yet
if self.stop_loss is None or self.stop_loss == 0:
logger.debug("assigning new stop loss")
self.stop_loss = new_loss
self.initial_stop_loss = new_loss
# evaluate if the stop loss needs to be updated
else:
2018-05-10 23:50:04 +00:00
if new_loss > self.stop_loss: # stop losses only walk up, never down!
self.stop_loss = new_loss
logger.debug("adjusted stop loss")
else:
2018-05-10 23:50:04 +00:00
logger.debug("keeping current stop loss")
logger.debug(
"{} - current price {:.8f}, bought at {:.8f} and calculated "
"stop loss is at: {:.8f} initial stop at {:.8f}. trailing stop loss saved us: {:.8f} "
"and max observed rate was {:.8f}".format(
self.pair, current_price, self.open_rate,
self.initial_stop_loss,
self.stop_loss, float(self.stop_loss) - float(self.initial_stop_loss),
self.max_rate
))
def update(self, order: Dict) -> None:
2017-06-08 18:01:01 +00:00
"""
Updates this entity with amount and actual open/close rates.
:param order: order retrieved by exchange.get_order()
:return: None
2017-06-08 18:01:01 +00:00
"""
# Ignore open and cancelled orders
if order['status'] == 'open' or order['price'] is None:
return
logger.info('Updating trade (id=%d) ...', self.id)
2017-12-17 21:07:56 +00:00
getcontext().prec = 8 # Bittrex do not go above 8 decimal
if order['type'] == 'limit' and order['side'] == 'buy':
# Update open rate and actual amount
self.open_rate = Decimal(order['price'])
2017-12-17 21:07:56 +00:00
self.amount = Decimal(order['amount'])
logger.info('LIMIT_BUY has been fulfilled for %s.', self)
self.open_order_id = None
elif order['type'] == 'limit' and order['side'] == 'sell':
self.close(order['price'])
else:
raise ValueError('Unknown order type: {}'.format(order['type']))
2017-12-27 10:41:11 +00:00
cleanup()
2017-06-08 18:01:01 +00:00
def close(self, rate: float) -> None:
"""
Sets close_rate to the given rate, calculates total profit
and marks trade as closed
"""
2017-12-17 21:07:56 +00:00
self.close_rate = Decimal(rate)
self.close_profit = self.calc_profit_percent()
self.close_date = datetime.utcnow()
self.is_open = False
self.open_order_id = None
logger.info(
'Marking %s as closed as the trade is fulfilled and found no open orders for it.',
self
)
def calc_open_trade_price(
self,
fee: Optional[float] = None) -> float:
2017-12-17 21:07:56 +00:00
"""
Calculate the open_rate in BTC
:param fee: fee to use on the open rate (optional).
If rate is not set self.fee will be used
:return: Price in BTC of the open trade
"""
getcontext().prec = 8
buy_trade = (Decimal(self.amount) * Decimal(self.open_rate))
fees = buy_trade * Decimal(fee or self.fee_open)
2017-12-17 21:07:56 +00:00
return float(buy_trade + fees)
def calc_close_trade_price(
self,
rate: Optional[float] = None,
fee: Optional[float] = None) -> float:
2017-12-17 21:07:56 +00:00
"""
Calculate the close_rate in BTC
:param fee: fee to use on the close rate (optional).
If rate is not set self.fee will be used
:param rate: rate to compare with (optional).
If rate is not set self.close_rate will be used
:return: Price in BTC of the open trade
"""
getcontext().prec = 8
if rate is None and not self.close_rate:
return 0.0
sell_trade = (Decimal(self.amount) * Decimal(rate or self.close_rate))
fees = sell_trade * Decimal(fee or self.fee_close)
2017-12-17 21:07:56 +00:00
return float(sell_trade - fees)
def calc_profit(
self,
rate: Optional[float] = None,
fee: Optional[float] = None) -> float:
2017-12-17 21:07:56 +00:00
"""
Calculate the profit in BTC between Close and Open trade
: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).
If rate is not set self.close_rate will be used
:return: profit in BTC as float
"""
open_trade_price = self.calc_open_trade_price()
close_trade_price = self.calc_close_trade_price(
rate=(rate or self.close_rate),
fee=(fee or self.fee_close)
2017-12-17 21:07:56 +00:00
)
return float("{0:.8f}".format(close_trade_price - open_trade_price))
def calc_profit_percent(
self,
rate: Optional[float] = None,
fee: Optional[float] = None) -> float:
"""
Calculates the profit in percentage (including fee).
:param rate: rate to compare with (optional).
If rate is not set self.close_rate will be used
2018-03-17 21:12:21 +00:00
:param fee: fee to use on the close rate (optional).
:return: profit in percentage as float
"""
2017-11-01 01:51:10 +00:00
getcontext().prec = 8
2017-12-17 21:07:56 +00:00
open_trade_price = self.calc_open_trade_price()
close_trade_price = self.calc_close_trade_price(
rate=(rate or self.close_rate),
fee=(fee or self.fee_close)
2017-12-17 21:07:56 +00:00
)
return float("{0:.8f}".format((close_trade_price / open_trade_price) - 1))