mirror of
https://github.com/wassname/catalyst.git
synced 2026-07-18 12:20:12 +08:00
487 lines
16 KiB
Python
487 lines
16 KiB
Python
import datetime
|
|
import pytz
|
|
import math
|
|
import pandas
|
|
import time
|
|
|
|
from collections import Counter
|
|
|
|
# from gevent.select import select
|
|
from zmq.core.poll import select
|
|
|
|
import zipline.messaging as qmsg
|
|
import zipline.util as qutil
|
|
import zipline.protocol as zp
|
|
import zipline.finance.performance as perf
|
|
|
|
from zipline.protocol_utils import Enum, namedict
|
|
|
|
# the simulation style enumerates the available transaction simulation
|
|
# strategies.
|
|
SIMULATION_STYLE = Enum(
|
|
'PARTIAL_VOLUME',
|
|
'BUY_ALL',
|
|
'FIXED_SLIPPAGE',
|
|
'NOOP'
|
|
)
|
|
|
|
class TradeSimulationClient(qmsg.Component):
|
|
|
|
def __init__(self, trading_environment, sim_style):
|
|
qmsg.Component.__init__(self)
|
|
self.received_count = 0
|
|
self.prev_dt = None
|
|
self.event_queue = None
|
|
self.txn_count = 0
|
|
self.order_count = 0
|
|
self.trading_environment = trading_environment
|
|
self.current_dt = trading_environment.period_start
|
|
self.last_iteration_dur = datetime.timedelta(seconds=0)
|
|
self.algorithm = None
|
|
self.max_wait = datetime.timedelta(seconds=60)
|
|
self.last_msg_dt = datetime.datetime.utcnow()
|
|
self.txn_sim = TransactionSimulator(sim_style)
|
|
|
|
assert self.trading_environment.frame_index != None
|
|
self.event_frame = pandas.DataFrame(
|
|
index=self.trading_environment.frame_index
|
|
)
|
|
|
|
self.perf = perf.PerformanceTracker(self.trading_environment)
|
|
|
|
@property
|
|
def get_id(self):
|
|
return str(zp.FINANCE_COMPONENT.TRADING_CLIENT)
|
|
|
|
def set_algorithm(self, algorithm):
|
|
"""
|
|
:param algorithm: must implement the algorithm protocol. See
|
|
:py:mod:`zipline.test.algorithm`
|
|
"""
|
|
self.algorithm = algorithm
|
|
#register the trading_client's order method with the algorithm
|
|
self.algorithm.set_order(self.order)
|
|
|
|
def open(self):
|
|
self.result_feed = self.connect_result()
|
|
|
|
def do_work(self):
|
|
# poll all the sockets
|
|
socks = dict(self.poll.poll(self.heartbeat_timeout))
|
|
|
|
# see if the poller has results for the result_feed
|
|
if self.result_feed in socks and \
|
|
socks[self.result_feed] == self.zmq.POLLIN:
|
|
|
|
self.last_msg_dt = datetime.datetime.utcnow()
|
|
|
|
# get the next message from the result feed
|
|
msg = self.result_feed.recv()
|
|
|
|
# if the feed is done, shut 'er down
|
|
if msg == str(zp.CONTROL_PROTOCOL.DONE):
|
|
qutil.LOGGER.info("Client is DONE!")
|
|
# signal the performance tracker that the simulation has
|
|
# ended. Perf will internally calculate the full risk report.
|
|
self.perf.handle_simulation_end()
|
|
|
|
# signal Simulator, our ComponentHost, that this component is
|
|
# done and Simulator needn't block exit on this component.
|
|
self.signal_done()
|
|
return
|
|
|
|
# result_feed is a merge component, so unframe accordingly
|
|
event = zp.MERGE_UNFRAME(msg)
|
|
self.received_count += 1
|
|
# update performance and relay the event to the algorithm
|
|
self.process_event(event)
|
|
|
|
|
|
def process_event(self, event):
|
|
|
|
if self.perf.exceeded_max_loss:
|
|
self.control_out.send(str(zp.CONTROL_PROTOCOL.SHUTDOWN))
|
|
return
|
|
|
|
# generate transactions, if applicable
|
|
txn = self.txn_sim.apply_trade_to_open_orders(event)
|
|
if txn:
|
|
event.TRANSACTION = txn
|
|
# track the number of transactions, for testing purposes.
|
|
self.txn_count += 1
|
|
else:
|
|
event.TRANSACTION = None
|
|
|
|
# the performance class needs to process each event, without
|
|
# skipping. Algorithm should wait until the performance has been
|
|
# updated, so that down stream components can safely assume that
|
|
# performance is up to date. Note that this is done before we
|
|
# mark the time for the algorithm's processing, thereby not
|
|
# running the algo's clock for performance book keeping.
|
|
self.perf.process_event(event)
|
|
|
|
# mark the start time for client's processing of this event.
|
|
event_start = datetime.datetime.utcnow()
|
|
|
|
|
|
# queue the event.
|
|
self.queue_event(event)
|
|
|
|
|
|
# if the event is later than our current time, run the algo
|
|
# otherwise, the algorithm has fallen behind the feed
|
|
# and processing per event is longer than time between events.
|
|
if event.dt >= self.current_dt:
|
|
# compress time by moving the current_time up to the event
|
|
# time.
|
|
self.current_dt = event.dt
|
|
self.run_algorithm()
|
|
|
|
# tally the time spent on this iteration
|
|
self.last_iteration_dur = datetime.datetime.utcnow() - event_start
|
|
# move the algorithm's clock forward to include iteration time
|
|
self.current_dt = self.current_dt + self.last_iteration_dur
|
|
|
|
|
|
def run_algorithm(self):
|
|
"""
|
|
As per the algorithm protocol:
|
|
|
|
- Set the current portfolio for the algorithm as per protocol.
|
|
- Construct frame based on backlog of events, send to algorithm.
|
|
"""
|
|
current_portfolio = self.perf.get_portfolio()
|
|
self.algorithm.set_portfolio(current_portfolio)
|
|
frame = self.get_frame()
|
|
if len(frame) > 0:
|
|
self.algorithm.handle_frame(frame)
|
|
|
|
def connect_order(self):
|
|
return self.connect_push_socket(self.addresses['order_address'])
|
|
|
|
def order(self, sid, amount):
|
|
order = zp.namedict({
|
|
'dt':self.current_dt,
|
|
'sid':sid,
|
|
'amount':amount
|
|
})
|
|
self.order_count += 1
|
|
self.perf.log_order(order)
|
|
self.txn_sim.add_open_order(order)
|
|
|
|
def signal_order_done(self):
|
|
self.order_socket.send(str(zp.ORDER_PROTOCOL.DONE))
|
|
|
|
def queue_event(self, event):
|
|
if self.event_queue == None:
|
|
self.event_queue = []
|
|
series = event.as_series()
|
|
self.event_queue.append(series)
|
|
|
|
def get_frame(self):
|
|
for event in self.event_queue:
|
|
self.event_frame[event['sid']] = event
|
|
self.event_queue = []
|
|
return self.event_frame
|
|
|
|
|
|
class TransactionSimulator(object):
|
|
|
|
def __init__(self, style=SIMULATION_STYLE.PARTIAL_VOLUME):
|
|
self.open_orders = {}
|
|
self.order_count = 0
|
|
self.txn_count = 0
|
|
self.trade_window = datetime.timedelta(seconds=30)
|
|
self.orderTTL = datetime.timedelta(days=1)
|
|
self.commission = 0.03
|
|
|
|
if not style or style == SIMULATION_STYLE.PARTIAL_VOLUME:
|
|
self.apply_trade_to_open_orders = self.simulate_with_partial_volume
|
|
elif style == SIMULATION_STYLE.BUY_ALL:
|
|
self.apply_trade_to_open_orders = self.simulate_buy_all
|
|
elif style == SIMULATION_STYLE.FIXED_SLIPPAGE:
|
|
self.apply_trade_to_open_orders = self.simulate_with_fixed_cost
|
|
elif style == SIMULATION_STYLE.NOOP:
|
|
self.apply_trade_to_open_orders = self.simulate_noop
|
|
|
|
def add_open_order(self, event):
|
|
"""Orders are captured in a buffer by sid. No calculations are done here.
|
|
Amount is explicitly converted to an int.
|
|
Orders of amount zero are ignored.
|
|
"""
|
|
self.order_count += 1
|
|
|
|
event.amount = int(event.amount)
|
|
if event.amount == 0:
|
|
log = "requested to trade zero shares of {sid}".format(
|
|
sid=event.sid
|
|
)
|
|
qutil.LOGGER.debug(log)
|
|
return
|
|
|
|
if(not self.open_orders.has_key(event.sid)):
|
|
self.open_orders[event.sid] = []
|
|
|
|
# set the filled property to zero
|
|
event.filled = 0
|
|
self.open_orders[event.sid].append(event)
|
|
|
|
def simulate_buy_all(self, event):
|
|
txn = self.create_transaction(
|
|
event.sid,
|
|
event.volume,
|
|
event.price,
|
|
event.dt,
|
|
1
|
|
)
|
|
return txn
|
|
|
|
def simulate_noop(self, event):
|
|
return None
|
|
|
|
def simulate_with_fixed_cost(self, event):
|
|
if self.open_orders.has_key(event.sid):
|
|
orders = self.open_orders[event.sid]
|
|
orders = sorted(orders, key=lambda o: o.dt)
|
|
else:
|
|
return None
|
|
|
|
amount = 0
|
|
for order in orders:
|
|
amount += order.amount
|
|
|
|
if(amount == 0):
|
|
return
|
|
|
|
direction = amount / math.fabs(amount)
|
|
|
|
|
|
txn = self.create_transaction(
|
|
event.sid,
|
|
amount,
|
|
event.price + 0.10,
|
|
event.dt,
|
|
direction
|
|
)
|
|
|
|
self.open_orders[event.sid] = []
|
|
|
|
return txn
|
|
|
|
def simulate_with_partial_volume(self, event):
|
|
if(event.volume == 0):
|
|
#there are zero volume events bc some stocks trade
|
|
#less frequently than once per minute.
|
|
return None
|
|
|
|
if self.open_orders.has_key(event.sid):
|
|
orders = self.open_orders[event.sid]
|
|
orders = sorted(orders, key=lambda o: o.dt)
|
|
else:
|
|
return None
|
|
|
|
dt = event.dt
|
|
expired = []
|
|
total_order = 0
|
|
simulated_amount = 0
|
|
simulated_impact = 0.0
|
|
direction = 1.0
|
|
for order in orders:
|
|
|
|
if(order.dt < event.dt):
|
|
|
|
# orders are only good on the day they are issued
|
|
if order.dt.day < event.dt.day:
|
|
continue
|
|
|
|
open_amount = order.amount - order.filled
|
|
|
|
if(open_amount != 0):
|
|
direction = open_amount / math.fabs(open_amount)
|
|
else:
|
|
direction = 1
|
|
|
|
desired_order = total_order + open_amount
|
|
|
|
volume_share = direction * (desired_order) / event.volume
|
|
if volume_share > .25:
|
|
volume_share = .25
|
|
simulated_amount = int(volume_share * event.volume * direction)
|
|
simulated_impact = (volume_share)**2 * .1 * direction * event.price
|
|
|
|
order.filled += (simulated_amount - total_order)
|
|
total_order = simulated_amount
|
|
|
|
# we cap the volume share at 25% of a trade
|
|
if volume_share == .25:
|
|
break
|
|
|
|
orders = [ x for x in orders if abs(x.amount - x.filled) > 0 and x.dt.day >= event.dt.day]
|
|
|
|
self.open_orders[event.sid] = orders
|
|
|
|
|
|
if simulated_amount != 0:
|
|
return self.create_transaction(
|
|
event.sid,
|
|
simulated_amount,
|
|
event.price + simulated_impact,
|
|
dt.replace(tzinfo = pytz.utc),
|
|
direction
|
|
)
|
|
elif len(orders) > 0:
|
|
warning = """
|
|
Calculated a zero volume transaction on trade:
|
|
{event}
|
|
for orders:
|
|
{orders}
|
|
"""
|
|
warning = warning.format(
|
|
event=str(event),
|
|
orders=str(orders)
|
|
)
|
|
qutil.LOGGER.warn(warning)
|
|
return None
|
|
|
|
|
|
def create_transaction(self, sid, amount, price, dt, direction):
|
|
self.txn_count += 1
|
|
txn = {'sid' : sid,
|
|
'amount' : int(amount),
|
|
'dt' : dt,
|
|
'price' : price,
|
|
'commission' : self.commission * amount * direction,
|
|
'source_id' : zp.FINANCE_COMPONENT.TRANSACTION_SIM
|
|
}
|
|
return zp.namedict(txn)
|
|
|
|
|
|
class TradingEnvironment(object):
|
|
|
|
def __init__(
|
|
self,
|
|
benchmark_returns,
|
|
treasury_curves,
|
|
period_start = None,
|
|
period_end = None,
|
|
capital_base = None,
|
|
max_drawdown = None
|
|
):
|
|
|
|
self.trading_days = []
|
|
self.trading_day_map = {}
|
|
self.treasury_curves = treasury_curves
|
|
self.benchmark_returns = benchmark_returns
|
|
self.frame_index = ['sid', 'volume', 'dt', 'price', 'changed']
|
|
self.period_start = period_start
|
|
self.period_end = period_end
|
|
self.capital_base = capital_base
|
|
self.period_trading_days = None
|
|
self.max_drawdown = max_drawdown
|
|
|
|
for bm in benchmark_returns:
|
|
self.trading_days.append(bm.date)
|
|
self.trading_day_map[bm.date] = bm
|
|
|
|
self.first_open = self.calculate_first_open()
|
|
self.last_close = self.calculate_last_close()
|
|
|
|
def calculate_first_open(self):
|
|
"""
|
|
Finds the first trading day on or after self.period_start.
|
|
"""
|
|
first_open = self.period_start
|
|
one_day = datetime.timedelta(days=1)
|
|
|
|
while not self.is_trading_day(first_open):
|
|
first_open = first_open + one_day
|
|
|
|
first_open = self.set_NYSE_time(first_open, 9, 30)
|
|
return first_open
|
|
|
|
def calculate_last_close(self):
|
|
"""
|
|
Finds the last trading day on or before self.period_end
|
|
"""
|
|
last_close = self.period_end
|
|
one_day = datetime.timedelta(days=1)
|
|
|
|
while not self.is_trading_day(last_close):
|
|
last_close = last_close - one_day
|
|
|
|
last_close = self.set_NYSE_time(last_close, 16, 00)
|
|
|
|
return last_close
|
|
|
|
#TODO: add other exchanges and timezones...
|
|
def set_NYSE_time(self, dt, hour, minute):
|
|
naive = datetime.datetime(
|
|
year=dt.year,
|
|
month=dt.month,
|
|
day=dt.day
|
|
)
|
|
local = pytz.timezone ('US/Eastern')
|
|
local_dt = naive.replace (tzinfo = local)
|
|
# set the clock to the opening bell in NYC time.
|
|
local_dt = local_dt.replace(hour=hour, minute=minute)
|
|
# convert to UTC
|
|
utc_dt = local_dt.astimezone (pytz.utc)
|
|
return utc_dt
|
|
|
|
def normalize_date(self, test_date):
|
|
return datetime.datetime(
|
|
year=test_date.year,
|
|
month=test_date.month,
|
|
day=test_date.day,
|
|
tzinfo=pytz.utc
|
|
)
|
|
|
|
@property
|
|
def days_in_period(self):
|
|
"""return the number of trading days within the period [start, end)"""
|
|
assert(self.period_start != None)
|
|
assert(self.period_end != None)
|
|
|
|
if self.period_trading_days == None:
|
|
self.period_trading_days = []
|
|
for date in self.trading_days:
|
|
if date > self.period_end:
|
|
break
|
|
if date >= self.period_start:
|
|
self.period_trading_days.append(date)
|
|
|
|
|
|
return len(self.period_trading_days)
|
|
|
|
def is_market_hours(self, test_date):
|
|
if not self.is_trading_day(test_date):
|
|
return False
|
|
|
|
mkt_open = self.set_NYSE_time(test_date, 9, 30)
|
|
#TODO: half days?
|
|
mkt_close = self.set_NYSE_time(test_date, 16, 00)
|
|
|
|
return test_date >= mkt_open and test_date <= mkt_close
|
|
|
|
def is_trading_day(self, test_date):
|
|
dt = self.normalize_date(test_date)
|
|
return self.trading_day_map.has_key(dt)
|
|
|
|
def get_benchmark_daily_return(self, test_date):
|
|
date = self.normalize_date(test_date)
|
|
if self.trading_day_map.has_key(date):
|
|
return self.trading_day_map[date].returns
|
|
else:
|
|
return 0.0
|
|
|
|
def add_to_frame(self, name):
|
|
"""
|
|
Add an entry to the frame index.
|
|
:param name: new index entry name. Used by TradingSimulationClient
|
|
to
|
|
"""
|
|
self.frame_index.append(name)
|
|
|
|
|
|
|