-
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__
|
||||||
|
.venv
|
||||||
|
*.egg-info
|
||||||
|
*.py[oc]
|
||||||
|
build
|
||||||
|
csv/*
|
||||||
|
dist
|
||||||
|
playground
|
||||||
|
wheels
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"eeyore.yapf",
|
||||||
|
"ms-python.isort",
|
||||||
|
"ms-python.python"
|
||||||
|
]
|
||||||
|
}
|
||||||
14
.vscode/settings.json
vendored
Normal file
14
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"[python]": {
|
||||||
|
"editor.defaultFormatter": "eeyore.yapf"
|
||||||
|
},
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports": "explicit"
|
||||||
|
},
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"python.analysis.typeCheckingMode": "standard",
|
||||||
|
"cSpell.words": [
|
||||||
|
"backtest",
|
||||||
|
"crossunder"
|
||||||
|
]
|
||||||
|
}
|
||||||
64
README.md
64
README.md
@@ -0,0 +1,64 @@
|
|||||||
|
## install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## add dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# prod dependencies
|
||||||
|
uv add PACKAGES
|
||||||
|
|
||||||
|
# dev dependencies
|
||||||
|
uv add --dev PACKAGES
|
||||||
|
```
|
||||||
|
|
||||||
|
## import dependencies from requirements.txt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add --requirements requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## format
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run isort .
|
||||||
|
uv run yapf --in-place --recursive . --parallel
|
||||||
|
```
|
||||||
|
|
||||||
|
## run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --module main
|
||||||
|
|
||||||
|
# or
|
||||||
|
uv sync
|
||||||
|
source ./.venv/bin/activate
|
||||||
|
python -m main
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run jupyter notebook \
|
||||||
|
--no-browser \
|
||||||
|
--NotebookApp.password='' \
|
||||||
|
--NotebookApp.token='' \
|
||||||
|
--port=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## clean
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf ./.venv ./dist
|
||||||
|
|
||||||
|
uv cache clean
|
||||||
|
rm -r "$(uv python dir)"
|
||||||
|
rm -r "$(uv tool dir)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## todo
|
||||||
|
|
||||||
|
- `trading_gateway.submit_order(pos_diff)`
|
||||||
|
- handle strategy that trades multiple instruments
|
||||||
|
- leveraged buy-and-hold with position sizing
|
||||||
|
- use asyncio to run multiple strategies concurrently
|
||||||
|
|||||||
28
historical_data/historical_data.py
Normal file
28
historical_data/historical_data.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from internal_types.types import OHLC, Instrument
|
||||||
|
|
||||||
|
# todo: download parquet from clickhouse and store locally
|
||||||
|
|
||||||
|
|
||||||
|
def historical_data_tradingview_csv(csv: str, instr: Instrument, t0: str, t1: str) -> List[OHLC]:
|
||||||
|
# [t0, t1)
|
||||||
|
# t0 & t1: YYYY-mm-dd
|
||||||
|
|
||||||
|
df = pd.read_csv(csv)
|
||||||
|
df['date_dt'] = pd.to_datetime(df['time'], unit='s')
|
||||||
|
df = df.rename(columns={'time': 'timestamp', 'Volume': 'volume'})
|
||||||
|
df = df[(pd.Timestamp(t0) <= df['date_dt']) & (df['date_dt'] < pd.Timestamp(t1))]
|
||||||
|
return [
|
||||||
|
OHLC(
|
||||||
|
instr=instr,
|
||||||
|
timestamp=row.timestamp, # type: ignore
|
||||||
|
open=row.open, # type: ignore
|
||||||
|
high=row.high, # type: ignore
|
||||||
|
low=row.low, # type: ignore
|
||||||
|
close=row.close, # type: ignore
|
||||||
|
volume=row.volume) # type: ignore
|
||||||
|
for row in df.itertuples(index=False)
|
||||||
|
]
|
||||||
45
internal_types/types.py
Normal file
45
internal_types/types.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityType(Enum):
|
||||||
|
CRYPTO = auto()
|
||||||
|
EQUITY = auto()
|
||||||
|
FUTURE = auto()
|
||||||
|
OPTION = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Instrument:
|
||||||
|
symbol: str
|
||||||
|
security_type: SecurityType
|
||||||
|
multiplier: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Position:
|
||||||
|
instr: Instrument
|
||||||
|
quantity: int # todo: crypto has fractional shares
|
||||||
|
price: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BidAsk:
|
||||||
|
instr: Instrument
|
||||||
|
timestamp: int
|
||||||
|
bid: float
|
||||||
|
ask: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OHLC:
|
||||||
|
instr: Instrument
|
||||||
|
timestamp: int
|
||||||
|
open: float
|
||||||
|
high: float
|
||||||
|
low: float
|
||||||
|
close: float
|
||||||
|
volume: int # todo: crypto volume is float
|
||||||
|
|
||||||
|
|
||||||
|
Quote = BidAsk | OHLC
|
||||||
121
main.py
Normal file
121
main.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from historical_data.historical_data import historical_data_tradingview_csv
|
||||||
|
from internal_types.types import Instrument, Position, SecurityType
|
||||||
|
from strategy.buy_and_hold import BuyAndHold
|
||||||
|
from strategy.sma_crossover import SMACrossover
|
||||||
|
from strategy.strategy import Strategy
|
||||||
|
from strategy.turtle_system_1 import TurtleSystem1
|
||||||
|
from trading_gateway.trading_gateway import BacktestGateway, TradingGateway
|
||||||
|
from utils.utils import (
|
||||||
|
SEC_1_DAY,
|
||||||
|
SEC_1_HOUR,
|
||||||
|
SEC_15_MINUTES,
|
||||||
|
SEC_30_MINUTES,
|
||||||
|
log_sharpe_ratio,
|
||||||
|
simple_sharpe_ratio,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def backtest(strategy: Strategy, instr: Instrument, interval_sec: int, initial_balance: float,
|
||||||
|
drawdown_limit: float, trading_gateway: TradingGateway):
|
||||||
|
balance_history: List[float] = []
|
||||||
|
|
||||||
|
for ohlc in trading_gateway.next_ohlc():
|
||||||
|
balance = strategy.net_liquid_value(ohlc.close)
|
||||||
|
balance_history.append(balance)
|
||||||
|
|
||||||
|
if balance <= initial_balance * (1 - drawdown_limit):
|
||||||
|
break
|
||||||
|
|
||||||
|
strategy.process_ohlc(ohlc)
|
||||||
|
|
||||||
|
unfilled_pos = strategy.unfilled_positions(instr)
|
||||||
|
if unfilled_pos.quantity:
|
||||||
|
if not (ohlc.low <= unfilled_pos.price <= ohlc.high):
|
||||||
|
# unfilled_pos = Position(unfilled_pos.instr, unfilled_pos.quantity, ohlc.open)
|
||||||
|
unfilled_pos = Position(unfilled_pos.instr, unfilled_pos.quantity, ohlc.close)
|
||||||
|
strategy.order_filled(unfilled_pos)
|
||||||
|
|
||||||
|
simple_sharpe = round(simple_sharpe_ratio(balance_history, interval_sec), 4)
|
||||||
|
log_sharpe = round(log_sharpe_ratio(balance_history, interval_sec), 4)
|
||||||
|
return_pct = round((balance_history[-1] / initial_balance - 1) * 100, 4)
|
||||||
|
return f'simple_sharpe: {simple_sharpe}, log_sharpe: {log_sharpe}, return_pct: {return_pct}%'
|
||||||
|
|
||||||
|
|
||||||
|
def backtest_turtle(csv: str, instr: Instrument, interval_sec: int, initial_balance: float,
|
||||||
|
drawdown_limit: float, warmup_t0: str, t0: str, t1: str):
|
||||||
|
warmup_historical_data = historical_data_tradingview_csv(csv, instr, warmup_t0, t0)
|
||||||
|
trading_gateway: TradingGateway = BacktestGateway(csv, instr, t0, t1)
|
||||||
|
|
||||||
|
strategy: Strategy = TurtleSystem1(initial_balance, instr)
|
||||||
|
strategy.warmup(warmup_historical_data)
|
||||||
|
|
||||||
|
return backtest(strategy, instr, interval_sec, initial_balance, drawdown_limit, trading_gateway)
|
||||||
|
|
||||||
|
|
||||||
|
def backtest_buy_and_hold(csv: str, instr: Instrument, interval_sec: int, initial_balance: float,
|
||||||
|
drawdown_limit: float, t0: str, t1: str):
|
||||||
|
trading_gateway: TradingGateway = BacktestGateway(csv, instr, t0, t1)
|
||||||
|
strategy: Strategy = BuyAndHold(initial_balance, instr)
|
||||||
|
return backtest(strategy, instr, interval_sec, initial_balance, drawdown_limit, trading_gateway)
|
||||||
|
|
||||||
|
|
||||||
|
def backtest_sma_crossover(csv: str, instr: Instrument, interval_sec: int, initial_balance: float,
|
||||||
|
drawdown_limit: float, warmup_t0: str, t0: str, t1: str):
|
||||||
|
warmup_historical_data = historical_data_tradingview_csv(csv, instr, warmup_t0, t0)
|
||||||
|
trading_gateway: TradingGateway = BacktestGateway(csv, instr, t0, t1)
|
||||||
|
|
||||||
|
strategy: Strategy = SMACrossover(initial_balance, instr, interval_sec, 12 * SEC_1_HOUR,
|
||||||
|
26 * SEC_1_HOUR)
|
||||||
|
strategy.warmup(warmup_historical_data)
|
||||||
|
|
||||||
|
return backtest(strategy, instr, interval_sec, initial_balance, drawdown_limit, trading_gateway)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# csv = './csv/qqq_1999_03_10_1_day.csv'
|
||||||
|
# csv = './csv/qqq_2023_02_01_15_min.csv'
|
||||||
|
csv = './csv/slv_2023_01_03_30_min.csv'
|
||||||
|
# instr = Instrument('QQQ', SecurityType.EQUITY, 1)
|
||||||
|
instr = Instrument('SLV', SecurityType.EQUITY, 1)
|
||||||
|
# interval_sec = SEC_15_MINUTES
|
||||||
|
interval_sec = SEC_30_MINUTES
|
||||||
|
# interval_sec = SEC_1_DAY
|
||||||
|
initial_balance = 1_000_000
|
||||||
|
drawdown_limit = 1.00 # allow 100% loss
|
||||||
|
# warmup_t0 = '2023-02-01'
|
||||||
|
# t0, t1 = '2023-03-15', '2026-03-01'
|
||||||
|
# warmup_t0 = '2025-10-01'
|
||||||
|
# t0, t1 = '2026-02-01', '2025-03-10'
|
||||||
|
|
||||||
|
# print(backtest_buy_and_hold(csv, instr, interval_sec, initial_balance, drawdown_limit, t0, t1))
|
||||||
|
# print(
|
||||||
|
# backtest_turtle(csv, instr, interval_sec, initial_balance, drawdown_limit, warmup_t0, t0, t1))
|
||||||
|
# print(
|
||||||
|
# backtest_sma_crossover(csv, instr, interval_sec, initial_balance, drawdown_limit, warmup_t0, t0,
|
||||||
|
# t1))
|
||||||
|
|
||||||
|
for m in range(1, 12):
|
||||||
|
warmup_t0 = '2023-02-01'
|
||||||
|
t0, t1 = f'2025-{m:02}-01', f'2025-{m+1:02}-01'
|
||||||
|
print(f'---- {t0} ----')
|
||||||
|
print('buy and hold:',
|
||||||
|
backtest_buy_and_hold(csv, instr, interval_sec, initial_balance, drawdown_limit, t0, t1))
|
||||||
|
print(
|
||||||
|
'12-16 SMA: ',
|
||||||
|
backtest_sma_crossover(csv, instr, interval_sec, initial_balance, drawdown_limit, warmup_t0,
|
||||||
|
t0, t1))
|
||||||
|
|
||||||
|
# for y in range(2000, 2026):
|
||||||
|
# warmup_t0, t0, t1 = f'{y-1}-10-01', f'{y}-01-01', f'{y+1}-01-01'
|
||||||
|
# print(f'---- {y} ----')
|
||||||
|
# print('buy and hold:',
|
||||||
|
# backtest_buy_and_hold(csv, instr, interval_sec, initial_balance, drawdown_limit, t0, t1))
|
||||||
|
# print(
|
||||||
|
# 'turtle :',
|
||||||
|
# backtest_turtle(csv, instr, interval_sec, initial_balance, drawdown_limit, warmup_t0, t0, t1))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
35
pyproject.toml
Normal file
35
pyproject.toml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
[project]
|
||||||
|
name = "strat"
|
||||||
|
version = "0.0.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=2.4.3",
|
||||||
|
"pandas>=3.0.1",
|
||||||
|
"scipy>=1.17.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ipython>=9.11.0",
|
||||||
|
"isort>=7.0.0",
|
||||||
|
"matplotlib>=3.10.8",
|
||||||
|
"mplfinance>=0.12.10b0",
|
||||||
|
"notebook>=7.5.5",
|
||||||
|
"plotly>=6.6.0",
|
||||||
|
"yapf>=0.43.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
line_length = 100
|
||||||
|
|
||||||
|
[tool.yapf]
|
||||||
|
based_on_style = "pep8"
|
||||||
|
column_limit = 100
|
||||||
|
continuation_indent_width = 2
|
||||||
|
indent_width = 2
|
||||||
|
|
||||||
|
[tool.yapfignore]
|
||||||
|
ignore_patterns = [
|
||||||
|
".venv",
|
||||||
|
]
|
||||||
58
strategy/buy_and_hold.py
Normal file
58
strategy/buy_and_hold.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import math
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import override
|
||||||
|
|
||||||
|
from internal_types.types import OHLC, BidAsk, Instrument, Position
|
||||||
|
from strategy.strategy import Strategy
|
||||||
|
from utils.utils import Portfolio
|
||||||
|
|
||||||
|
|
||||||
|
class State(Enum):
|
||||||
|
POS_0 = auto()
|
||||||
|
POS_1 = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class BuyAndHold(Strategy):
|
||||||
|
|
||||||
|
def __init__(self, init_balance, instr: Instrument):
|
||||||
|
self.state = State.POS_0
|
||||||
|
self.balance = init_balance
|
||||||
|
self.instr = instr
|
||||||
|
self.portfolio = Portfolio()
|
||||||
|
self.desired_portfolio = Portfolio()
|
||||||
|
|
||||||
|
def __process(self, at_price: float):
|
||||||
|
match self.state:
|
||||||
|
case State.POS_0:
|
||||||
|
quantity = math.floor(self.balance / at_price / self.instr.multiplier)
|
||||||
|
self.desired_portfolio.add_position(Position(self.instr, quantity, at_price))
|
||||||
|
self.state = State.POS_1
|
||||||
|
case State.POS_1:
|
||||||
|
pass
|
||||||
|
case _:
|
||||||
|
raise RuntimeError('invalid state')
|
||||||
|
|
||||||
|
@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):
|
||||||
|
if bid_ask.instr != self.instr:
|
||||||
|
return
|
||||||
|
self.__process(bid_ask.ask)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def process_ohlc(self, ohlc: OHLC):
|
||||||
|
if ohlc.instr != self.instr:
|
||||||
|
return
|
||||||
|
self.__process(ohlc.close)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def net_liquid_value(self, at_price: float) -> float:
|
||||||
|
return self.balance + self.portfolio.total_gains(self.instr, at_price)
|
||||||
92
strategy/sma_crossover.py
Normal file
92
strategy/sma_crossover.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import math
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import List, override
|
||||||
|
|
||||||
|
from internal_types.types import OHLC, BidAsk, Instrument, Position
|
||||||
|
from strategy.strategy import Strategy
|
||||||
|
from utils.utils import SEC_1_HOUR, SMA, Portfolio
|
||||||
|
|
||||||
|
|
||||||
|
class State(Enum):
|
||||||
|
POS_0 = auto()
|
||||||
|
POS_1 = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class Cross(Enum):
|
||||||
|
UNSPECIFIED = auto()
|
||||||
|
GOLDEN = auto()
|
||||||
|
DEATH = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class SMACrossover(Strategy):
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
init_balance,
|
||||||
|
instr: Instrument,
|
||||||
|
interval_sec: int,
|
||||||
|
short_window_sec: int = 12 * SEC_1_HOUR,
|
||||||
|
long_window_sec: int = 26 * SEC_1_HOUR):
|
||||||
|
self.state = State.POS_0
|
||||||
|
self.init_balance = init_balance
|
||||||
|
self.instr = instr
|
||||||
|
self.short_sma = SMA(interval_sec, short_window_sec)
|
||||||
|
self.long_sma = SMA(interval_sec, long_window_sec)
|
||||||
|
self.portfolio = Portfolio()
|
||||||
|
self.desired_portfolio = Portfolio()
|
||||||
|
|
||||||
|
def __cross(self) -> Cross:
|
||||||
|
return Cross.GOLDEN if self.short_sma.val() > self.long_sma.val() else Cross.DEATH
|
||||||
|
|
||||||
|
def __unit_limit(self, price_at: float) -> int:
|
||||||
|
return math.floor(self.net_liquid_value(price_at) / price_at / self.instr.multiplier)
|
||||||
|
|
||||||
|
def warmup(self, warmup_historical_data: List[OHLC]):
|
||||||
|
for ohlc in warmup_historical_data:
|
||||||
|
self.short_sma.append(ohlc.close)
|
||||||
|
self.long_sma.append(ohlc.close)
|
||||||
|
|
||||||
|
if not (self.short_sma.has_val() and self.long_sma.has_val()):
|
||||||
|
raise ValueError('need as least `long_window_sec` of OHLC to warmup')
|
||||||
|
|
||||||
|
self.state = State.POS_1
|
||||||
|
|
||||||
|
@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):
|
||||||
|
if self.state == State.POS_0:
|
||||||
|
raise RuntimeError('strategy wasn\'t warmed up yet')
|
||||||
|
|
||||||
|
if ohlc.instr != self.instr:
|
||||||
|
return
|
||||||
|
|
||||||
|
prev_cross = self.__cross()
|
||||||
|
|
||||||
|
self.short_sma.append(ohlc.close)
|
||||||
|
self.long_sma.append(ohlc.close)
|
||||||
|
|
||||||
|
curr_cross = self.__cross()
|
||||||
|
|
||||||
|
if prev_cross != curr_cross:
|
||||||
|
outstanding_shares = self.desired_portfolio.outstanding_shares(self.instr)
|
||||||
|
desired_shares = self.__unit_limit(ohlc.close)
|
||||||
|
if curr_cross == Cross.DEATH:
|
||||||
|
# desired_shares = 0 # long only
|
||||||
|
desired_shares = -desired_shares
|
||||||
|
quantity = desired_shares - outstanding_shares
|
||||||
|
self.desired_portfolio.add_position(Position(self.instr, quantity, ohlc.close))
|
||||||
|
|
||||||
|
@override
|
||||||
|
def net_liquid_value(self, at_price: float) -> float:
|
||||||
|
return self.init_balance + self.portfolio.total_gains(self.instr, at_price)
|
||||||
33
strategy/strategy.py
Normal file
33
strategy/strategy.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from internal_types.types import OHLC, BidAsk, Instrument, Position
|
||||||
|
|
||||||
|
|
||||||
|
# todo:
|
||||||
|
# handle strategy that trades multiple instruments
|
||||||
|
# fractional position/volume for crypto
|
||||||
|
# callback for async order
|
||||||
|
#
|
||||||
|
# def instruments_to_trade
|
||||||
|
# def 'stuff' to stream (stock price, vix, ...)
|
||||||
|
class Strategy(ABC):
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def unfilled_positions(self, instr: Instrument) -> Position:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def order_filled(self, new_pos: Position):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def process_bid_ask(self, bid_ask: BidAsk):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def process_ohlc(self, ohlc: OHLC):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def net_liquid_value(self, at_price: float) -> float:
|
||||||
|
pass
|
||||||
234
strategy/turtle_system_1.py
Normal file
234
strategy/turtle_system_1.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
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)
|
||||||
42
trading_gateway/trading_gateway.py
Normal file
42
trading_gateway/trading_gateway.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Generator, override
|
||||||
|
|
||||||
|
from historical_data.historical_data import historical_data_tradingview_csv
|
||||||
|
from internal_types.types import OHLC, BidAsk, Instrument
|
||||||
|
|
||||||
|
|
||||||
|
class TradingGateway(ABC):
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def next_bid_ask(self) -> Generator[BidAsk, None, None]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def next_ohlc(self) -> Generator[OHLC, None, None]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def submit_order(self) -> None:
|
||||||
|
# todo: submit_order -> async submit_order with callback
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BacktestGateway(TradingGateway):
|
||||||
|
|
||||||
|
def __init__(self, csv: str, instr: Instrument, t0: str, t1: str):
|
||||||
|
self.instr = instr
|
||||||
|
self.historical_data = historical_data_tradingview_csv(csv, instr, t0, t1)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def next_bid_ask(self) -> Generator[BidAsk, None, None]:
|
||||||
|
assert False, 'not available for backtesting'
|
||||||
|
|
||||||
|
@override
|
||||||
|
def next_ohlc(self) -> Generator[OHLC, None, None]:
|
||||||
|
for ohlc in self.historical_data:
|
||||||
|
yield ohlc
|
||||||
|
|
||||||
|
@override
|
||||||
|
def submit_order(self) -> None:
|
||||||
|
# todo: submit_order -> async submit_order with callback
|
||||||
|
assert False, 'todo'
|
||||||
316
utils/utils.py
Normal file
316
utils/utils.py
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import copy
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from internal_types.types import OHLC, BidAsk, Instrument, Position, Quote
|
||||||
|
|
||||||
|
TRADING_DAYS = 252
|
||||||
|
|
||||||
|
SEC_15_MINUTES = 15 * 60
|
||||||
|
SEC_30_MINUTES = 30 * 60
|
||||||
|
SEC_1_HOUR = 60 * 60
|
||||||
|
SEC_1_DAY = 24 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
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 * SEC_1_DAY / 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 * SEC_1_DAY / 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, at_price: float) -> float:
|
||||||
|
return (at_price - pos.price) * pos.quantity * pos.instr.multiplier
|
||||||
|
|
||||||
|
|
||||||
|
def crossover(quote: Quote, price: float) -> bool:
|
||||||
|
# return true if quote crosses over price
|
||||||
|
return (quote.bid if isinstance(quote, BidAsk) else quote.high) > price
|
||||||
|
|
||||||
|
|
||||||
|
def crossunder(quote: Quote, price: float) -> bool:
|
||||||
|
# return true if quote crosses under price
|
||||||
|
return (quote.ask if isinstance(quote, BidAsk) else quote.low) < price
|
||||||
|
|
||||||
|
|
||||||
|
class Portfolio:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._realized_gains = defaultdict(float)
|
||||||
|
self.outstanding_pos = defaultdict(deque[Position])
|
||||||
|
self.pos_history = defaultdict(list[Position])
|
||||||
|
|
||||||
|
def empty(self, instr: Instrument) -> bool:
|
||||||
|
return len(self.outstanding_pos[instr]) == 0
|
||||||
|
|
||||||
|
def add_position(self, new_pos: Position):
|
||||||
|
if new_pos.quantity == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
instr = new_pos.instr
|
||||||
|
|
||||||
|
self.pos_history[instr].append(copy.deepcopy(new_pos))
|
||||||
|
|
||||||
|
if self.empty(instr) or long(self.outstanding_pos[instr][0]) == long(new_pos):
|
||||||
|
self.outstanding_pos[instr].append(new_pos)
|
||||||
|
return
|
||||||
|
|
||||||
|
while not self.empty(instr) and new_pos.quantity:
|
||||||
|
old_pos = self.outstanding_pos[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 = Position(instr, old_pos.quantity - min_abs_qty * sign(old_pos.quantity),
|
||||||
|
old_pos.price)
|
||||||
|
new_pos = Position(instr, new_pos.quantity - min_abs_qty * sign(new_pos.quantity),
|
||||||
|
new_pos.price)
|
||||||
|
|
||||||
|
if old_pos.quantity:
|
||||||
|
self.outstanding_pos[instr].appendleft(old_pos)
|
||||||
|
|
||||||
|
if new_pos.quantity:
|
||||||
|
self.outstanding_pos[instr].append(new_pos)
|
||||||
|
|
||||||
|
def liquidate_positions(self, instr: Instrument, price: float):
|
||||||
|
self.add_position(Position(instr, -self.outstanding_shares(instr), price))
|
||||||
|
|
||||||
|
def outstanding_shares(self, instr: Instrument) -> int:
|
||||||
|
# todo: handle fractional shares (crypto)
|
||||||
|
return sum(pos.quantity for pos in self.outstanding_pos[instr])
|
||||||
|
|
||||||
|
def has_outstanding_shares(self, instr: Instrument) -> bool:
|
||||||
|
return len(self.outstanding_pos[instr]) != 0
|
||||||
|
|
||||||
|
def consolidate_last_x_shares(self, instr: Instrument, x: int) -> Position:
|
||||||
|
# todo: handle fractional shares (crypto)
|
||||||
|
qty = 0
|
||||||
|
outstanding_balance = 0
|
||||||
|
|
||||||
|
for pos in self.pos_history[instr][::-1]:
|
||||||
|
if qty == x:
|
||||||
|
break
|
||||||
|
to_add = pos.quantity
|
||||||
|
if (qty < x < qty + to_add) or (qty + to_add < x < qty):
|
||||||
|
to_add = x - qty
|
||||||
|
qty += to_add
|
||||||
|
outstanding_balance += pos.price * to_add
|
||||||
|
|
||||||
|
# todo: should price be 0 or np.nan if qty == 0?
|
||||||
|
# todo: take care of the case where qty != x
|
||||||
|
|
||||||
|
return Position(instr, qty, outstanding_balance / qty if qty else 0)
|
||||||
|
|
||||||
|
def realized_gains(self, instr: Instrument) -> float:
|
||||||
|
return self._realized_gains[instr]
|
||||||
|
|
||||||
|
def unrealized_gains(self, instr: Instrument, at_price: float) -> float:
|
||||||
|
return sum(unrealized_gains(pos, at_price) for pos in self.outstanding_pos[instr])
|
||||||
|
|
||||||
|
def total_gains(self, instr: Instrument, at_price: float) -> float:
|
||||||
|
return self._realized_gains[instr] + self.unrealized_gains(instr, at_price)
|
||||||
|
|
||||||
|
|
||||||
|
class SMA:
|
||||||
|
|
||||||
|
def __init__(self, interval_sec: int, window_sec: int):
|
||||||
|
if interval_sec == 0:
|
||||||
|
raise ValueError('interval_sec == 0')
|
||||||
|
if window_sec < interval_sec:
|
||||||
|
raise ValueError('window_sec < interval_sec')
|
||||||
|
self.periods = window_sec // interval_sec
|
||||||
|
self.cnt = 0
|
||||||
|
self.xs: List[float] = [0 for _ in range(self.periods)]
|
||||||
|
self.rolling_sum = 0
|
||||||
|
self.sma = 0
|
||||||
|
|
||||||
|
def append(self, x: float):
|
||||||
|
self.cnt += 1
|
||||||
|
idx = (self.cnt - 1) % self.periods
|
||||||
|
if self.has_val():
|
||||||
|
self.rolling_sum -= self.xs[idx]
|
||||||
|
self.xs[idx] = x
|
||||||
|
self.rolling_sum += x
|
||||||
|
self.sma = self.rolling_sum / self.periods
|
||||||
|
|
||||||
|
def has_val(self):
|
||||||
|
return self.cnt >= self.periods
|
||||||
|
|
||||||
|
def val(self) -> float:
|
||||||
|
if not self.has_val():
|
||||||
|
raise RuntimeError('cnt < periods')
|
||||||
|
return self.sma
|
||||||
|
|
||||||
|
|
||||||
|
class EMA:
|
||||||
|
|
||||||
|
def __init__(self, interval_sec: int, window_sec: int):
|
||||||
|
if interval_sec == 0:
|
||||||
|
raise ValueError('interval_sec == 0')
|
||||||
|
if window_sec < interval_sec:
|
||||||
|
raise ValueError('window_sec < interval_sec')
|
||||||
|
|
||||||
|
self.periods = window_sec // interval_sec
|
||||||
|
self.alpha = 2 / (self.periods + 1)
|
||||||
|
self.cnt = 0
|
||||||
|
self.tmp_sum = 0
|
||||||
|
self.ema = 0
|
||||||
|
|
||||||
|
def append(self, x: float):
|
||||||
|
self.cnt += 1
|
||||||
|
|
||||||
|
if self.cnt <= self.periods:
|
||||||
|
self.tmp_sum += x
|
||||||
|
if self.cnt == self.periods:
|
||||||
|
self.ema = self.tmp_sum / self.periods
|
||||||
|
else:
|
||||||
|
self.ema = self.alpha * x + (1 - self.alpha) * self.ema
|
||||||
|
|
||||||
|
def has_val(self):
|
||||||
|
return self.cnt >= self.periods
|
||||||
|
|
||||||
|
def val(self) -> float:
|
||||||
|
if not self.has_val():
|
||||||
|
raise RuntimeError('cnt < periods')
|
||||||
|
return self.ema
|
||||||
|
|
||||||
|
|
||||||
|
class BlendedOHLC:
|
||||||
|
|
||||||
|
def __init__(self, instr: Instrument, interval_sec: int):
|
||||||
|
self.instr = instr
|
||||||
|
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: OHLC | None = None
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.timestamps)
|
||||||
|
|
||||||
|
def __append(self, ohlc: OHLC):
|
||||||
|
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 = ohlc
|
||||||
|
|
||||||
|
def __blend(self, ohlc: OHLC):
|
||||||
|
if self.incomplete_bar is None:
|
||||||
|
self.incomplete_bar = ohlc
|
||||||
|
else:
|
||||||
|
self.incomplete_bar = OHLC(instr=self.instr,
|
||||||
|
timestamp=self.incomplete_bar.timestamp,
|
||||||
|
open=self.incomplete_bar.open,
|
||||||
|
high=max(self.incomplete_bar.high, ohlc.high),
|
||||||
|
low=min(self.incomplete_bar.low, ohlc.low),
|
||||||
|
close=ohlc.close,
|
||||||
|
volume=self.incomplete_bar.volume + ohlc.volume)
|
||||||
|
|
||||||
|
def __to_blend(self, ohlc: OHLC):
|
||||||
|
if not self.timestamps or self.incomplete_bar is None:
|
||||||
|
return False
|
||||||
|
last_interval_idx = interval_idx(self.incomplete_bar.timestamp, self.interval_sec)
|
||||||
|
ohlc_interval_idx = interval_idx(ohlc.timestamp, self.interval_sec)
|
||||||
|
return last_interval_idx == ohlc_interval_idx
|
||||||
|
|
||||||
|
def rolling_min(self, period: int) -> float:
|
||||||
|
# todo: check if index is out of bound
|
||||||
|
return min(self.lows[-period:])
|
||||||
|
|
||||||
|
def rolling_max(self, period: int) -> float:
|
||||||
|
# todo: check if index is out of bound
|
||||||
|
return max(self.highs[-period:])
|
||||||
|
|
||||||
|
def crossunder_x_period_min(self, window: int, quote: Quote) -> bool:
|
||||||
|
return self.__len__() >= window and crossunder(quote, self.rolling_min(window))
|
||||||
|
|
||||||
|
def crossover_x_period_max(self, window: int, quote: Quote) -> bool:
|
||||||
|
return self.__len__() >= window and crossover(quote, self.rolling_max(window))
|
||||||
|
|
||||||
|
def append(self, ohlc: OHLC):
|
||||||
|
if self.__to_blend(ohlc):
|
||||||
|
self.__blend(ohlc)
|
||||||
|
else:
|
||||||
|
self.__append(ohlc)
|
||||||
|
|
||||||
|
def blended_ohlc(self, idx: int) -> OHLC:
|
||||||
|
# todo: throw runtime error if idx >= len(self)
|
||||||
|
return OHLC(instr=self.instr,
|
||||||
|
timestamp=self.timestamps[idx],
|
||||||
|
open=self.opens[idx],
|
||||||
|
high=self.highs[idx],
|
||||||
|
low=self.lows[idx],
|
||||||
|
close=self.closes[idx],
|
||||||
|
volume=self.volumes[idx])
|
||||||
Reference in New Issue
Block a user