from abc import ABC, abstractmethod
import datetime as dt
import logging
import pandas as pd
from decimal import Decimal
import time
from apscheduler.schedulers.blocking import BlockingScheduler
from sqlalchemy import exc
from . import datafeed
from . import utils
from . import order
from .database import Session
from .database.asset import Asset
from .database.instrument import Instrument
from .database.timetable import Timetable
from .database.broker import Broker
from .database.ts import Ts
from .database.ptf_allocation import PtfAllocation
[docs]class TradingSystem():
"""
Basic class for every trading system.
The run method will start the live-trading.
Attributes:
name (str): The name of the trading system.
portfolio (alchemist_lib.portfolio.*): An istance of a portfolio class.
broker (alchemist_lib.broker.*): An instance of a module in alchemist_lib.broker package.
_set_weights (callable): The function to set the weights of every asset in the portfolio.
_select_universe (callable): The function to select the universe of asset.
_handle_data (callable): The function to manage the trading logic.
paper_trading (boolean): If this arg is True no orders will be executed, they will be just printed and saved.
rebalance_time (int): Autoincrement number, used to manage the frequency of rebalancing.
session (sqlalchemy.orm.session.Session): Connection to the database.
"""
[docs] def __init__(self, name, portfolio, set_weights, select_universe, handle_data, broker, paper_trading = False):
"""
Costructor method.
After setting the attributes it will register the trading system in the database.
Args:
name (str): Name of the trading system.
portfolio (alchemist_lib.portfolio.*): An istance of a portfolio class.
broker (alchemist_lib.broker.*): An instance of a module in alchemist_lib.broker package.
set_weights (callable): The function to set the weights of every asset in the portfolio.
select_universe (callable): The function to select the universe of asset.
handle_data (callable): The function to manage the trading logic.
paper_trading (boolean, optional): Specify if the trading system has to execute orders or just simulate.
"""
assert isinstance(name, str), "The name of the trading system must be a string (str)."
#https://docs.python.org/3/library/logging.handlers.html
file_handler = logging.FileHandler(filename = "{}.log".format(name), mode = "w")
#console_handler = logging.StreamHandler()
#console_handler.setLevel(level = logging.INFO)
#console_handler.setFormatter(logging.Formatter('%(asctime)s : %(message)s'))
logging.basicConfig(handlers = [
file_handler,
#console_handler,
],
level = logging.DEBUG,
format = '%(asctime)s - %(name)s - %(levelname)s : %(message)s',
datefmt = '%m/%d/%Y %I:%M:%S')
logging.Formatter.converter = time.gmtime
logging.info("Started.")
print(utils.now(), ": Started.")
self.name = name
self.portfolio = portfolio
self.broker = broker
self._set_weights = set_weights
self._select_universe = select_universe
self._handle_data = handle_data
self.paper_trading = paper_trading
self.rebalance_time = 0
self.session = Session()
self.broker.set_session(session = self.session)
ts = Ts(ts_name = self.name,
datetime_added = dt.datetime.utcnow(),
aum = self.portfolio.capital,
ptf_type = type(self.portfolio).__name__
)
logging.debug("Saving the trading system {}.".format(self.name))
try:
self.session.add(ts)
self.session.commit()
except exc.IntegrityError:
self.session.rollback()
logging.warning("Trading system already present.")
print(utils.now(), ": Trading system already present.")
self.session.query(Ts).filter(Ts.ts_name == self.name).update({"datetime_added" : ts.datetime_added,
"ptf_type" : ts.ptf_type
})
self.session.commit()
[docs] def set_weights(self, df):
"""
Call the _set_weights callable attribute if it's not None.
Args:
df (pandas.DataFrame): The alpha dataframe setted in handle_data().
Returns:
weights (pandas.DataFrame): Return a dataframe with a weight for every asset. Empty if _set_weights is None.
"""
if self._set_weights == None:
loggin.warning("_set_weights is None.")
print(utils.now(), ": _set_weights is None.")
return pd.DataFrame(columns = ["asset", "alpha", "weight"]).set_index("asset")
weights = self._set_weights(df = df)
if isinstance(weights, pd.DataFrame) == False:
raise Exception("The set_weight function must return a pandas.DataFrame!")
if "weight" not in weights.columns:
raise Exception("The set_weight function must have a column called 'weight'!")
weights_sum = weights["weight"].sum()
if weights_sum < 0.9 or weights_sum > 1.1:
raise Exception("The sum of the weights returned by the set_weights function must be near 1.0!")
return weights
[docs] def on_market_open(self, timeframe, frequency):
"""
Save new data and call the rebalance function.
Args:
timeframe (str): The timeframe we want to collect informations about for every asset in the universe.
frequency (int): Frequency of rebalancing.
"""
start_time = time.time()
datafeed.save_last_ohlcv(session = self.session, assets = self.select_universe(), timeframe = timeframe)
end_time = time.time()
delta_time = round(end_time - start_time, 2)
logging.info("Last OHLCV data retrived in {} seconds.".format(delta_time))
print(utils.now(), ": Last OHLCV data retrived in {} seconds.".format(delta_time))
self.rebalance(alphas = self.handle_data(), orders_type = order.MARKET, frequency = frequency)
[docs] def select_universe(self):
"""
Call the _select_universe callable attribute if it's not None.
Returns:
universe (list[alchemist_lib.database.Asset.asset]): Return a list of assets.
"""
if self._select_universe == None:
logging.warning("_select_universe is None.")
print(utils.now(), ": _select_universe is None.")
return []
universe = self._select_universe(session = self.session)
if isinstance(universe, list) == False:
raise Exception("The select_universe function must returns a list!")
if len(universe) <= 0:
raise Exception("The select_universe must not returns an empty list!")
for asset in universe:
if isinstance(asset, Asset) == False:
raise Exception("The select_universe function has returned a list composed by somthing that is not an Asset!")
return utils.to_list(universe)
[docs] def handle_data(self):
"""
Call the _handle_data callable attribute if it's not None.
Returns:
data (pandas.DataFrame): Return a dataframe with an alpha value for every asset. Empty if _handle_data is None.
"""
if self._handle_data == None:
logging.warning("_handle_data is None.")
print(utils.now(), ": _handle_data is None.")
return pd.DataFrame(columns = ["asset", "alpha"]).set_index("alpha")
start_time = time.time()
data = self._handle_data(session = self.session, universe = self.select_universe())
if isinstance(data, pd.DataFrame) == False:
raise Exception("The handle_data function must return a pandas.DataFrame!")
if "asset" not in list(data.index.names):
raise Exception("The set_weight function must have the index called 'asset'!")
if "alpha" not in data.columns:
raise Exception("The set_weight function must have a column called 'alpha'!")
data.dropna(inplace = True)
end_time = time.time()
delta_time = round(end_time - start_time, 2)
logging.info("The handle_data function was executed in {} seconds.".format(delta_time))
print(utils.now(), ": The handle_data function was executed in {} seconds.".format(delta_time))
return data
[docs] def rebalance(self, alphas, orders_type, frequency):
"""
This method rebalance the portfolio based on the alphas parameters. It also update the current AUM value on the database.
Args:
alphas (pandas.DataFrame): A dataframe with the following columns:
* asset (alchemist_lib.database.asset.Asset): Must be the index.
* alpha (decimal.Decimal): The value that will be used to calculate the weight of the asset within the portfolio.
orders_type (str): Order type identifier.
frequency (int): Frequency of rebalancing.
"""
logging.debug("Rebalance start datetime: {}".format(dt.datetime.utcnow()))
start_time = time.time()
curr_ptf = self.portfolio.load_ptf(session = self.session, name = self.name)
if len(curr_ptf) == 0:
cryptocurrency_id = self.session.query(Instrument).filter(Instrument.instrument_type == "cryptocurrency").one().instrument_id
curr_ptf.append(PtfAllocation(ticker = "BTC",
instrument_id = cryptocurrency_id,
amount = self.portfolio.capital,
base_currency_amount = self.portfolio.capital,
ts_name = self.name))
logging.info("Current portfolio: {}".format(utils.print_list(curr_ptf)))
print(utils.now(), ": Current portfolio: {}".format(utils.print_list(curr_ptf)))
if self.rebalance_time % frequency == 0:
logging.debug("It's rebalance time.")
aum = self.session.query(Ts).filter(Ts.ts_name == self.name).one().aum
self.portfolio.capital = aum
logging.debug("The aum is {}.".format(aum))
target_ptf_df = self.set_weights(df = alphas)
target_ptf = self.portfolio.set_allocation(session = self.session, name = self.name, df = target_ptf_df)
logging.info("Target portfolio: {}".format(utils.print_list(target_ptf)))
print(utils.now(), ": Target portfolio: {}".format(utils.print_list(target_ptf)))
orders_allocs = self.portfolio.rebalance(curr_ptf = curr_ptf, target_ptf = target_ptf)
logging.debug("Orders to execute to get the ideal portfolio: {}".format(utils.print_list(orders_allocs)))
if self.paper_trading:
new_target_ptf = target_ptf
else:
new_target_ptf = self.broker.execute(allocs = orders_allocs, orders_type = orders_type, ts_name = self.name, curr_ptf = curr_ptf)
logging.info("Result of orders execution: {}".format(utils.print_list(new_target_ptf)))
print(utils.now(), ": Result of orders execution: {}".format(utils.print_list(new_target_ptf)))
try:
self.session.query(PtfAllocation).filter(PtfAllocation.ts_name == self.name).delete()
self.session.add_all(new_target_ptf)
self.session.commit()
except Exception as e:
self.session.rollback()
logging.error("An exception occurs on the transaction on the rebalance function.")
logging.exception("Exception: {}".format(e))
print(utils.now(), ": An exception occurs on the transaction on the rebalance function.")
raise
curr_ptf = new_target_ptf
last_price = datafeed.get_last_price(assets = [alloc.asset for alloc in curr_ptf])
new_aum = Decimal(0)
for alloc in curr_ptf:
if alloc.ticker == "BTC":
new_aum += alloc.amount
else:
new_aum += (abs(alloc.amount) * last_price.loc[alloc.asset, "last_price"])
logging.debug("The new aum is {}".format(new_aum))
self.session.query(Ts).filter(Ts.ts_name == self.name).update({"aum" : new_aum})
self.session.commit()
self.rebalance_time += 1
end_time = time.time()
delta_time = round(end_time - start_time, 2)
logging.info("The rebalance function was executed in {} seconds.".format(delta_time))
print(utils.now(), ": The rebalance function was executed in {} seconds.".format(delta_time))
[docs] def run(self, delay, frequency):
"""
This method manages the "event-driven" interface. Start every method at the right time.
Args:
delay (str): Timeframe identifier. Every delay time the on_market_open is executed.
frequency (int): Frequency of rebalancing.
"""
assert frequency > 0, "The frequency must be > 0."
instrument_timetable = {}
for asset in self.select_universe():
if asset.instrument not in list(instrument_timetable.keys()):
instrument_timetable[asset.instrument] = asset.exchanges[0].timetable
blocking_sched = BlockingScheduler()
for instrument, timetable in instrument_timetable.items():
if timetable == None:
time_expression = utils.execution_time_str(timetable = timetable, delay = delay)
logging.debug("Time expressione for add_job(): {}".format(time_expression))
blocking_sched.add_job(func = self.on_market_open, kwargs = {"timeframe" : delay, "frequency" : frequency}, max_instances = 3, **time_expression)
else:
logging.critical("Timetable is not None. NotImplemented raised.")
raise NotImplemented("Timetable is not None. NotImplemented raised.")
blocking_sched.start()
"""
self.on_market_open(timeframe = delay, frequency = frequency)
print("######################################################")
self.on_market_open(timeframe = delay, frequency = frequency)
"""