Add Yang-Zhang Volatility (YZV) Indicator Implementation

- Introduced YZV class for calculating Yang-Zhang Volatility, a comprehensive volatility measure that incorporates overnight, open-to-close, and high-low components.
- Implemented calculation methods, including batch processing for TBarSeries and spans.
- Added documentation for YZV, detailing its mathematical foundation, performance profile, and trading applications.
- Updated volume index documentation to reflect changes in file paths.
- Refactored VWMA calculation method to use a more generic source parameter instead of price.
This commit is contained in:
Miha Kralj
2026-02-02 19:47:21 -08:00
parent a03d7aa0ce
commit c034cbd5e5
78 changed files with 16662 additions and 366 deletions
+244
View File
@@ -0,0 +1,244 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class BiasIndicatorTests
{
[Fact]
public void BiasIndicator_Constructor_SetsDefaults()
{
var indicator = new BiasIndicator();
Assert.Equal(20, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("BIAS - Price Deviation from SMA", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void BiasIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new BiasIndicator();
Assert.Equal(0, BiasIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void BiasIndicator_ShortName_IncludesPeriodAndSource()
{
var indicator = new BiasIndicator { Period = 20 };
Assert.Contains("BIAS", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("20", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void BiasIndicator_Initialize_CreatesInternalBias()
{
var indicator = new BiasIndicator { Period = 10 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void BiasIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new BiasIndicator { Period = 5 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
// Process update
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
// Line series should have a value
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void BiasIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new BiasIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void BiasIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new BiasIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstValue = indicator.LinesSeries[0].GetValue(0);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
double secondValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(firstValue));
Assert.True(double.IsFinite(secondValue));
}
[Fact]
public void BiasIndicator_MultipleUpdates_ProducesCorrectBiasSequence()
{
var indicator = new BiasIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
double[] closes = { 100, 100, 100, 110, 100 };
foreach (var close in closes)
{
indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
now = now.AddMinutes(1);
}
// All values should be finite
for (int i = 0; i < closes.Length; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
}
}
[Fact]
public void BiasIndicator_DifferentSourceTypes_Work()
{
var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
foreach (var source in sources)
{
var indicator = new BiasIndicator { Period = 5, Source = source };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
$"Source {source} should produce finite value");
}
}
[Fact]
public void BiasIndicator_CalculatesBiasCorrectly()
{
var indicator = new BiasIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Add 3 bars with close = 100, then one with close = 110
// SMA(3) of [100, 100, 100] = 100
// BIAS when price = 100, SMA = 100 → 0%
indicator.HistoricalData.AddBar(now, 100, 100, 100, 100);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.Equal(0.0, indicator.LinesSeries[0].GetValue(0), 1e-10);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 100, 100, 100);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(0.0, indicator.LinesSeries[0].GetValue(0), 1e-10);
indicator.HistoricalData.AddBar(now.AddMinutes(2), 100, 100, 100, 100);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(0.0, indicator.LinesSeries[0].GetValue(0), 1e-10);
// Now add a bar with close = 110
// SMA(3) of [100, 100, 110] = 310/3 ≈ 103.333
// BIAS = (110 / 103.333) - 1 ≈ 0.0645 (6.45%)
indicator.HistoricalData.AddBar(now.AddMinutes(3), 110, 110, 110, 110);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double expectedSma = (100.0 + 100.0 + 110.0) / 3.0;
double expectedBias = (110.0 / expectedSma) - 1.0;
Assert.Equal(expectedBias, indicator.LinesSeries[0].GetValue(0), 1e-10);
}
[Fact]
public void BiasIndicator_ConstantPrice_ZeroBias()
{
var indicator = new BiasIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
// All bars at same price should produce zero bias
for (int i = 0; i < 10; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100, 100, 100);
indicator.ProcessUpdate(i == 0 ? new UpdateArgs(UpdateReason.HistoricalBar) : new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(0.0, indicator.LinesSeries[0].GetValue(0), 1e-10);
}
}
[Fact]
public void BiasIndicator_UpTrend_PositiveBias()
{
var indicator = new BiasIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
double[] closes = { 100, 101, 102, 103, 104, 105 };
for (int i = 0; i < closes.Length; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), closes[i], closes[i], closes[i], closes[i]);
indicator.ProcessUpdate(i == 0 ? new UpdateArgs(UpdateReason.HistoricalBar) : new UpdateArgs(UpdateReason.NewBar));
}
// In uptrend, price should be above SMA, so bias > 0
Assert.True(indicator.LinesSeries[0].GetValue(0) > 0, "Bias should be positive in uptrend");
}
[Fact]
public void BiasIndicator_DownTrend_NegativeBias()
{
var indicator = new BiasIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
double[] closes = { 105, 104, 103, 102, 101, 100 };
for (int i = 0; i < closes.Length; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), closes[i], closes[i], closes[i], closes[i]);
indicator.ProcessUpdate(i == 0 ? new UpdateArgs(UpdateReason.HistoricalBar) : new UpdateArgs(UpdateReason.NewBar));
}
// In downtrend, price should be below SMA, so bias < 0
Assert.True(indicator.LinesSeries[0].GetValue(0) < 0, "Bias should be negative in downtrend");
}
[Fact]
public void BiasIndicator_Period_CanBeChanged()
{
var indicator = new BiasIndicator { Period = 50 };
Assert.Equal(50, indicator.Period);
indicator.Period = 100;
Assert.Equal(100, indicator.Period);
}
}
+55
View File
@@ -0,0 +1,55 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class BiasIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 0, minimum: 1, maximum: 10000)]
public int Period { get; set; } = 20;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Bias _bias = null!;
private readonly LineSeries _series;
private string _sourceName = null!;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"BIAS({Period}):{_sourceName}";
public BiasIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "BIAS - Price Deviation from SMA";
Description = "Measures the percentage difference between price and its Simple Moving Average";
_series = new LineSeries(name: "BIAS", color: IndicatorExtensions.Statistics, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_priceSelector = Source.GetPriceSelector();
_sourceName = Source.ToString();
_bias = new Bias(Period);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
bool isNew = args.IsNewBar();
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
double value = _bias.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
_series.SetValue(value, _bias.IsHot, ShowColdValues);
}
}
+594
View File
@@ -0,0 +1,594 @@
namespace QuanTAlib.Tests;
public class BiasTests
{
[Fact]
public void Bias_Constructor_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new Bias(0));
Assert.Throws<ArgumentException>(() => new Bias(-1));
var bias = new Bias(10);
Assert.NotNull(bias);
}
[Fact]
public void Bias_Calc_ReturnsValue()
{
var bias = new Bias(10);
Assert.Equal(0, bias.Last.Value);
TValue result = bias.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(double.IsFinite(result.Value));
Assert.Equal(result.Value, bias.Last.Value);
}
[Fact]
public void Bias_FirstValue_ReturnsZero()
{
// Bias = (Price - SMA) / SMA = (100 - 100) / 100 = 0
var bias = new Bias(10);
TValue result = bias.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(0.0, result.Value, 1e-10);
}
[Fact]
public void Bias_Calc_IsNew_AcceptsParameter()
{
var bias = new Bias(10);
bias.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
double value1 = bias.Last.Value;
bias.Update(new TValue(DateTime.UtcNow, 200), isNew: true);
double value2 = bias.Last.Value;
Assert.NotEqual(value1, value2);
}
[Fact]
public void Bias_Calc_IsNew_False_UpdatesValue()
{
var bias = new Bias(10);
bias.Update(new TValue(DateTime.UtcNow, 100));
bias.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
double beforeUpdate = bias.Last.Value;
bias.Update(new TValue(DateTime.UtcNow, 120), isNew: false);
double afterUpdate = bias.Last.Value;
Assert.NotEqual(beforeUpdate, afterUpdate);
}
[Fact]
public void Bias_Reset_ClearsState()
{
var bias = new Bias(10);
bias.Update(new TValue(DateTime.UtcNow, 100));
bias.Update(new TValue(DateTime.UtcNow, 105));
double valueBefore = bias.Last.Value;
bias.Reset();
Assert.Equal(0, bias.Last.Value);
Assert.False(bias.IsHot);
bias.Update(new TValue(DateTime.UtcNow, 50));
Assert.Equal(0, bias.Last.Value); // First value, Bias = 0
Assert.NotEqual(valueBefore, bias.Last.Value);
}
[Fact]
public void Bias_Properties_Accessible()
{
var bias = new Bias(10);
Assert.Equal(0, bias.Last.Value);
Assert.False(bias.IsHot);
bias.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(0, bias.Last.Value); // First value, Bias = 0
}
[Fact]
public void Bias_IsHot_BecomesTrueWhenBufferFull()
{
var bias = new Bias(5);
Assert.False(bias.IsHot);
for (int i = 1; i <= 4; i++)
{
bias.Update(new TValue(DateTime.UtcNow, i * 10));
Assert.False(bias.IsHot);
}
bias.Update(new TValue(DateTime.UtcNow, 50));
Assert.True(bias.IsHot);
}
[Fact]
public void Bias_CalculatesCorrectBias()
{
// Bias = (Price - SMA) / SMA
var bias = new Bias(3);
// Value 10: SMA = 10, Bias = (10-10)/10 = 0
bias.Update(new TValue(DateTime.UtcNow, 10));
Assert.Equal(0.0, bias.Last.Value, 1e-10);
// Value 20: SMA = (10+20)/2 = 15, Bias = (20-15)/15 = 1/3
bias.Update(new TValue(DateTime.UtcNow, 20));
Assert.Equal(1.0 / 3.0, bias.Last.Value, 1e-10);
// Value 30: SMA = (10+20+30)/3 = 20, Bias = (30-20)/20 = 0.5
bias.Update(new TValue(DateTime.UtcNow, 30));
Assert.Equal(0.5, bias.Last.Value, 1e-10);
}
[Fact]
public void Bias_SlidingWindow_Works()
{
var bias = new Bias(3);
bias.Update(new TValue(DateTime.UtcNow, 10));
bias.Update(new TValue(DateTime.UtcNow, 20));
bias.Update(new TValue(DateTime.UtcNow, 30));
// SMA = 20, Bias = (30-20)/20 = 0.5
Assert.Equal(0.5, bias.Last.Value, 1e-10);
bias.Update(new TValue(DateTime.UtcNow, 40));
// SMA = (20+30+40)/3 = 30, Bias = (40-30)/30 = 1/3
Assert.Equal(1.0 / 3.0, bias.Last.Value, 1e-10);
bias.Update(new TValue(DateTime.UtcNow, 50));
// SMA = (30+40+50)/3 = 40, Bias = (50-40)/40 = 0.25
Assert.Equal(0.25, bias.Last.Value, 1e-10);
}
[Fact]
public void Bias_IterativeCorrections_RestoreToOriginalState()
{
var bias = new Bias(5);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
// Feed 10 new values
TValue tenthInput = default;
for (int i = 0; i < 10; i++)
{
var bar = gbm.Next(isNew: true);
tenthInput = new TValue(bar.Time, bar.Close);
bias.Update(tenthInput, isNew: true);
}
// Remember state after 10 values
double stateAfterTen = bias.Last.Value;
// Generate 9 corrections with isNew=false (different values)
for (int i = 0; i < 9; i++)
{
var bar = gbm.Next(isNew: false);
bias.Update(new TValue(bar.Time, bar.Close), isNew: false);
}
// Feed the remembered 10th input again with isNew=false
TValue finalResult = bias.Update(tenthInput, isNew: false);
// State should match the original state after 10 values
Assert.Equal(stateAfterTen, finalResult.Value, 1e-10);
}
[Fact]
public void Bias_BatchCalc_MatchesIterativeCalc()
{
var biasIterative = new Bias(10);
var biasBatch = new Bias(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
var series = new TSeries();
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
Assert.True(series.Count > 0);
// Calculate iteratively
var iterativeResults = new TSeries();
foreach (var item in series)
{
iterativeResults.Add(biasIterative.Update(item));
}
// Calculate batch
var batchResults = biasBatch.Update(series);
// Compare
Assert.Equal(iterativeResults.Count, batchResults.Count);
for (int i = 0; i < iterativeResults.Count; i++)
{
Assert.Equal(iterativeResults[i].Value, batchResults[i].Value, 1e-10);
Assert.Equal(iterativeResults[i].Time, batchResults[i].Time);
}
}
[Fact]
public void Bias_NaN_Input_UsesLastValidValue()
{
var bias = new Bias(5);
bias.Update(new TValue(DateTime.UtcNow, 100));
bias.Update(new TValue(DateTime.UtcNow, 110));
var resultAfterNaN = bias.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(resultAfterNaN.Value));
}
[Fact]
public void Bias_Infinity_Input_UsesLastValidValue()
{
var bias = new Bias(5);
bias.Update(new TValue(DateTime.UtcNow, 100));
bias.Update(new TValue(DateTime.UtcNow, 110));
var resultAfterPosInf = bias.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(resultAfterPosInf.Value));
var resultAfterNegInf = bias.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
Assert.True(double.IsFinite(resultAfterNegInf.Value));
}
[Fact]
public void Bias_MultipleNaN_ContinuesWithLastValid()
{
var bias = new Bias(5);
bias.Update(new TValue(DateTime.UtcNow, 100));
bias.Update(new TValue(DateTime.UtcNow, 110));
bias.Update(new TValue(DateTime.UtcNow, 120));
var r1 = bias.Update(new TValue(DateTime.UtcNow, double.NaN));
var r2 = bias.Update(new TValue(DateTime.UtcNow, double.NaN));
var r3 = bias.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(r1.Value));
Assert.True(double.IsFinite(r2.Value));
Assert.True(double.IsFinite(r3.Value));
}
[Fact]
public void Bias_BatchCalc_HandlesNaN()
{
var bias = new Bias(5);
var series = new TSeries();
series.Add(DateTime.UtcNow.Ticks, 100);
series.Add(DateTime.UtcNow.Ticks + 1, 110);
series.Add(DateTime.UtcNow.Ticks + 2, double.NaN);
series.Add(DateTime.UtcNow.Ticks + 3, 120);
series.Add(DateTime.UtcNow.Ticks + 4, double.PositiveInfinity);
series.Add(DateTime.UtcNow.Ticks + 5, 130);
var results = bias.Update(series);
foreach (var result in results)
{
Assert.True(double.IsFinite(result.Value), $"Expected finite value but got {result.Value}");
}
}
[Fact]
public void Bias_Reset_ClearsLastValidValue()
{
var bias = new Bias(5);
bias.Update(new TValue(DateTime.UtcNow, 100));
bias.Update(new TValue(DateTime.UtcNow, double.NaN));
bias.Reset();
var result = bias.Update(new TValue(DateTime.UtcNow, 50));
Assert.Equal(0.0, result.Value, 1e-10); // First value, Bias = 0
}
[Fact]
public void Bias_StaticBatch_Works()
{
var series = new TSeries();
series.Add(DateTime.UtcNow.Ticks, 10);
series.Add(DateTime.UtcNow.Ticks + 1, 20);
series.Add(DateTime.UtcNow.Ticks + 2, 30);
series.Add(DateTime.UtcNow.Ticks + 3, 40);
series.Add(DateTime.UtcNow.Ticks + 4, 50);
var results = Bias.Batch(series, 3);
Assert.Equal(5, results.Count);
// Last value: SMA(3) = (30+40+50)/3 = 40, Bias = (50-40)/40 = 0.25
Assert.Equal(0.25, results.Last.Value, 1e-10);
}
[Fact]
public void Bias_FlatLine_ReturnsZero()
{
var bias = new Bias(10);
for (int i = 0; i < 20; i++)
{
bias.Update(new TValue(DateTime.UtcNow, 100));
}
// Price = SMA = 100, Bias = (100-100)/100 = 0
Assert.Equal(0.0, bias.Last.Value, 1e-10);
}
// ============== Span API Tests ==============
[Fact]
public void Bias_SpanBatch_ValidatesInput()
{
double[] source = [1, 2, 3, 4, 5];
double[] output = new double[5];
double[] wrongSizeOutput = new double[3];
Assert.Throws<ArgumentException>(() => Bias.Batch(source.AsSpan(), output.AsSpan(), 0));
Assert.Throws<ArgumentException>(() => Bias.Batch(source.AsSpan(), output.AsSpan(), -1));
Assert.Throws<ArgumentException>(() => Bias.Batch(source.AsSpan(), wrongSizeOutput.AsSpan(), 3));
}
[Fact]
public void Bias_SpanBatch_MatchesTSeriesBatch()
{
var series = new TSeries();
double[] source = new double[100];
double[] output = new double[100];
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
source[i] = bar.Close;
series.Add(bar.Time, bar.Close);
}
var tseriesResult = Bias.Batch(series, 10);
Bias.Batch(source.AsSpan(), output.AsSpan(), 10);
for (int i = 0; i < 100; i++)
{
Assert.Equal(tseriesResult[i].Value, output[i], 1e-10);
}
}
[Fact]
public void Bias_SpanBatch_CalculatesCorrectly()
{
double[] source = [10, 20, 30, 40, 50];
double[] output = new double[5];
Bias.Batch(source.AsSpan(), output.AsSpan(), 3);
// i=0: SMA=10, Bias=(10-10)/10=0
Assert.Equal(0.0, output[0], 1e-10);
// i=1: SMA=15, Bias=(20-15)/15=1/3
Assert.Equal(1.0 / 3.0, output[1], 1e-10);
// i=2: SMA=20, Bias=(30-20)/20=0.5
Assert.Equal(0.5, output[2], 1e-10);
// i=3: SMA=30, Bias=(40-30)/30=1/3
Assert.Equal(1.0 / 3.0, output[3], 1e-10);
// i=4: SMA=40, Bias=(50-40)/40=0.25
Assert.Equal(0.25, output[4], 1e-10);
}
[Fact]
public void Bias_SpanBatch_ZeroAllocation()
{
double[] source = new double[10000];
double[] output = new double[10000];
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < source.Length; i++)
{
source[i] = gbm.Next().Close;
}
Bias.Batch(source.AsSpan(), output.AsSpan(), 100);
Assert.True(double.IsFinite(output[^1]));
}
[Fact]
public void Bias_SpanBatch_HandlesNaN()
{
double[] source = [100, 110, double.NaN, 120, 130];
double[] output = new double[5];
Bias.Batch(source.AsSpan(), output.AsSpan(), 3);
foreach (var val in output)
{
Assert.True(double.IsFinite(val), $"Expected finite value but got {val}");
}
}
[Fact]
public void Bias_AllModes_ProduceSameResult()
{
// Arrange
const int period = 10;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
// 1. Batch Mode
var batchSeries = Bias.Batch(series, period);
double expected = batchSeries.Last.Value;
// 2. Span Mode
var tValues = series.Values.ToArray();
var spanInput = new ReadOnlySpan<double>(tValues);
var spanOutput = new double[tValues.Length];
Bias.Batch(spanInput, spanOutput, period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Bias(period);
for (int i = 0; i < series.Count; i++)
{
streamingInd.Update(series[i]);
}
double streamingResult = streamingInd.Last.Value;
// 4. Eventing Mode
var pubSource = new TSeries();
var eventingInd = new Bias(pubSource, period);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
double eventingResult = eventingInd.Last.Value;
// Assert
Assert.Equal(expected, spanResult, precision: 9);
Assert.Equal(expected, streamingResult, precision: 9);
Assert.Equal(expected, eventingResult, precision: 9);
}
[Fact]
public void Bias_Chainability_Works()
{
var source = new TSeries();
var bias = new Bias(source, 10);
source.Add(new TValue(DateTime.UtcNow, 100));
Assert.Equal(0, bias.Last.Value); // First value, Bias = 0
}
[Fact]
public void Bias_WarmupPeriod_IsSetCorrectly()
{
var bias = new Bias(10);
Assert.Equal(10, bias.WarmupPeriod);
}
[Fact]
public void Bias_Prime_SetsStateCorrectly()
{
var bias = new Bias(5);
double[] history = [10, 20, 30, 40, 50];
// SMA = 30, Bias = (50-30)/30 = 2/3
bias.Prime(history);
Assert.True(bias.IsHot);
Assert.Equal(2.0 / 3.0, bias.Last.Value, 1e-10);
// Verify it continues correctly with sliding window
bias.Update(new TValue(DateTime.UtcNow, 60));
// SMA = (20+30+40+50+60)/5 = 40, Bias = (60-40)/40 = 0.5
Assert.Equal(0.5, bias.Last.Value, 1e-10);
}
[Fact]
public void Bias_Prime_WithInsufficientHistory_IsNotHot()
{
var bias = new Bias(10);
double[] history = [10, 20, 30, 40, 50];
bias.Prime(history);
Assert.False(bias.IsHot);
Assert.True(double.IsFinite(bias.Last.Value));
}
[Fact]
public void Bias_Prime_HandlesNaN_InHistory()
{
var bias = new Bias(3);
double[] history = [10, 20, double.NaN, 40];
// Values used: 10, 20, 20 (NaN replaced), 40
// Final window (3): 20, 20, 40 - SMA = 26.67
bias.Prime(history);
Assert.True(bias.IsHot);
Assert.True(double.IsFinite(bias.Last.Value));
}
[Fact]
public void Bias_Calculate_ReturnsCorrectResultsAndHotIndicator()
{
var series = new TSeries();
for (int i = 1; i <= 10; i++)
{
series.Add(DateTime.UtcNow, i * 10);
}
// 10, 20, 30, 40, 50, 60, 70, 80, 90, 100
var (results, indicator) = Bias.Calculate(series, 5);
// Check results
Assert.Equal(10, results.Count);
// Check indicator state
Assert.True(indicator.IsHot);
Assert.Equal(5, indicator.WarmupPeriod);
// Verify indicator continues correctly
indicator.Update(new TValue(DateTime.UtcNow, 110));
// SMA = (70+80+90+100+110)/5 = 90, Bias = (110-90)/90 = 2/9
Assert.Equal(2.0 / 9.0, indicator.Last.Value, 1e-10);
}
[Fact]
public void Bias_Period1_ReturnsPriceMinusSmaOverSma()
{
var bias = new Bias(1);
bias.Update(new TValue(DateTime.UtcNow, 100));
// SMA(1) = 100, Bias = (100-100)/100 = 0
Assert.Equal(0.0, bias.Last.Value, 1e-10);
bias.Update(new TValue(DateTime.UtcNow, 200));
// SMA(1) = 200, Bias = (200-200)/200 = 0
Assert.Equal(0.0, bias.Last.Value, 1e-10);
bias.Update(new TValue(DateTime.UtcNow, 150));
// SMA(1) = 150, Bias = (150-150)/150 = 0
Assert.Equal(0.0, bias.Last.Value, 1e-10);
}
[Fact]
public void Bias_NegativePrice_CalculatesCorrectly()
{
var bias = new Bias(3);
bias.Update(new TValue(DateTime.UtcNow, -10));
bias.Update(new TValue(DateTime.UtcNow, -20));
bias.Update(new TValue(DateTime.UtcNow, -30));
// SMA = -20, Bias = (-30 - (-20)) / (-20) = -10 / -20 = 0.5
Assert.Equal(0.5, bias.Last.Value, 1e-10);
}
[Fact]
public void Bias_ZeroPrice_HandlesGracefully()
{
var bias = new Bias(3);
bias.Update(new TValue(DateTime.UtcNow, 0));
bias.Update(new TValue(DateTime.UtcNow, 0));
bias.Update(new TValue(DateTime.UtcNow, 0));
// SMA = 0, Bias = (0-0)/0 = 0/0 -> should return 0 to avoid NaN
Assert.Equal(0.0, bias.Last.Value, 1e-10);
}
}
@@ -0,0 +1,436 @@
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for Bias indicator.
/// Validates against mathematical calculations since BIAS = (Price - SMA) / SMA.
/// No direct TA-Lib/Tulip/Skender equivalent exists.
/// </summary>
public sealed class BiasValidationTests : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public BiasValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
public void Dispose()
{
Dispose(true);
}
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
_disposed = true;
if (disposing)
{
_testData?.Dispose();
}
}
[Fact]
public void Validate_MathematicalCorrectness_Batch()
{
const int period = 10;
var bias = new Bias(period);
var qResult = bias.Update(_testData.Data);
var rawData = _testData.RawData.ToArray();
for (int i = 0; i < rawData.Length; i++)
{
// Calculate SMA manually
double sum = 0;
int startIdx = Math.Max(0, i - period + 1);
int windowSize = i - startIdx + 1;
for (int j = startIdx; j <= i; j++)
{
sum += rawData[j];
}
double sma = sum / windowSize;
// BIAS = (Price - SMA) / SMA = Price/SMA - 1
double expectedBias = sma != 0 ? (rawData[i] / sma) - 1.0 : 0;
double qValue = qResult[i].Value;
Assert.True(
Math.Abs(qValue - expectedBias) <= ValidationHelper.DefaultTolerance,
$"Mismatch at index {i}: QuanTAlib={qValue:G17}, Expected={expectedBias:G17}");
}
_output.WriteLine("Bias Batch(TSeries) validated against manual calculation");
}
[Fact]
public void Validate_MathematicalCorrectness_Streaming()
{
int period = 10;
var bias = new Bias(period);
var qResults = new List<double>();
var rawData = _testData.RawData.ToArray();
foreach (var item in _testData.Data)
{
qResults.Add(bias.Update(item).Value);
}
for (int i = 0; i < rawData.Length; i++)
{
// Calculate SMA manually
double sum = 0;
int startIdx = Math.Max(0, i - period + 1);
int windowSize = i - startIdx + 1;
for (int j = startIdx; j <= i; j++)
{
sum += rawData[j];
}
double sma = sum / windowSize;
// BIAS = (Price - SMA) / SMA = Price/SMA - 1
double expectedBias = sma != 0 ? (rawData[i] / sma) - 1.0 : 0;
Assert.True(
Math.Abs(qResults[i] - expectedBias) <= ValidationHelper.DefaultTolerance,
$"Mismatch at index {i}: QuanTAlib={qResults[i]:G17}, Expected={expectedBias:G17}");
}
_output.WriteLine("Bias Streaming validated against manual calculation");
}
[Fact]
public void Validate_MathematicalCorrectness_Span()
{
int period = 10;
var sourceData = _testData.RawData.ToArray();
var qOutput = new double[sourceData.Length];
Bias.Batch(sourceData.AsSpan(), qOutput.AsSpan(), period);
for (int i = 0; i < sourceData.Length; i++)
{
// Calculate SMA manually
double sum = 0;
int startIdx = Math.Max(0, i - period + 1);
int windowSize = i - startIdx + 1;
for (int j = startIdx; j <= i; j++)
{
sum += sourceData[j];
}
double sma = sum / windowSize;
// BIAS = (Price - SMA) / SMA = Price/SMA - 1
double expectedBias = sma != 0 ? (sourceData[i] / sma) - 1.0 : 0;
Assert.True(
Math.Abs(qOutput[i] - expectedBias) <= ValidationHelper.DefaultTolerance,
$"Mismatch at index {i}: QuanTAlib={qOutput[i]:G17}, Expected={expectedBias:G17}");
}
_output.WriteLine("Bias Span validated against manual calculation");
}
[Fact]
public void Validate_KnownValues_UpTrend()
{
// Steadily increasing prices: bias should be positive after warmup
double[] values = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110];
var bias = new Bias(5);
for (int i = 0; i < values.Length; i++)
{
bias.Update(new TValue(DateTime.UtcNow, values[i]));
// Calculate expected
int startIdx = Math.Max(0, i - 4);
double sum = 0;
for (int j = startIdx; j <= i; j++)
{
sum += values[j];
}
double sma = sum / (i - startIdx + 1);
double expectedBias = (values[i] / sma) - 1.0;
Assert.Equal(expectedBias, bias.Last.Value, 1e-10);
}
// After warmup, bias should be positive (price above SMA)
Assert.True(bias.Last.Value > 0, "Bias should be positive in uptrend");
_output.WriteLine($"Uptrend bias: {bias.Last.Value:P4}");
}
[Fact]
public void Validate_KnownValues_DownTrend()
{
// Steadily decreasing prices: bias should be negative after warmup
double[] values = [110, 109, 108, 107, 106, 105, 104, 103, 102, 101, 100];
var bias = new Bias(5);
for (int i = 0; i < values.Length; i++)
{
bias.Update(new TValue(DateTime.UtcNow, values[i]));
}
// After warmup, bias should be negative (price below SMA)
Assert.True(bias.Last.Value < 0, "Bias should be negative in downtrend");
_output.WriteLine($"Downtrend bias: {bias.Last.Value:P4}");
}
[Fact]
public void Validate_KnownValues_Constant()
{
// Constant prices: bias should be exactly 0
double constant = 100.0;
var bias = new Bias(10);
for (int i = 0; i < 100; i++)
{
bias.Update(new TValue(DateTime.UtcNow, constant));
}
// Bias = (Price - SMA) / SMA = (100 - 100) / 100 = 0
Assert.Equal(0.0, bias.Last.Value, 1e-10);
_output.WriteLine("Constant sequence bias = 0 confirmed");
}
[Fact]
public void Validate_KnownValues_SinglePriceSpike()
{
// 9 values at 100, then one spike to 200
var bias = new Bias(10);
for (int i = 0; i < 9; i++)
{
bias.Update(new TValue(DateTime.UtcNow, 100.0));
}
bias.Update(new TValue(DateTime.UtcNow, 200.0));
// SMA = (9 * 100 + 200) / 10 = 1100 / 10 = 110
// BIAS = (200 / 110) - 1 = 1.8181818... - 1 = 0.8181818...
double expectedSma = 110.0;
double expectedBias = (200.0 / expectedSma) - 1.0;
Assert.Equal(expectedBias, bias.Last.Value, 1e-10);
_output.WriteLine($"Single spike bias: {bias.Last.Value:P4} (expected {expectedBias:P4})");
}
[Fact]
public void Validate_KnownValues_PriceAtSMA()
{
// When current price equals SMA, bias should be 0
// Use sequence where last value equals the average
// Values: 90, 110, 90, 110, 100 → SMA(5) = 100, last price = 100 → bias = 0
double[] values = [90, 110, 90, 110, 100];
var bias = new Bias(5);
foreach (var val in values)
{
bias.Update(new TValue(DateTime.UtcNow, val));
}
Assert.Equal(0.0, bias.Last.Value, 1e-10);
_output.WriteLine("Price at SMA produces bias = 0 confirmed");
}
[Fact]
public void Validate_NumericalStability_LargeValues()
{
// Test numerical stability with large values
var bias = new Bias(100);
double baseValue = 1e10;
for (int i = 0; i < 1000; i++)
{
double value = baseValue + i;
bias.Update(new TValue(DateTime.UtcNow, value));
if (i >= 99) // After warmup
{
Assert.True(double.IsFinite(bias.Last.Value), $"Bias should be finite at index {i}");
}
}
_output.WriteLine($"Large values stability test passed: {bias.Last.Value:G10}");
}
[Fact]
public void Validate_NumericalStability_SmallValues()
{
// Test with small values
var bias = new Bias(10);
double baseValue = 1e-10;
for (int i = 0; i < 100; i++)
{
double value = baseValue * (1 + i * 0.01);
bias.Update(new TValue(DateTime.UtcNow, value));
Assert.True(double.IsFinite(bias.Last.Value), $"Bias should be finite at index {i}");
}
_output.WriteLine($"Small values stability test passed: {bias.Last.Value:G10}");
}
[Fact]
public void Validate_AllModes_Consistency()
{
int period = 20;
var sourceData = _testData.RawData.ToArray();
// Mode 1: TSeries Batch
var bias1 = new Bias(period);
var batchResult = bias1.Update(_testData.Data);
// Mode 2: Streaming
var bias2 = new Bias(period);
var streamingResults = new List<double>();
foreach (var item in _testData.Data)
{
streamingResults.Add(bias2.Update(item).Value);
}
// Mode 3: Span
var spanOutput = new double[sourceData.Length];
Bias.Batch(sourceData.AsSpan(), spanOutput.AsSpan(), period);
// Compare all three
for (int i = 0; i < sourceData.Length; i++)
{
double batchVal = batchResult[i].Value;
double streamVal = streamingResults[i];
double spanVal = spanOutput[i];
Assert.Equal(batchVal, streamVal, 1e-10);
Assert.Equal(batchVal, spanVal, 1e-10);
}
_output.WriteLine("All Bias calculation modes produce consistent results");
}
[Fact]
public void Validate_MultiplePeriods()
{
int[] periods = [5, 10, 20, 50, 100];
var rawData = _testData.RawData.ToArray();
foreach (var period in periods)
{
var bias = new Bias(period);
var qResult = bias.Update(_testData.Data);
// Verify last 50 values
for (int i = rawData.Length - 50; i < rawData.Length; i++)
{
// Calculate SMA manually
double sum = 0;
int startIdx = Math.Max(0, i - period + 1);
int windowSize = i - startIdx + 1;
for (int j = startIdx; j <= i; j++)
{
sum += rawData[j];
}
double sma = sum / windowSize;
double expectedBias = sma != 0 ? (rawData[i] / sma) - 1.0 : 0;
Assert.True(
Math.Abs(qResult[i].Value - expectedBias) <= ValidationHelper.DefaultTolerance,
$"Period {period}, index {i}: QuanTAlib={qResult[i].Value:G17}, Expected={expectedBias:G17}");
}
}
_output.WriteLine("Bias validated for multiple periods");
}
[Fact]
public void Validate_PercentageInterpretation()
{
// Bias of 0.05 means price is 5% above SMA
// Bias of -0.05 means price is 5% below SMA
var bias = new Bias(10);
// Create scenario where we know the exact bias
// SMA will be 100, price will be 105 → bias = 0.05
for (int i = 0; i < 9; i++)
{
bias.Update(new TValue(DateTime.UtcNow, 100.0));
}
// For 10th value: need SMA = 100 and price = 105
// SMA of (9 * 100 + x) / 10 = 100 → x = 100
// So we add another 100 first
bias.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(0.0, bias.Last.Value, 1e-10);
// Now add one more value at 105 (old 100 drops out, new comes in)
bias.Update(new TValue(DateTime.UtcNow, 105.0));
// SMA = (9 * 100 + 105) / 10 = 1005 / 10 = 100.5
// Bias = (105 / 100.5) - 1 = 1.04477... - 1 ≈ 0.04478
double expectedSma = 100.5;
double expectedBias = (105.0 / expectedSma) - 1.0;
Assert.Equal(expectedBias, bias.Last.Value, 1e-10);
_output.WriteLine($"Percentage interpretation validated: {bias.Last.Value:P4}");
}
[Fact]
public void Validate_AgainstSmaIndicator()
{
// Cross-validate with Sma indicator
int period = 20;
var bias = new Bias(period);
var sma = new Sma(period);
foreach (var item in _testData.Data)
{
var biasResult = bias.Update(item);
var smaResult = sma.Update(item);
// BIAS = (Price - SMA) / SMA = Price/SMA - 1
double expectedBias = smaResult.Value != 0
? (item.Value / smaResult.Value) - 1.0
: 0;
Assert.Equal(expectedBias, biasResult.Value, 1e-10);
}
_output.WriteLine("Bias validated against Sma indicator");
}
[Fact]
public void Validate_OscillatingSequence()
{
// Oscillating around a mean: bias should oscillate around 0
var bias = new Bias(10);
double mean = 100.0;
double amplitude = 10.0;
var biasValues = new List<double>();
for (int i = 0; i < 100; i++)
{
double value = mean + amplitude * Math.Sin(i * 0.5);
bias.Update(new TValue(DateTime.UtcNow, value));
if (i >= 9) // After warmup
{
biasValues.Add(bias.Last.Value);
}
}
// Average bias should be close to 0 for oscillating sequence
double avgBias = biasValues.Average();
Assert.True(Math.Abs(avgBias) < 0.01, $"Average bias should be near 0, got {avgBias}");
// Should have both positive and negative values
Assert.True(biasValues.Any(b => b > 0), "Should have positive bias values");
Assert.True(biasValues.Any(b => b < 0), "Should have negative bias values");
_output.WriteLine($"Oscillating sequence: avg bias = {avgBias:F6}");
}
}
+391
View File
@@ -0,0 +1,391 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// Bias: Measures the percentage deviation of a price from its moving average.
/// </summary>
/// <remarks>
/// Bias (BIAS) calculates how far the current price deviates from its Simple Moving Average (SMA),
/// expressed as a percentage. It's commonly used to identify overbought/oversold conditions.
///
/// Formula:
/// BIAS = (Price - SMA) / SMA = Price/SMA - 1
///
/// Key Features:
/// - O(1) time complexity per update using running sum
/// - Zero allocation in hot path
/// - Handles division by zero (returns 0 when SMA is 0)
/// - NaN/Infinity safe with last-valid-value substitution
///
/// IsHot:
/// Becomes true when the buffer is full (period samples processed).
/// </remarks>
[SkipLocalsInit]
public sealed class Bias : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
private readonly TValuePublishedHandler _handler;
[StructLayout(LayoutKind.Auto)]
private record struct State
{
public double Sum;
public double LastInput;
public double LastValidValue;
public int TickCount;
}
private State _state;
private State _p_state;
private const int ResyncInterval = 1000;
/// <summary>
/// Creates Bias with specified period.
/// </summary>
/// <param name="period">Number of values for SMA calculation (must be > 0)</param>
public Bias(int period)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
_period = period;
_buffer = new RingBuffer(period);
Name = $"Bias({period})";
WarmupPeriod = period;
_handler = Handle;
}
public Bias(ITValuePublisher source, int period) : this(period)
{
source.Pub += _handler;
}
public Bias(TSeries source, int period) : this(period)
{
source.Pub += _handler;
Prime(source.Values);
if (source.Count > 0)
{
Last = new TValue(source.LastTime, Last.Value);
}
_p_state = _state;
}
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
/// <summary>
/// True if Bias has enough data to produce valid results.
/// Bias is "hot" when the buffer is full (has received at least 'period' values).
/// </summary>
public override bool IsHot => _buffer.IsFull;
/// <summary>
/// Initializes the indicator state using the provided history.
/// </summary>
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
if (source.Length == 0)
{
return;
}
// Reset state
_buffer.Clear();
_state = default;
_p_state = default;
int warmupLength = Math.Min(source.Length, WarmupPeriod);
int startIndex = source.Length - warmupLength;
// Seed LastValidValue
_state.LastValidValue = double.NaN;
for (int i = startIndex - 1; i >= 0; i--)
{
if (double.IsFinite(source[i]))
{
_state.LastValidValue = source[i];
break;
}
}
if (double.IsNaN(_state.LastValidValue))
{
for (int i = startIndex; i < source.Length; i++)
{
if (double.IsFinite(source[i]))
{
_state.LastValidValue = source[i];
break;
}
}
}
// Feed the buffer and calculate sum
for (int i = startIndex; i < source.Length; i++)
{
double val = GetValidValue(source[i]);
_buffer.Add(val);
_state.Sum += val;
_state.LastInput = val;
}
// Calculate final Bias
double sma = _state.Sum / _buffer.Count;
double bias = sma != 0 ? (_state.LastInput - sma) / sma : 0;
Last = new TValue(DateTime.MinValue, bias);
_p_state = _state;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double GetValidValue(double input)
{
if (double.IsFinite(input))
{
_state.LastValidValue = input;
return input;
}
return _state.LastValidValue;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void UpdateState(double val)
{
if (_buffer.Count == _buffer.Capacity)
{
_state.Sum -= _buffer.Oldest;
}
_buffer.Add(val);
_state.Sum += val;
_state.TickCount++;
if (_buffer.IsFull && _state.TickCount >= ResyncInterval)
{
_state.TickCount = 0;
_state.Sum = _buffer.GetSpan().SumSIMD();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
_buffer.Snapshot();
double val = GetValidValue(input.Value);
UpdateState(val);
_state.LastInput = val;
}
else
{
// Restore both scalar state and buffer state
_state = _p_state;
_buffer.Restore();
// Use restored LastValidValue for NaN handling without updating it
double val = double.IsFinite(input.Value) ? input.Value : _state.LastValidValue;
// Replicate the same operation as isNew=true: UpdateState
// This properly removes oldest and adds newest, maintaining sliding window
UpdateState(val);
_state.LastInput = val;
}
// Calculate Bias: (Price - SMA) / SMA
double sma = _state.Sum / _buffer.Count;
double bias = sma != 0 ? (_state.LastInput - sma) / sma : 0;
Last = new TValue(input.Time, bias);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
Batch(source.Values, vSpan, _period);
source.Times.CopyTo(tSpan);
Prime(source.Values);
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
/// <summary>
/// Calculates Bias for the entire series using a new instance.
/// </summary>
public static TSeries Batch(TSeries source, int period)
{
var bias = new Bias(period);
return bias.Update(source);
}
/// <summary>
/// Calculates Bias in-place using O(1) running sum.
/// Zero-allocation method for maximum performance.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
int len = source.Length;
if (len == 0)
{
return;
}
CalculateScalarCore(source, output, period);
}
/// <summary>
/// Runs a batch calculation and returns a "Hot" Bias instance.
/// </summary>
public static (TSeries Results, Bias Indicator) Calculate(TSeries source, int period)
{
var bias = new Bias(period);
TSeries results = bias.Update(source);
return (results, bias);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CalculateScalarCore(ReadOnlySpan<double> source, Span<double> output, int period)
{
int len = source.Length;
const int StackAllocThreshold = 256;
double[]? bufferArray = period > StackAllocThreshold ? ArrayPool<double>.Shared.Rent(period) : null;
Span<double> buffer = period <= StackAllocThreshold
? stackalloc double[period]
: bufferArray!.AsSpan(0, period);
double sum = 0;
double lastValid = double.NaN;
// Find first valid value
for (int k = 0; k < len; k++)
{
if (double.IsFinite(source[k]))
{
lastValid = source[k];
break;
}
}
try
{
int bufferIndex = 0;
int tickCount = 0;
// Warmup phase
int warmupEnd = Math.Min(period, len);
for (int i = 0; i < warmupEnd; i++)
{
double val = source[i];
if (double.IsFinite(val))
{
lastValid = val;
}
else
{
val = lastValid;
}
sum += val;
buffer[i] = val;
double n = i + 1;
double sma = sum / n;
output[i] = sma != 0 ? (val - sma) / sma : 0;
}
// Main phase with sliding window
for (int i = period; i < len; i++)
{
double val = source[i];
if (double.IsFinite(val))
{
lastValid = val;
}
else
{
val = lastValid;
}
double oldVal = buffer[bufferIndex];
sum = sum - oldVal + val;
buffer[bufferIndex] = val;
bufferIndex++;
if (bufferIndex >= period)
{
bufferIndex = 0;
}
double sma = sum / period;
output[i] = sma != 0 ? (val - sma) / sma : 0;
// Periodic resync for long sequences
tickCount++;
if (tickCount >= ResyncInterval)
{
tickCount = 0;
sum = 0;
for (int k = 0; k < period; k++)
{
sum += buffer[k];
}
}
}
}
finally
{
if (bufferArray != null)
{
ArrayPool<double>.Shared.Return(bufferArray);
}
}
}
/// <summary>
/// Resets the Bias state.
/// </summary>
public override void Reset()
{
_buffer.Clear();
_state = default;
_p_state = default;
Last = default;
}
}
+205
View File
@@ -0,0 +1,205 @@
# Bias: Price Deviation from Moving Average
> "When traders ask 'how overbought is it?', they're really asking how far price has strayed from its anchor. Bias answers that question in percentage terms, telling you whether the current price is 5% above or 10% below its moving average. It's the market's stretch marks made visible."
The Bias indicator measures the percentage difference between the current price and its Simple Moving Average (SMA). A positive bias indicates price is above the average (potentially overbought), while negative bias suggests price is below average (potentially oversold). This is one of the simplest yet most effective tools for identifying mean-reversion opportunities.
## Historical Context
Bias (also known as "Rate of Change from MA" or "Price Oscillator Percentage") has been used by traders since the early days of technical analysis. The concept is straightforward: prices tend to oscillate around their moving averages, and extreme deviations often precede reversions.
In TradingView's PineScript, this indicator is known simply as "Bias" and represents the normalized distance between price and its moving average. Unlike momentum oscillators that measure price change over time, Bias measures price deviation from a smoothed reference point.
## Architecture & Physics
### The Mean-Reversion Foundation
Markets exhibit mean-reversion tendencies across multiple timeframes. When price deviates significantly from its moving average, several forces conspire to pull it back:
1. **Value Seekers**: Buyers appear when price falls too far below average
2. **Profit Taking**: Sellers emerge when price rises too far above average
3. **Statistical Gravity**: Extreme deviations are by definition unsustainable
Bias quantifies this deviation in normalized (percentage) terms, making it comparable across different price scales.
### Calculation Formula
The Bias is computed as:
$$\text{Bias} = \frac{P - \text{SMA}}{\text{SMA}} = \frac{P}{\text{SMA}} - 1$$
Where:
* $P$ = Current price
* $\text{SMA}$ = Simple Moving Average over the specified period
This formula normalizes the deviation as a ratio, expressing it as a decimal. A Bias of 0.05 means price is 5% above the SMA; a Bias of -0.10 means price is 10% below.
### O(1) Streaming Implementation
Rather than recalculating the SMA from scratch each tick, this implementation maintains a running sum with RingBuffer:
```csharp
// Efficient sliding window sum
_sum = _sum - oldValue + newValue;
double sma = _sum / Period;
double bias = (currentPrice / sma) - 1.0;
```
This achieves constant-time updates regardless of period length.
## Mathematical Foundation
### 1. Simple Moving Average
$$\text{SMA}_t = \frac{1}{n} \sum_{i=0}^{n-1} P_{t-i}$$
Where $n$ is the period and $P_t$ is the price at time $t$.
### 2. Bias Calculation
$$\text{Bias}_t = \frac{P_t - \text{SMA}_t}{\text{SMA}_t}$$
Equivalently:
$$\text{Bias}_t = \frac{P_t}{\text{SMA}_t} - 1$$
### 3. Relationship to Price
Given the Bias value, you can recover the implied SMA:
$$\text{SMA}_t = \frac{P_t}{1 + \text{Bias}_t}$$
### 4. Boundary Behavior
* When $P_t = \text{SMA}_t$: $\text{Bias} = 0$
* When $P_t > \text{SMA}_t$: $\text{Bias} > 0$
* When $P_t < \text{SMA}_t$: $\text{Bias} < 0$
* When $\text{SMA}_t = 0$: $\text{Bias} = 0$ (division guard)
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| ADD/SUB | 3 | 1 | 3 |
| MUL | 0 | 3 | 0 |
| DIV | 2 | 15 | 30 |
| Buffer Access | 2 | 3 | 6 |
| **Total** | **7** | — | **~39 cycles** |
Division dominates the cost (two divisions: one for SMA, one for bias ratio).
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 10/10 | Exact formula, no approximation |
| **Timeliness** | 7/10 | Inherits SMA lag |
| **Smoothness** | 8/10 | SMA provides inherent smoothing |
| **Interpretability** | 10/10 | Direct percentage meaning |
## Validation
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib** | N/A | No direct equivalent |
| **Skender** | N/A | No direct equivalent |
| **Tulip** | N/A | No direct equivalent |
| **TradingView** | ✅ | Matches PineScript Bias indicator |
| **Mathematical** | ✅ | Validated against manual calculation |
## Use Cases
### 1. Mean-Reversion Trading
* **Long Entry**: Bias < -0.05 (price 5%+ below SMA)
* **Short Entry**: Bias > 0.05 (price 5%+ above SMA)
* **Exit**: Bias returns toward zero
### 2. Trend Confirmation
Persistent positive Bias confirms uptrend; persistent negative confirms downtrend.
### 3. Overbought/Oversold Detection
Extreme Bias values (e.g., ±10%) suggest extended conditions ripe for reversal.
### 4. Multi-Timeframe Analysis
Compare Bias across different periods to identify nested mean-reversion setups.
## API Usage
### Streaming Mode
```csharp
var bias = new Bias(period: 20);
foreach (var price in prices)
{
var result = bias.Update(new TValue(DateTime.UtcNow, price));
Console.WriteLine($"Bias: {result.Value:P2}"); // e.g., "Bias: 3.45%"
}
```
### Batch Mode
```csharp
var series = new TSeries();
// ... populate series ...
var results = Bias.Batch(series, period: 20);
```
### Span Mode (Zero Allocation)
```csharp
double[] input = new double[1000];
double[] output = new double[1000];
// ... populate input ...
Bias.Batch(input.AsSpan(), output.AsSpan(), period: 20);
```
### Event-Driven Mode
```csharp
var source = new TSeries();
var bias = new Bias(source, period: 20);
// Bias automatically updates when source publishes
source.Add(new TValue(DateTime.UtcNow, 100.0));
```
## Common Pitfalls
1. **Interpreting Raw Values**: Bias of 0.05 means 5%, not 0.05%. Display as percentage or multiply by 100 for human consumption.
2. **Period Selection**: Shorter periods (10-20) respond faster but generate more noise. Longer periods (50-200) are smoother but lag more.
3. **Asymmetric Interpretation**: A +10% Bias doesn't necessarily have the same significance as -10% Bias due to asymmetric price distributions.
4. **Division by Zero**: When SMA is zero (rare but possible with price data starting at zero), the indicator returns 0 as a safeguard.
5. **Not a Standalone Signal**: Extreme Bias suggests potential reversal but doesn't guarantee it. Prices can stay overbought/oversold longer than expected.
6. **Warmup Period**: The indicator needs `period` bars to reach full window. Before that, it uses a growing window for calculation.
## When to Use Bias
**Use it when:**
* You want a simple, interpretable overbought/oversold measure
* Mean-reversion strategies are your focus
* You need to compare deviation across instruments with different price scales
* Quick assessment of price extension from average
**Skip it when:**
* You need trend-following signals (use moving average crossovers instead)
* Strong trending markets where mean-reversion fails
* You prefer momentum-based indicators (RSI, ROC)
* Price data starts at or crosses zero
## References
* TradingView. "Bias Indicator (PineScript)." *TradingView Documentation*.
* Kaufman, P.J. (2013). "Trading Systems and Methods." *Wiley Trading*, 5th Edition.