pekam / ameba-ts

A multi-asset backtester for TypeScript
MIT License
2 stars 0 forks source link

API proposal: compose strategies from common building blocks #3

Closed pekam closed 1 year ago

pekam commented 1 year ago

TLDR: See how to implement a trading strategy with the current API below, then see how it would be implemented with the proposed API.

Problem

In most cases the implementation of TradingStrategy follows the same steps:

const smaCrossoverStrategy: TradingStrategy = (state: AssetState) => {
  // 1. Get required indicator values
  const smaFast = getSma(state, 20);
  const smaSlow = getSma(state, 50);
  const atr = getAtr(state, 10);

  // 2. Wait for indicators to be ready
  if (!smaFast || !smaSlow || !atr) {
    return { entryOrder: null };
  }

  const currentPrice = last(state.series).close;

  // 3. Check if there's an active position or not
  if (!state.position) {
    // 4. Check entry filters (SMA crossover in this case) and cancel
    //    potentially existing entry if filters are not satisfied
    if (smaFast < smaSlow) {
      return { entryOrder: null };
    }

    // 5. Enter and set initial exits
    return {
      entryOrder: {
        side: "buy",
        type: "market",
      },
      stopLoss: currentPrice * 0.99,
      takeProfit: currentPrice + atr * 5,
    };
  } else {
    return {
      // 6. Manage exits
      stopLoss: Math.max(state.stopLoss || 0, currentPrice * 0.99),
    };
  }
};

This pattern includes some boilerplate, and makes it unnecessarily tedious to rapidly experiment with different settings.

Example 1: Let's say we want to have another entry filter. We want to confirm the trend strength by checking that the ADX indicator is above 50. We need to make three changes: get the ADX value, check that it's ready and write the actual filter check. Removing and adding entry filters is not as simple as it could be.

Example 2: Let's say we want to change the stoploss to at the 5-period low price (min of last 5 candle's lows). We need to change it in two places: when setting the initial stoploss and when managing existing position. If we decide to use an indicator for tracking the 5-period low, we need a couple of more changes.

Solution proposal

We can recognize that the strategy is composed of following building blocks, which contain all the information to perfectly define the strategy:

The proposed API includes types for these building blocks and a function to compose a TradingStrategy from them:

const NOT_READY = "notready";

type EntryFilter = (state: AssetState) => boolean;
type Entry = (state: AssetState) => SizelessOrder | typeof NOT_READY;
type Exit = (state: AssetState) => Nullable<number> | typeof NOT_READY;

function composeStrategy(args: {
  filters: EntryFilter[];
  entry: Entry;
  takeProfit: Exit;
  stopLoss: Exit;
}): TradingStrategy

If any of the components returns the NOT_READY special value, composeStrategy knows that it shouldn't do anything yet.

At this point the usage would look something like this:

const smaCrossoverStrategy = composeStrategy({
  filters: [
    (state) => {
      const smaFast = getSma(state, 20);
      const smaSlow = getSma(state, 50);
      return !!smaFast && !!smaSlow && smaFast > smaSlow;
    },
  ],
  entry: () => ({
    type: "market",
    side: "buy",
  }),
  takeProfit: (state) => {
    const atr = getAtr(state, 10);
    if (!atr) {
      return NOT_READY;
    }
    return last(state.series).close + atr * 5;
  },
  stopLoss: (state) =>
    Math.max(state.stopLoss || 0, last(state.series).close * 0.99),
});

This is not yet very concise, and we still need to manually check when indicators are ready. The real power of this API comes when we turn these strategy components into reusable building blocks.

Reusable building blocks

Let's start with filters and add a helper function that we can use if an indicator (or some other value) is above another, and handles the undefined-check:

type ValueProvider = (state: AssetState) => number | undefined;
function isAbove(
  valueProvider1: ValueProvider,
  valueProvider2: ValueProvider
): EntryFilter {
  return (state) => {
    const value1 = valueProvider1(state);
    const value2 = valueProvider2(state);
    return isDefined(value1) && isDefined(value2) && value1 > value2;
  };
}

// Usage:
composeStrategy({
  filters: [
    isAbove(
      (state) => getSma(state, 20),
      (state) => getSma(state, 50)
    )
  ],
...

We can improve this by adding a curried version of the indicator (needs to be done separately for all indicators unfortunately):

const sma = (period: number, indexFromEnd?: number) => (state: AssetState) =>
  getSma(state, period, indexFromEnd);

// Usage:
composeStrategy({
  filters: [
    isAbove(sma(20), sma(50))
  ],
...

Now defining this filter is a pleasure. We could add even higher abstraction like smaCrossoverFilter(20, 50), but IMO the previous API is simple-enough while remaining generic for many other use cases.

Example strategy with the new API

After adding similar building blocks for entries and exits, creating our example strategy will look like this:

const smaCrossoverStrategy = composeStrategy({
  filters: [
    isAbove(sma(20), sma(50))
  ],
  entry: marketBuy,
  takeProfit: atrTakeProfit(10, 5), // ATR-period and multiplier
  stopLoss: trailingPercentStopLoss(0.01),
});

Making changes to a strategy

Now, assuming that we've implemented the required building blocks, let's do the changes described earlier in the problem description: add an ADX-filter and change stoploss to trail at 5-period low (assuming we've implemented the required building blocks). Let's also change the entry to be a stop order at the highs of last 10 candles.

const smaCrossoverStrategy = composeStrategy({
  filters: [
    isAbove(sma(20), sma(50)),
    isAbove(adx(30), 50) // isAbove changed to accept numbers as well
  ],
  entry: buyBreakout(10),
  takeProfit: atrTakeProfit(10, 5),
  stopLoss: trailingMinStopLoss(5),
});

As we can see, changing the components is straight-forward with this API. Changes are not needed in several places and the reusability of building blocks makes the code free of boilerplate.

Considerations

Candle trading patterns work symmetrically in both directions. The strategy components could be direction-agnostic, meaning that they would work out-of-the-box for both long and short trades. For example, isAbove would be isTowardsTradeDirection (needs a better name) and buyBreakout would be enterBreakout. With direction-agnostic components, there should be another parameter that defines whether to trade long, short or both.