High-Alpha Strategy
Your hunt for alpha ends here

Polysynth Trading Strategy #1

Rationale

  • The Mark Price on Polysynth currently lags the index price which offers significant Alpha to converge the spread
  • The Alpha comes from earning the funding payment due to active convergent trades and the convergence of spread between mark price & index price on Polysynth
  • The following strategy is suitable for Polysynth (vAMM) that aims to capture directional patterns using index/oracle price as a reference and also benefitting from funding payment earned by placing convergent orders
  • Index/Oracle Price on Polysynth is fetched from Chainlink

Key Points

  • The portfolio of long/short positions is managed using the perpetual futures contract trading on Polysynth
  • The model is based on Kalman Filter (to update Spread in an online manner), volatility-based measures to manage risk across positions

Backtest Data & Parameters

We backtested our strategy using data from perpetual protocol from December 2020 to September 2021 with the below parameters in place.

Equity Curve (net of costs)

The below figure depicts what $100 invested at the start of December 2020 would have gone on to become by September 2021 using our strategy net of transaction fees, gas costs and drawdowns.

Results

Our battle-tested strategy yielded a 263.55% annualised return during the backtesting period with a maximum drawdown of only -8.98%.
The figure depicts the drawdown experienced while executing the strategy

Implementation Details

Install or Update Python

Before installing Polysynth, install Python 3.7.2 or later. For information about how to get the latest version of P~ython, see the official Python documentation.

Install Polysynth

Install the latest release via pip:
pip install polysynth or pip3 install polysynth
​
# If you have a version of polysynth installed already. Please make sure it is upgraded to the latest version using:
​
pip install --upgrade polysynth

Initialising the Polysynth Class

from polysynth import Polysynth
address = "YOUR WALLET ADDRESS" # or None if you're not going to make transactions
private_key = "YOUR PRIVATE KEY" # or None if you're not going to make transactions
provider = "https://rpc-mumbai.matic.today" # can look at other recommended RPCs here
polysynth = Polysynth(address=address, private_key=private_key, provider=provider)

Setting up Kalman Filter

Install the latest release of pykalman module via pip
from pykalman import KalmanFilter
​
class py_KalmanFilter:
​
def __init__(self, data):
self.kf = KalmanFilter(transition_matrices = [1],
observation_matrices = [1],
initial_state_mean = 0,
initial_state_covariance = 1,
observation_covariance = 1,
transition_covariance = 0.1)
​
self.filtered_state_means, self.filtered_state_covariances = self.kf.filter(data)
​
def update(self, data):
self.filtered_state_means, self.filtered_state_covariances = (self.kf.filter_update(
self.filtered_state_means.reshape(1)[-1],
self.filtered_state_covariances[-1],
data))
​
return self.filtered_state_means.reshape(1)[-1]

Trading Engine

The trading engine includes methods for all the required computation, data processing and signal generation
import datetime
import pickle
import pandas as pd
import numpy as np
​
class TradingEngine:
​
def __init__(self, symbol, multiplier, weight_cutoff, lookback):
print ("Engine initialised for {}....".format(symbol))
self.symbol = symbol
self.multiplier = multiplier # multiplier for the volatility of spread
self.weight_cutoff = weight_cutoff # to generate signal only when spread is closer to extremes
self.lookback = lookback
self.count = 0
self.prev_mark = None
self.mark = None
self.prev_index = None
self.index = None
self.spread = None
self.prev_spread = None
self.position = None
self.prev_day = None
self.pos_data = []
self.spread_factor = 50000
self.exit_trigger = 0.01
self.stoploss_trigger = 0.02
self.trade_df = pd.DataFrame([], columns = ['entry_time', 'dir', 'entry_price', 'spread', 'exit_time', 'exit_price'])
self.ohlc_df = pd.DataFrame([], columns = ['time', 'open', 'high', 'low', 'close'])
self.spread_df = pd.DataFrame([], columns = ['time', 'spread'])
self.signal_dict = {}
self.signal_dict['5min'] = {'buy_spd': None, 'sell_spd': None}
​
def EMA(self, df, base, target, period, alpha = False):
​
con = pd.concat([df[:period][base].rolling(window=period).mean(), df[period:][base]])
​
if (alpha == True):
df[target] = con.ewm(alpha=1 / period, adjust=False).mean()
else:
df[target] = con.ewm(span=period, adjust=False).mean()
​
df[target].fillna(0, inplace=True)
return df
​
def ATR(self, df, period = 14, ohlc=['open', 'high', 'low', 'close']):
atr = 'ATR_' + str(period)
​
# Compute true range only if it is not computed and stored earlier in the df
if not 'TR' in df.columns:
df['h-l'] = df[ohlc[1]] - df[ohlc[2]]
df['h-yc'] = abs(df[ohlc[1]] - df[ohlc[3]].shift())
df['l-yc'] = abs(df[ohlc[2]] - df[ohlc[3]].shift())
​
df['TR'] = df[['h-l', 'h-yc', 'l-yc']].max(axis=1)
​
df.drop(['h-l', 'h-yc', 'l-yc'], inplace=True, axis=1)
​
# Compute EMA of true range using ATR formula after ignoring first row
self.EMA(df, 'TR', atr, period, alpha=True)
​
return df
​
def calculate_spread(self):
self.adj_spread = (self.mark - self.index) + self.spread_factor #factor added to ensure positive spread for ATR calc
self.spread = (self.mark - self.index)
​
def process_data(self, timestamp):
self.min_ts = timestamp
self.day = timestamp.day
if self.prev_day != self.day:
self.porfolio_sl_count = 0
​
if self.index is not None and self.mark is not None and ((self.prev_mark != self.mark) or (self.prev_index != self.index)):
self.calculate_spread()
​
if self.count == 0:
self.kf = py_KalmanFilter(self.spread)
else:
spread_kalman = self.kf.update(self.spread)
​
if self.count > 15: # wait time for kalman filter to tune its hyperparameters
if self.min_ts.minute % 5 == 0:
self.spread_df.set_index('time', inplace = True)
self.sampled_df = self.spread_df.spread.resample('5 Min').ohlc()
self.sampled_df.reset_index(inplace = True)
self.ohlc_df = pd.concat([self.ohlc_df, self.sampled_df], axis = 0)
self.ohlc_df.dropna(inplace = True)
with open('ohlc_df_{}.pickle'.format(self.symbol), 'wb') as f:
pickle.dump(self.ohlc_df, f)
​
self.spread_df = pd.DataFrame([], columns = ['time', 'spread'])
self.ohlc_df.set_index('time', inplace = True)
​
self.minval = np.min(self.ohlc_df.close.values[-self.lookback:]) - self.spread_factor
self.maxval = np.max(self.ohlc_df.close.values[-self.lookback:]) - self.spread_factor
weight = (spread_kalman - self.minval)/(self.maxval - self.minval)
weight = min(max(0, weight), 1)
​
self.ohlc_df.reset_index(inplace = True)
self.short_atr_df = self.ATR(self.ohlc_df.copy())
self.short_atr_df.dropna(inplace = True)
​
if len(self.short_atr_df) > 0:
min_atr = self.short_atr_df.iloc[-1]['ATR_14']
if min_atr > 0:
buy_spd = round(spread_kalman - min_atr * self.multiplier * weight * 2, 2)
sell_spd = round(spread_kalman + min_atr * self.multiplier * (1-weight) * 2, 2)
​
if weight < self.weight_cutoff or weight > (1 - self.weight_cutoff):
self.signal_dict['5min']['buy_spd'] = buy_spd
self.signal_dict['5min']['sell_spd'] = sell_spd
​
self.spread_df.loc[len(self.spread_df)] = [self.min_ts, self.adj_spread]
​
self.count += 1
self.prev_index = self.index
self.prev_mark = self.mark
self.prev_day = self.day
def check_signal(self):
if self.position == "LONG" and (self.mark/self.entry_price - 1 > self.exit_trigger or self.mark/self.entry_price - 1 < -self.stoploss_trigger):
print ("LONG trade EXITED at {} with mark price = {}; index price = {}; spread = {}".format(self.min_ts, self.mark, self.index, self.spread))
print ("Spread = {}".format(self.signal_dict['5min']))
if self.mark/self.entry_price - 1 < -self.stoploss_trigger:
self.porfolio_sl_count += 1 # check to avoid heavy drawdown at portfolio level in a day
self.pos_data.extend([self.min_ts, self.mark])
self.trade_df.loc[len(self.trade_df)] = self.pos_data
self.pos_data = []
self.position = None
if self.position == "SHORT" and (self.mark/self.entry_price - 1 > self.stoploss_trigger or self.mark/self.entry_price - 1 < -self.exit_trigger):
print ("SHORT trade EXITED at {} with mark price = {}; index price = {}; spread = {}".format(self.min_ts, self.mark, self.index, self.spread))
print ("Spread = {}".format(self.signal_dict['5min']))
if self.mark/self.entry_price - 1 > self.stoploss_trigger:
self.porfolio_sl_count += 1 # check to avoid heavy drawdown at portfolio level in a day
self.pos_data.extend([self.min_ts, self.mark])
self.trade_df.loc[len(self.trade_df)] = self.pos_data
self.pos_data = []
self.position = None
if self.spread < self.signal_dict['5min']['buy_spd'] and self.position == None and self.porfolio_sl_count <= 3:
print ("LONG trade ENTERED at {} with mark price = {}; index price = {}; spread = {}".format(self.min_ts, self.mark, self.index, self.spread))
print ("Spread = {}".format(self.signal_dict['5min']))
self.pos_data = [self.min_ts, 'LONG', self.mark, self.spread]
self.entry_price = self.mark
self.position = "LONG"
if self.spread > self.signal_dict['5min']['sell_spd'] and self.position == None and self.porfolio_sl_count <= 3:
print ("SHORT trade ENTERED at {} with mark price = {}; index price = {}; spread = {}".format(self.min_ts, self.mark, self.index, self.spread))
print ("Spread = {}".format(self.signal_dict['5min']))
self.pos_data = [self.min_ts, 'SHORT', self.mark, self.spread]
self.entry_price = self.mark
self.position = "SHORT"

Runner

Integrates all modules and triggers every minute
Code snippet for Backtesting
te = TradingEngine('ETH', 3, 0.5, 288)
data = pd.read_csv('Combined_ProcessedData.csv') # link to access the csv is mentioned below
data['datetime'] = pd.to_datetime(data['datetime'])
data.set_index('datetime', inplace=True)
​
for index, row in data.iterrows():
te.mark = row['mark_price']
te.index = row['index_price']
te.process_data(index)
if te.signal_dict['5min']['buy_spd'] is not None:
te.check_signal()
Code snippet for deploying on Polysynth Testnet. To deploy the same on Mainnet; we recommend using a dedicated free RPC URL from Infura, as public RPCs may have traffic or rate-limits depending on usage. Anyways, if you want to use a public RPC or dedicated RPCs other than Infure you can find them here.
from apscheduler.schedulers.blocking import BlockingScheduler
from polysynth import Polysynth
​
class Runner:
​
def __init__(self):
self.provider = "https://rpc-mumbai.matic.today" #Testnet RPC
self.address = "YOUR WALLET ADDRESS"
self.private_key = "YOUR PRIVATE KEY"
self.polysynth = Polysynth(address=self.address, private_key=self.private_key, provider=self.provider)
self.te = TradingEngine('ETH', 3, 0.5, 288)
def run_trading_step(self):
self.te.mark = self.polysynth.mark_price('ETH-USDC')['data']['mark_price']
self.te.index = self.polysynth.index_price('ETH-USDC')['data']['index_price'] #index price is updated sporadically on Testnet
self.te.process_data(datetime.datetime.now())
if self.te.signal_dict['5min']['buy_spd'] is not None:
self.te.check_signal()
runner = Runner()
sched = BlockingScheduler()
sched.add_job(runner.run_trading_step, 'cron', minute = '*')
sched.start()
Note: Please add open_position and close_position calls to the Trading Engine module for placing trades on Testnet/Mainnet. Kindly refer to the SDK documentation here​

Assumptions

  • Platform transaction cost + Gas Fees are assumed to be 0.05%
  • Funding Payment earned is not accounted for in the P&L. The strategy by design aims to take convergent trades hence will earn funding payment rather than pay it
  • Trading rewards in form of POL tokens are not accounted for in the P&L. This could lead to an additional overall 900k POL token payout on a weekly basis across all traders
  • Position sizing per trade isn’t implemented in this version of the strategy [it is assumed to be 1 unit of the base asset per trade]. Position sizing strategy should be based on the discretion of the trader
  • Positions can be increased/decreased as per the signals. This version of the strategy doesn’t implement incremental position size management
  • Slippage cost is assumed to be 0.10% per trade [while opening positions the slippage tolerance threshold can be set to 0 or lesser value than 0.10%].
  • Price impact cost is based on position size and vAMM parameters. It is assumed to be 0.15% per trade on a conservative basis

Data

Further Considerations for the Pros

  • The exit triggers are kept static for simplicity. The trader can make them dynamic based on the volatility of the underlying asset
  • All other parameters of the strategy can be tinkered with to yield superior performance
  • The exit signal can also be based on the opposite side of the spread trade. That protects the fund from a drawdown in case the index price experiences higher volatility as compared to the platform