Skip to content

RelativeRotationSeries

H.P. Gansevoort edited this page Oct 21, 2026 · 5 revisions

RelativeRotationSeries

v1.11.0 — A Relative Rotation Graph (RRG). It plots RS-Ratio against RS-Momentum for each asset relative to a benchmark, draws a fading tail, and marks the four quadrants.

Quick start

using MatPlotLibNet;

// 104 weekly closes per asset: a seeded random walk stands in for your price data
var rng = new Random(42);
double[] btcWeekly = Weekly(40_000, rng);
double[] ethWeekly = Weekly(3_000, rng);
double[] bnbWeekly = Weekly(400, rng);
double[] solWeekly = Weekly(120, rng);

double[][] assetCloses    = [ethWeekly, bnbWeekly, solWeekly];
double[]   benchmarkClose = btcWeekly;
string[]   labels         = ["ETH", "BNB", "SOL"];

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .RelativeRotation(assetCloses, benchmarkClose, labels))
    .Save("rrg.svg");

static double[] Weekly(double price, Random rng)
{
    var closes = new double[104];
    for (var i = 0; i < closes.Length; i++)
    {
        price *= 1 + 0.08 * (rng.NextDouble() - 0.45);
        closes[i] = price;
    }
    return closes;
}

What is an RRG?

A Relative Rotation Graph (invented by Julius de Kempenaer) plots each asset in a 2D space:

  • X-axis (RS-Ratio): how strong the asset's relative trend is compared to the benchmark. A value above 100 means the asset outperforms the benchmark; a value below 100 means it underperforms.
  • Y-axis (RS-Momentum): whether that relative trend is accelerating or decelerating. A value above 100 means momentum is improving; a value below 100 means it is weakening.

The 100/100 centrepoint divides the chart into four quadrants:

Quadrant RS-Ratio RS-Momentum Meaning
Leading (top-right) > 100 > 100 Outperforming and accelerating — buy candidates
Weakening (bottom-right) > 100 < 100 Still outperforming but momentum fading
Lagging (bottom-left) < 100 < 100 Underperforming and decelerating
Improving (top-left) < 100 > 100 Underperforming but momentum recovering

The canonical rotation is clockwise: Leading → Weakening → Lagging → Improving → Leading. It describes how assets rotate in a typical market cycle.

Fluent entry point

// On AxesBuilder (inside AddSubPlot):
ax.RelativeRotation(
    assetCloses:     double[][],        // jagged: one row per asset
    benchmarkCloses: double[],          // same length as each asset row
    assetLabels:     string[],          // one label per asset
    configure:       Action<RelativeRotationSeries>?  // optional
);

Properties

Property Type Default Description
Formula RrgFormula DualEma Computation model (see below)
ShortPeriod int 10 Short EMA window / z-score window
LongPeriod int 26 Long EMA window (DualEma only)
MomentumLookback int 10 ROC lookback period (ZScore / LogReturn)
TailLength int 8 How many historical points to draw as a fading trail
ShowQuadrantGrid bool true Draw the 100/100 crosshair and four quadrant fills
ColorMap IColorMap? null (Tab10) Per-asset colour ramp
AbsorptionRatioPerBar double[]? null Per-bar absorption ratio [0..1]. When set, trail dots are coloured through a green-to-red diverging map (RdYlGn reversed); the asset colour becomes the edge ring instead of the fill.
EnbPerBar double[]? null Per-bar Effective Number of Bets (ENB, Meucci 2009). When set, each dot's radius scales with ENB (max(1.5, enb × 1.5) px); the head is 1.5× larger than trail dots.

Absorption + ENB overlay (Layer 2+3 feedback)

When AbsorptionRatioPerBar or EnbPerBar (or both) are set the renderer switches to overlay mode:

  • A gray ghost trail polyline is drawn behind the per-point circles. Its alpha fades from 0.2 at the oldest point to 1.0 at the newest.
  • Each bar gets its own circle. The absorption colormap sets its fill (green means low absorption, or safe; red means high absorption, or panic), and its radius scales with ENB.
  • When neither overlay is set, the original behaviour applies: fading coloured trail lines and a single head dot.
// Computed elsewhere, one value per bar: the same length as the close series
double[] absorption = [.. Enumerable.Range(0, 104).Select(bar => 0.2 + 0.6 * (bar % 26) / 26.0)];
double[] enb        = [.. Enumerable.Range(0, 104).Select(bar => 1.0 + bar % 5)];

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .RelativeRotation(assetCloses, benchmarkClose, labels, s =>
        {
            s.AbsorptionRatioPerBar = absorption;
            s.EnbPerBar             = enb;
            s.TailLength            = 12;
        }))
    .Save("rrg-overlay.svg");

Visual encoding summary:

Overlay Not set Set
AbsorptionRatioPerBar Dot filled with asset colour Dot filled green-to-red (RdYlGn); asset colour becomes the edge ring
EnbPerBar Head is a fixed 5 px Radius is max(1.5, enb × 1.5) px; head is 1.5× larger
Trail style Fading coloured polyline Gray ghost trail and per-point circles

Formulas (RrgFormula enum)

DualEma (default)

This is the canonical JdK formula. It makes no mean-reversion assumption, and it is well suited to trending markets such as crypto.

RS(t)         = AssetClose(t) / BenchmarkClose(t) × 100
RsRatio(t)    = EMA(RS, shortPeriod) / EMA(RS, longPeriod) × 100
RsMomentum(t) = EMA(RsRatio, shortPeriod) / EMA(RsRatio, longPeriod) × 100

Minimum bars required: longPeriod × 2 + shortPeriod bars are needed to produce at least one valid momentum point. With the defaults (short=10, long=26) that is 62 bars. Use ShortPeriod=3; LongPeriod=5 for shorter datasets.

ZScore

This is the mean-reversion formulation. It suits assets that oscillate around a stable benchmark, such as equities against an index or sector ETFs against SPY.

RsRatio(t)    = 100 + (RS(t) − SMA(RS, w)) / StdDev(RS, w)
Roc(t)        = RS(t) / RS(t − MomentumLookback) − 1
RsMomentum(t) = 100 + (Roc(t) − SMA(Roc, w)) / StdDev(Roc, w)

LogReturn

This is the log-return formulation. It combines long- and short-period log returns, and it suits high-volatility assets where percentage changes are large.

RsRatio(t)    = ZScore formulation (same as above)
RsMomentum(t) = ln(1 + R_long(t)) − ln(1 + R_short(t))   [then z-score wrapped]

Worked example — crypto rotation radar

using MatPlotLibNet;
using MatPlotLibNet.Styling.ColorMaps;

// 104 weekly bars per asset, vs BTC benchmark
var rng = new Random(42);
double[] btc = GenerateWalk(104, start: 40_000, drift: 0.005, vol: 0.06, rng);

double[][] alts = [
    GenerateWalk(104, start: 3_000, drift:  0.007, vol: 0.08, rng),  // ETH
    GenerateWalk(104, start:   400, drift:  0.003, vol: 0.07, rng),  // BNB
    GenerateWalk(104, start:   120, drift:  0.009, vol: 0.10, rng),  // SOL
];

Plt.Create()
    .WithTitle("Crypto Coin Rotation — Weekly — DualEma(10,26)")
    .WithSize(900, 700)
    .AddSubPlot(1, 1, 1, ax => ax
        .SetXLabel("RS-Ratio")
        .SetYLabel("RS-Momentum")
        .RelativeRotation(alts, btc, ["ETH", "BNB", "SOL"], s =>
        {
            s.TailLength       = 12;   // show 12-week trail
            s.ShowQuadrantGrid = true;
            s.ColorMap         = ColorMaps.Plasma;
        }))
    .Save("crypto-rotation.svg");

// A geometric random walk: each week moves by the drift plus a random step of up to the volatility
static double[] GenerateWalk(int n, double start, double drift, double vol, Random rng)
{
    var closes = new double[n];
    var price = start;
    for (var i = 0; i < n; i++)
    {
        price *= 1 + drift + vol * (2 * rng.NextDouble() - 1);
        closes[i] = price;
    }
    return closes;
}

Changing formula

using MatPlotLibNet.Models.Series;

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .RelativeRotation(assetCloses, benchmarkClose, labels, s =>
        {
            s.Formula          = RrgFormula.ZScore;
            s.ShortPeriod      = 14;
            s.MomentumLookback = 14;
        }))
    .Save("rrg-zscore.svg");

Minimum data requirements

Formula Minimum bars for valid points
DualEma longPeriod + (longPeriod - 1) + shortPeriod ≈ 2 × longPeriod + shortPeriod
ZScore shortPeriod + momentumLookback
LogReturn longPeriod + shortPeriod

With the defaults, DualEma needs at least 62 bars, ZScore needs at least 20 bars, and LogReturn needs at least 36 bars.

Serialization

RelativeRotationSeries fully round-trips through ChartSerializer:

using MatPlotLibNet.Serialization;

var figure  = Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax.RelativeRotation(assetCloses, benchmarkClose, labels))
    .Build();
string json = new ChartSerializer().ToJson(figure);
var restored = new ChartSerializer().FromJson(json);

Default values (DualEma, ShortPeriod=10, LongPeriod=26, MomentumLookback=10, TailLength=8, ShowQuadrantGrid=true) are not emitted to JSON. AbsorptionRatioPerBar and EnbPerBar are emitted only when they are non-null, as the rrgAbsorptionPerBar and rrgEnbPerBar JSON fields.

Related

Clone this wiki locally