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,126 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public sealed class PivotextIndicatorTests
{
[Fact]
public void PivotextIndicator_Constructor_SetsDefaults()
{
var indicator = new PivotextIndicator();
Assert.True(indicator.ShowColdValues);
Assert.Contains("PIVOTEXT", indicator.Name, StringComparison.Ordinal);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void PivotextIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new PivotextIndicator();
Assert.Equal(0, PivotextIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void PivotextIndicator_ShortName_IsPivotext()
{
var indicator = new PivotextIndicator();
indicator.Initialize();
Assert.Contains("PIVOTEXT", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void PivotextIndicator_SourceCodeLink_IsValid()
{
var indicator = new PivotextIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Pivotext", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void PivotextIndicator_Initialize_CreatesInternalIndicator()
{
var indicator = new PivotextIndicator();
indicator.Initialize();
// 11 line series: PP, R1, R2, R3, R4, R5, S1, S2, S3, S4, S5
Assert.Equal(11, indicator.LinesSeries.Count);
}
[Fact]
public void PivotextIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new PivotextIndicator();
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 PivotextIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new PivotextIndicator();
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 PivotextIndicator_ElevenLineSeries_ArePresent()
{
var indicator = new PivotextIndicator();
indicator.Initialize();
// PP=0, R1=1, R2=2, R3=3, R4=4, R5=5, S1=6, S2=7, S3=8, S4=9, S5=10
Assert.Equal(11, indicator.LinesSeries.Count);
Assert.Contains("PP", indicator.LinesSeries[0].Name, StringComparison.OrdinalIgnoreCase);
Assert.Contains("R1", indicator.LinesSeries[1].Name, StringComparison.OrdinalIgnoreCase);
Assert.Contains("R5", indicator.LinesSeries[5].Name, StringComparison.OrdinalIgnoreCase);
Assert.Contains("S1", indicator.LinesSeries[6].Name, StringComparison.OrdinalIgnoreCase);
Assert.Contains("S5", indicator.LinesSeries[10].Name, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PivotextIndicator_Description_IsSet()
{
var indicator = new PivotextIndicator();
Assert.NotNull(indicator.Description);
Assert.NotEmpty(indicator.Description);
Assert.Contains("Extended", indicator.Description, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,88 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class PivotextIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Pivotext _indicator = null!;
private readonly LineSeries _ppSeries;
private readonly LineSeries _r1Series;
private readonly LineSeries _r2Series;
private readonly LineSeries _r3Series;
private readonly LineSeries _r4Series;
private readonly LineSeries _r5Series;
private readonly LineSeries _s1Series;
private readonly LineSeries _s2Series;
private readonly LineSeries _s3Series;
private readonly LineSeries _s4Series;
private readonly LineSeries _s5Series;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => "PIVOTEXT";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/reversals/pivotext/Pivotext.cs";
public PivotextIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "PIVOTEXT - Extended Traditional Pivot Points";
Description = "Extended traditional pivot points: 11 support/resistance levels (PP, R1-R5, S1-S5) 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, 180, 180), width: 1, style: LineStyle.Solid);
_r2Series = new LineSeries(name: "R2", color: Color.FromArgb(255, 140, 140), width: 1, style: LineStyle.Solid);
_r3Series = new LineSeries(name: "R3", color: Color.FromArgb(255, 100, 100), width: 1, style: LineStyle.Solid);
_r4Series = new LineSeries(name: "R4", color: Color.FromArgb(255, 60, 60), width: 1, style: LineStyle.Dash);
_r5Series = new LineSeries(name: "R5", color: Color.Red, width: 1, style: LineStyle.Dash);
_s1Series = new LineSeries(name: "S1", color: Color.FromArgb(180, 255, 180), width: 1, style: LineStyle.Solid);
_s2Series = new LineSeries(name: "S2", color: Color.FromArgb(140, 255, 140), width: 1, style: LineStyle.Solid);
_s3Series = new LineSeries(name: "S3", color: Color.FromArgb(100, 255, 100), width: 1, style: LineStyle.Solid);
_s4Series = new LineSeries(name: "S4", color: Color.FromArgb(60, 255, 60), width: 1, style: LineStyle.Dash);
_s5Series = new LineSeries(name: "S5", color: Color.Green, width: 1, style: LineStyle.Dash);
AddLineSeries(_ppSeries);
AddLineSeries(_r1Series);
AddLineSeries(_r2Series);
AddLineSeries(_r3Series);
AddLineSeries(_r4Series);
AddLineSeries(_r5Series);
AddLineSeries(_s1Series);
AddLineSeries(_s2Series);
AddLineSeries(_s3Series);
AddLineSeries(_s4Series);
AddLineSeries(_s5Series);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_indicator = new Pivotext();
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);
_r4Series.SetValue(_indicator.R4, _indicator.IsHot, ShowColdValues);
_r5Series.SetValue(_indicator.R5, _indicator.IsHot, ShowColdValues);
_s1Series.SetValue(_indicator.S1, _indicator.IsHot, ShowColdValues);
_s2Series.SetValue(_indicator.S2, _indicator.IsHot, ShowColdValues);
_s3Series.SetValue(_indicator.S3, _indicator.IsHot, ShowColdValues);
_s4Series.SetValue(_indicator.S4, _indicator.IsHot, ShowColdValues);
_s5Series.SetValue(_indicator.S5, _indicator.IsHot, ShowColdValues);
}
}
+617
View File
@@ -0,0 +1,617 @@
// PIVOTEXT Tests - Extended Traditional Pivot Points
namespace QuanTAlib.Tests;
// -- A) Constructor Validation ------------------------------------------------
public sealed class PivotextConstructorTests
{
[Fact]
public void Constructor_Default_SetsProperties()
{
var p = new Pivotext();
Assert.Equal(2, p.WarmupPeriod);
Assert.Contains("Pivotext", p.Name, StringComparison.Ordinal);
Assert.False(p.IsHot);
}
[Fact]
public void Constructor_InitialState_AllNaN()
{
var p = new Pivotext();
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.R4));
Assert.True(double.IsNaN(p.R5));
Assert.True(double.IsNaN(p.S1));
Assert.True(double.IsNaN(p.S2));
Assert.True(double.IsNaN(p.S3));
Assert.True(double.IsNaN(p.S4));
Assert.True(double.IsNaN(p.S5));
}
}
// -- B) Basic Calculation -----------------------------------------------------
public sealed class PivotextBasicTests
{
[Fact]
public void Update_ReturnsTValue()
{
var p = new Pivotext();
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 Pivotext();
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, range=20
// PP = (110+90+100)/3 = 100
// ppMinusL = 100-90 = 10, hMinusPP = 110-100 = 10
// R1 = 2*100 - 90 = 110
// S1 = 2*100 - 110 = 90
// R2 = 100 + 20 = 120
// S2 = 100 - 20 = 80
// R3 = 110 + 2*10 = 130
// S3 = 90 - 2*10 = 70
// R4 = 110 + 3*10 = 140
// S4 = 90 - 3*10 = 60
// R5 = 110 + 4*10 = 150
// S5 = 90 - 4*10 = 50
var p = new Pivotext();
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);
Assert.Equal(140.0, p.R4, precision: 10);
Assert.Equal(60.0, p.S4, precision: 10);
Assert.Equal(150.0, p.R5, precision: 10);
Assert.Equal(50.0, p.S5, precision: 10);
}
[Fact]
public void Update_SecondKnownValues_CorrectPivotLevels()
{
// Given previous bar H=120, L=100, C=115, range=20
// PP = (120+100+115)/3 = 111.6667
// ppMinusL = 111.6667-100 = 11.6667, hMinusPP = 120-111.6667 = 8.3333
// R1 = 2*111.6667 - 100 = 123.3333
// S1 = 2*111.6667 - 120 = 103.3333
// R2 = 111.6667 + 20 = 131.6667
// S2 = 111.6667 - 20 = 91.6667
// R3 = 120 + 2*11.6667 = 143.3333
// S3 = 100 - 2*8.3333 = 83.3333
// R4 = 120 + 3*11.6667 = 155.0
// S4 = 100 - 3*8.3333 = 75.0
// R5 = 120 + 4*11.6667 = 166.6667
// S5 = 100 - 4*8.3333 = 66.6667
var p = new Pivotext();
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;
double ppMinusL = expectedPP - 100.0;
double hMinusPP = 120.0 - expectedPP;
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 * ppMinusL, p.R3, precision: 10);
Assert.Equal(100.0 - 2.0 * hMinusPP, p.S3, precision: 10);
Assert.Equal(120.0 + 3.0 * ppMinusL, p.R4, precision: 10);
Assert.Equal(100.0 - 3.0 * hMinusPP, p.S4, precision: 10);
Assert.Equal(120.0 + 4.0 * ppMinusL, p.R5, precision: 10);
Assert.Equal(100.0 - 4.0 * hMinusPP, p.S5, precision: 10);
}
[Fact]
public void Update_LevelsHaveCorrectOrdering()
{
// For any normal bar: S5 < S4 < S3 < S2 < S1 < PP < R1 < R2 < R3 < R4 < R5
var p = new Pivotext();
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.S5 < p.S4);
Assert.True(p.S4 < p.S3);
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);
Assert.True(p.R3 < p.R4);
Assert.True(p.R4 < p.R5);
}
[Fact]
public void Name_ContainsPivotext()
{
var p = new Pivotext();
Assert.Contains("Pivotext", p.Name, StringComparison.Ordinal);
}
}
// -- C) State + Bar Correction ------------------------------------------------
public sealed class PivotextStateCorrectionTests
{
[Fact]
public void IsNew_True_AdvancesState()
{
var p = new Pivotext();
_ = 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 Pivotext();
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 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)
Assert.Equal(ppBefore, p.PP, precision: 10);
}
[Fact]
public void IterativeCorrections_ProduceSameResult()
{
var p = new Pivotext();
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 Pivotext();
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;
double r3a = p.R3, s3a = p.S3, r4a = p.R4, s4a = p.S4;
double r5a = p.R5, s5a = p.S5;
_ = 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);
Assert.Equal(r4a, p.R4);
Assert.Equal(s4a, p.S4);
Assert.Equal(r5a, p.R5);
Assert.Equal(s5a, p.S5);
}
[Fact]
public void Reset_ClearsAllState()
{
var p = new Pivotext();
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.R4));
Assert.True(double.IsNaN(p.R5));
Assert.True(double.IsNaN(p.S1));
Assert.True(double.IsNaN(p.S2));
Assert.True(double.IsNaN(p.S3));
Assert.True(double.IsNaN(p.S4));
Assert.True(double.IsNaN(p.S5));
}
}
// -- D) Warmup / Convergence --------------------------------------------------
public sealed class PivotextWarmupTests
{
[Fact]
public void IsHot_FlipsAfterWarmup()
{
var p = new Pivotext();
// 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 Pivotext();
Assert.Equal(2, p.WarmupPeriod);
}
}
// -- E) Robustness ------------------------------------------------------------
public sealed class PivotextRobustnessTests
{
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var p = new Pivotext();
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);
Assert.True(p.IsHot);
Assert.True(double.IsFinite(p.PP));
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var p = new Pivotext();
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 Pivotext();
_ = 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 PivotextConsistencyTests
{
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 Pivotext();
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 = Pivotext.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 Pivotext();
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];
Pivotext.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 Pivotext();
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];
var sR4 = new double[bars.Count];
var sS4 = new double[bars.Count];
var sR5 = new double[bars.Count];
var sS5 = 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;
sR4[i] = streaming.R4;
sS4[i] = streaming.S4;
sR5[i] = streaming.R5;
sS5[i] = streaming.S5;
}
// 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];
var bR4 = new double[bars.Count];
var bS4 = new double[bars.Count];
var bR5 = new double[bars.Count];
var bS5 = new double[bars.Count];
Pivotext.BatchAll(bars.HighValues, bars.LowValues, bars.CloseValues,
bPP, bR1, bS1, bR2, bS2, bR3, bS3, bR4, bS4, bR5, bS5);
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);
Assert.Equal(sR4[i], bR4[i], precision: 10);
Assert.Equal(sS4[i], bS4[i], precision: 10);
Assert.Equal(sR5[i], bR5[i], precision: 10);
Assert.Equal(sS5[i], bS5[i], precision: 10);
}
}
[Fact]
public void TValue_Update_MatchesTBar_Update()
{
var p1 = new Pivotext();
var p2 = new Pivotext();
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 PivotextSpanTests
{
[Fact]
public void Batch_Span_MismatchedLengths_Throws()
{
var ex = Assert.Throws<ArgumentException>(() =>
Pivotext.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>(() =>
Pivotext.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(() =>
Pivotext.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>(() =>
Pivotext.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], new double[10], new double[10],
new double[10], new double[10]));
Assert.Equal("r1Out", ex.ParamName);
}
}
// -- H) Event / Chainability -------------------------------------------------
public sealed class PivotextEventTests
{
[Fact]
public void Pub_FiresOnUpdate()
{
var p = new Pivotext();
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 Pivotext();
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 PivotextPrimeTests
{
[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 Pivotext();
p.Prime(bars);
Assert.True(p.IsHot);
}
[Fact]
public void Prime_EmptySource_NoException()
{
var p = new Pivotext();
var bars = new TBarSeries();
var ex = Record.Exception(() => p.Prime(bars));
Assert.Null(ex);
Assert.False(p.IsHot);
}
}
@@ -0,0 +1,288 @@
// PIVOTEXT Validation Tests - Extended Traditional Pivot Points
// Self-consistency validation across all API modes.
//
// Note: No external library (Skender, TA-Lib, Tulip, Ooples) implements
// Extended Traditional Pivot Points with R4/R5/S4/S5. Validation focuses
// on mathematical correctness and mode consistency.
namespace QuanTAlib.Tests;
public sealed class PivotextValidationTests
{
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 Pivotext();
for (int i = 0; i < bars.Count; i++)
{
_ = p.Update(bars[i], isNew: true);
if (i >= 1)
{
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_MatchExtendedFormula()
{
var bars = CreateGbmBars(count: 100);
var p = new Pivotext();
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;
double ppMinusL = pp - pL;
double hMinusPP = pH - pp;
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 * ppMinusL, p.R3, precision: 10);
Assert.Equal(pL - 2.0 * hMinusPP, p.S3, precision: 10);
Assert.Equal(pH + 3.0 * ppMinusL, p.R4, precision: 10);
Assert.Equal(pL - 3.0 * hMinusPP, p.S4, precision: 10);
Assert.Equal(pH + 4.0 * ppMinusL, p.R5, precision: 10);
Assert.Equal(pL - 4.0 * hMinusPP, p.S5, precision: 10);
}
}
}
// -- Self-Consistency: Streaming == Batch --------------------------------------
[Fact]
public void StreamingMatchesBatch_PP()
{
var bars = CreateGbmBars();
// Streaming
var streaming = new Pivotext();
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 = Pivotext.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 Pivotext();
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];
Pivotext.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 11 levels) -------------------
[Fact]
public void StreamingMatchesBatchAll_AllLevels()
{
var bars = CreateGbmBars(count: 300);
// Streaming
var streaming = new Pivotext();
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];
var sR4 = new double[bars.Count];
var sS4 = new double[bars.Count];
var sR5 = new double[bars.Count];
var sS5 = 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;
sR4[i] = streaming.R4;
sS4[i] = streaming.S4;
sR5[i] = streaming.R5;
sS5[i] = streaming.S5;
}
// 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];
var bR4 = new double[bars.Count];
var bS4 = new double[bars.Count];
var bR5 = new double[bars.Count];
var bS5 = new double[bars.Count];
Pivotext.BatchAll(bars.HighValues, bars.LowValues, bars.CloseValues,
bPP, bR1, bS1, bR2, bS2, bR3, bS3, bR4, bS4, bR5, bS5);
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);
Assert.Equal(sR4[i], bR4[i], precision: 10);
Assert.Equal(sS4[i], bS4[i], precision: 10);
Assert.Equal(sR5[i], bR5[i], precision: 10);
Assert.Equal(sS5[i], bS5[i], precision: 10);
}
}
// -- Determinism ---------------------------------------------------------------
[Fact]
public void SameInput_ProducesSameOutput()
{
var bars = CreateGbmBars(count: 200, seed: 123);
var p1 = new Pivotext();
var p2 = new Pivotext();
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);
Assert.Equal(p1.R4, p2.R4);
Assert.Equal(p1.S4, p2.S4);
Assert.Equal(p1.R5, p2.R5);
Assert.Equal(p1.S5, p2.S5);
}
// -- Calculate Returns Valid Indicator -----------------------------------------
[Fact]
public void Calculate_ReturnsValidIndicatorAndResults()
{
var bars = CreateGbmBars(count: 100);
var (results, indicator) = Pivotext.Calculate(bars);
Assert.NotNull(results);
Assert.Equal(bars.Count, results.Count);
Assert.True(indicator.IsHot);
}
// -- Level Ordering Invariant --------------------------------------------------
[Fact]
public void AllBars_SupportResistanceLevelsOrdered()
{
// Extended: S5 < S4 < S3 < S2 < S1 < PP < R1 < R2 < R3 < R4 < R5
// (when close equals midpoint of range, PP lies at center)
var bars = CreateGbmBars(count: 200);
var p = new Pivotext();
for (int i = 0; i < bars.Count; i++)
{
_ = p.Update(bars[i], isNew: true);
if (p.IsHot)
{
Assert.True(p.S5 <= p.S4, $"S5 > S4 at bar {i}");
Assert.True(p.S4 <= p.S3, $"S4 > S3 at bar {i}");
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.R1 <= p.R2, $"R1 > R2 at bar {i}");
Assert.True(p.R2 <= p.R3, $"R2 > R3 at bar {i}");
Assert.True(p.R3 <= p.R4, $"R3 > R4 at bar {i}");
Assert.True(p.R4 <= p.R5, $"R4 > R5 at bar {i}");
}
}
}
}
+462
View File
@@ -0,0 +1,462 @@
// PIVOTEXT: Extended Traditional Pivot Points
// Calculates 11 support/resistance levels from previous bar's HLC.
// Classic floor trader formula extended with R4/R5 and S4/S5 levels.
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// PIVOTEXT: Extended Traditional Pivot Points
/// </summary>
/// <remarks>
/// Computes 11 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-R5) and support (S1-S5) levels are derived from
/// PP and the prior bar's range. R1-R3/S1-S3 are identical to classic pivots;
/// R4/R5 and S4/S5 extend the range further for extreme move scenarios.
///
/// 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)
/// R4 = H + 3 * (PP - L) S4 = L - 3 * (H - PP)
/// R5 = H + 4 * (PP - L) S5 = L - 4 * (H - PP)
/// </code>
///
/// <b>Key characteristics:</b>
/// - O(1) computation: pure arithmetic from previous bar's HLC
/// - 11 outputs: PP, R1, R2, R3, R4, R5, S1, S2, S3, S4, S5
/// - WarmupPeriod = 2 (need previous bar's HLC)
/// - No configurable parameters
/// - Levels remain constant until a new bar arrives
/// - R4/R5 and S4/S5 provide extreme support/resistance for gap scenarios
/// </remarks>
/// <seealso href="Pivotext.md">Detailed documentation</seealso>
[SkipLocalsInit]
public sealed class Pivotext : 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>Resistance 4: prevH + 3 * (PP - prevL)</summary>
public double R4 { get; private set; }
/// <summary>Resistance 5: prevH + 4 * (PP - prevL)</summary>
public double R5 { 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>Support 4: prevL - 3 * (prevH - PP)</summary>
public double S4 { get; private set; }
/// <summary>Support 5: prevL - 4 * (prevH - PP)</summary>
public double S5 { 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 an Extended Traditional Pivot Points indicator.
/// </summary>
public Pivotext()
{
_count = 0;
_s = new State(double.NaN, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
_ps = _s;
SetAllNaN();
Name = "Pivotext";
WarmupPeriod = 2;
_barHandler = HandleBar;
}
/// <summary>
/// Creates an Extended Traditional Pivot Points indicator chained to a TBarSeries source.
/// </summary>
public Pivotext(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 extended 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;
double ppMinusL = pp - pL; // used for R3, R4, R5
double hMinusPP = pH - pp; // used for S3, S4, S5
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, ppMinusL, pH); // pH + 2*(pp - pL)
S3 = Math.FusedMultiplyAdd(-2.0, hMinusPP, pL); // pL - 2*(pH - pp)
R4 = Math.FusedMultiplyAdd(3.0, ppMinusL, pH); // pH + 3*(pp - pL)
S4 = Math.FusedMultiplyAdd(-3.0, hMinusPP, pL); // pL - 3*(pH - pp)
R5 = Math.FusedMultiplyAdd(4.0, ppMinusL, pH); // pH + 4*(pp - pL)
S5 = Math.FusedMultiplyAdd(-4.0, hMinusPP, pL); // pL - 4*(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;
R4 = double.NaN;
R5 = double.NaN;
S1 = double.NaN;
S2 = double.NaN;
S3 = double.NaN;
S4 = double.NaN;
S5 = double.NaN;
}
/// <summary>
/// Batch computation of Extended Traditional 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 11 Extended Traditional 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,
Span<double> r4Out,
Span<double> s4Out,
Span<double> r5Out,
Span<double> s5Out)
{
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 (r4Out.Length < len) { throw new ArgumentException("Output span too short.", nameof(r4Out)); }
if (s4Out.Length < len) { throw new ArgumentException("Output span too short.", nameof(s4Out)); }
if (r5Out.Length < len) { throw new ArgumentException("Output span too short.", nameof(r5Out)); }
if (s5Out.Length < len) { throw new ArgumentException("Output span too short.", nameof(s5Out)); }
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;
r4Out[0] = double.NaN;
s4Out[0] = double.NaN;
r5Out[0] = double.NaN;
s5Out[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;
double ppMinusL = pp - pL;
double hMinusPP = pH - pp;
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, ppMinusL, pH);
s3Out[i] = Math.FusedMultiplyAdd(-2.0, hMinusPP, pL);
r4Out[i] = Math.FusedMultiplyAdd(3.0, ppMinusL, pH);
s4Out[i] = Math.FusedMultiplyAdd(-3.0, hMinusPP, pL);
r5Out[i] = Math.FusedMultiplyAdd(4.0, ppMinusL, pH);
s5Out[i] = Math.FusedMultiplyAdd(-4.0, hMinusPP, pL);
}
}
public static (TSeries Results, Pivotext Indicator) Calculate(TBarSeries source)
{
var indicator = new Pivotext();
var results = indicator.Update(source);
return (results, indicator);
}
}
+176
View File
@@ -0,0 +1,176 @@
# PIVOTEXT: Extended Traditional Pivot Points
> "Classic pivots tell you where the crowd expects the market to pause. Extended pivots tell you where the crowd starts to panic."
Extended Traditional Pivot Points calculate eleven horizontal support and resistance levels from the previous bar's high, low, and close. The core levels (PP, R1-R3, S1-S3) are identical to classic floor trader pivots. The extension adds R4/R5 and S4/S5 levels that project further beyond the prior bar's range, covering extreme move scenarios such as gap opens, news-driven spikes, and trend continuation through multiple prior-range increments. The formula is pure arithmetic with zero parameters.
## Historical Context
Floor trader pivots date to the 1930s when pit traders computed PP = (H + L + C) / 3 and derived three symmetric support/resistance levels from it. The formula was simple enough to compute by hand before the market open, making it one of the earliest systematic approaches to intraday level identification.
Classic pivots (R1-R3, S1-S3) cover the range from roughly 1x to 2x the prior bar's range projected from the high or low. In practice, large gap opens or momentum-driven moves routinely exceed R3/S3. Traders discovered they needed additional levels to bracket these extreme scenarios without switching to entirely different frameworks (Fibonacci extensions, measured moves, etc.).
The extended formula simply continues the same arithmetic progression. R3 uses a 2x multiplier on (PP - L) added to H; R4 uses 3x; R5 uses 4x. The symmetry holds for support levels. This mechanical extension preserves the simplicity of the original system while providing reference levels for moves that exceed the "normal" 1-3 range pivots.
Unlike Fibonacci pivots, Camarilla pivots, or DeMark pivots, the extended traditional formula makes no claim about specific retracement ratios or market microstructure. The levels are pure geometric projections of the prior range. Their value lies in consensus: enough traders watch these levels that they become self-reinforcing reference points.
## 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 them 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 HLC. This is the gravitational center of the level system. All other levels derive from PP and the prior range.
### 3. Range and Intermediate Values
$$range = H_{prev} - L_{prev}$$
$$ppMinusL = PP - L_{prev}$$
$$hMinusPP = H_{prev} - PP$$
The range scales the distance between levels. The asymmetric terms $ppMinusL$ and $hMinusPP$ determine how far resistance extends above H and support extends below L.
### 4. Classic Levels (R1-R3, S1-S3)
$$R_1 = 2 \cdot PP - L_{prev} \qquad S_1 = 2 \cdot PP - H_{prev}$$
$$R_2 = PP + range \qquad S_2 = PP - range$$
$$R_3 = H_{prev} + 2 \cdot (PP - L_{prev}) \qquad S_3 = L_{prev} - 2 \cdot (H_{prev} - PP)$$
### 5. Extended Levels (R4-R5, S4-S5)
$$R_4 = H_{prev} + 3 \cdot (PP - L_{prev}) \qquad S_4 = L_{prev} - 3 \cdot (H_{prev} - PP)$$
$$R_5 = H_{prev} + 4 \cdot (PP - L_{prev}) \qquad S_5 = L_{prev} - 4 \cdot (H_{prev} - PP)$$
The progression is arithmetic: each successive level adds one more $ppMinusL$ (resistance) or $hMinusPP$ (support) increment.
### 6. Level Ordering Invariant
For any bar where $H_{prev} > L_{prev}$ (non-degenerate range):
$$S_5 < S_4 < S_3 < S_2 < S_1 \leq PP \leq R_1 < R_2 < R_3 < R_4 < R_5$$
When the close is exactly at the midpoint of the range, $PP = S_1 = R_1$ (all collapse to the midpoint), and the S/R levels fan out symmetrically. In general, the close's position within the range determines the asymmetry between resistance and support spacing.
### 7. Eleven Outputs
All eleven levels are computed simultaneously and remain constant until a new bar arrives. The primary output (`Last.Val`) returns PP; individual properties expose all eleven levels.
### Signal Interpretation
| Condition | Interpretation |
| :--- | :--- |
| Price between S1 and R1 | Normal range; no directional bias |
| Price tests R2 from below | First extension test; watch for rejection |
| Price tests S2 from above | First support extension; potential bounce |
| Price reaches R3/S3 | Classic extreme; high-probability reversal zone |
| Price breaks R4/S4 | Significant momentum; extended trend likely |
| Price reaches R5/S5 | Rare extreme; potential exhaustion or blow-off |
| Levels cluster tightly | Prior bar had low range; expect volatility expansion |
| Wide level spacing | Prior bar was volatile; levels may be less precise |
## Mathematical Foundation
### Parameters
Extended Traditional 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 | 11 | PP, R1, R2, R3, R4, R5, S1, S2, S3, S4, S5 |
| 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 classic pivot formula is not derived from statistical theory. It is an empirical heuristic that became standardized through widespread adoption. The extension to R4/R5 and S4/S5 follows the same arithmetic progression pattern already established by R3/S3. No new constants or empirical fitting are introduced.
R3 and S3 use a coefficient of 2 on the asymmetric terms. R4/S4 use 3. R5/S5 use 4. The progression could continue indefinitely, but levels beyond R5/S5 are rarely referenced in practice.
All R/S level computations use `Math.FusedMultiplyAdd` for the `multiplier * offset + base` pattern, providing single-rounding precision.
## Performance Profile
### Implementation Design
Pure arithmetic with no loops, no buffers, no auxiliary data structures. Each `Update` call performs 1 division (PP), 1 subtraction (range), 2 subtractions (ppMinusL, hMinusPP), 2 additions (R2, S2), and 8 FMA operations (R1, S1, R3-R5, S3-S5), plus 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 via FMA; 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 (13 arithmetic operations) is too simple to benefit from vectorization overhead.
### FMA Usage
The implementation uses `Math.FusedMultiplyAdd` for eight of the eleven R/S level computations. R1 and S1 use `FMA(2, pp, -pL)` and `FMA(2, pp, -pH)`. R3-R5 and S3-S5 use FMA with the precomputed `ppMinusL` and `hMinusPP` intermediate values. R2 and S2 are simple additions/subtractions that do not benefit from FMA.
## 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 11 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 | Does not implement extended traditional variant |
| **TA-Lib** | N/A | Not implemented |
| **Tulip** | N/A | Not implemented |
| **Ooples** | N/A | Not validated |
Mathematical correctness is validated by computing expected values from the extended 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. **R1-R3/S1-S3 are identical to classic PIVOT.** The extended indicator adds R4/R5/S4/S5 but does not modify the classic levels. If you only need 7 levels, use the `Pivot` class instead to avoid computing unused outputs.
3. **R4/R5 and S4/S5 project far from the current range.** These levels represent 3x and 4x prior-range extensions. For low-volatility instruments, they may be so distant as to be meaningless. For high-volatility instruments or gap scenarios, they provide the only pre-computed reference levels.
4. **Level spacing is asymmetric when close is not at the range midpoint.** When the close is near the high, resistance levels are spaced more tightly than support levels, and vice versa. This is by design: the formula reflects where the close sits within the prior range.
5. **Zero-range bars collapse all levels to a single price.** When $H_{prev} = L_{prev}$ (doji or single-print bar), all eleven levels equal the prior close, and PP equals the prior close. This is mathematically correct but provides no useful levels.
6. **TValue input collapses range to zero.** When updating with `TValue` instead of `TBar`, all OHLC fields equal the single price, producing zero range and all levels equal to that price. Use `TBar` input for meaningful pivot calculations.
7. **R5/S5 are rarely reached.** In typical market conditions, price reaching R5 or S5 represents approximately a 4x prior-range move. This occurs during panic selling, short squeezes, or major news events. Do not expect these levels to act as regular support/resistance.
## References
- 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: [Pivot Points Standard](https://www.tradingview.com/support/solutions/43000521824-pivot-points-standard/)
- Nison, S. (2001). *Japanese Candlestick Charting Techniques*. Prentice Hall Press. (Discussion of floor trader pivot methodology.)