190 lines
5.9 KiB
Python
190 lines
5.9 KiB
Python
from collections import defaultdict, deque
|
|
from datetime import datetime
|
|
from typing import List
|
|
|
|
import numpy as np
|
|
|
|
from internal_types.types import Instrument, Position, Quote
|
|
|
|
TRADING_DAYS = 252
|
|
|
|
|
|
def sign(x):
|
|
# return x // abs(x)
|
|
# e.g. sign(-5) = -1, sign(0) = 0, sign(5) = 1
|
|
return (x > 0) - (x < 0)
|
|
|
|
|
|
def max_abs(*xs):
|
|
return max(map(abs, xs))
|
|
|
|
|
|
def min_abs(*xs):
|
|
return min(map(abs, xs))
|
|
|
|
|
|
def simple_sharpe_ratio(historical_net_liquid_value: List[float], interval_sec: int) -> float:
|
|
if len(historical_net_liquid_value) < 2:
|
|
return np.nan
|
|
|
|
xs = np.asarray(historical_net_liquid_value, dtype=float)
|
|
returns = xs[1:] / xs[:-1] - 1.0
|
|
mean_r = np.mean(returns)
|
|
std_r = np.std(returns, ddof=1)
|
|
|
|
if std_r == 0:
|
|
return np.nan
|
|
|
|
periods_per_year = TRADING_DAYS * 24 * 60 * 60 / interval_sec
|
|
|
|
return (mean_r / std_r) * np.sqrt(periods_per_year)
|
|
|
|
|
|
def log_sharpe_ratio(historical_net_liquid_value: List[float], interval_sec: int) -> float:
|
|
if len(historical_net_liquid_value) < 2:
|
|
return np.nan
|
|
|
|
xs = np.asarray(historical_net_liquid_value, dtype=float)
|
|
log_returns = np.log(xs[1:] / xs[:-1])
|
|
mean_r = log_returns.mean()
|
|
std_r = log_returns.std(ddof=1)
|
|
|
|
if std_r == 0:
|
|
return np.nan
|
|
|
|
periods_per_year = TRADING_DAYS * 24 * 60 * 60 / interval_sec
|
|
|
|
return (mean_r / std_r) * np.sqrt(periods_per_year)
|
|
|
|
|
|
# def consolidate_positions(ps: List[Position]) -> Position:
|
|
# # assumes that ps is not empty and contains the same instruments
|
|
# qty = 0
|
|
# outstanding_balance = 0
|
|
# for p in ps:
|
|
# qty += p.quantity
|
|
# outstanding_balance += p.price * p.quantity
|
|
# return Position(ps[0].instr, qty, outstanding_balance / qty if qty else 0)
|
|
|
|
|
|
def timestamp_to_str(timestamp: int) -> str:
|
|
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S UTC')
|
|
|
|
|
|
def interval_idx(timestamp: int, interval_sec: int) -> int:
|
|
return timestamp // interval_sec
|
|
|
|
|
|
def long(pos: Position) -> bool:
|
|
return pos.quantity > 0
|
|
|
|
|
|
def unrealized_gains(pos: Position, closing_price: float) -> float:
|
|
return (closing_price - pos.price) * pos.quantity * pos.instr.multiplier
|
|
|
|
|
|
class Portfolio:
|
|
|
|
def __init__(self):
|
|
self._realized_gains = defaultdict(float)
|
|
self.positions = defaultdict(deque[Position])
|
|
|
|
def empty(self, instr: Instrument) -> bool:
|
|
return len(self.positions[instr]) == 0
|
|
|
|
def add_pos(self, new_pos: Position):
|
|
if new_pos.quantity == 0:
|
|
return
|
|
|
|
instr = new_pos.instr
|
|
|
|
if self.empty(instr) or long(self.positions[instr][0]) == long(new_pos):
|
|
self.positions[instr].append(new_pos)
|
|
return
|
|
|
|
while not self.empty(instr) and new_pos.quantity:
|
|
old_pos = self.positions[instr].popleft()
|
|
min_abs_qty = min_abs(old_pos.quantity, new_pos.quantity)
|
|
tmp_pos = Position(instr, min_abs_qty * sign(old_pos.quantity), old_pos.price)
|
|
self._realized_gains[instr] += unrealized_gains(tmp_pos, new_pos.price)
|
|
old_pos.quantity -= min_abs_qty * sign(old_pos.quantity)
|
|
new_pos.quantity -= min_abs_qty * sign(new_pos.quantity)
|
|
|
|
if old_pos.quantity:
|
|
self.positions[instr].appendleft(old_pos)
|
|
|
|
if new_pos.quantity:
|
|
self.positions[instr].append(new_pos)
|
|
|
|
def curr_position(self, instr: Instrument) -> int:
|
|
# todo: handle fractional shares (crypto)
|
|
return sum(pos.quantity for pos in self.positions[instr])
|
|
|
|
def realized_gains(self, instr: Instrument) -> float:
|
|
return self._realized_gains[instr]
|
|
|
|
def unrealized_gains(self, instr: Instrument, closing_price: float) -> float:
|
|
return sum(unrealized_gains(pos, closing_price) for pos in self.positions[instr])
|
|
|
|
def total_gains(self, instr: Instrument, closing_price: float) -> float:
|
|
return self._realized_gains[instr] + self.unrealized_gains(instr, closing_price)
|
|
|
|
|
|
class BlendedCandlesticks:
|
|
|
|
def __init__(self, interval_sec: int):
|
|
self.interval_sec = interval_sec
|
|
self.timestamps: List[int] = []
|
|
self.opens: List[float] = []
|
|
self.highs: List[float] = []
|
|
self.lows: List[float] = []
|
|
self.closes: List[float] = []
|
|
self.volumes: List[int] = [] # todo: float for crypto
|
|
self.incomplete_bar: Quote | None = None
|
|
|
|
def __len__(self):
|
|
return len(self.timestamps)
|
|
|
|
def __append(self, quote: Quote):
|
|
if self.incomplete_bar is not None:
|
|
self.timestamps.append(self.incomplete_bar.timestamp)
|
|
self.opens.append(self.incomplete_bar.open)
|
|
self.highs.append(self.incomplete_bar.high)
|
|
self.lows.append(self.incomplete_bar.low)
|
|
self.closes.append(self.incomplete_bar.close)
|
|
self.volumes.append(self.incomplete_bar.volume)
|
|
self.incomplete_bar = quote
|
|
|
|
def __blend(self, quote: Quote):
|
|
if self.incomplete_bar is None:
|
|
self.incomplete_bar = quote
|
|
else:
|
|
self.incomplete_bar = Quote(timestamp=self.incomplete_bar.timestamp,
|
|
open=self.incomplete_bar.open,
|
|
high=max(self.incomplete_bar.high, quote.high),
|
|
low=min(self.incomplete_bar.low, quote.low),
|
|
close=quote.close,
|
|
volume=self.incomplete_bar.volume + quote.volume)
|
|
|
|
def __to_blend(self, quote: Quote):
|
|
if not self.timestamps or self.incomplete_bar is None:
|
|
return False
|
|
last_interval_idx = interval_idx(self.incomplete_bar.timestamp, self.interval_sec)
|
|
quote_interval_idx = interval_idx(quote.timestamp, self.interval_sec)
|
|
return last_interval_idx == quote_interval_idx
|
|
|
|
def append(self, quote: Quote):
|
|
if self.__to_blend(quote):
|
|
self.__blend(quote)
|
|
else:
|
|
self.__append(quote)
|
|
|
|
def blended_quote(self, idx: int) -> Quote:
|
|
# todo: throw runtime error if idx >= len(self)
|
|
return Quote(timestamp=self.timestamps[idx],
|
|
open=self.opens[idx],
|
|
high=self.highs[idx],
|
|
low=self.lows[idx],
|
|
close=self.closes[idx],
|
|
volume=self.volumes[idx])
|