LiuAlgoTrader — Part II — Putting it all together
This article goes over an end-to-end example of a real-life trading strategy backtested with LiuAlgoTrader Framework
Please note that this article is for educational purposes only. Alpaca does not recommend any specific securities or investment strategies.
Part I introduced the LiuAlgoTrader framework. This post presents an end-to-end example of a real-life trading strategy.
If you read Part I and still struggle with your local environment, head over to Liu Chat room and post your question, or check the Issues section on Github.
The post has three segments:
- Details of the trading strategy
- Step-by-Step tutorial for implementing the strategy using Liu
- Backtesting and analyzing the results
Details of the trading strategy
The strategy presented here is for educational purposes only; it is not investment advice.
The strategy trades stock-pairs volatility: a pair of inversely-correlated equities is selected, and at any given time, one of the two is picked based on underlying market conditions.
The strategy would go long on the picked equity, at the end of a down-turn, and sell when the stock starts to trend down. There is no traditional “stop-loss” in this strategy.
The strategy calculates Bollinger Band with a seven-day Exponential Moving Average window and 1 STD either up or down.
The buy signal is triggered if:
- The previous trading day closed below the lower band,
- In the current day, the equity gaps and opens above the last day’s close price,
- The current price is trending above the lower band.
The strategy will close the position when the price crosses above the upper band.
There are three steps to this particular strategy: picking the correct stock pairs, selecting the appropriate pair member at any given time, and implementing the “band-trading” strategy on the chosen asset.
It isn't straightforward, but we will break it step by step to ensure it’s all understood. As said in Part I we’re not here for the easy; we are here for winning.
The idea, though, is not that complex: pick a stock that has high volatility; when it’s trending, there are good chances for excellent profits on good entry and exit points. Adding the inverse correlated makes a profit on a down-trend. In some cases, it’s possible to short on down-trends, but that requires a different discussion.
I picked two leveraged ETFs in this example, which are by definition reversely correlated: JNUG and JDST. They are both Leveraged (2x) ETFs on the GDXJ index. Word of caution: before picking up leveraged ETFs, you need to do your research well, understand the dynamics (especially for the bearish leveraged ETFs) of the picked stocks, as well as the underlying market dynamics.
For the current example, I set up two Liu components: “Scanner” and “Strategy” The Scanner object calculates 50-days Simple Moving Average (SMA) if the current GDXJ is above the SMA: go long on JNUG; otherwise, go long on JDST. The Strategy object is generic; it trades the bands, regardless of the passed asset.
Motivation: Before we look at the code, let us review the combined strategy results. Below are three tear sheets.
The first tear sheets show the results pair-wise between June 2020 and June 2021.
The second shows what it would be like to trade the JNUG on up-trends.
The third what it would be like to trade on JDST on GDXJ down-trends.
As you can see, there are times where each would be a good choice based on the underlying market conditions, but neither is sustainable compared to S&P500. Putting them both together produces stellar results that beat S&P500 thru choppy waters for Gold.
Step-by-Step tutorial for implementing the strategy using Liu
Before we start, I suggest you create your repo for Liu strategies. In that repo, you should have a folder for scanners
, strategy
and also keep track of your tradeplan. toml
file.
To create a scanner, we need to create a class that inherits from the Scanner base class. Let’s start with importing the relevant Liu packages:
from liualgotrader.scanners.base import Scanner
from liualgotrader.common.data_loader import DataLoader
DataLoader implements a DataFrame-like construct that automatically loads data from a data-provider abstraction (more on that, in later posts). We will see how to use it shortly.
Next is the Scanner class definition:
class GoldDigger(Scanner):
name = “GoldDigger”
def __init__(
self,
data_loader: DataLoader,
recurrence: Optional[timedelta] = None,
target_strategy_name: str = None,
):
super().__init__(
name=self.name,
data_loader=data_loader,
recurrence=recurrence,
target_strategy_name=target_strategy_name,
)
Our Class, GoldDigger, inherits from the Scanner base class. Liu passes parameters to __init__()
: data_loader
, recurrence
, and target_strategy_name
are mandatory fields for any Scanner you will write.
Lastly, we need to implement the run()
function. Liu calls the run()
function at the start of the trading day and consecutively, based on the recurrence
parameter.
async def run(self, back_time: datetime = None) -> List[str]:
sma_50 = (
self.data_loader[“GDXJ”
.close[back_time — timedelta(days=90) : back_time]
.between_time(“9:30”, “16:00”)
.resample(“1D”)
.last()
.dropna()
.rolling(50)
.mean()
.iloc[-1]
)
return (
["JNUG"] if self.data_loader["GDXJ"].close[back_time] > sma_50 else ["JDST"]
)
Don’t you love how neat Python is? Few things to note in this excerpt:
- Everything in Liu is type-specific.
- Check out the DataLoader class usage. Wrapping a DataFrame makes it easy to operate on a
self.data_loader[ticker]
like an ordinary Pandas DataFrame, with the added benefit, that if data is missing, it will be automatically and optimally fetched from the data provider. More on that later.
The run function calculates SMA 50 and returns [“JNUG”] if the close price is above the moving average; otherwise, [“JDST”] is returned.
That’s all you need to do to get the Scanner going. Liu does all the heavy lifting behind the scenes: loading data, making sure it's continuously updated and consistent, passes your results to the proper strategy, and running it all in a multi-processing environment.
Here is a link to the complete code from my personal Liu strategies repo.
Next comes the Strategy. Strategy class starts quite similarly:
class BandTrade(Strategy):
def __init__(
self,
batch_id: str,
data_loader: DataLoader,
portfolio_id: str,
ref_run_id: int = None,
):
self.name = type(self).__name__
self.portfolio_id = portfolio_id
super().__init__(
name=type(self).__name__,
type=StrategyType.SWING,
batch_id=batch_id,
ref_run_id=ref_run_id,
schedule=[],
data_loader=data_loader,
)
This __init()__
function is slightly different:
- The strategy name is pulled directly from the Class name vs. statically defined in the Scanner. Both are valid, though I like the strategy way better.
- The
__init()__
function expectsportfolio_id
- a string passed from thetradeplan.toml
file. - Strategy type
StrategyType.SWING
. For further details, refer to the full documentation - An empty
schedule
list passed to the base class. An empty schedule will not be the case in Day-Trading strategies.
The Strategy Class is called by Liu either per symbol or with all positions together per execution cycle. To select which use the should_run_all()
function:
async def should_run_all(self):
return True
Liu uses asyncio, and most functions require async abbreviations. To learn more about Liu internals, read the official documentation.
If should_run_all()
returns True, as in our case, the platform will call the run_all()
inherited from the base class:
async def run_all(
self,
symbols_position: Dict[str, float],
data_loader: DataLoader,
now: datetime,
portfolio_value: float = None,
trading_api: tradeapi = None,
debug: bool = False,
backtesting: bool = False,
) -> Dict[str, Dict]:
actions = {}
if await self.is_buy_time(now) and not open_orders:
actions.update(
await self.handle_buy_side(
symbols_position=symbols_position,
data_loader=data_loader,
now=now,
)
)
if (
await self.is_sell_time(now)
and (
len(symbols_position)
or any(symbols_position[x] for x in symbols_position)
)
and not open_orders
):
actions.update(
await self.handle_sell_side(
symbols_position=symbols_position,
data_loader=data_loader,
now=now,
)
)
return actions
The function calls handle_buy_side()
if now
is inside the “buy window” or handle_sell_side()
if now
is inside the “sell window,” and there are open symbol positions. open_orders
is a platform parameter with all current open orders in-flight, while symbols_position
holds all current symbols_position
.
In our case, the GoldDigger scanner will return either “JNUG” or “JDST.” Hence symbols_position
will include either, with position 0 or above.
Let’s take a look at handle_buy_side()
:
async def handle_buy_side(
self,
symbols_position: Dict[str, float],
data_loader: DataLoader,
now: datetime,
) -> Dict[str, Dict]:
actions = {}
for symbol in symbols_position:
current_price = data_loader[symbol].close[now]
serie = (
self.data_loader[symbol]
.close[now — timedelta(days=30) : now]
.between_time(“9:30”, “16:00”)
)
if not len(serie):
serie = self.data_loader[symbol].close[now — timedelta(days=30) : now
]
resampled_close = serie.resample(“1D”).last().dropna()
bband = BBANDS(
resampled_close,
timeperiod=7,
nbdevdn=1,
nbdevup=1,
matype=MA_Type.SMA,
)
yesterday_lower_band = bband[2][-2]
today_lower_band = bband[2][-1]
yesterday_close = resampled_close[-2]
today_open = self.data_loader[symbol].open[
config.market_open.replace(second=0, microsecond=0)
]
if (
yesterday_close < yesterday_lower_band
and today_open > yesterday_close
and current_price > today_lower_band
):
yesterday_upper_band = bband[0][-2]
if current_price > yesterday_upper_band:
return {}
buy_indicators[symbol] = {
“lower_band”: bband[2][-2:].tolist(),
}
cash = await Accounts.get_balance(self.account_id)
shares_to_buy = cash // current_price
actions[symbol] = {
“side”: “buy”,
“qty”: str(shares_to_buy),
“type”: “limit”,
“limit_price”: str(current_price),
}
return actions
There are few subtleties to note in the function:
- The function returns a Dictionary with the symbol as a Dictionary key (=” JNUG” or “JDST”) and an action Dictionary that notifies Liu what action to execute. You may find details here.
- The
data_loader
passed to the strategy may differ by scale ( minute/daily); the Strategy needs to query thedata_loader
to understand if and how to resample. - The strategy uses TA-LIB, which may be a little tricky to install on some Windows distributions. You can find installation details here.
Here is a link to the complete Strategy code.
Backtesting and analyzing the results
It’s time to put it all together: construct the tradeplan. toml
file, execute the back-testing session, and view the results Notebook.
The first step is to create a portfolio_id
, to hold our trades:
$ liu create portfolio 10000 -credit=1000
The response should look like this:
[create_db_connection()][82171]2021–06–28 15:29:01.902386:db connection pool initialized
Portfolio ID ceb333aa-7668–493c-bc4d-a415ca47be5a created
Our tradeplan.toml
file:
[scanners]
[scanners.GoldDigger]
filename = “scanners/gold_digger.py”
recurrence = 5
target_strategy_name = “BandTrade”
[strategies.BandTrade]
filename = "strats/bandtrade.py"
portfolio_id = "ceb333aa-7668-493c-bc4d-a415ca47be5a"
Finally, it’s time to execute our back-testing session; type:
backtester from ‘2020–06–01’ -scanners=GoldDigger -strats=BandTrade
and watch the below unfolding:
The back-testing session runs for about 4 minutes on my local Mac (2019, 1.7 GHz Quad-Core i7 w/ 16 GB)
Congratulations
You just went thru an end-to-end development and execution of a year-long back-testing session. It is now time to view the results. Fire up your Jupyter Notebook and use the portfolio analysis notebook to view the results of your efforts:
Thank you for reading this post. Feel free to post questions, ideas, and suggestions on the LiuAlgoTrader Gitter page or open an issue on GitHub.
Brokerage services are provided by Alpaca Securities LLC ("Alpaca"), memberFINRA/SIPC, a wholly-owned subsidiary of AlpacaDB, Inc. Technology and services are offered by AlpacaDB, Inc.