Remove multiple Pine Script indicators: SSFDSP, STARCHANNEL, STBANDS, STC, UBANDS, UCHANNEL, VWAPBANDS, and VWAPSD. These indicators were deleted to streamline the library and remove unused or redundant code.

This commit is contained in:
Miha Kralj
2026-02-20 18:44:56 -08:00
parent 3dd05f23e4
commit cbeefc9d64
283 changed files with 23963 additions and 3838 deletions
+159
View File
@@ -0,0 +1,159 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class TsfIndicatorTests
{
[Fact]
public void TsfIndicator_Constructor_SetsDefaults()
{
var indicator = new TsfIndicator();
Assert.Equal(14, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("TSF - Time Series Forecast", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void TsfIndicator_MinHistoryDepths_IsZero()
{
var indicator = new TsfIndicator { Period = 20 };
Assert.Equal(0, TsfIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void TsfIndicator_ShortName_IncludesPeriodAndSource()
{
var indicator = new TsfIndicator { Period = 15 };
Assert.Contains("TSF", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void TsfIndicator_SourceCodeLink_IsValid()
{
var indicator = new TsfIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Tsf.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void TsfIndicator_Initialize_CreatesInternalTsf()
{
var indicator = new TsfIndicator { Period = 10 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void TsfIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new TsfIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void TsfIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new TsfIndicator { Period = 3 };
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 TsfIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new TsfIndicator { Period = 3 };
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 TsfIndicator_MultipleUpdates_ProducesCorrectSequence()
{
var indicator = new TsfIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
double[] closes = { 100, 102, 104, 103, 105 };
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);
}
for (int i = 0; i < closes.Length; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
}
}
[Fact]
public void TsfIndicator_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 TsfIndicator { Period = 3, 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 TsfIndicator_Period_CanBeChanged()
{
var indicator = new TsfIndicator { Period = 5 };
Assert.Equal(5, indicator.Period);
indicator.Period = 20;
Assert.Equal(20, indicator.Period);
Assert.Equal(0, TsfIndicator.MinHistoryDepths);
}
}
+56
View File
@@ -0,0 +1,56 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class TsfIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 2000, 1, 0)]
public int Period { get; set; } = 14;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Tsf _tsf = 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 => $"TSF {Period}:{_sourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_FIR/tsf/Tsf.Quantower.cs";
public TsfIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "TSF - Time Series Forecast";
Description = "Time Series Forecast (Linear Regression one-step-ahead projection)";
_series = new LineSeries(name: $"TSF {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_priceSelector = Source.GetPriceSelector();
_sourceName = Source.ToString();
_tsf = new Tsf(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 = _tsf.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
_series.SetValue(value, _tsf.IsHot, ShowColdValues);
}
}
+494
View File
@@ -0,0 +1,494 @@
namespace QuanTAlib.Tests;
public class TsfTests
{
private static TSeries MakeSeries(int count = 500)
{
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
}
// ── A) Constructor validation ──────────────────────────────────────
[Fact]
public void Constructor_InvalidPeriod_ThrowsArgumentException()
{
var ex0 = Assert.Throws<ArgumentException>(() => new Tsf(0));
Assert.Equal("period", ex0.ParamName);
var exNeg = Assert.Throws<ArgumentException>(() => new Tsf(-1));
Assert.Equal("period", exNeg.ParamName);
}
[Fact]
public void Constructor_ValidParameters_SetsProperties()
{
var tsf = new Tsf(14);
Assert.Equal("Tsf(14)", tsf.Name);
Assert.False(tsf.IsHot);
Assert.Equal(14, tsf.WarmupPeriod);
}
[Fact]
public void Constructor_NullSource_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new Tsf(null!, 14));
}
// ── B) Basic calculation ───────────────────────────────────────────
[Fact]
public void Update_SingleValue_ReturnsSameValue()
{
var tsf = new Tsf(14);
var result = tsf.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(100, result.Value);
}
[Fact]
public void Update_Last_IsAccessible()
{
var tsf = new Tsf(5);
var series = MakeSeries(20);
foreach (var item in series)
{
tsf.Update(item);
}
Assert.True(double.IsFinite(tsf.Last.Value));
Assert.True(tsf.IsHot);
Assert.Contains("Tsf", tsf.Name, StringComparison.Ordinal);
}
[Fact]
public void Update_LinearTrend_ReturnsNextValue()
{
// For a perfect linear trend y = x,
// TSF should return x+1 (one step forecast) after warmup
const int period = 10;
var tsf = new Tsf(period);
for (int i = 0; i < period * 2; i++)
{
var result = tsf.Update(new TValue(DateTime.UtcNow, i));
if (i >= period)
{
// TSF forecasts one step ahead: should be i+1
Assert.Equal(i + 1, result.Value, 1e-9);
}
}
}
[Fact]
public void Update_ConstantValue_ReturnsSameValue()
{
const int period = 10;
var tsf = new Tsf(period);
const double value = 123.45;
for (int i = 0; i < period * 2; i++)
{
var result = tsf.Update(new TValue(DateTime.UtcNow, value));
Assert.Equal(value, result.Value, 1e-9);
}
}
[Fact]
public void Update_LinearSlope_ForecastsCorrectly()
{
// y = 2x + 5
// At bar i, the next bar's value should be 2*(i+1) + 5
const int period = 8;
var tsf = new Tsf(period);
for (int i = 0; i < 30; i++)
{
double y = 2.0 * i + 5.0;
var result = tsf.Update(new TValue(DateTime.UtcNow, y));
if (i >= period)
{
double expected = 2.0 * (i + 1) + 5.0;
Assert.Equal(expected, result.Value, 1e-9);
}
}
}
// ── C) State + bar correction ──────────────────────────────────────
[Fact]
public void Calc_IsNew_AcceptsParameter()
{
var tsf = new Tsf(5);
var result = tsf.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Calc_IsNew_False_UpdatesValue()
{
var tsf = new Tsf(5);
var series = MakeSeries(20);
foreach (var item in series)
{
tsf.Update(item, isNew: true);
}
double valueBefore = tsf.Last.Value;
tsf.Update(new TValue(DateTime.UtcNow, series[^1].Value * 1.1), isNew: false);
double valueAfter = tsf.Last.Value;
Assert.NotEqual(valueBefore, valueAfter);
}
[Fact]
public void IterativeCorrections_RestoreToOriginalState()
{
var tsf = new Tsf(10);
var series = MakeSeries(50);
// Feed N values
for (int i = 0; i < 30; i++)
{
tsf.Update(series[i], isNew: true);
}
double expectedValue = tsf.Last.Value;
// Feed M corrections with isNew: false
for (int j = 0; j < 5; j++)
{
tsf.Update(new TValue(DateTime.UtcNow, 999.0 + j), isNew: false);
}
// Restore original value
tsf.Update(series[29], isNew: false);
Assert.Equal(expectedValue, tsf.Last.Value, 1e-6);
}
[Fact]
public void Reset_ClearsState()
{
var tsf = new Tsf(10);
var series = MakeSeries(50);
foreach (var item in series)
{
tsf.Update(item);
}
Assert.True(tsf.IsHot);
tsf.Reset();
Assert.False(tsf.IsHot);
// Re-feed same data should produce identical results
var tsf2 = new Tsf(10);
foreach (var item in series)
{
tsf.Update(item);
tsf2.Update(item);
}
Assert.Equal(tsf2.Last.Value, tsf.Last.Value, 1e-12);
}
// ── D) Warmup/convergence ──────────────────────────────────────────
[Fact]
public void IsHot_BecomesTrueWhenBufferFull()
{
var tsf = new Tsf(10);
for (int i = 0; i < 9; i++)
{
tsf.Update(new TValue(DateTime.UtcNow, i));
Assert.False(tsf.IsHot);
}
tsf.Update(new TValue(DateTime.UtcNow, 9));
Assert.True(tsf.IsHot);
}
[Fact]
public void IsHot_IsPeriodDependent()
{
foreach (int period in new[] { 5, 10, 20, 50 })
{
var tsf = new Tsf(period);
for (int i = 0; i < period - 1; i++)
{
tsf.Update(new TValue(DateTime.UtcNow, i));
Assert.False(tsf.IsHot);
}
tsf.Update(new TValue(DateTime.UtcNow, period - 1));
Assert.True(tsf.IsHot);
}
}
// ── E) Robustness (NaN/Infinity) ───────────────────────────────────
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var tsf = new Tsf(5);
var series = MakeSeries(20);
for (int i = 0; i < 10; i++)
{
tsf.Update(series[i]);
}
_ = tsf.Last.Value;
tsf.Update(new TValue(DateTime.UtcNow, double.NaN), isNew: true);
Assert.True(double.IsFinite(tsf.Last.Value));
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var tsf = new Tsf(5);
var series = MakeSeries(20);
for (int i = 0; i < 10; i++)
{
tsf.Update(series[i]);
}
tsf.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity), isNew: true);
Assert.True(double.IsFinite(tsf.Last.Value));
tsf.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity), isNew: true);
Assert.True(double.IsFinite(tsf.Last.Value));
}
[Fact]
public void MultipleNaN_ContinuesWithLastValid()
{
var tsf = new Tsf(5);
var series = MakeSeries(20);
for (int i = 0; i < 10; i++)
{
tsf.Update(series[i]);
}
for (int j = 0; j < 5; j++)
{
tsf.Update(new TValue(DateTime.UtcNow, double.NaN), isNew: true);
Assert.True(double.IsFinite(tsf.Last.Value));
}
}
[Fact]
public void BatchCalc_HandlesNaN()
{
double[] input = { 1, 2, 3, double.NaN, 5, 6, 7, 8, 9, 10 };
double[] output = new double[input.Length];
Tsf.Batch(input.AsSpan(), output.AsSpan(), 5);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]));
}
}
// ── F) Consistency (all 4 modes match) ─────────────────────────────
[Fact]
public void AllModes_ProduceSameResult()
{
int period = 14;
var series = MakeSeries(500);
// 1. Batch (TSeries)
var batchResult = Tsf.Batch(series, period);
// 2. Span
double[] spanOutput = new double[series.Count];
Tsf.Batch(series.Values, spanOutput.AsSpan(), period);
// 3. Streaming
var streamTsf = new Tsf(period);
var streamResults = new List<double>();
foreach (var item in series)
{
streamResults.Add(streamTsf.Update(item).Value);
}
// 4. Eventing
var pubSource = new TSeries();
var eventTsf = new Tsf(pubSource, period);
foreach (var item in series)
{
pubSource.Add(item);
}
// Compare last values
double batchLast = batchResult.Values[^1];
double spanLast = spanOutput[^1];
double streamLast = streamResults[^1];
double eventLast = eventTsf.Last.Value;
Assert.Equal(batchLast, spanLast, 1e-9);
Assert.Equal(batchLast, streamLast, 1e-9);
Assert.Equal(batchLast, eventLast, 1e-9);
}
[Fact]
public void BatchCalc_MatchesIterativeCalc()
{
int period = 10;
var series = MakeSeries(200);
// Batch
var batchResult = Tsf.Batch(series, period);
// Iterative
var tsf = new Tsf(period);
TSeries streamResult = tsf.Update(series);
int compareCount = Math.Min(100, series.Count);
int start = series.Count - compareCount;
for (int i = start; i < series.Count; i++)
{
Assert.Equal(batchResult.Values[i], streamResult.Values[i], 1e-9);
}
}
// ── G) Span API tests ──────────────────────────────────────────────
[Fact]
public void SpanCalc_ValidatesInput_LengthMismatch()
{
double[] input = { 1, 2, 3, 4, 5 };
double[] output = new double[3];
var ex = Assert.Throws<ArgumentException>(() =>
Tsf.Batch(input.AsSpan(), output.AsSpan(), 3));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void SpanCalc_ValidatesInput_InvalidPeriod()
{
double[] input = { 1, 2, 3, 4, 5 };
double[] output = new double[5];
var ex = Assert.Throws<ArgumentException>(() =>
Tsf.Batch(input.AsSpan(), output.AsSpan(), 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void SpanCalc_MatchesTSeriesCalc()
{
int period = 20;
var series = MakeSeries(500);
var batchResult = Tsf.Batch(series, period);
double[] spanOutput = new double[series.Count];
Tsf.Batch(series.Values, spanOutput.AsSpan(), period);
int compareCount = 100;
int start = series.Count - compareCount;
for (int i = start; i < series.Count; i++)
{
Assert.Equal(batchResult.Values[i], spanOutput[i], 1e-9);
}
}
[Fact]
public void SpanCalc_EmptyInput_NoException()
{
double[] input = Array.Empty<double>();
double[] output = Array.Empty<double>();
Tsf.Batch(input.AsSpan(), output.AsSpan(), 5);
Assert.Empty(output);
}
[Fact]
public void SpanCalc_LargeDataset_NoStackOverflow()
{
int size = 10_000;
double[] input = new double[size];
double[] output = new double[size];
var gbm = new GBM(100, 0.05, 0.2, seed: 99);
var series = gbm.Fetch(size, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
for (int i = 0; i < size; i++)
{
input[i] = series.Values[i];
}
Tsf.Batch(input.AsSpan(), output.AsSpan(), 300);
Assert.True(double.IsFinite(output[^1]));
}
// ── H) Chainability ────────────────────────────────────────────────
[Fact]
public void Pub_FiresOnUpdate()
{
var tsf = new Tsf(5);
int fireCount = 0;
tsf.Pub += (object? _, in TValueEventArgs _) => fireCount++;
var series = MakeSeries(20);
foreach (var item in series)
{
tsf.Update(item);
}
Assert.Equal(series.Count, fireCount);
}
[Fact]
public void EventChaining_Works()
{
int period = 5;
var source = new TSeries();
var tsf = new Tsf(source, period);
var series = MakeSeries(50);
foreach (var item in series)
{
source.Add(item);
}
Assert.True(tsf.IsHot);
Assert.True(double.IsFinite(tsf.Last.Value));
}
// ── TSF-specific tests ─────────────────────────────────────────────
[Fact]
public void TSF_EqualsLSMA_PlusSlope()
{
// TSF = LSMA(offset=0) + slope
// Which is the same as LSMA(offset=1)?
// Yes: LSMA uses result = b - m * offset
// LSMA(offset=1) = b - m*1 = b - m = TSF
const int period = 14;
var series = MakeSeries(500);
var lsma = new Lsma(period, offset: 1);
var tsf = new Tsf(period);
for (int i = 0; i < series.Count; i++)
{
var lsmaResult = lsma.Update(series[i]);
var tsfResult = tsf.Update(series[i]);
Assert.Equal(lsmaResult.Value, tsfResult.Value, 1e-9);
}
}
[Fact]
public void Calculate_ReturnsBothResultsAndIndicator()
{
var series = MakeSeries(100);
var (results, indicator) = Tsf.Calculate(series, 10);
Assert.True(results.Count > 0);
Assert.True(indicator.IsHot);
Assert.Equal(results[^1].Value, indicator.Last.Value);
}
}
+189
View File
@@ -0,0 +1,189 @@
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public sealed class TsfValidationTests : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public TsfValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
private void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
_testData.Dispose();
_disposed = true;
}
}
// ── Cross-validate against LSMA(offset=1) ─────────────────────────
// TSF = LSMA with offset=1. This is a mathematical identity.
[Fact]
public void Validate_LSMA_Batch()
{
int[] periods = { 5, 10, 14, 20, 50 };
foreach (var period in periods)
{
var tsf = new global::QuanTAlib.Tsf(period);
var tsfResult = tsf.Update(_testData.Data);
var lsma = new global::QuanTAlib.Lsma(period, offset: 1);
var lsmaResult = lsma.Update(_testData.Data);
int compareCount = 100;
int start = tsfResult.Count - compareCount;
for (int i = start; i < tsfResult.Count; i++)
{
Assert.Equal(lsmaResult.Values[i], tsfResult.Values[i], 1e-9);
}
}
_output.WriteLine("TSF Batch validated successfully against LSMA(offset=1)");
}
[Fact]
public void Validate_LSMA_Streaming()
{
int[] periods = { 5, 10, 14, 20, 50 };
foreach (var period in periods)
{
var tsf = new global::QuanTAlib.Tsf(period);
var lsma = new global::QuanTAlib.Lsma(period, offset: 1);
var tsfResults = new List<double>();
var lsmaResults = new List<double>();
foreach (var item in _testData.Data)
{
tsfResults.Add(tsf.Update(item).Value);
lsmaResults.Add(lsma.Update(item).Value);
}
int compareCount = 100;
int start = tsfResults.Count - compareCount;
for (int i = start; i < tsfResults.Count; i++)
{
Assert.Equal(lsmaResults[i], tsfResults[i], 1e-9);
}
}
_output.WriteLine("TSF Streaming validated successfully against LSMA(offset=1)");
}
[Fact]
public void Validate_LSMA_Span()
{
int[] periods = { 5, 10, 14, 20, 50 };
foreach (var period in periods)
{
double[] tsfOutput = new double[_testData.RawData.Length];
double[] lsmaOutput = new double[_testData.RawData.Length];
global::QuanTAlib.Tsf.Batch(_testData.RawData.Span, tsfOutput.AsSpan(), period);
global::QuanTAlib.Lsma.Batch(_testData.RawData.Span, lsmaOutput.AsSpan(), period, offset: 1);
int compareCount = 100;
int start = tsfOutput.Length - compareCount;
for (int i = start; i < tsfOutput.Length; i++)
{
Assert.Equal(lsmaOutput[i], tsfOutput[i], 1e-9);
}
}
_output.WriteLine("TSF Span validated successfully against LSMA(offset=1)");
}
// ── Self-consistency checks ────────────────────────────────────────
[Fact]
public void Validate_Batch_Streaming_Consistency()
{
const int period = 14;
// Batch
var batchResult = global::QuanTAlib.Tsf.Batch(_testData.Data, period);
// Streaming
var tsf = new global::QuanTAlib.Tsf(period);
var streamResults = new List<double>();
foreach (var item in _testData.Data)
{
streamResults.Add(tsf.Update(item).Value);
}
int compareCount = 100;
int start = batchResult.Count - compareCount;
for (int i = start; i < batchResult.Count; i++)
{
Assert.Equal(batchResult.Values[i], streamResults[i], 1e-6);
}
_output.WriteLine("TSF Batch vs Streaming consistency verified");
}
[Fact]
public void Validate_DifferentPeriods()
{
int[] periods = { 5, 10, 20, 50, 100 };
foreach (var period in periods)
{
var result = global::QuanTAlib.Tsf.Batch(_testData.Data, period);
Assert.True(result.Count == _testData.Data.Count);
Assert.True(double.IsFinite(result.Values[^1]));
}
_output.WriteLine("TSF different periods validated");
}
[Fact]
public void Validate_Calculate_ReturnsHotIndicator()
{
const int period = 14;
var (results, indicator) = global::QuanTAlib.Tsf.Calculate(_testData.Data, period);
Assert.True(indicator.IsHot);
Assert.True(results.Count == _testData.Data.Count);
Assert.Equal(results.Values[^1], indicator.Last.Value);
_output.WriteLine("TSF Calculate returns hot indicator verified");
}
[Fact]
public void Validate_BarCorrection_Consistency()
{
const int period = 14;
// Feed initial data
var tsf = new global::QuanTAlib.Tsf(period);
for (int i = 0; i < 100; i++)
{
tsf.Update(_testData.Data[i], isNew: true);
}
double expectedLast = tsf.Last.Value;
// Apply multiple corrections, then restore
for (int j = 0; j < 5; j++)
{
tsf.Update(new TValue(DateTime.UtcNow, 999.0), isNew: false);
}
tsf.Update(_testData.Data[99], isNew: false);
Assert.Equal(expectedLast, tsf.Last.Value, 1e-6);
_output.WriteLine("TSF bar correction consistency verified");
}
}
+424
View File
@@ -0,0 +1,424 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// TSF: Time Series Forecast
/// </summary>
/// <remarks>
/// Projects the linear regression line one step forward, forecasting the
/// next bar's value based on the least-squares trend over the lookback period.
///
/// Calculation: <c>TSF = slope × period + intercept</c> (standard convention)
/// or equivalently <c>TSF = b m</c> (reversed-x convention where b = current bar value).
///
/// Uses O(1) incremental running sums (SumY, SumXY) identical to LSMA.
/// Relationship: TSF = LSMA(offset=0) + slope = LSMA(offset=1).
/// </remarks>
/// <seealso href="Tsf.md">Detailed documentation</seealso>
[SkipLocalsInit]
public sealed class Tsf : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
private readonly double _sumX;
private readonly double _denominator;
private readonly TValuePublishedHandler _handler;
private ITValuePublisher? _source;
private int _disposed;
[StructLayout(LayoutKind.Auto)]
private record struct State(double SumY, double SumXY, double LastVal, double LastValidValue);
private State _s;
private State _ps;
private int _tickCount;
private bool _isNew;
private const int ResyncInterval = 1000;
public override bool IsHot => _buffer.IsFull;
public bool IsNew => _isNew;
/// <summary>
/// Creates TSF with specified period.
/// </summary>
/// <param name="period">Lookback period for linear regression (must be &gt; 0)</param>
public Tsf(int period = 14)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
_period = period;
_buffer = new RingBuffer(period);
Name = $"Tsf({period})";
WarmupPeriod = period;
_handler = Handle;
// Precompute constants (reversed-x convention: x=0=newest, x=n-1=oldest)
// sumX = 0 + 1 + ... + (n-1) = n(n-1)/2
_sumX = 0.5 * period * (period - 1);
// sumX2 = 0^2 + ... + (n-1)^2 = (n-1)n(2n-1)/6
double sumX2 = (period - 1.0) * period * (2.0 * period - 1.0) / 6.0;
// denominator = n * sumX2 - sumX^2
_denominator = period * sumX2 - _sumX * _sumX;
_s.LastValidValue = double.NaN;
}
public Tsf(ITValuePublisher source, int period = 14) : this(period)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
_source.Pub += _handler;
}
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double GetValidValue(double input)
{
if (double.IsFinite(input))
{
_s.LastValidValue = input;
return input;
}
return _s.LastValidValue;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void UpdateState(double val)
{
if (_buffer.IsFull)
{
double oldest = _buffer.Oldest;
double prevSumY = _s.SumY;
// O(1) update for SumXY (reversed-x convention)
// New value enters at x=0, existing values shift x+1, oldest drops off
// sumXY_new = sumXY_old + sumY_prev - n * oldest
_s.SumXY = Math.FusedMultiplyAdd(-_period, oldest, _s.SumXY + prevSumY);
// O(1) update for SumY
_s.SumY = _s.SumY - oldest + val;
_buffer.Add(val);
}
else
{
if (_buffer.Count > 0)
{
_s.SumXY += _s.SumY;
}
_s.SumY += val;
_buffer.Add(val);
}
_tickCount++;
if (_buffer.IsFull && _tickCount >= ResyncInterval)
{
_tickCount = 0;
Resync();
}
}
private void Resync()
{
_s.SumY = _buffer.Sum;
_s.SumXY = 0;
var span = _buffer.GetSpan();
for (int i = 0; i < span.Length; i++)
{
int x = span.Length - 1 - i;
_s.SumXY = Math.FusedMultiplyAdd(x, span[i], _s.SumXY);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
_isNew = isNew;
if (isNew)
{
double val = GetValidValue(input.Value);
UpdateState(val);
_s.LastVal = val;
_ps = _s;
}
else
{
_s.LastValidValue = _ps.LastValidValue;
double val = GetValidValue(input.Value);
// For isNew=false, update the current bar without advancing.
// SumXY remains constant (depends on previous window state).
// SumY updates to reflect the change in the newest value.
_s.SumY = _ps.SumY - _ps.LastVal + val;
_s.SumXY = _ps.SumXY;
_buffer.UpdateNewest(val);
_s.LastVal = val;
}
double result;
if (_buffer.Count <= 1)
{
result = _buffer.Newest;
}
else
{
double n = _buffer.Count;
double sx = _sumX;
double denom = _denominator;
if (!_buffer.IsFull)
{
// Recalculate constants for smaller n during warmup
sx = 0.5 * n * (n - 1);
double sx2 = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
denom = n * sx2 - sx * sx;
}
if (Math.Abs(denom) < 1e-10)
{
result = _buffer.Newest;
}
else
{
// Reversed-x convention: m is negative for uptrend
double m = Math.FusedMultiplyAdd(n, _s.SumXY, -sx * _s.SumY) / denom;
double b = Math.FusedMultiplyAdd(-m, sx, _s.SumY) / n;
// b = value at x=0 (current bar endpoint)
// TSF = forecast one step ahead = b - m
// (In reversed-x, stepping forward means x=-1, so y = b - m*(-1)... wait)
// Actually: b - m * offset, where offset=1 projects one step ahead
// TSF = b - m * 1 = b - m
result = b - m;
}
}
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries 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);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
double initialLastValid = _s.LastValidValue;
Batch(source.Values, vSpan, _period, initialLastValid);
source.Times.CopyTo(tSpan);
// Restore state by replaying the last 'period' bars
int windowSize = Math.Min(len, _period);
int startIndex = len - windowSize;
Reset();
if (startIndex > 0)
{
for (int i = startIndex - 1; i >= 0; i--)
{
if (double.IsFinite(source.Values[i]))
{
_s.LastValidValue = source.Values[i];
break;
}
}
}
else
{
_s.LastValidValue = initialLastValid;
}
double lastProcessedValue = _s.LastValidValue;
for (int i = startIndex; i < len; i++)
{
double val = GetValidValue(source.Values[i]);
UpdateState(val);
lastProcessedValue = val;
}
_s.LastVal = lastProcessedValue;
_ps = _s;
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (var value in source)
{
Update(new TValue(DateTime.MinValue, value));
}
}
public static TSeries Batch(TSeries source, int period = 14)
{
var tsf = new Tsf(period);
return tsf.Update(source);
}
/// <summary>
/// Calculates TSF in-place, writing results to pre-allocated output span.
/// Zero-allocation method for maximum performance.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period = 14, double initialLastValid = double.NaN)
{
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;
}
const int StackAllocThreshold = 256;
Span<double> buffer = period <= StackAllocThreshold
? stackalloc double[period]
: new double[period];
double sumY = 0;
double sumXY = 0;
double lastValid = initialLastValid;
int bufferIndex = 0;
int count = 0;
// Precalculate constants for full period
double fullSumX = 0.5 * period * (period - 1);
double fullSumX2 = (period - 1.0) * period * (2.0 * period - 1.0) / 6.0;
double fullDenom = period * fullSumX2 - fullSumX * fullSumX;
for (int i = 0; i < len; i++)
{
double val = source[i];
if (double.IsFinite(val))
{
lastValid = val;
}
else
{
val = lastValid;
}
if (count < period)
{
// Warmup phase
buffer[count] = val;
count++;
if (count > 1)
{
sumXY += sumY;
}
sumY += val;
if (count <= 1)
{
output[i] = val;
}
else
{
double n = count;
double sx = 0.5 * n * (n - 1);
double sx2 = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
double denom = n * sx2 - sx * sx;
if (Math.Abs(denom) < 1e-10)
{
output[i] = val;
}
else
{
double m = Math.FusedMultiplyAdd(n, sumXY, -sx * sumY) / denom;
double b = Math.FusedMultiplyAdd(-m, sx, sumY) / n;
// TSF = b - m (one step ahead forecast)
output[i] = b - m;
}
}
if (count == period)
{
bufferIndex = 0;
}
}
else
{
// Full buffer phase — O(1) update
double oldest = buffer[bufferIndex];
double prevSumY = sumY;
sumXY = Math.FusedMultiplyAdd(-period, oldest, sumXY + prevSumY);
sumY = sumY - oldest + val;
buffer[bufferIndex] = val;
bufferIndex++;
if (bufferIndex >= period)
{
bufferIndex = 0;
}
double m = Math.FusedMultiplyAdd(period, sumXY, -fullSumX * sumY) / fullDenom;
double b = Math.FusedMultiplyAdd(-m, fullSumX, sumY) / period;
// TSF = b - m (one step ahead forecast)
output[i] = b - m;
}
}
}
public static (TSeries Results, Tsf Indicator) Calculate(TSeries source, int period = 14)
{
var indicator = new Tsf(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
public override void Reset()
{
_buffer.Clear();
_s = default;
_s.LastValidValue = double.NaN;
_ps = default;
Last = default;
_tickCount = 0;
}
protected override void Dispose(bool disposing)
{
if (Interlocked.CompareExchange(ref _disposed, 1, 0) == 0 && _source != null)
{
_source.Pub -= _handler;
_source = null;
}
base.Dispose(disposing);
}
}
+130
View File
@@ -0,0 +1,130 @@
# TSF: Time Series Forecast
> "The best prediction of the future is the trend that's already in motion — extended by exactly one step."
TSF projects the least-squares regression line one bar forward, providing a statistically grounded forecast of the next bar's value. Unlike simple moving averages that smooth past data, TSF answers the question: "If the current trend continues, where will price be next?" This makes it inherently leading rather than lagging, though the forecast degrades quickly beyond one step.
## Historical Context
Time Series Forecast originates from classical linear regression applied to financial time series. The concept appeared in TA-Lib as `TA_TSF` and has been a standard offering in technical analysis software since the 1990s. Tushar Chande's *The New Technical Trader* (1994) formalized several regression-based indicators including the closely related Chande Forecast Oscillator (CFO), which measures the percentage error between the current price and the TSF value.
TSF is mathematically identical to the Least Squares Moving Average (LSMA) evaluated one step beyond the window endpoint. Where LSMA answers "what is the trend value now?", TSF answers "what will the trend value be next bar?" The relationship is exact: `TSF = LSMA + slope`, where slope is the per-bar rate of change of the regression line.
## Architecture & Physics
### 1. O(1) Incremental Linear Regression
The implementation uses running sums (`SumY`, `SumXY`) with a reversed-x convention where `x=0` corresponds to the newest bar. This allows O(1) updates without maintaining the full regression matrix.
**Constants (precomputed once):**
$$\Sigma_x = \frac{n(n-1)}{2}, \quad \Sigma_{x^2} = \frac{(n-1) \cdot n \cdot (2n-1)}{6}$$
$$D = n \cdot \Sigma_{x^2} - \Sigma_x^2$$
### 2. O(1) Sum Updates
When a new value enters and the oldest drops:
$$\Sigma_{xy}^{new} = \Sigma_{xy}^{old} + \Sigma_y^{old} - n \cdot v_{oldest}$$
$$\Sigma_y^{new} = \Sigma_y^{old} - v_{oldest} + v_{new}$$
### 3. Regression Parameters
$$m = \frac{n \cdot \Sigma_{xy} - \Sigma_x \cdot \Sigma_y}{D}$$
$$b = \frac{\Sigma_y - m \cdot \Sigma_x}{n}$$
In the reversed-x convention, `b` is the regression value at the current bar (x=0), and `m` is negative for uptrends.
### 4. TSF Calculation
$$\text{TSF} = b - m$$
This projects one step forward from the current bar. Equivalently, in standard convention (x=0=oldest):
$$\text{TSF} = \text{slope} \cdot n + \text{intercept}$$
### 5. Resync Guard
After every 1000 ticks, running sums are recomputed from the buffer to prevent floating-point drift accumulation.
## Mathematical Precision & Implementation Philosophy
### Relationship to Other Indicators
| Indicator | Formula | Interpretation |
|-----------|---------|----------------|
| **LSMA** (offset=0) | `b` | Regression value at current bar |
| **TSF** | `b - m` | Regression value one step ahead |
| **LSMA** (offset=1) | `b - m × 1` | Same as TSF |
| **CFO** | `100 × (price - TSF_at_current) / price` | Forecast error as percentage |
| **Inertia** | `price - TSF_at_current` | Raw forecast error (residual) |
### FMA Usage
All critical multiplications use `Math.FusedMultiplyAdd` for precision, including:
- SumXY O(1) update: `FMA(-period, oldest, sumXY + prevSumY)`
- Slope calculation: `FMA(n, sumXY, -sumX × sumY)`
- Intercept calculation: `FMA(-m, sumX, sumY)`
## Performance Profile
### Operation Count (Streaming Mode, Scalar)
| Operation | Count | Cost (cycles) | Subtotal |
|-----------|-------|---------------|----------|
| ADD/SUB | 4 | 1 | 4 |
| MUL | 0 | 3 | 0 |
| DIV | 2 | 12 | 24 |
| FMA | 3 | 5 | 15 |
| CMP | 1 | 1 | 1 |
| **Total** | **10** | | **~44** |
### Batch Mode (SIMD Analysis)
The O(1) running-sum algorithm is inherently serial due to data dependencies. Batch mode uses `stackalloc` for small buffers (≤256 elements) to avoid heap allocation.
| Mode | Per-Bar Cost | Notes |
|------|-------------|-------|
| Streaming | ~44 cycles | O(1) update |
| Batch (Span) | ~44 cycles | Same algorithm, zero-alloc |
| Batch (TSeries) | ~44 cycles + state restore | Replays last N bars |
### Quality Metrics
| Metric | Score (1-10) | Justification |
|--------|-------------|---------------|
| Accuracy | 9 | Exact OLS regression, FMA precision |
| Timeliness | 10 | Leading indicator (projects forward) |
| Overshoot | 7 | Extrapolation amplifies noise |
| Smoothness | 5 | Less smooth than LSMA (forecast adds slope) |
## Validation
| Library | Status | Notes |
|---------|--------|-------|
| LSMA(offset=1) | ✅ | Mathematical identity, exact match |
| TA-Lib | 🔲 | `TA_TSF` available in TALib.NETCore |
| Skender | ❌ | No direct TSF method |
## Common Pitfalls
1. **TSF is not LSMA.** LSMA = regression value at the current bar. TSF = one step ahead. The difference equals the regression slope. Using TSF as a smoothing average will produce systematically biased results.
2. **Single-step forecast only.** TSF projects exactly one bar forward. Multi-step extrapolation (TSF at offset=2, 3, ...) accumulates error quadratically. For multi-step forecasting, use AFIRMA or dedicated time-series models.
3. **Warmup = period bars.** The indicator needs a full window of data before regression is meaningful. During warmup, TSF returns raw input values.
4. **Noise amplification.** Because TSF adds the slope to the endpoint value, it amplifies short-term noise. Use longer periods (20+) for less noisy forecasts, or combine with a smoother like LSMA.
5. **Bar correction support.** The `isNew=false` pathway correctly rolls back state using the `_ps` (previous state) pattern. Always use `isNew=false` for intra-bar updates in live trading.
6. **Resync interval.** Running sums are recomputed every 1000 ticks to prevent floating-point drift. This adds negligible overhead but ensures long-running accuracy.
## References
- Tushar Chande, *The New Technical Trader*, 1994
- TA-Lib: `TA_TSF` function (www.ta-lib.org)
- PineScript: `ta.linreg(source, length, -1)` (offset=-1 = one step ahead)