Add TTM Scalper indicator implementation in C# and Pine Script; update Blma class for average calculation; remove missing indicators report and oscillator docs rewrite plans.

This commit is contained in:
Miha Kralj
2026-02-16 21:26:44 -08:00
parent b3a64f18fa
commit 63ae2c9ab2
68 changed files with 16069 additions and 587 deletions
@@ -0,0 +1,124 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public sealed class PivotIndicatorTests
{
[Fact]
public void PivotIndicator_Constructor_SetsDefaults()
{
var indicator = new PivotIndicator();
Assert.True(indicator.ShowColdValues);
Assert.Contains("PIVOT", indicator.Name, StringComparison.Ordinal);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void PivotIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new PivotIndicator();
Assert.Equal(0, PivotIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void PivotIndicator_ShortName_IsPivot()
{
var indicator = new PivotIndicator();
indicator.Initialize();
Assert.Contains("PIVOT", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void PivotIndicator_SourceCodeLink_IsValid()
{
var indicator = new PivotIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Pivot", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void PivotIndicator_Initialize_CreatesInternalIndicator()
{
var indicator = new PivotIndicator();
indicator.Initialize();
// 7 line series: PP, R1, R2, R3, S1, S2, S3
Assert.Equal(7, indicator.LinesSeries.Count);
}
[Fact]
public void PivotIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new PivotIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
double basePrice = 100 + i * 2;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// PP is index 0
double pp = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(pp) || double.IsNaN(pp));
}
[Fact]
public void PivotIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new PivotIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 5; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Simulate a new bar
indicator.HistoricalData.AddBar(now.AddMinutes(5), 110, 120, 100, 115);
var newArgs = new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(newArgs);
double pp = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(pp) || double.IsNaN(pp));
}
[Fact]
public void PivotIndicator_SevenLineSeries_ArePresent()
{
var indicator = new PivotIndicator();
indicator.Initialize();
// PP=0, R1=1, R2=2, R3=3, S1=4, S2=5, S3=6
Assert.Equal(7, indicator.LinesSeries.Count);
Assert.Contains("PP", indicator.LinesSeries[0].Name, StringComparison.OrdinalIgnoreCase);
Assert.Contains("R1", indicator.LinesSeries[1].Name, StringComparison.OrdinalIgnoreCase);
Assert.Contains("S1", indicator.LinesSeries[4].Name, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PivotIndicator_Description_IsSet()
{
var indicator = new PivotIndicator();
Assert.NotNull(indicator.Description);
Assert.NotEmpty(indicator.Description);
Assert.Contains("pivot", indicator.Description, StringComparison.OrdinalIgnoreCase);
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class PivotIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Pivot _indicator = null!;
private readonly LineSeries _ppSeries;
private readonly LineSeries _r1Series;
private readonly LineSeries _r2Series;
private readonly LineSeries _r3Series;
private readonly LineSeries _s1Series;
private readonly LineSeries _s2Series;
private readonly LineSeries _s3Series;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => "PIVOT";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/reversals/pivot/Pivot.cs";
public PivotIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "PIVOT - Classic Pivot Points";
Description = "Floor trader pivot points: 7 support/resistance levels (PP, R1-R3, S1-S3) derived from previous bar's HLC.";
_ppSeries = new LineSeries(name: "PP", color: Color.Yellow, width: 2, style: LineStyle.Solid);
_r1Series = new LineSeries(name: "R1", color: Color.FromArgb(255, 128, 128), width: 1, style: LineStyle.Solid);
_r2Series = new LineSeries(name: "R2", color: Color.FromArgb(255, 80, 80), width: 1, style: LineStyle.Solid);
_r3Series = new LineSeries(name: "R3", color: Color.Red, width: 1, style: LineStyle.Dash);
_s1Series = new LineSeries(name: "S1", color: Color.FromArgb(128, 255, 128), width: 1, style: LineStyle.Solid);
_s2Series = new LineSeries(name: "S2", color: Color.FromArgb(80, 255, 80), width: 1, style: LineStyle.Solid);
_s3Series = new LineSeries(name: "S3", color: Color.Green, width: 1, style: LineStyle.Dash);
AddLineSeries(_ppSeries);
AddLineSeries(_r1Series);
AddLineSeries(_r2Series);
AddLineSeries(_r3Series);
AddLineSeries(_s1Series);
AddLineSeries(_s2Series);
AddLineSeries(_s3Series);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_indicator = new Pivot();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
_ = _indicator.Update(this.GetInputBar(args), args.IsNewBar());
_ppSeries.SetValue(_indicator.PP, _indicator.IsHot, ShowColdValues);
_r1Series.SetValue(_indicator.R1, _indicator.IsHot, ShowColdValues);
_r2Series.SetValue(_indicator.R2, _indicator.IsHot, ShowColdValues);
_r3Series.SetValue(_indicator.R3, _indicator.IsHot, ShowColdValues);
_s1Series.SetValue(_indicator.S1, _indicator.IsHot, ShowColdValues);
_s2Series.SetValue(_indicator.S2, _indicator.IsHot, ShowColdValues);
_s3Series.SetValue(_indicator.S3, _indicator.IsHot, ShowColdValues);
}
}
+560
View File
@@ -0,0 +1,560 @@
// PIVOT Tests - Classic Pivot Points (Floor Trader Pivots)
namespace QuanTAlib.Tests;
// -- A) Constructor Validation ------------------------------------------------
public sealed class PivotConstructorTests
{
[Fact]
public void Constructor_Default_SetsProperties()
{
var p = new Pivot();
Assert.Equal(2, p.WarmupPeriod);
Assert.Contains("Pivot", p.Name, StringComparison.Ordinal);
Assert.False(p.IsHot);
}
[Fact]
public void Constructor_InitialState_AllNaN()
{
var p = new Pivot();
Assert.True(double.IsNaN(p.PP));
Assert.True(double.IsNaN(p.R1));
Assert.True(double.IsNaN(p.R2));
Assert.True(double.IsNaN(p.R3));
Assert.True(double.IsNaN(p.S1));
Assert.True(double.IsNaN(p.S2));
Assert.True(double.IsNaN(p.S3));
}
}
// -- B) Basic Calculation -----------------------------------------------------
public sealed class PivotBasicTests
{
[Fact]
public void Update_ReturnsTValue()
{
var p = new Pivot();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000);
TValue result = p.Update(bar);
Assert.IsType<TValue>(result);
}
[Fact]
public void Update_Last_IsAccessible()
{
var p = new Pivot();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000);
_ = p.Update(bar);
Assert.True(double.IsFinite(p.Last.Value) || double.IsNaN(p.Last.Value));
}
[Fact]
public void Update_KnownValues_CorrectPivotLevels()
{
// Given previous bar H=110, L=90, C=100
// PP = (110+90+100)/3 = 100
// R1 = 2*100-90 = 110, S1 = 2*100-110 = 90
// R2 = 100+(110-90) = 120, S2 = 100-(110-90) = 80
// R3 = 110+2*(100-90) = 130, S3 = 90-2*(110-100) = 70
var p = new Pivot();
var dt = DateTime.UtcNow;
// First bar: stores HLC, no output yet
_ = p.Update(new TBar(dt, 100, 110, 90, 100, 1000), isNew: true);
Assert.True(double.IsNaN(p.PP));
// Second bar: computes from first bar's HLC
_ = p.Update(new TBar(dt.AddMinutes(1), 105, 115, 95, 105, 1000), isNew: true);
Assert.Equal(100.0, p.PP, precision: 10);
Assert.Equal(110.0, p.R1, precision: 10);
Assert.Equal(90.0, p.S1, precision: 10);
Assert.Equal(120.0, p.R2, precision: 10);
Assert.Equal(80.0, p.S2, precision: 10);
Assert.Equal(130.0, p.R3, precision: 10);
Assert.Equal(70.0, p.S3, precision: 10);
}
[Fact]
public void Update_SecondKnownValues_CorrectPivotLevels()
{
// Given previous bar H=120, L=100, C=115
// PP = (120+100+115)/3 = 111.666...
// R1 = 2*111.666... - 100 = 123.333...
// S1 = 2*111.666... - 120 = 103.333...
// R2 = 111.666... + 20 = 131.666...
// S2 = 111.666... - 20 = 91.666...
// R3 = 120 + 2*(111.666...-100) = 143.333...
// S3 = 100 - 2*(120-111.666...) = 83.333...
var p = new Pivot();
var dt = DateTime.UtcNow;
_ = p.Update(new TBar(dt, 110, 120, 100, 115, 1000), isNew: true);
_ = p.Update(new TBar(dt.AddMinutes(1), 115, 125, 105, 120, 1000), isNew: true);
double expectedPP = (120.0 + 100.0 + 115.0) / 3.0;
Assert.Equal(expectedPP, p.PP, precision: 10);
Assert.Equal(2.0 * expectedPP - 100.0, p.R1, precision: 10);
Assert.Equal(2.0 * expectedPP - 120.0, p.S1, precision: 10);
Assert.Equal(expectedPP + 20.0, p.R2, precision: 10);
Assert.Equal(expectedPP - 20.0, p.S2, precision: 10);
Assert.Equal(120.0 + 2.0 * (expectedPP - 100.0), p.R3, precision: 10);
Assert.Equal(100.0 - 2.0 * (120.0 - expectedPP), p.S3, precision: 10);
}
[Fact]
public void Update_LevelsHaveCorrectOrdering()
{
// For any normal bar, S3 < S2 < S1 < PP < R1 < R2 < R3
var p = new Pivot();
var dt = DateTime.UtcNow;
_ = p.Update(new TBar(dt, 100, 110, 90, 100, 1000), isNew: true);
_ = p.Update(new TBar(dt.AddMinutes(1), 105, 115, 95, 105, 1000), isNew: true);
Assert.True(p.S3 < p.S2);
Assert.True(p.S2 < p.S1);
Assert.True(p.S1 < p.PP);
Assert.True(p.PP < p.R1);
Assert.True(p.R1 < p.R2);
Assert.True(p.R2 < p.R3);
}
[Fact]
public void Name_ContainsPivot()
{
var p = new Pivot();
Assert.Contains("Pivot", p.Name, StringComparison.Ordinal);
}
}
// -- C) State + Bar Correction ------------------------------------------------
public sealed class PivotStateCorrectionTests
{
[Fact]
public void IsNew_True_AdvancesState()
{
var p = new Pivot();
_ = p.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000), isNew: true);
var first = p.Last;
_ = p.Update(new TBar(DateTime.UtcNow.AddMinutes(1), 105, 115, 95, 105, 1000), isNew: true);
var second = p.Last;
Assert.NotEqual(first.Time, second.Time);
}
[Fact]
public void IsNew_False_CorrectionRestoresState()
{
var p = new Pivot();
var dt = DateTime.UtcNow;
// Two bars: first stores HLC, second computes
_ = p.Update(new TBar(dt, 100, 110, 90, 100, 1000), isNew: true);
_ = p.Update(new TBar(dt.AddMinutes(1), 105, 115, 95, 105, 1000), isNew: true);
double ppBefore = p.PP;
// Correct the second bar (isNew=false)
_ = p.Update(new TBar(dt.AddMinutes(1), 108, 118, 92, 108, 1000), isNew: false);
// PP should still be based on bar 0's HLC (H=110, L=90, C=100)
// since isNew=false doesn't change the "previous" bar
Assert.Equal(ppBefore, p.PP, precision: 10);
}
[Fact]
public void IterativeCorrections_ProduceSameResult()
{
var p = new Pivot();
var dt = DateTime.UtcNow;
_ = p.Update(new TBar(dt, 100, 110, 90, 100, 1000), isNew: true);
_ = p.Update(new TBar(dt.AddMinutes(1), 105, 115, 95, 105, 1000), isNew: true);
double[] ppResults = new double[3];
for (int i = 0; i < 3; i++)
{
_ = p.Update(new TBar(dt.AddMinutes(1), 108, 120, 88, 110, 1000), isNew: false);
ppResults[i] = p.PP;
}
Assert.Equal(ppResults[0], ppResults[1]);
Assert.Equal(ppResults[1], ppResults[2]);
}
[Fact]
public void IsNew_False_AllLevelsStable()
{
var p = new Pivot();
var dt = DateTime.UtcNow;
_ = p.Update(new TBar(dt, 100, 110, 90, 100, 1000), isNew: true);
_ = p.Update(new TBar(dt.AddMinutes(1), 105, 115, 95, 105, 1000), isNew: true);
_ = p.Update(new TBar(dt.AddMinutes(1), 108, 120, 88, 110, 1000), isNew: false);
double r1a = p.R1, s1a = p.S1, r2a = p.R2, s2a = p.S2, r3a = p.R3, s3a = p.S3;
_ = p.Update(new TBar(dt.AddMinutes(1), 108, 120, 88, 110, 1000), isNew: false);
Assert.Equal(r1a, p.R1);
Assert.Equal(s1a, p.S1);
Assert.Equal(r2a, p.R2);
Assert.Equal(s2a, p.S2);
Assert.Equal(r3a, p.R3);
Assert.Equal(s3a, p.S3);
}
[Fact]
public void Reset_ClearsAllState()
{
var p = new Pivot();
var dt = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
double price = 100.0 + i;
_ = p.Update(new TBar(dt.AddMinutes(i), price, price + 5, price - 5, price + 1, 1000));
}
Assert.True(p.IsHot);
p.Reset();
Assert.False(p.IsHot);
Assert.True(double.IsNaN(p.PP));
Assert.True(double.IsNaN(p.R1));
Assert.True(double.IsNaN(p.R2));
Assert.True(double.IsNaN(p.R3));
Assert.True(double.IsNaN(p.S1));
Assert.True(double.IsNaN(p.S2));
Assert.True(double.IsNaN(p.S3));
}
}
// -- D) Warmup / Convergence --------------------------------------------------
public sealed class PivotWarmupTests
{
[Fact]
public void IsHot_FlipsAfterWarmup()
{
var p = new Pivot();
// First bar - not hot
_ = p.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
Assert.False(p.IsHot, "Should not be hot after 1 bar");
// Second bar - should be hot
_ = p.Update(new TBar(DateTime.UtcNow.AddMinutes(1), 105, 115, 95, 105, 1000));
Assert.True(p.IsHot, "Should be hot after 2 bars");
}
[Fact]
public void WarmupPeriod_Equals2()
{
var p = new Pivot();
Assert.Equal(2, p.WarmupPeriod);
}
}
// -- E) Robustness ------------------------------------------------------------
public sealed class PivotRobustnessTests
{
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var p = new Pivot();
var dt = DateTime.UtcNow;
// Feed valid bars
_ = p.Update(new TBar(dt, 100, 110, 90, 100, 1000), isNew: true);
_ = p.Update(new TBar(dt.AddMinutes(1), 105, 115, 95, 105, 1000), isNew: true);
Assert.True(p.IsHot);
// Feed NaN bar
_ = p.Update(new TBar(dt.AddMinutes(2), double.NaN, double.NaN, double.NaN, double.NaN, 0), isNew: true);
// Should still be hot and produce valid pivots from last-valid values
Assert.True(p.IsHot);
Assert.True(double.IsFinite(p.PP));
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var p = new Pivot();
var dt = DateTime.UtcNow;
_ = p.Update(new TBar(dt, 100, 110, 90, 100, 1000), isNew: true);
_ = p.Update(new TBar(dt.AddMinutes(1), 105, 115, 95, 105, 1000), isNew: true);
_ = p.Update(new TBar(dt.AddMinutes(2),
double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, 0),
isNew: true);
Assert.True(p.IsHot);
Assert.True(double.IsFinite(p.PP));
}
[Fact]
public void FirstBar_NaN_ReturnsNaN()
{
var p = new Pivot();
_ = p.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 0));
Assert.True(double.IsNaN(p.Last.Value));
Assert.True(double.IsNaN(p.PP));
}
}
// -- F) Consistency -----------------------------------------------------------
public sealed class PivotConsistencyTests
{
private static TBarSeries CreateGbmBars(int count = 500)
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.20, seed: 42);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
[Fact]
public void Streaming_MatchesBatch()
{
var bars = CreateGbmBars();
// Streaming
var streaming = new Pivot();
var streamPP = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
_ = streaming.Update(bars[i], isNew: true);
streamPP[i] = streaming.PP;
}
// Batch
var batchResults = Pivot.Batch(bars);
for (int i = 1; i < bars.Count; i++)
{
if (double.IsNaN(streamPP[i]))
{
Assert.True(double.IsNaN(batchResults[i].Value), $"Mismatch at {i}");
}
else
{
Assert.Equal(streamPP[i], batchResults[i].Value, precision: 10);
}
}
}
[Fact]
public void Streaming_MatchesSpan()
{
var bars = CreateGbmBars();
// Streaming
var streaming = new Pivot();
var streamPP = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
_ = streaming.Update(bars[i], isNew: true);
streamPP[i] = streaming.PP;
}
// Span
var spanPP = new double[bars.Count];
Pivot.Batch(bars.HighValues, bars.LowValues, bars.CloseValues, spanPP);
for (int i = 1; i < bars.Count; i++)
{
if (double.IsNaN(streamPP[i]))
{
Assert.True(double.IsNaN(spanPP[i]), $"PP mismatch at {i}");
}
else
{
Assert.Equal(streamPP[i], spanPP[i], precision: 10);
}
}
}
[Fact]
public void Streaming_MatchesBatchAll_AllLevels()
{
var bars = CreateGbmBars(count: 200);
// Streaming
var streaming = new Pivot();
var sPP = new double[bars.Count];
var sR1 = new double[bars.Count];
var sS1 = new double[bars.Count];
var sR2 = new double[bars.Count];
var sS2 = new double[bars.Count];
var sR3 = new double[bars.Count];
var sS3 = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
_ = streaming.Update(bars[i], isNew: true);
sPP[i] = streaming.PP;
sR1[i] = streaming.R1;
sS1[i] = streaming.S1;
sR2[i] = streaming.R2;
sS2[i] = streaming.S2;
sR3[i] = streaming.R3;
sS3[i] = streaming.S3;
}
// BatchAll
var bPP = new double[bars.Count];
var bR1 = new double[bars.Count];
var bS1 = new double[bars.Count];
var bR2 = new double[bars.Count];
var bS2 = new double[bars.Count];
var bR3 = new double[bars.Count];
var bS3 = new double[bars.Count];
Pivot.BatchAll(bars.HighValues, bars.LowValues, bars.CloseValues,
bPP, bR1, bS1, bR2, bS2, bR3, bS3);
for (int i = 1; i < bars.Count; i++)
{
if (double.IsNaN(sPP[i])) { Assert.True(double.IsNaN(bPP[i])); continue; }
Assert.Equal(sPP[i], bPP[i], precision: 10);
Assert.Equal(sR1[i], bR1[i], precision: 10);
Assert.Equal(sS1[i], bS1[i], precision: 10);
Assert.Equal(sR2[i], bR2[i], precision: 10);
Assert.Equal(sS2[i], bS2[i], precision: 10);
Assert.Equal(sR3[i], bR3[i], precision: 10);
Assert.Equal(sS3[i], bS3[i], precision: 10);
}
}
[Fact]
public void TValue_Update_MatchesTBar_Update()
{
var p1 = new Pivot();
var p2 = new Pivot();
double[] prices = [100, 102, 98, 105, 99, 103, 107, 95, 110, 108];
for (int i = 0; i < prices.Length; i++)
{
double pr = prices[i];
_ = p1.Update(new TBar(DateTime.UtcNow.AddMinutes(i), pr, pr, pr, pr, 0), isNew: true);
_ = p2.Update(new TValue(DateTime.UtcNow.AddMinutes(i), pr), isNew: true);
}
Assert.Equal(p1.PP, p2.PP);
Assert.Equal(p1.R1, p2.R1);
Assert.Equal(p1.S1, p2.S1);
}
}
// -- G) Span API Tests --------------------------------------------------------
public sealed class PivotSpanTests
{
[Fact]
public void Batch_Span_MismatchedLengths_Throws()
{
var ex = Assert.Throws<ArgumentException>(() =>
Pivot.Batch(new double[10], new double[5], new double[10], new double[10]));
Assert.Equal("high", ex.ParamName);
}
[Fact]
public void Batch_Span_OutputTooShort_Throws()
{
var ex = Assert.Throws<ArgumentException>(() =>
Pivot.Batch(new double[10], new double[10], new double[10], new double[5]));
Assert.Equal("ppOutput", ex.ParamName);
}
[Fact]
public void Batch_Span_Empty_NoException()
{
var ex = Record.Exception(() =>
Pivot.Batch(ReadOnlySpan<double>.Empty, ReadOnlySpan<double>.Empty,
ReadOnlySpan<double>.Empty, Span<double>.Empty));
Assert.Null(ex);
}
[Fact]
public void BatchAll_OutputTooShort_Throws()
{
var ex = Assert.Throws<ArgumentException>(() =>
Pivot.BatchAll(new double[10], new double[10], new double[10],
new double[10], new double[5], new double[10],
new double[10], new double[10], new double[10], new double[10]));
Assert.Equal("r1Out", ex.ParamName);
}
}
// -- H) Event / Chainability -------------------------------------------------
public sealed class PivotEventTests
{
[Fact]
public void Pub_FiresOnUpdate()
{
var p = new Pivot();
int fireCount = 0;
p.Pub += (object? _, in TValueEventArgs _e) => { fireCount++; };
_ = p.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
Assert.Equal(1, fireCount);
}
[Fact]
public void Pub_FiresOnEachUpdate()
{
var p = new Pivot();
int fireCount = 0;
p.Pub += (object? _, in TValueEventArgs _e) => { fireCount++; };
for (int i = 0; i < 5; i++)
{
double price = 100.0 + i;
_ = p.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 5, price - 5, price + 1, 1000));
}
Assert.Equal(5, fireCount);
}
}
// -- I) Prime Tests -----------------------------------------------------------
public sealed class PivotPrimeTests
{
[Fact]
public void Prime_TBarSeries_SetsState()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.20, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var p = new Pivot();
p.Prime(bars);
Assert.True(p.IsHot);
}
[Fact]
public void Prime_EmptySource_NoException()
{
var p = new Pivot();
var bars = new TBarSeries();
var ex = Record.Exception(() => p.Prime(bars));
Assert.Null(ex);
Assert.False(p.IsHot);
}
}
@@ -0,0 +1,260 @@
// PIVOT Validation Tests - Classic Pivot Points (Floor Trader Pivots)
// Self-consistency validation across all API modes.
//
// Note: Skender.Stock.Indicators ToPivotPoints() uses calendar-window periods
// (Day/Week/Month) which is conceptually different from our bar-to-bar implementation.
// Direct cross-validation is not applicable. TA-Lib, Tulip, and Ooples do not
// implement floor trader pivot points either.
// Validation focuses on mathematical correctness and mode consistency.
namespace QuanTAlib.Tests;
public sealed class PivotValidationTests
{
private static TBarSeries CreateGbmBars(int count = 500, int seed = 42)
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.20, seed: seed);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
// -- Mathematical Correctness -------------------------------------------------
[Fact]
public void MathCorrectness_PP_EqualsHLC_Over3()
{
var bars = CreateGbmBars(count: 100);
var p = new Pivot();
for (int i = 0; i < bars.Count; i++)
{
_ = p.Update(bars[i], isNew: true);
if (i >= 1) // Need previous bar
{
double prevH = bars[i - 1].High;
double prevL = bars[i - 1].Low;
double prevC = bars[i - 1].Close;
double expectedPP = (prevH + prevL + prevC) / 3.0;
Assert.Equal(expectedPP, p.PP, precision: 10);
}
}
}
[Fact]
public void MathCorrectness_AllLevels_MatchFormula()
{
var bars = CreateGbmBars(count: 100);
var p = new Pivot();
for (int i = 0; i < bars.Count; i++)
{
_ = p.Update(bars[i], isNew: true);
if (i >= 1)
{
double pH = bars[i - 1].High;
double pL = bars[i - 1].Low;
double pC = bars[i - 1].Close;
double pp = (pH + pL + pC) / 3.0;
double range = pH - pL;
Assert.Equal(pp, p.PP, precision: 10);
Assert.Equal(2.0 * pp - pL, p.R1, precision: 10);
Assert.Equal(2.0 * pp - pH, p.S1, precision: 10);
Assert.Equal(pp + range, p.R2, precision: 10);
Assert.Equal(pp - range, p.S2, precision: 10);
Assert.Equal(pH + 2.0 * (pp - pL), p.R3, precision: 10);
Assert.Equal(pL - 2.0 * (pH - pp), p.S3, precision: 10);
}
}
}
// -- Self-Consistency: Streaming == Batch --------------------------------------
[Fact]
public void StreamingMatchesBatch_PP()
{
var bars = CreateGbmBars();
// Streaming
var streaming = new Pivot();
var streamPP = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
_ = streaming.Update(bars[i], isNew: true);
streamPP[i] = streaming.PP;
}
// Batch
var batchResults = Pivot.Batch(bars);
for (int i = 1; i < bars.Count; i++)
{
if (double.IsNaN(streamPP[i]))
{
Assert.True(double.IsNaN(batchResults[i].Value),
$"Mismatch at {i}: streaming=NaN, batch={batchResults[i].Value}");
}
else
{
Assert.Equal(streamPP[i], batchResults[i].Value, precision: 10);
}
}
}
// -- Self-Consistency: Streaming == Span ---------------------------------------
[Fact]
public void StreamingMatchesSpan_PP()
{
var bars = CreateGbmBars();
// Streaming
var streaming = new Pivot();
var streamPP = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
_ = streaming.Update(bars[i], isNew: true);
streamPP[i] = streaming.PP;
}
// Span
var spanPP = new double[bars.Count];
Pivot.Batch(bars.HighValues, bars.LowValues, bars.CloseValues, spanPP);
for (int i = 1; i < bars.Count; i++)
{
if (double.IsNaN(streamPP[i]))
{
Assert.True(double.IsNaN(spanPP[i]));
}
else
{
Assert.Equal(streamPP[i], spanPP[i], precision: 10);
}
}
}
// -- Self-Consistency: Streaming == BatchAll (all 7 levels) --------------------
[Fact]
public void StreamingMatchesBatchAll_AllLevels()
{
var bars = CreateGbmBars(count: 300);
// Streaming
var streaming = new Pivot();
var sPP = new double[bars.Count];
var sR1 = new double[bars.Count];
var sS1 = new double[bars.Count];
var sR2 = new double[bars.Count];
var sS2 = new double[bars.Count];
var sR3 = new double[bars.Count];
var sS3 = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
_ = streaming.Update(bars[i], isNew: true);
sPP[i] = streaming.PP;
sR1[i] = streaming.R1;
sS1[i] = streaming.S1;
sR2[i] = streaming.R2;
sS2[i] = streaming.S2;
sR3[i] = streaming.R3;
sS3[i] = streaming.S3;
}
// BatchAll
var bPP = new double[bars.Count];
var bR1 = new double[bars.Count];
var bS1 = new double[bars.Count];
var bR2 = new double[bars.Count];
var bS2 = new double[bars.Count];
var bR3 = new double[bars.Count];
var bS3 = new double[bars.Count];
Pivot.BatchAll(bars.HighValues, bars.LowValues, bars.CloseValues,
bPP, bR1, bS1, bR2, bS2, bR3, bS3);
for (int i = 1; i < bars.Count; i++)
{
if (double.IsNaN(sPP[i]))
{
Assert.True(double.IsNaN(bPP[i]));
continue;
}
Assert.Equal(sPP[i], bPP[i], precision: 10);
Assert.Equal(sR1[i], bR1[i], precision: 10);
Assert.Equal(sS1[i], bS1[i], precision: 10);
Assert.Equal(sR2[i], bR2[i], precision: 10);
Assert.Equal(sS2[i], bS2[i], precision: 10);
Assert.Equal(sR3[i], bR3[i], precision: 10);
Assert.Equal(sS3[i], bS3[i], precision: 10);
}
}
// -- Determinism ---------------------------------------------------------------
[Fact]
public void SameInput_ProducesSameOutput()
{
var bars = CreateGbmBars(count: 200, seed: 123);
var p1 = new Pivot();
var p2 = new Pivot();
for (int i = 0; i < bars.Count; i++)
{
_ = p1.Update(bars[i], isNew: true);
_ = p2.Update(bars[i], isNew: true);
}
Assert.Equal(p1.PP, p2.PP);
Assert.Equal(p1.R1, p2.R1);
Assert.Equal(p1.S1, p2.S1);
Assert.Equal(p1.R2, p2.R2);
Assert.Equal(p1.S2, p2.S2);
Assert.Equal(p1.R3, p2.R3);
Assert.Equal(p1.S3, p2.S3);
}
// -- Calculate Returns Valid Indicator -----------------------------------------
[Fact]
public void Calculate_ReturnsValidIndicatorAndResults()
{
var bars = CreateGbmBars(count: 100);
var (results, indicator) = Pivot.Calculate(bars);
Assert.NotNull(results);
Assert.Equal(bars.Count, results.Count);
Assert.True(indicator.IsHot);
}
// -- Level Ordering Invariant --------------------------------------------------
[Fact]
public void AllBars_LevelsOrdered_S3_S2_S1_PP_R1_R2_R3()
{
var bars = CreateGbmBars(count: 200);
var p = new Pivot();
for (int i = 0; i < bars.Count; i++)
{
_ = p.Update(bars[i], isNew: true);
if (p.IsHot)
{
Assert.True(p.S3 <= p.S2, $"S3 > S2 at bar {i}");
Assert.True(p.S2 <= p.S1, $"S2 > S1 at bar {i}");
Assert.True(p.S1 <= p.PP, $"S1 > PP at bar {i}");
Assert.True(p.PP <= p.R1, $"PP > R1 at bar {i}");
Assert.True(p.R1 <= p.R2, $"R1 > R2 at bar {i}");
Assert.True(p.R2 <= p.R3, $"R2 > R3 at bar {i}");
}
}
}
}
+424
View File
@@ -0,0 +1,424 @@
// PIVOT: Classic Pivot Points (Floor Trader Pivots)
// Calculates 7 support/resistance levels from previous bar's HLC.
// Standard floor trader formula used since the 1930s.
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// PIVOT: Classic Pivot Points (Floor Trader Pivots)
/// </summary>
/// <remarks>
/// Computes 7 horizontal support/resistance levels from the previous bar's
/// high, low, and close. The central pivot point (PP) is the arithmetic mean
/// of HLC; resistance (R1-R3) and support (S1-S3) levels are derived from
/// PP and the prior bar's range.
///
/// Calculation (using previous bar's H, L, C):
/// <code>
/// PP = (H + L + C) / 3
/// R1 = 2 * PP - L S1 = 2 * PP - H
/// R2 = PP + (H - L) S2 = PP - (H - L)
/// R3 = H + 2 * (PP - L) S3 = L - 2 * (H - PP)
/// </code>
///
/// <b>Key characteristics:</b>
/// - O(1) computation: pure arithmetic from previous bar's HLC
/// - 7 outputs: PP, R1, R2, R3, S1, S2, S3
/// - WarmupPeriod = 2 (need previous bar's HLC)
/// - No configurable parameters
/// - Levels remain constant until a new bar arrives
/// </remarks>
/// <seealso href="Pivot.md">Detailed documentation</seealso>
[SkipLocalsInit]
public sealed class Pivot : ITValuePublisher
{
[StructLayout(LayoutKind.Auto)]
private record struct State(
double PrevHigh,
double PrevLow,
double PrevClose,
double LastValidHigh,
double LastValidLow,
double LastValidClose);
private State _s;
private State _ps;
private int _count;
private readonly TBarPublishedHandler _barHandler;
/// <summary>Display name for the indicator.</summary>
public string Name { get; }
/// <summary>Bars required for the indicator to warm up.</summary>
public int WarmupPeriod { get; }
/// <summary>Central Pivot Point: (prevH + prevL + prevC) / 3</summary>
public double PP { get; private set; }
/// <summary>Resistance 1: 2 * PP - prevL</summary>
public double R1 { get; private set; }
/// <summary>Resistance 2: PP + (prevH - prevL)</summary>
public double R2 { get; private set; }
/// <summary>Resistance 3: prevH + 2 * (PP - prevL)</summary>
public double R3 { get; private set; }
/// <summary>Support 1: 2 * PP - prevH</summary>
public double S1 { get; private set; }
/// <summary>Support 2: PP - (prevH - prevL)</summary>
public double S2 { get; private set; }
/// <summary>Support 3: prevL - 2 * (prevH - PP)</summary>
public double S3 { get; private set; }
/// <summary>Primary output value (PP as TValue).</summary>
public TValue Last { get; private set; }
/// <summary>True when enough bars have been processed for valid output.</summary>
public bool IsHot => _count >= 2;
public event TValuePublishedHandler? Pub;
/// <summary>
/// Creates a Classic Pivot Points indicator.
/// </summary>
public Pivot()
{
_count = 0;
_s = new State(double.NaN, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
_ps = _s;
PP = double.NaN;
R1 = double.NaN;
R2 = double.NaN;
R3 = double.NaN;
S1 = double.NaN;
S2 = double.NaN;
S3 = double.NaN;
Name = "Pivot";
WarmupPeriod = 2;
_barHandler = HandleBar;
}
/// <summary>
/// Creates a Classic Pivot Points indicator chained to a TBarSeries source.
/// </summary>
public Pivot(TBarSeries source)
: this()
{
Prime(source);
source.Pub += _barHandler;
}
private void HandleBar(object? sender, in TBarEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void PubEvent(TValue value, bool isNew = true) =>
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
_count++;
}
else
{
_s = _ps;
}
var s = _s;
// Validate inputs - substitute last-valid on NaN/Infinity
double high = input.High;
double low = input.Low;
double close = input.Close;
if (double.IsFinite(high)) { s.LastValidHigh = high; }
else { high = s.LastValidHigh; }
if (double.IsFinite(low)) { s.LastValidLow = low; }
else { low = s.LastValidLow; }
if (double.IsFinite(close)) { s.LastValidClose = close; }
else { close = s.LastValidClose; }
// If still no valid data, return NaN
if (double.IsNaN(high) || double.IsNaN(low) || double.IsNaN(close))
{
_s = s;
SetAllNaN();
Last = new TValue(input.Time, double.NaN);
PubEvent(Last, isNew);
return Last;
}
// First bar: store HLC but cannot compute pivots yet (no previous bar)
if (_count < 2)
{
s.PrevHigh = high;
s.PrevLow = low;
s.PrevClose = close;
_s = s;
SetAllNaN();
Last = new TValue(input.Time, double.NaN);
PubEvent(Last, isNew);
return Last;
}
// Compute pivot levels from PREVIOUS bar's HLC
double pH = s.PrevHigh;
double pL = s.PrevLow;
double pC = s.PrevClose;
double pp = (pH + pL + pC) / 3.0;
double range = pH - pL;
PP = pp;
R1 = Math.FusedMultiplyAdd(2.0, pp, -pL); // 2*pp - pL
S1 = Math.FusedMultiplyAdd(2.0, pp, -pH); // 2*pp - pH
R2 = pp + range; // pp + (pH - pL)
S2 = pp - range; // pp - (pH - pL)
R3 = Math.FusedMultiplyAdd(2.0, pp - pL, pH); // pH + 2*(pp - pL)
S3 = Math.FusedMultiplyAdd(-2.0, pH - pp, pL); // pL - 2*(pH - pp)
// Store current bar's HLC as "previous" for next bar
s.PrevHigh = high;
s.PrevLow = low;
s.PrevClose = close;
_s = s;
Last = new TValue(input.Time, PP);
PubEvent(Last, isNew);
return Last;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true) =>
Update(new TBar(input.Time, input.Value, input.Value, input.Value, input.Value, 0), isNew);
public TSeries Update(TBarSeries source)
{
if (source.Count == 0)
{
return new TSeries([], []);
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
Batch(source.HighValues, source.LowValues, source.CloseValues,
CollectionsMarshal.AsSpan(v));
source.Times.CopyTo(CollectionsMarshal.AsSpan(t));
// Prime internal state for continued streaming
Prime(source);
var lastTime = new DateTime(source.Times[^1], DateTimeKind.Utc);
Last = new TValue(lastTime, CollectionsMarshal.AsSpan(v)[^1]);
return new TSeries(t, v);
}
public void Prime(TBarSeries source)
{
Reset();
if (source.Count == 0)
{
return;
}
for (int i = 0; i < source.Count; i++)
{
Update(source[i], isNew: true);
}
}
public void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
Reset();
if (source.Length == 0)
{
return;
}
long t = DateTime.UtcNow.Ticks;
long stepTicks = (step ?? TimeSpan.FromMinutes(1)).Ticks;
for (int i = 0; i < source.Length; i++)
{
double val = source[i];
Update(new TBar(t, val, val, val, val, 0), isNew: true);
t += stepTicks;
}
}
public void Reset()
{
_count = 0;
_s = new State(double.NaN, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
_ps = _s;
SetAllNaN();
Last = default;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void SetAllNaN()
{
PP = double.NaN;
R1 = double.NaN;
R2 = double.NaN;
R3 = double.NaN;
S1 = double.NaN;
S2 = double.NaN;
S3 = double.NaN;
}
/// <summary>
/// Batch computation of Classic Pivot Points over span data.
/// Writes PP values to <paramref name="ppOutput"/>.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
Span<double> ppOutput)
{
if (high.Length != low.Length || high.Length != close.Length)
{
throw new ArgumentException("Input spans must have the same length.", nameof(high));
}
if (ppOutput.Length < high.Length)
{
throw new ArgumentException("Output span must be at least as long as input.", nameof(ppOutput));
}
int len = high.Length;
if (len == 0)
{
return;
}
// First bar: no previous data
ppOutput[0] = double.NaN;
// Remaining bars: compute from previous bar's HLC
for (int i = 1; i < len; i++)
{
double pH = high[i - 1];
double pL = low[i - 1];
double pC = close[i - 1];
ppOutput[i] = (pH + pL + pC) / 3.0;
}
}
public static TSeries Batch(TBarSeries source)
{
if (source == null || source.Count == 0)
{
return new TSeries([], []);
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
Batch(source.HighValues, source.LowValues, source.CloseValues,
CollectionsMarshal.AsSpan(v));
source.Times.CopyTo(CollectionsMarshal.AsSpan(t));
return new TSeries(t, v);
}
/// <summary>
/// Batch computation of all 7 Classic Pivot Point levels over span data.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void BatchAll(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
Span<double> ppOut,
Span<double> r1Out,
Span<double> s1Out,
Span<double> r2Out,
Span<double> s2Out,
Span<double> r3Out,
Span<double> s3Out)
{
if (high.Length != low.Length || high.Length != close.Length)
{
throw new ArgumentException("Input spans must have the same length.", nameof(high));
}
int len = high.Length;
if (ppOut.Length < len) { throw new ArgumentException("Output span too short.", nameof(ppOut)); }
if (r1Out.Length < len) { throw new ArgumentException("Output span too short.", nameof(r1Out)); }
if (s1Out.Length < len) { throw new ArgumentException("Output span too short.", nameof(s1Out)); }
if (r2Out.Length < len) { throw new ArgumentException("Output span too short.", nameof(r2Out)); }
if (s2Out.Length < len) { throw new ArgumentException("Output span too short.", nameof(s2Out)); }
if (r3Out.Length < len) { throw new ArgumentException("Output span too short.", nameof(r3Out)); }
if (s3Out.Length < len) { throw new ArgumentException("Output span too short.", nameof(s3Out)); }
if (len == 0)
{
return;
}
// First bar: no previous data
ppOut[0] = double.NaN;
r1Out[0] = double.NaN;
s1Out[0] = double.NaN;
r2Out[0] = double.NaN;
s2Out[0] = double.NaN;
r3Out[0] = double.NaN;
s3Out[0] = double.NaN;
for (int i = 1; i < len; i++)
{
double pH = high[i - 1];
double pL = low[i - 1];
double pC = close[i - 1];
double pp = (pH + pL + pC) / 3.0;
double range = pH - pL;
ppOut[i] = pp;
r1Out[i] = Math.FusedMultiplyAdd(2.0, pp, -pL);
s1Out[i] = Math.FusedMultiplyAdd(2.0, pp, -pH);
r2Out[i] = pp + range;
s2Out[i] = pp - range;
r3Out[i] = Math.FusedMultiplyAdd(2.0, pp - pL, pH);
s3Out[i] = Math.FusedMultiplyAdd(-2.0, pH - pp, pL);
}
}
public static (TSeries Results, Pivot Indicator) Calculate(TBarSeries source)
{
var indicator = new Pivot();
var results = indicator.Update(source);
return (results, indicator);
}
}
+173
View File
@@ -0,0 +1,173 @@
# PIVOT: Classic Pivot Points (Floor Trader Pivots)
> "The floor traders had it figured out before the quants arrived. Three numbers from yesterday's bar, seven levels for today. No optimization, no curve fitting, no excuses."
Classic Pivot Points calculate seven horizontal support and resistance levels from the previous bar's high, low, and close. The central pivot point (PP) is the arithmetic mean of HLC; three resistance levels (R1-R3) and three support levels (S1-S3) are derived from PP and the prior bar's range. The formula has been in continuous use since the 1930s among floor traders at commodity exchanges. Zero parameters, zero lag, zero ambiguity.
## Historical Context
Floor traders at the Chicago Board of Trade developed pivot points as a pre-session planning tool. Before electronic markets, traders needed levels they could calculate by hand during the commute to work. The HLC average was the simplest possible summary of the prior session; the support and resistance levels followed from elementary arithmetic on the range.
The method spread through oral tradition among pit traders for decades before appearing in print. Neil Weintraub documented the technique in *Tricks of the Floor Trader* (1996), and John Person expanded on it in *A Complete Guide to Technical Trading Tactics* (2004). By then, pivot points were already embedded in virtually every trading terminal.
The beauty of the formula is its universality. Unlike moving averages (which require choosing a period), Bollinger Bands (which require choosing a standard deviation multiplier), or Fibonacci retracements (which require choosing swing points), pivot points have no parameters. Every trader using the same prior bar's HLC computes identical levels. This made them natural Schelling points: self-fulfilling prophecies where enough participants watched the same numbers to create genuine support and resistance.
Several variants emerged over the decades: Woodie (weighting close double), Camarilla (using range fractions), DeMark (conditional on open-close relationship), and Fibonacci (applying golden ratios to range). This implementation covers the original "Standard" or "Floor Trader" formulation only. The variants differ in the derivation of R/S levels but share the core concept of previous-bar HLC as input.
## Architecture and Physics
### 1. Previous Bar's HLC
The indicator stores the high ($H$), low ($L$), and close ($C$) of the most recently completed bar. On each new bar, these stored values become the basis for computing the current bar's pivot levels, and the new bar's HLC replaces the stored values for the next computation.
### 2. Central Pivot Point (PP)
$$PP = \frac{H_{prev} + L_{prev} + C_{prev}}{3}$$
The arithmetic mean of the previous bar's high, low, and close. This represents the "fair value" or equilibrium price implied by the prior period's trading range.
### 3. Support and Resistance Levels
First-level support and resistance reflect the previous range from PP:
$$R_1 = 2 \cdot PP - L_{prev}$$
$$S_1 = 2 \cdot PP - H_{prev}$$
Second-level support and resistance add the full range:
$$R_2 = PP + (H_{prev} - L_{prev})$$
$$S_2 = PP - (H_{prev} - L_{prev})$$
Third-level support and resistance extend from the extremes:
$$R_3 = H_{prev} + 2 \cdot (PP - L_{prev})$$
$$S_3 = L_{prev} - 2 \cdot (H_{prev} - PP)$$
### 4. Level Ordering Invariant
For any bar where $H_{prev} > L_{prev}$ (non-degenerate range):
$$S_3 < S_2 < S_1 < PP < R_1 < R_2 < R_3$$
When $H_{prev} = L_{prev}$ (zero range), all seven levels collapse to a single value equal to the close.
### 5. Seven Outputs
All seven levels are computed simultaneously and remain constant until a new bar arrives. The primary output (`Last.Val`) returns PP; individual properties expose all seven levels.
### Signal Interpretation
| Condition | Interpretation |
| :--- | :--- |
| Price above PP | Bullish bias for current bar |
| Price below PP | Bearish bias for current bar |
| Price tests R1 | First resistance; potential reversal or breakout level |
| Price tests S1 | First support; potential bounce or breakdown level |
| Price reaches R3/S3 | Extended move; third-level tests rare, indicate strong momentum |
| All levels cluster tightly | Low volatility prior bar; expect range expansion |
| Wide level spacing | High volatility prior bar; wider intraday range expected |
## Mathematical Foundation
### Parameters
Classic Pivot Points has no configurable parameters. The formula is fixed by definition.
| Parameter | Value | Notes |
| :--- | :---: | :--- |
| Inputs | H, L, C | Previous bar's high, low, close |
| Outputs | 7 | PP, R1, R2, R3, S1, S2, S3 |
| Parameters | 0 | No tuning required |
### Warmup Period
$$W = 2$$
The indicator requires 2 bars: the first bar provides HLC for storage; the second bar triggers computation from the stored values. Prior to warmup completion, all outputs are NaN.
### Derivation Notes
The R1/S1 formulas can be rewritten to show the geometric relationship:
$$R_1 = PP + (PP - L_{prev}) \quad \text{(PP reflected above its distance to the low)}$$
$$S_1 = PP - (H_{prev} - PP) \quad \text{(PP reflected below its distance to the high)}$$
R2/S2 add the full range to/from PP. R3/S3 extend beyond the previous extremes by the distance from PP to the opposite extreme.
## Performance Profile
### Implementation Design
Pure arithmetic with no loops, no buffers, no auxiliary data structures. Each `Update` call performs 3 divisions (via the single division in PP), 6 multiplications/additions, and 3 comparisons for NaN validation.
| Metric | Score | Notes |
| :--- | :--- | :--- |
| **Complexity** | O(1) | Fixed arithmetic; no iteration |
| **Allocations** | 0 | Hot path is allocation-free |
| **Warmup** | 2 bars | Minimum possible |
| **Accuracy** | 10/10 | Exact arithmetic; no approximation |
| **Timeliness** | 10/10 | No lag; levels available immediately on new bar |
| **Smoothness** | N/A | Discrete levels; smooth/noisy not applicable |
### State Management
Internal state uses a `record struct` with local copy pattern for JIT struct promotion. The state tracks previous bar's HLC and last-valid values for NaN/Infinity input substitution. Bar correction via `isNew` flag enables same-timestamp rewrites without state corruption.
### SIMD Applicability
Not applicable for streaming (single bar computation). The `BatchAll` span API processes multiple bars but the per-bar computation is too simple (7 arithmetic operations) to benefit from vectorization overhead. The `Batch` span API for PP-only output could theoretically use SIMD but the division-heavy computation and small operation count make the benefit negligible.
### FMA Usage
The implementation uses `Math.FusedMultiplyAdd` for R1, S1, R3, and S3 computations, providing both a minor precision benefit (single rounding instead of two) and potential performance benefit on hardware with FMA support.
## Validation
Self-consistency validation confirms all API modes produce identical results:
| Mode | Status | Notes |
| :--- | :--- | :--- |
| **Streaming** (`Update`) | Passed | Bar-by-bar with `isNew` support |
| **Batch** (`Batch(TBarSeries)`) | Passed | PP values match streaming |
| **Span** (`Batch(Span)`) | Passed | PP values match streaming |
| **BatchAll** (`BatchAll(Span)`) | Passed | All 7 levels match streaming |
| **Event** (`Pub` subscription) | Passed | Fires on every update |
| Library | Status | Notes |
| :--- | :--- | :--- |
| **QuanTAlib** | Passed | All modes self-consistent; level ordering invariant holds |
| **Skender** | N/A | Uses calendar-window periods (Day/Week/Month); conceptually different |
| **TA-Lib** | N/A | Not implemented |
| **Tulip** | N/A | Not implemented |
| **Ooples** | N/A | Not validated |
Skender.Stock.Indicators provides `ToPivotPoints()` which computes pivot levels over calendar windows (daily, weekly, monthly). This is a fundamentally different granularity from our bar-to-bar implementation. Skender summarizes an entire period's HLC into one set of pivots for the next period; our implementation uses each individual bar's HLC for the subsequent bar's levels. Both approaches are valid floor trader pivot calculations at different time scales. Direct numerical comparison is not meaningful.
Mathematical correctness is validated by computing expected values from the formula for each bar and comparing against the indicator output at precision 10.
## Common Pitfalls
1. **First bar returns NaN.** The indicator needs the previous bar's HLC to compute pivots. The first bar stores HLC but produces no output. This is correct behavior, not a bug. `WarmupPeriod = 2`.
2. **Levels are constant within a bar.** Pivot levels do not change as the current bar's price moves. They change only when a new bar starts (providing new "previous" HLC). Multiple `isNew=false` corrections on the current bar do not alter the pivot levels because they are derived from the already-stored previous bar.
3. **Skender comparison is not applicable.** Skender.Stock.Indicators uses calendar-period windows (Day/Week/Month). Our implementation is bar-to-bar. Comparing numbers directly will produce mismatches that are not errors.
4. **Zero-range bars collapse all levels.** When $H_{prev} = L_{prev}$ (a doji or single-print bar), all seven levels equal the close. This is mathematically correct but may surprise users expecting spread levels.
5. **PP is not the midpoint of High and Low.** PP includes the close, weighting it equally with high and low. For bars where close is near the high, PP shifts upward; near the low, PP shifts downward. This is intentional and reflects the market's closing sentiment.
6. **TValue input uses price as all four OHLC fields.** When updating with `TValue` instead of `TBar`, the single price value is used for open, high, low, and close. This means $range = 0$ and all levels collapse to the price. Use `TBar` input for meaningful pivot calculations.
7. **NaN/Infinity inputs use last-valid substitution.** If any of H, L, C is NaN or Infinity, the last valid value for that field is substituted. This prevents NaN propagation but may produce stale levels. Monitor data quality upstream.
## References
- Weintraub, N. (1996). *Tricks of the Floor Trader*. McGraw-Hill.
- Person, J. L. (2004). *A Complete Guide to Technical Trading Tactics: How to Profit Using Pivot Points, Candlesticks & Other Indicators*. John Wiley and Sons.
- Wikipedia: [Pivot point (technical analysis)](https://en.wikipedia.org/wiki/Pivot_point_(technical_analysis))
- TradingView PineScript Reference: [`ta.pivothigh()`](https://www.tradingview.com/pine-script-reference/v5/#fun_ta.pivothigh), [`ta.pivotlow()`](https://www.tradingview.com/pine-script-reference/v5/#fun_ta.pivotlow)