Skip to content

Technical Analysis

Underdog's TA module provides a series of tools for financial technical analysis.

Tutorial

Prerequisites

Dependencies

To be able to follow the tutorial you should add the following modules to your gradle project:

// technical analysis
implementation 'com.github.grooviter:underdog-ta:VERSION'

// plots
implementation 'com.github.grooviter:underdog-plots:VERSION'
<!-- technical analysis -->
<dependency>
    <groupId>com.github.grooviter</groupId>
    <artifactId>underdog-ta</artifactId>
    <version>VERSION</version>
</dependency>
<!-- plots -->
<dependency>
    <groupId>com.github.grooviter</groupId>
    <artifactId>underdog-plots</artifactId>
    <version>VERSION</version>
</dependency>
@Grapes([
    // technical analysis
    @Grab('com.github.grooviter:underdog-ta:VERSION'),
    // plots
    @Grab('com.github.grooviter:underdog-plots:VERSION')
])

Data

You can find the data used in this tutorial here

BarSeries vs DataFrame

Info

This getting started section mimics the getting started section steps from Ta4j wiki but using underdog-ta module. You can compare both entries to see the differences.

It's important to start defining some concepts.

  • BarSeries: Ta4j's BarSeries is like a dataframe containing series (columns) such as open price, lower price, close price, and volume data.

  • DataFrame: Underdog's dataframe which is composed of different Series (columns), Each Series or column is like an array of objects.

As this module integrates Ta4j with Underdog there will be methods which converts from BarSeries to DataFrame and vice versa.

Loading data

In this example we are using Underdog to load stock quotes from a csv file and create a DataFrame:

stock quotes from csv
// The file path where the csv file can be found
def filePath = "src/test/resources/data/ta/stock_quotes_10_years.csv"

// The format of the dates included in the file
def dateFormat = "yyyy-MM-dd HH:mm:ss+00:00"

def quotes = Underdog.df().read_csv(filePath, dateFormat: dateFormat)

Warning

If dates are not treated as dates the csv reader will consider them as strings. That will cause you problems when converting an Underdog's DataFrame to Ta4j's BarSeries.

Which outputs something like the following (the prices and volume are truncated here to make it look good).

output
                                stock_quotes_10_years.csv
           Date             |  Adj Close  |  Close |  High  |  Low  |  Open  |   Volume |
-----------------------------------------------------------------------------------------
 2014-12-05 00:00:00+00:00  |   0.50      |  0.52  |  0.52  |  0.52 |  0.52  |  165680  |

In order to successfully convert the dataframe to a bar series the name of the series (columns) should match to the expected ones which are: DATE, CLOSE, HIGH, LOW, OPEN, VOLUME.

renaming
quotes = quotes
    .drop("Adj Close") // Removing the "Adj Close" series
    .renameSeries(fn: String::toUpperCase) // Renaming all remaining columns to upper case to match
output
                          stock_quotes_10_years.csv
           DATE             |  CLOSE |  HIGH  |  LOW  |  OPEN  |  VOLUME  |
---------------------------------------------------------------------------
 2014-12-05 00:00:00+00:00  |  0.52  |  0.52  |  0.52 |  0.52  |  165680  |

DataFrame to BarSeries

Because we need to convert the dataframe to a BarSeries in order to create technical analysis rules:

to bar series
def quotesBarSeries = quotes.toBarSeries()

Now we can operate with the bar series.

Indicators

Now we can start creating some indicators and metrics. This time we are getting metrics based on the closing price indicator:

indicators
// Using the close price indicator as root indicator...
def closePrice = quotesBarSeries.closePriceIndicator
// Getting the simple moving average (SMA) of the close price over the last 5 bars
SMAIndicator shortSma = closePrice.sma(5)

// Here is the 5-bars-SMA value at the 42nd index
println("5-bars-SMA value at the 42nd index: " + shortSma.getValue(42).doubleValue())

// Getting a longer SMA (e.g. over the 30 last bars)
SMAIndicator longSma = closePrice.sma(30)
output
5-bars-SMA value at the 42nd index: 0.503899997472763

Building a trading strategy

Now that we've got a couple of indicators ready lets build a strategy. Strategies are made of two trading rules: one for entry (buying), the other for exit (selling).

strategy base
// Buying rules
// We want to buy:
//  - if the 5-bars SMA crosses over 30-bars SMA
//  - or if the price goes below a defined price (e.g $800.00)
Rule buyingRule = shortSma.xUp(longSma)
    .or(closePrice.xDown(120))

// Selling rules
// We want to sell:
//  - if the 5-bars SMA crosses under 30-bars SMA
//  - or if the price loses more than 3%
//  - or if the price earns more than 2%
Rule sellingRule = shortSma.xDown(longSma)
    .or(closePrice.stopLoss(3))
    .or(closePrice.stopGain(2))

// Create the strategy
Strategy strategy = new BaseStrategy(buyingRule, sellingRule)

We can use Groovy's syntax to refactor the rules a little bit:

strategy using operators
buyingRule = shortSma.xUp(longSma)
    | closePrice.xDown(120)

sellingRule = shortSma.xDown(longSma)
    | closePrice.stopLoss(3)
    | closePrice.stopGain(2)

In Groovy | and & represent calls to methods or and and of any object (any object having those methods implemented). So if your object has these methods, you can substitute your method call by the operators.

Backtesting

What is backtesting ? According to Investopedia:

"Backtesting is the general method for seeing how well a strategy or model would have done after the fact. It assesses the viability of a trading strategy by discovering how it would play out using historical data. If backtesting works, traders and analysts may have the confidence to employ it going forward" -- Investopedia, https://www.investopedia.com/terms/b/backtesting.asp

The backtest step is pretty simple:

backtesting
// Running our juicy trading strategy...
BarSeriesManager seriesManager = new BarSeriesManager(quotesBarSeries)
TradingRecord tradingRecord = seriesManager.run(strategy)
println("Number of positions (trades) for our strategy: " + tradingRecord.getPositionCount())
output
Number of positions (trades) for our strategy: 57

For many scenarios we can run a base strategy backtesting by just executing the run method in the BarSeries object and pass directly the entry (buying) rule and the exit (selling) rule:

backtesting
tradingRecord = quotesBarSeries.run(buyingRule, sellingRule)

We can see this visually. Follow-up showing only trades from 2024-04-01:

plotting trades
// getting only stocks from 2024-04-01
quotes = quotes[quotes['DATE'] >= LocalDate.parse('2024-04-01')]

// getting Underdog's series for x and y coordinates
def xs = quotes['DATE'](LocalDate, String) { it.format("dd/MM/yyyy") }
def ys = quotes['CLOSE']

// building a line plot
def plot = Underdog.plots()
    .line(
        xs.rename("Dates"),
        ys.rename("Closing Price"),
        title: "Trades from 2024-01-01",
        subtitle: "Using Underdog's TA and Ta4j")

// showing trades over stock quotes
tradingRecord.trades.each {trade ->
    String x = quotesBarSeries.getBar(trade.index).endTime.format('dd/MM/yyyy')
    double y = trade.value.doubleValue()
    String t = trade.type.name()[0] // first letter of type name ('B' for buy, 'S' for sell)
    plot.addAnnotation(x, y, text: t, color: t == 'B' ? 'green' : '#dd7474')
}

// showing the plot
plot.show()

We can also visualize winning vs losing positions.

winning vs losing
// creating a function to map every position to a map containing date and profit
def positionToDataFrameEntry = { Position pos ->
    return [
        date: quotesBarSeries.getBar(pos.exit.index).endTime.toLocalDate(),
        profit: pos.grossProfit.doubleValue()
    ]
}

// getting gross profit and date of every position
def wins = tradingRecord
    .positions
    .collect(positionToDataFrameEntry)
    .toDataFrame("trade values")

// mark every position as winners/losers
wins['winner'] = wins['profit'](Double, String) { it > 0 ? "winners" : "losers" }

// grouping by winners/losers and get the count
def byMonth = wins
    .agg(profit: 'count')
    .by('winner')
    .renameSeries(mapper: ["count [profit]": "value", winner: "name"])

// getting the min/max date for the subtitle of the plot
def minDate = wins['date'].min(LocalDate).format("dd/MM/yyyy")
def maxDate = wins['date'].max(LocalDate).format("dd/MM/yyyy")

// building the plot
def piePlot = Underdog
    .plots()
    .pie(
        byMonth['name'].toList(),
        byMonth['value'].toList(),
        title: "Winners vs Losers positions",
        subtitle: "From ${minDate} to ${maxDate}"
    )

// showing the plot
piePlot.show()

Analyzing our results

Here is how we can analyze the results of our backtest:

analysis
// Getting the winning positions ratio
AnalysisCriterion winningPositionsRatio = new PositionsRatioCriterion(AnalysisCriterion.PositionFilter.PROFIT)
double winningPositionRatioValue = winningPositionsRatio.calculate(quotesBarSeries, tradingRecord).doubleValue()
println("Winning positions ratio: " + winningPositionRatioValue)

// Getting a risk-reward ratio
AnalysisCriterion romad = new ReturnOverMaxDrawdownCriterion()
double nomadValue = romad.calculate(quotesBarSeries, tradingRecord).doubleValue()
println("Return over Max Drawdown: " + nomadValue)

// Total return of our strategy vs total return of a buy-and-hold strategy
AnalysisCriterion vsBuyAndHold = new VersusEnterAndHoldCriterion(new ReturnCriterion())
double vsBuyAndHoldValue = vsBuyAndHold.calculate(quotesBarSeries, tradingRecord).doubleValue()
println("Our return vs buy-and-hold return: " + vsBuyAndHoldValue)
output
Winning positions ratio: 0.54385964912280701754385964912281
Return over Max Drawdown: 3.4519153297405649777237484258582
Our return vs buy-and-hold return: 0.0040904249296843023287166775087440

Showing these metrics in a chart:

radar
def winningRatioPlot = Underdog.plots()
    .radar(
        // names of metrics
        ['winning ratio', 'return over drawdown', 'return vs buy-and-hold'],
        // maximum possible value of each metric
        [1, 100, 1],
        // values from metrics
        [winningPositionRatioValue, nomadValue, vsBuyAndHoldValue],
        title: "Metrics comparison",
        subtitle: "Winning Ratio / Risk Reward Ratio / Return vs Buy-And-Hold"
    )
winningRatioPlot.show()

Which displays: