mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-22 20:48:04 +00:00
Refactor tests and implementations for various indicators
- Updated RsiIndicatorTests to ensure proper initialization and state checks. - Added new tests for Rsx, Vel, and Adosc indicators to validate behavior under iterative corrections and edge cases (NaN, Infinity). - Enhanced Bessel indicator tests and implementation with consistent formatting. - Improved Ema and Pwma implementations by ensuring proper handling of values. - Introduced mock classes for charting to facilitate testing without dependencies. - Ensured all indicators produce consistent results across different modes of operation. - Cleaned up code formatting and added missing commas for better readability.
This commit is contained in:
+673
-40
@@ -1,15 +1,42 @@
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class CsvFeedTests
|
||||
public sealed class CsvFeedTests : IDisposable
|
||||
{
|
||||
private const string TestCsvPath = "daily_IBM.csv";
|
||||
private readonly List<string> _tempFiles = new();
|
||||
private bool _disposed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
foreach (var file in _tempFiles)
|
||||
{
|
||||
if (File.Exists(file))
|
||||
{
|
||||
try { File.Delete(file); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string CreateTempCsv(string[] lines)
|
||||
{
|
||||
string tempPath = Path.GetTempFileName() + ".csv";
|
||||
File.WriteAllLines(tempPath, lines);
|
||||
_tempFiles.Add(tempPath);
|
||||
return tempPath;
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidFile_LoadsData()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
Assert.NotNull(feed);
|
||||
Assert.True(feed.Count > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -21,15 +48,185 @@ public class CsvFeedTests
|
||||
[Fact]
|
||||
public void Constructor_NullPath_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new CsvFeed(null!));
|
||||
var ex = Assert.Throws<ArgumentException>(() => new CsvFeed(null!));
|
||||
Assert.Equal("filePath", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_EmptyPath_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new CsvFeed(""));
|
||||
var ex = Assert.Throws<ArgumentException>(() => new CsvFeed(""));
|
||||
Assert.Equal("filePath", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhitespacePath_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new CsvFeed(" "));
|
||||
Assert.Equal("filePath", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_EmptyCsv_ThrowsInvalidDataException()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(Array.Empty<string>());
|
||||
Assert.Throws<InvalidDataException>(() => new CsvFeed(tempCsv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_HeaderOnlyCsv_ThrowsInvalidDataException()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[] { "timestamp,open,high,low,close,volume" });
|
||||
Assert.Throws<InvalidDataException>(() => new CsvFeed(tempCsv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_MalformedDate_ThrowsFormatException()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"not-a-date,100,101,99,100,1000"
|
||||
});
|
||||
Assert.Throws<FormatException>(() => new CsvFeed(tempCsv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_MalformedPrice_ThrowsFormatException()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,not-a-number,101,99,100,1000"
|
||||
});
|
||||
Assert.Throws<FormatException>(() => new CsvFeed(tempCsv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_MissingColumns_ThrowsFormatException()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,100,101,99,100" // Missing volume
|
||||
});
|
||||
Assert.Throws<FormatException>(() => new CsvFeed(tempCsv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ExtraColumns_ThrowsFormatException()
|
||||
{
|
||||
// Extra columns should throw format exception (strict 6-column format)
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume,extra",
|
||||
"2023-01-01,100,101,99,100,1000,extra_data"
|
||||
});
|
||||
Assert.Throws<FormatException>(() => new CsvFeed(tempCsv));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Tests
|
||||
|
||||
[Fact]
|
||||
public void Count_ReturnsCorrectNumber()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
Assert.True(feed.Count > 0);
|
||||
// IBM CSV has 100 rows of data
|
||||
Assert.Equal(100, feed.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilePath_ReturnsLoadedPath()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
Assert.Equal(TestCsvPath, feed.FilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasMore_TrueAtStart()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
Assert.True(feed.HasMore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasMore_FalseWhenExhausted()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,100,101,99,100,1000"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
|
||||
Assert.True(feed.HasMore);
|
||||
feed.Next(isNew: true);
|
||||
Assert.False(feed.HasMore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentIndex_StartsAtZero()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
Assert.Equal(0, feed.CurrentIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentIndex_IncrementsOnNext()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
Assert.Equal(0, feed.CurrentIndex);
|
||||
feed.Next(isNew: true);
|
||||
Assert.Equal(1, feed.CurrentIndex);
|
||||
feed.Next(isNew: true);
|
||||
Assert.Equal(2, feed.CurrentIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentIndex_DoesNotIncrementOnUpdate()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
feed.Next(isNew: true);
|
||||
int indexAfterFirst = feed.CurrentIndex;
|
||||
|
||||
feed.Next(isNew: false);
|
||||
Assert.Equal(indexAfterFirst, feed.CurrentIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasCurrentBar_FalseAtStart()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
Assert.False(feed.HasCurrentBar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasCurrentBar_TrueAfterNext()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
feed.Next(isNew: true);
|
||||
Assert.True(feed.HasCurrentBar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Data_ReturnsUnderlyingSeries()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
var data = feed.Data;
|
||||
|
||||
Assert.NotNull(data);
|
||||
Assert.Equal(feed.Count, data.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Next Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Next_StreamsDataChronologically()
|
||||
{
|
||||
@@ -109,6 +306,43 @@ public class CsvFeedTests
|
||||
Assert.Equal(lastBar.Time, finalBar.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_EmptyData_ReturnsDefaultAndSignalsNoMore()
|
||||
{
|
||||
// Create a mock scenario - but since constructor throws on empty,
|
||||
// we test the behavior when all data is consumed
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,100,101,99,100,1000"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
|
||||
// Consume all data
|
||||
bool isNew = true;
|
||||
feed.Next(ref isNew);
|
||||
|
||||
// Now at end
|
||||
isNew = true;
|
||||
var bar = feed.Next(ref isNew);
|
||||
Assert.False(isNew);
|
||||
Assert.Equal(100.0, bar.Close); // Returns last bar
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_DefaultParameter_IsNewTrue()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
var bar1 = feed.Next(); // Default isNew = true
|
||||
var bar2 = feed.Next(); // Default isNew = true
|
||||
Assert.True(bar2.Time > bar1.Time);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fetch Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Fetch_ReturnsCorrectNumberOfBars()
|
||||
{
|
||||
@@ -124,15 +358,25 @@ public class CsvFeedTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_InvalidCount_ThrowsArgumentException()
|
||||
public void Fetch_ZeroCount_ThrowsArgumentException()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
var startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromDays(1);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => feed.Fetch(0, startTime, interval));
|
||||
Assert.Throws<ArgumentException>(() => feed.Fetch(-1, startTime, interval));
|
||||
var ex = Assert.Throws<ArgumentException>(() => feed.Fetch(0, startTime, interval));
|
||||
Assert.Equal("count", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_NegativeCount_ThrowsArgumentException()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
var startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromDays(1);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => feed.Fetch(-1, startTime, interval));
|
||||
Assert.Equal("count", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -154,6 +398,174 @@ public class CsvFeedTests
|
||||
Assert.True(bar.Time >= startTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_ResetsHasCurrentBar()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
feed.Next(isNew: true);
|
||||
Assert.True(feed.HasCurrentBar);
|
||||
|
||||
var startTime = new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
||||
feed.Fetch(5, startTime, TimeSpan.FromDays(1));
|
||||
|
||||
Assert.False(feed.HasCurrentBar);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Reset_ReturnsToStart()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
// Advance several bars
|
||||
var firstBar = feed.Next(isNew: true);
|
||||
feed.Next(isNew: true);
|
||||
feed.Next(isNew: true);
|
||||
Assert.Equal(3, feed.CurrentIndex);
|
||||
|
||||
// Reset
|
||||
feed.Reset();
|
||||
|
||||
Assert.Equal(0, feed.CurrentIndex);
|
||||
Assert.True(feed.HasMore);
|
||||
Assert.False(feed.HasCurrentBar);
|
||||
|
||||
// Next bar should be first bar again
|
||||
var afterReset = feed.Next(isNew: true);
|
||||
Assert.Equal(firstBar.Time, afterReset.Time);
|
||||
Assert.Equal(firstBar.Close, afterReset.Close);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_WithIndex_SetsCorrectPosition()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
// Reset to middle
|
||||
int targetIndex = 50;
|
||||
feed.Reset(targetIndex);
|
||||
|
||||
Assert.Equal(targetIndex, feed.CurrentIndex);
|
||||
Assert.False(feed.HasCurrentBar);
|
||||
|
||||
// Next bar should be at that index
|
||||
var bar = feed.Next(isNew: true);
|
||||
var expectedBar = feed.GetBar(targetIndex);
|
||||
Assert.Equal(expectedBar.Time, bar.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_WithNegativeIndex_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => feed.Reset(-1));
|
||||
Assert.Equal("index", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_WithIndexBeyondCount_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => feed.Reset(feed.Count + 1));
|
||||
Assert.Equal("index", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_WithIndexAtCount_IsValid()
|
||||
{
|
||||
// Resetting to exactly Count means "at end" - valid but no more data
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
feed.Reset(feed.Count);
|
||||
|
||||
Assert.Equal(feed.Count, feed.CurrentIndex);
|
||||
Assert.False(feed.HasMore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetBar Method Tests
|
||||
|
||||
[Fact]
|
||||
public void GetBar_ReturnsCorrectBar()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
// Get bar without affecting streaming
|
||||
var bar0 = feed.GetBar(0);
|
||||
var bar1 = feed.GetBar(1);
|
||||
|
||||
// Streaming position unchanged
|
||||
Assert.Equal(0, feed.CurrentIndex);
|
||||
|
||||
// Bars should be in chronological order
|
||||
Assert.True(bar1.Time > bar0.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBar_NegativeIndex_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => feed.GetBar(-1));
|
||||
Assert.Equal("index", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBar_IndexAtCount_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => feed.GetBar(feed.Count));
|
||||
Assert.Equal("index", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBar_DoesNotAffectStreaming()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
// Stream first bar
|
||||
var streamed = feed.Next(isNew: true);
|
||||
int indexAfter = feed.CurrentIndex;
|
||||
|
||||
// Random access
|
||||
var bar50 = feed.GetBar(50);
|
||||
Assert.True(bar50.Time > 0);
|
||||
|
||||
// Streaming position unchanged
|
||||
Assert.Equal(indexAfter, feed.CurrentIndex);
|
||||
|
||||
// Continue streaming
|
||||
var next = feed.Next(isNew: true);
|
||||
Assert.True(next.Time > streamed.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBar_ConsistentWithNext()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
// Get bars via random access
|
||||
var bar0 = feed.GetBar(0);
|
||||
var bar1 = feed.GetBar(1);
|
||||
var bar2 = feed.GetBar(2);
|
||||
|
||||
// Get same bars via streaming
|
||||
var streamed0 = feed.Next(isNew: true);
|
||||
var streamed1 = feed.Next(isNew: true);
|
||||
var streamed2 = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(bar0.Time, streamed0.Time);
|
||||
Assert.Equal(bar1.Time, streamed1.Time);
|
||||
Assert.Equal(bar2.Time, streamed2.Time);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region OHLCV Validation Tests
|
||||
|
||||
[Fact]
|
||||
public void LoadFromCsv_ParsesValuesCorrectly()
|
||||
{
|
||||
@@ -190,10 +602,91 @@ public class CsvFeedTests
|
||||
for (int i = 1; i < bars.Count; i++)
|
||||
{
|
||||
Assert.True(bars[i].Time > bars[i - 1].Time,
|
||||
$"Bar {i} time ({bars[i].AsDateTime}) should be after bar {i-1} time ({bars[i-1].AsDateTime})");
|
||||
$"Bar {i} time ({bars[i].AsDateTime}) should be after bar {i - 1} time ({bars[i - 1].AsDateTime})");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromCsv_AllBarsHaveValidOHLCV()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
for (int i = 0; i < feed.Count; i++)
|
||||
{
|
||||
var bar = feed.GetBar(i);
|
||||
|
||||
Assert.True(double.IsFinite(bar.Open), $"Bar {i} has non-finite Open");
|
||||
Assert.True(double.IsFinite(bar.High), $"Bar {i} has non-finite High");
|
||||
Assert.True(double.IsFinite(bar.Low), $"Bar {i} has non-finite Low");
|
||||
Assert.True(double.IsFinite(bar.Close), $"Bar {i} has non-finite Close");
|
||||
Assert.True(double.IsFinite(bar.Volume), $"Bar {i} has non-finite Volume");
|
||||
|
||||
Assert.True(bar.High >= bar.Low, $"Bar {i}: High ({bar.High}) < Low ({bar.Low})");
|
||||
Assert.True(bar.High >= bar.Open, $"Bar {i}: High ({bar.High}) < Open ({bar.Open})");
|
||||
Assert.True(bar.High >= bar.Close, $"Bar {i}: High ({bar.High}) < Close ({bar.Close})");
|
||||
Assert.True(bar.Low <= bar.Open, $"Bar {i}: Low ({bar.Low}) > Open ({bar.Open})");
|
||||
Assert.True(bar.Low <= bar.Close, $"Bar {i}: Low ({bar.Low}) > Close ({bar.Close})");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromCsv_ParsesDecimalsCorrectly()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,100.1234,101.5678,99.9999,100.0001,1234567.89"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var bar = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(100.1234, bar.Open, precision: 4);
|
||||
Assert.Equal(101.5678, bar.High, precision: 4);
|
||||
Assert.Equal(99.9999, bar.Low, precision: 4);
|
||||
Assert.Equal(100.0001, bar.Close, precision: 4);
|
||||
Assert.Equal(1234567.89, bar.Volume, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromCsv_ParsesNegativeValues()
|
||||
{
|
||||
// While negative prices are unusual, the parser should handle them
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,-100,50,-150,-50,1000"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var bar = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(-100, bar.Open);
|
||||
Assert.Equal(50, bar.High);
|
||||
Assert.Equal(-150, bar.Low);
|
||||
Assert.Equal(-50, bar.Close);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromCsv_ParsesScientificNotation()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,1.5e2,2e2,1e2,1.75e2,1e6"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var bar = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(150, bar.Open);
|
||||
Assert.Equal(200, bar.High);
|
||||
Assert.Equal(100, bar.Low);
|
||||
Assert.Equal(175, bar.Close);
|
||||
Assert.Equal(1000000, bar.Volume);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IFeed Interface Tests
|
||||
|
||||
[Fact]
|
||||
public void CsvFeed_WorksWithIFeedInterface()
|
||||
{
|
||||
@@ -206,6 +699,35 @@ public class CsvFeedTests
|
||||
Assert.True(bar2.Time > bar1.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CsvFeed_IFeedRefOverload()
|
||||
{
|
||||
IFeed feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
bool isNew = true;
|
||||
var bar1 = feed.Next(ref isNew);
|
||||
Assert.True(bar1.Time > 0);
|
||||
|
||||
isNew = false;
|
||||
var bar1Update = feed.Next(ref isNew);
|
||||
Assert.Equal(bar1.Time, bar1Update.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CsvFeed_IFeedFetch()
|
||||
{
|
||||
IFeed feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
var startTime = new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
||||
var series = feed.Fetch(5, startTime, TimeSpan.FromDays(1));
|
||||
|
||||
Assert.True(series.Count > 0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Case Tests
|
||||
|
||||
[Fact]
|
||||
public void Next_MixedNewAndUpdate_WorksCorrectly()
|
||||
{
|
||||
@@ -248,49 +770,160 @@ public class CsvFeedTests
|
||||
var series = feed.Fetch(5, startTime, TimeSpan.FromDays(1));
|
||||
|
||||
// Should return empty or minimal data
|
||||
Assert.True(series.Count == 0);
|
||||
Assert.Empty(series);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_HandlesGapsCorrectly()
|
||||
{
|
||||
string tempCsv = Path.GetTempFileName() + ".csv";
|
||||
try
|
||||
// Create CSV with gaps using helper
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
// Create CSV with gaps
|
||||
// Date, Open, High, Low, Close, Volume
|
||||
// 2023-01-01 (Sunday)
|
||||
// 2023-01-02 (Monday)
|
||||
// 2023-01-04 (Wednesday) - Gap of Tuesday
|
||||
// 2023-01-05 (Thursday)
|
||||
var lines = new[]
|
||||
{
|
||||
"Date,Open,High,Low,Close,Volume",
|
||||
"2023-01-05,103,104,102,103,1000",
|
||||
"2023-01-04,102,103,101,102,1000",
|
||||
"2023-01-02,101,102,100,101,1000",
|
||||
"2023-01-01,100,101,99,100,1000"
|
||||
};
|
||||
File.WriteAllLines(tempCsv, lines);
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-05,103,104,102,103,1000",
|
||||
"2023-01-04,102,103,101,102,1000",
|
||||
"2023-01-02,101,102,100,101,1000",
|
||||
"2023-01-01,100,101,99,100,1000"
|
||||
});
|
||||
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var startTime = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
||||
var interval = TimeSpan.FromDays(1);
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var startTime = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
||||
var interval = TimeSpan.FromDays(1);
|
||||
|
||||
// Fetch 5 bars. Should get 4 bars (Jan 1, 2, 4, 5).
|
||||
var series = feed.Fetch(10, startTime, interval);
|
||||
// Fetch bars. Should get 4 bars (Jan 1, 2, 4, 5).
|
||||
var series = feed.Fetch(10, startTime, interval);
|
||||
|
||||
Assert.Equal(4, series.Count);
|
||||
Assert.Equal(startTime, series[0].Time); // Jan 1
|
||||
Assert.Equal(startTime + interval.Ticks, series[1].Time); // Jan 2
|
||||
// Gap here
|
||||
Assert.Equal(startTime + 3 * interval.Ticks, series[2].Time); // Jan 4
|
||||
Assert.Equal(startTime + 4 * interval.Ticks, series[3].Time); // Jan 5
|
||||
Assert.Equal(4, series.Count);
|
||||
Assert.Equal(startTime, series[0].Time); // Jan 1
|
||||
Assert.Equal(startTime + interval.Ticks, series[1].Time); // Jan 2
|
||||
// Gap here (Jan 3 missing)
|
||||
Assert.Equal(startTime + 3 * interval.Ticks, series[2].Time); // Jan 4
|
||||
Assert.Equal(startTime + 4 * interval.Ticks, series[3].Time); // Jan 5
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleBar_StreamsAndEnds()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,100,101,99,100,1000"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
|
||||
Assert.Equal(1, feed.Count);
|
||||
Assert.True(feed.HasMore);
|
||||
|
||||
bool isNew = true;
|
||||
var bar = feed.Next(ref isNew);
|
||||
Assert.True(isNew);
|
||||
Assert.Equal(100.0, bar.Close);
|
||||
Assert.False(feed.HasMore);
|
||||
|
||||
// Try to get next
|
||||
isNew = true;
|
||||
var noMore = feed.Next(ref isNew);
|
||||
Assert.False(isNew); // Signals end
|
||||
Assert.Equal(bar.Time, noMore.Time); // Returns last bar
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhitespaceInValues_Trimmed()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
" 2023-01-01 , 100 , 101 , 99 , 100 , 1000 "
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var bar = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(100.0, bar.Open);
|
||||
Assert.Equal(101.0, bar.High);
|
||||
Assert.Equal(99.0, bar.Low);
|
||||
Assert.Equal(100.0, bar.Close);
|
||||
Assert.Equal(1000.0, bar.Volume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroValues_Accepted()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,0,0,0,0,0"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var bar = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(0.0, bar.Open);
|
||||
Assert.Equal(0.0, bar.High);
|
||||
Assert.Equal(0.0, bar.Low);
|
||||
Assert.Equal(0.0, bar.Close);
|
||||
Assert.Equal(0.0, bar.Volume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VeryLargeValues_Parsed()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,999999999.99,1000000000.01,999999999.00,999999999.50,9999999999999"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var bar = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(999999999.99, bar.Open, precision: 2);
|
||||
Assert.Equal(1000000000.01, bar.High, precision: 2);
|
||||
Assert.Equal(999999999.00, bar.Low, precision: 2);
|
||||
Assert.Equal(999999999.50, bar.Close, precision: 2);
|
||||
Assert.Equal(9999999999999.0, bar.Volume, precision: 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsecutiveResets_WorkCorrectly()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
feed.Next(isNew: true);
|
||||
feed.Next(isNew: true);
|
||||
feed.Reset();
|
||||
feed.Reset();
|
||||
feed.Reset();
|
||||
|
||||
Assert.Equal(0, feed.CurrentIndex);
|
||||
Assert.False(feed.HasCurrentBar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamThenResetThenStream_Consistent()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
// First pass
|
||||
var firstPass = new List<double>();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
firstPass.Add(feed.Next(isNew: true).Close);
|
||||
}
|
||||
finally
|
||||
|
||||
// Reset
|
||||
feed.Reset();
|
||||
|
||||
// Second pass
|
||||
var secondPass = new List<double>();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
if (File.Exists(tempCsv))
|
||||
File.Delete(tempCsv);
|
||||
secondPass.Add(feed.Next(isNew: true).Close);
|
||||
}
|
||||
|
||||
// Should be identical
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
Assert.Equal(firstPass[i], secondPass[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
+258
-38
@@ -1,28 +1,80 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed OHLCV data from a CSV line.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
internal readonly record struct ParsedOhlcv(long Time, double Open, double High, double Low, double Close, double Volume);
|
||||
|
||||
/// <summary>
|
||||
/// Mutable state for parsing OHLCV columns. Used as ref parameter to reduce method signature size.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
internal ref struct OhlcvParseState
|
||||
{
|
||||
public long Time;
|
||||
public double Open;
|
||||
public double High;
|
||||
public double Low;
|
||||
public double Close;
|
||||
public double Volume;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CSV file feed for loading historical OHLCV data.
|
||||
/// Loads data in constructor and streams through it with Next() or returns batches with Fetch().
|
||||
/// CSV format: timestamp,open,high,low,close,volume (header required)
|
||||
/// Timestamp format: YYYY-MM-DD (UTC midnight assumed)
|
||||
/// </summary>
|
||||
public class CsvFeed : IFeed
|
||||
[SkipLocalsInit]
|
||||
public sealed class CsvFeed : IFeed
|
||||
{
|
||||
private readonly TBarSeries _data;
|
||||
private readonly string _filePath;
|
||||
|
||||
// Streaming state
|
||||
private int _currentIndex;
|
||||
private TBar _currentBar;
|
||||
private bool _hasCurrentBar;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of bars available in the CSV file.
|
||||
/// </summary>
|
||||
public int Count => _data.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file path of the loaded CSV.
|
||||
/// </summary>
|
||||
public string FilePath => _filePath;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are more bars to stream.
|
||||
/// </summary>
|
||||
public bool HasMore => _currentIndex < _data.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current streaming position (0-based index).
|
||||
/// </summary>
|
||||
public int CurrentIndex => _currentIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the feed has a current bar in progress.
|
||||
/// </summary>
|
||||
public bool HasCurrentBar => _hasCurrentBar;
|
||||
|
||||
/// <summary>
|
||||
/// Loads CSV file and prepares data for streaming.
|
||||
/// Data is reversed to chronological order (oldest first).
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to CSV file</param>
|
||||
/// <exception cref="ArgumentException">Thrown when filePath is null or empty</exception>
|
||||
/// <exception cref="FileNotFoundException">Thrown when the specified file does not exist</exception>
|
||||
/// <exception cref="InvalidDataException">Thrown when CSV file is empty or contains only header</exception>
|
||||
/// <exception cref="FormatException">Thrown when CSV format is invalid</exception>
|
||||
public CsvFeed(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
@@ -31,6 +83,7 @@ public class CsvFeed : IFeed
|
||||
if (!File.Exists(filePath))
|
||||
throw new FileNotFoundException($"CSV file not found: {filePath}", filePath);
|
||||
|
||||
_filePath = filePath;
|
||||
_data = LoadFromCsv(filePath);
|
||||
_currentIndex = 0;
|
||||
}
|
||||
@@ -65,37 +118,131 @@ public class CsvFeed : IFeed
|
||||
|
||||
var series = new TBarSeries(dataLines.Count);
|
||||
|
||||
// Pre-allocate arrays for bulk loading (SoA layout)
|
||||
long[] t = new long[dataLines.Count];
|
||||
double[] o = new double[dataLines.Count];
|
||||
double[] h = new double[dataLines.Count];
|
||||
double[] l = new double[dataLines.Count];
|
||||
double[] c = new double[dataLines.Count];
|
||||
double[] v = new double[dataLines.Count];
|
||||
|
||||
for (int i = 0; i < dataLines.Count; i++)
|
||||
{
|
||||
var line = dataLines[i];
|
||||
|
||||
var parts = line.Split(',');
|
||||
int originalLineNumber = dataLines.Count - i + 1;
|
||||
if (parts.Length != 6)
|
||||
throw new FormatException($"Invalid CSV format at line {originalLineNumber}. Expected 6 columns, found {parts.Length}");
|
||||
|
||||
// Parse timestamp (YYYY-MM-DD format, assume UTC midnight)
|
||||
if (!DateTime.TryParseExact(parts[0].Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var timestamp))
|
||||
{
|
||||
throw new FormatException($"Failed to parse timestamp at line {originalLineNumber}: {line}");
|
||||
}
|
||||
|
||||
// Parse OHLCV values
|
||||
if (!double.TryParse(parts[1].Trim(), CultureInfo.InvariantCulture, out double open) ||
|
||||
!double.TryParse(parts[2].Trim(), CultureInfo.InvariantCulture, out double high) ||
|
||||
!double.TryParse(parts[3].Trim(), CultureInfo.InvariantCulture, out double low) ||
|
||||
!double.TryParse(parts[4].Trim(), CultureInfo.InvariantCulture, out double close) ||
|
||||
!double.TryParse(parts[5].Trim(), CultureInfo.InvariantCulture, out double volume))
|
||||
{
|
||||
throw new FormatException($"Failed to parse CSV line {originalLineNumber}: {line}");
|
||||
}
|
||||
|
||||
series.Add(timestamp, open, high, low, close, volume, isNew: true);
|
||||
var parsed = ParseCsvLine(line, originalLineNumber);
|
||||
t[i] = parsed.Time;
|
||||
o[i] = parsed.Open;
|
||||
h[i] = parsed.High;
|
||||
l[i] = parsed.Low;
|
||||
c[i] = parsed.Close;
|
||||
v[i] = parsed.Volume;
|
||||
}
|
||||
|
||||
// Bulk add to series
|
||||
series.Add(t, o, h, l, c, v);
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single CSV line into OHLCV components.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static ParsedOhlcv ParseCsvLine(string line, int lineNumber)
|
||||
{
|
||||
// Use Span-based splitting for reduced allocations
|
||||
ReadOnlySpan<char> lineSpan = line.AsSpan();
|
||||
|
||||
int col = 0;
|
||||
int start = 0;
|
||||
OhlcvParseState state = default;
|
||||
|
||||
for (int i = 0; i < lineSpan.Length; i++)
|
||||
{
|
||||
if (lineSpan[i] == ',')
|
||||
{
|
||||
var segment = lineSpan[start..i].Trim();
|
||||
ParseColumn(segment, col, lineNumber, line, ref state);
|
||||
col++;
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Process the last segment after the final comma
|
||||
if (start <= lineSpan.Length)
|
||||
{
|
||||
var segment = lineSpan[start..].Trim();
|
||||
ParseColumn(segment, col, lineNumber, line, ref state);
|
||||
col++;
|
||||
}
|
||||
|
||||
if (col != 6)
|
||||
{
|
||||
throw new FormatException($"Invalid CSV format at line {lineNumber}. Expected 6 columns, found {col}");
|
||||
}
|
||||
|
||||
return new ParsedOhlcv(state.Time, state.Open, state.High, state.Low, state.Close, state.Volume);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single column value into the appropriate OHLCV field.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void ParseColumn(
|
||||
ReadOnlySpan<char> segment,
|
||||
int col,
|
||||
int lineNumber,
|
||||
string line,
|
||||
ref OhlcvParseState state)
|
||||
{
|
||||
switch (col)
|
||||
{
|
||||
case 0: // Timestamp
|
||||
if (!DateTime.TryParseExact(segment, "yyyy-MM-dd", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var timestamp))
|
||||
{
|
||||
throw new FormatException($"Failed to parse timestamp at line {lineNumber}: {line}");
|
||||
}
|
||||
state.Time = timestamp.Ticks;
|
||||
break;
|
||||
case 1: // Open
|
||||
if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.Open))
|
||||
{
|
||||
throw new FormatException($"Failed to parse open price at line {lineNumber}: {line}");
|
||||
}
|
||||
break;
|
||||
case 2: // High
|
||||
if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.High))
|
||||
{
|
||||
throw new FormatException($"Failed to parse high price at line {lineNumber}: {line}");
|
||||
}
|
||||
break;
|
||||
case 3: // Low
|
||||
if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.Low))
|
||||
{
|
||||
throw new FormatException($"Failed to parse low price at line {lineNumber}: {line}");
|
||||
}
|
||||
break;
|
||||
case 4: // Close
|
||||
if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.Close))
|
||||
{
|
||||
throw new FormatException($"Failed to parse close price at line {lineNumber}: {line}");
|
||||
}
|
||||
break;
|
||||
case 5: // Volume
|
||||
if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.Volume))
|
||||
{
|
||||
throw new FormatException($"Failed to parse volume at line {lineNumber}: {line}");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Extra columns are ignored - this handles the default case requirement
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next bar with full bidirectional control.
|
||||
/// When end of data reached, returns last bar and sets isNew=false.
|
||||
@@ -123,11 +270,8 @@ public class CsvFeed : IFeed
|
||||
_currentIndex++;
|
||||
_hasCurrentBar = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update current bar - CSV has no intra-bar updates, return same bar
|
||||
// No change to _currentBar or _currentIndex
|
||||
}
|
||||
// else: Update current bar - CSV has no intra-bar updates, return same bar
|
||||
// No change to _currentBar or _currentIndex
|
||||
|
||||
return _currentBar;
|
||||
}
|
||||
@@ -140,10 +284,16 @@ public class CsvFeed : IFeed
|
||||
{
|
||||
return Next(ref isNew);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a filtered subset of data matching the criteria.
|
||||
/// Resets streaming position to start of returned data.
|
||||
/// </summary>
|
||||
/// <param name="count">Number of bars to retrieve (must be positive)</param>
|
||||
/// <param name="startTime">Starting timestamp in ticks</param>
|
||||
/// <param name="interval">Time interval between bars</param>
|
||||
/// <returns>A TBarSeries containing the matched bars</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when count is not positive</exception>
|
||||
public TBarSeries Fetch(int count, long startTime, TimeSpan interval)
|
||||
{
|
||||
if (count <= 0)
|
||||
@@ -151,16 +301,8 @@ public class CsvFeed : IFeed
|
||||
|
||||
var result = new TBarSeries(count);
|
||||
|
||||
// Find starting index
|
||||
int startIndex = -1;
|
||||
for (int i = 0; i < _data.Count; i++)
|
||||
{
|
||||
if (_data[i].Time >= startTime)
|
||||
{
|
||||
startIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Find starting index using binary search for better performance
|
||||
int startIndex = FindStartIndex(startTime);
|
||||
|
||||
if (startIndex == -1)
|
||||
return result;
|
||||
@@ -168,6 +310,7 @@ public class CsvFeed : IFeed
|
||||
// Collect bars matching interval
|
||||
long expectedTime = startTime;
|
||||
int collected = 0;
|
||||
long tolerance = interval.Ticks / 2; // Allow 50% tolerance
|
||||
|
||||
for (int i = startIndex; i < _data.Count && collected < count; i++)
|
||||
{
|
||||
@@ -175,7 +318,6 @@ public class CsvFeed : IFeed
|
||||
|
||||
// Check if bar time matches expected time (within tolerance)
|
||||
long timeDiff = Math.Abs(bar.Time - expectedTime);
|
||||
long tolerance = interval.Ticks / 2; // Allow 50% tolerance
|
||||
|
||||
if (timeDiff <= tolerance)
|
||||
{
|
||||
@@ -204,4 +346,82 @@ public class CsvFeed : IFeed
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the starting index for the given start time using binary search.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private int FindStartIndex(long startTime)
|
||||
{
|
||||
if (_data.Count == 0)
|
||||
return -1;
|
||||
|
||||
// If startTime is before first bar, return 0
|
||||
if (_data[0].Time >= startTime)
|
||||
return 0;
|
||||
|
||||
// If startTime is after last bar, return -1
|
||||
if (_data[_data.Count - 1].Time < startTime)
|
||||
return -1;
|
||||
|
||||
// Binary search for the first bar >= startTime
|
||||
int left = 0;
|
||||
int right = _data.Count - 1;
|
||||
|
||||
while (left < right)
|
||||
{
|
||||
int mid = left + (right - left) / 2;
|
||||
|
||||
if (_data[mid].Time < startTime)
|
||||
left = mid + 1;
|
||||
else
|
||||
right = mid;
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the streaming position to the beginning.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_currentIndex = 0;
|
||||
_hasCurrentBar = false;
|
||||
_currentBar = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the streaming position to a specific index.
|
||||
/// </summary>
|
||||
/// <param name="index">The index to reset to (must be valid)</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when index is out of range</exception>
|
||||
public void Reset(int index)
|
||||
{
|
||||
if (index < 0 || index > _data.Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(index), index, $"Index must be between 0 and {_data.Count}");
|
||||
|
||||
_currentIndex = index;
|
||||
_hasCurrentBar = false;
|
||||
_currentBar = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bar at the specified index without affecting streaming position.
|
||||
/// </summary>
|
||||
/// <param name="index">The index of the bar to retrieve</param>
|
||||
/// <returns>The bar at the specified index</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when index is out of range</exception>
|
||||
public TBar GetBar(int index)
|
||||
{
|
||||
if (index < 0 || index >= _data.Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(index), index, $"Index must be between 0 and {_data.Count - 1}");
|
||||
|
||||
return _data[index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying data series (read-only access).
|
||||
/// </summary>
|
||||
public TBarSeries Data => _data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user