import math from enum import Enum, auto from typing import List, override import numpy as np from internal_types.types import OHLC, BidAsk, Instrument, Position, Quote from strategy.strategy import Strategy from utils.utils import SEC_1_DAY, BlendedOHLC, Portfolio, crossover, crossunder, max_abs class State(Enum): WARMUP = auto() INIT = auto() LONG = auto() SHORT = auto() SHADOW_LONG = auto() SHADOW_SHORT = auto() class TurtleSystem1(Strategy): def __init__(self, init_balance: float, instr: Instrument): self.state = State.WARMUP self.init_balance = init_balance self.N: float = 0 self.tradable_acct_balance = init_balance self.unit_size: int = 0 self.instr = instr self.unit_limit = 4 self.trade_next_20_day_breakout = True self.daily_ohlc = BlendedOHLC(instr, SEC_1_DAY) self.shadow_portfolio = Portfolio() self.portfolio = Portfolio() self.desired_portfolio = Portfolio() def warmup(self, warmup_historical_data: List[OHLC]): for ohlc in warmup_historical_data: self.daily_ohlc.append(ohlc) if len(self.daily_ohlc) < 20: raise ValueError('need as least 20 days of OHLC to warmup') self.__init_day_20_N() self.__adjust_unit_size() self.state = State.INIT def __true_range(self, day_i) -> float: # todo: check index out of bound curr_q = self.daily_ohlc.blended_ohlc(day_i) if (day_i + len(self.daily_ohlc)) % len(self.daily_ohlc) == 0: return curr_q.high - curr_q.low prev_q = self.daily_ohlc.blended_ohlc(day_i - 1) return max_abs(curr_q.high - curr_q.low, curr_q.high - prev_q.close, prev_q.close - curr_q.low) def __init_day_20_N(self): days = min(len(self.daily_ohlc), 20) self.N = float(np.mean([self.__true_range(day_i) for day_i in range(-days, 0)])) def __adjust_unit_size(self): if self.N == 0: raise RuntimeError('N can\'t be 0') elif self.instr.multiplier == 0: raise RuntimeError('multiplier can\'t be 0') self.unit_size = \ math.floor(self.tradable_acct_balance * 0.01 / (self.N * self.instr.multiplier)) def __reach_unit_limit(self): outstanding_shares = self.desired_portfolio.outstanding_shares(self.instr) return abs(outstanding_shares) >= self.unit_limit * self.unit_size def __shadow_reach_unit_limit(self): shadow_outstanding_shares = self.shadow_portfolio.outstanding_shares(self.instr) return abs(shadow_outstanding_shares) >= self.unit_limit * self.unit_size def __submit_order(self, quantity: int, price: float): self.desired_portfolio.add_position(Position(self.instr, quantity, price)) def __shadow_submit_order(self, quantity: int, price: float): self.shadow_portfolio.add_position(Position(self.instr, quantity, price)) def __last_order_price(self) -> float: return self.desired_portfolio.pos_history[self.instr][-1].price def __shadow_last_order_price(self) -> float: return self.shadow_portfolio.pos_history[self.instr][-1].price def __to_INIT_state(self, price: float): self.desired_portfolio.liquidate_positions(self.instr, price) self.shadow_portfolio = Portfolio() self.state = State.INIT def __to_LONG_state(self, price: float): self.__submit_order(self.unit_size, price) self.shadow_portfolio = Portfolio() self.state = State.LONG def __to_SHORT_state(self, price: float): self.__submit_order(-self.unit_size, price) self.shadow_portfolio = Portfolio() self.state = State.SHORT def __to_SHADOW_LONG_state(self, price: float): self.shadow_portfolio = Portfolio() self.__shadow_submit_order(self.unit_size, price) self.state = State.SHADOW_LONG def __to_SHADOW_SHORT_state(self, price: float): self.shadow_portfolio = Portfolio() self.__shadow_submit_order(self.unit_size, price) self.state = State.SHADOW_SHORT def __process_INIT_state(self, quote: Quote): breakthrough_20_high = self.daily_ohlc.crossover_x_period_max(20, quote) breakthrough_20_low = self.daily_ohlc.crossunder_x_period_min(20, quote) breakthrough_55_high = self.daily_ohlc.crossover_x_period_max(55, quote) breakthrough_55_low = self.daily_ohlc.crossunder_x_period_min(55, quote) if self.trade_next_20_day_breakout and breakthrough_20_high: self.__to_LONG_state(self.daily_ohlc.rolling_max(20)) elif self.trade_next_20_day_breakout and breakthrough_20_low: self.__to_SHORT_state(self.daily_ohlc.rolling_min(20)) elif breakthrough_55_high: self.__to_LONG_state(self.daily_ohlc.rolling_max(55)) elif breakthrough_55_low: self.__to_SHORT_state(self.daily_ohlc.rolling_min(55)) elif breakthrough_20_high: self.__to_SHADOW_LONG_state(self.daily_ohlc.rolling_max(20)) elif breakthrough_20_low: self.__to_SHADOW_SHORT_state(self.daily_ohlc.rolling_min(20)) def __process_LONG_state(self, quote: Quote): exit_price = max(self.daily_ohlc.rolling_min(10), self.__last_order_price() - self.N * 2) next_entry_price = self.__last_order_price() + self.N / 2 if crossunder(quote, exit_price): self.trade_next_20_day_breakout = self.portfolio.unrealized_gains(self.instr, exit_price) < 0 self.__to_INIT_state(exit_price) elif crossover(quote, next_entry_price) and not self.__reach_unit_limit(): self.__submit_order(self.unit_size, next_entry_price) def __process_SHORT_state(self, quote: Quote): exit_price = min(self.daily_ohlc.rolling_max(10), self.__last_order_price() + self.N * 2) next_entry_price = self.__last_order_price() - self.N / 2 if crossover(quote, exit_price): self.trade_next_20_day_breakout = self.portfolio.unrealized_gains(self.instr, exit_price) < 0 self.__to_INIT_state(exit_price) elif crossunder(quote, next_entry_price) and not self.__reach_unit_limit(): self.__submit_order(-self.unit_size, next_entry_price) def __process_SHADOW_LONG_state(self, quote: Quote): exit_price = max(self.daily_ohlc.rolling_min(10), self.__shadow_last_order_price() - self.N * 2) next_entry_price = self.__shadow_last_order_price() + self.N / 2 if self.daily_ohlc.crossover_x_period_max(55, quote): self.__to_LONG_state(self.daily_ohlc.rolling_max(55)) elif self.daily_ohlc.crossunder_x_period_min(55, quote): self.__to_SHORT_state(self.daily_ohlc.rolling_min(55)) elif crossunder(quote, exit_price): shadow_unrealized_gains = self.shadow_portfolio.unrealized_gains(self.instr, exit_price) self.trade_next_20_day_breakout = shadow_unrealized_gains < 0 self.__to_INIT_state(exit_price) elif crossover(quote, next_entry_price) and not self.__shadow_reach_unit_limit(): self.__shadow_submit_order(self.unit_size, next_entry_price) def __process_SHADOW_SHORT_state(self, quote: Quote): exit_price = min(self.daily_ohlc.rolling_max(10), self.__shadow_last_order_price() + self.N * 2) next_entry_price = self.__shadow_last_order_price() - self.N / 2 if self.daily_ohlc.crossover_x_period_max(55, quote): self.__to_LONG_state(self.daily_ohlc.rolling_max(55)) elif self.daily_ohlc.crossunder_x_period_min(55, quote): self.__to_SHORT_state(self.daily_ohlc.rolling_min(55)) elif crossover(quote, exit_price): shadow_unrealized_gains = self.shadow_portfolio.unrealized_gains(self.instr, exit_price) self.trade_next_20_day_breakout = shadow_unrealized_gains < 0 self.__to_INIT_state(exit_price) elif crossunder(quote, next_entry_price) and not self.__shadow_reach_unit_limit(): self.__shadow_submit_order(-self.unit_size, next_entry_price) @override def unfilled_positions(self, instr: Instrument) -> Position: x = self.desired_portfolio.outstanding_shares(instr) - self.portfolio.outstanding_shares(instr) return self.desired_portfolio.consolidate_last_x_shares(instr, x) @override def order_filled(self, new_pos: Position): self.portfolio.add_position(new_pos) @override def process_bid_ask(self, bid_ask: BidAsk): assert False, 'todo' @override def process_ohlc(self, ohlc: OHLC): prev_day_cnt = len(self.daily_ohlc) self.daily_ohlc.append(ohlc) curr_day_cnt = len(self.daily_ohlc) if self.state == State.WARMUP: raise RuntimeError('strategy wasn\'t warmed up yet') if prev_day_cnt != curr_day_cnt: self.N = (19 * self.N + self.__true_range(-1)) / 20 net_liq = self.net_liquid_value(ohlc.close) while net_liq < self.tradable_acct_balance * 0.9: self.tradable_acct_balance *= 0.8 self.__adjust_unit_size() while net_liq > self.tradable_acct_balance / 0.8: self.tradable_acct_balance /= 0.8 self.__adjust_unit_size() match self.state: case State.INIT: self.__process_INIT_state(ohlc) case State.LONG: self.__process_LONG_state(ohlc) case State.SHORT: self.__process_SHORT_state(ohlc) case State.SHADOW_LONG: self.__process_SHADOW_LONG_state(ohlc) case State.SHADOW_SHORT: self.__process_SHADOW_SHORT_state(ohlc) case _: raise RuntimeError('invalid state') @override def net_liquid_value(self, at_price: float) -> float: return self.init_balance + self.portfolio.total_gains(self.instr, at_price)