Skip to content

RelativeRotationSeries

H.P. Gansevoort edited this page Sep 12, 2026 · 4 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

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))
    .ToSvg();

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.
// Pre-computed from AbsorptionTimeSeries / EnbCalculator (Ait.RL.Core Layer 3)
double[] absorption = ...; // length = same as close series
double[] enb        = ...; // same length

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .RelativeRotation(assetCloses, bench, labels, s =>
        {
            s.AbsorptionRatioPerBar = absorption;
            s.EnbPerBar             = enb;
            s.TailLength            = 12;
        }))
    .ToSvg();

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

// 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;
        }))
    .ToSvg();

Changing formula

ax.RelativeRotation(assetCloses, bench, labels, s =>
{
    s.Formula          = RrgFormula.ZScore;
    s.ShortPeriod      = 14;
    s.MomentumLookback = 14;
});

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:

var figure  = Plt.Create().AddSubPlot(...).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