mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-07-31 19:07:42 +00:00
060649192f
- Remove 'C# Implementation Considerations' sections from 34 indicator .md files - Delete 29 temp PowerShell scripts (_fix_mojibake.ps1, _hex_scan.ps1, etc.) - Move test files into tests/ subdirectories for consistent project structure - Add trader-focused bullet points to indicator documentation
949 lines
28 KiB
C#
949 lines
28 KiB
C#
|
|
#pragma warning disable CS0618 // Tests intentionally use obsolete Next(bool) overload to verify it still works
|
|
|
|
namespace QuanTAlib.Tests;
|
|
|
|
public sealed class CsvFeedTests : IDisposable
|
|
{
|
|
private const string TestCsvPath = "daily_IBM.csv";
|
|
private readonly List<string> _tempFiles = new();
|
|
private bool _disposed;
|
|
|
|
// Static test data arrays to avoid CA1861 (constant arrays as arguments)
|
|
private static readonly string[] HeaderOnlyData = ["timestamp,open,high,low,close,volume"];
|
|
private static readonly string[] MalformedDateData = ["timestamp,open,high,low,close,volume", "not-a-date,100,101,99,100,1000"];
|
|
private static readonly string[] MalformedPriceData = ["timestamp,open,high,low,close,volume", "2023-01-01,not-a-number,101,99,100,1000"];
|
|
private static readonly string[] MissingColumnsData = ["timestamp,open,high,low,close,volume", "2023-01-01,100,101,99,100"];
|
|
private static readonly string[] ExtraColumnsData = ["timestamp,open,high,low,close,volume,extra", "2023-01-01,100,101,99,100,1000,extra_data"];
|
|
private static readonly string[] SingleBarData = ["timestamp,open,high,low,close,volume", "2023-01-01,100,101,99,100,1000"];
|
|
private static readonly string[] DecimalPrecisionData = ["timestamp,open,high,low,close,volume", "2023-01-01,100.1234,101.5678,99.9999,100.0001,1234567.89"];
|
|
private static readonly string[] NegativeValuesData = ["timestamp,open,high,low,close,volume", "2023-01-01,-100,50,-150,-50,1000"];
|
|
private static readonly string[] ScientificNotationData = ["timestamp,open,high,low,close,volume", "2023-01-01,1.5e2,2e2,1e2,1.75e2,1e6"];
|
|
private static readonly string[] GapDataReversed = ["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"];
|
|
private static readonly string[] TwoBarsWithGapData = ["timestamp,open,high,low,close,volume", "2023-01-03,101,102,100,101,1000", "2023-01-01,100,101,99,100,1000"];
|
|
private static readonly string[] WhitespaceData = ["timestamp,open,high,low,close,volume", " 2023-01-01 , 100 , 101 , 99 , 100 , 1000 "];
|
|
private static readonly string[] ZeroValuesData = ["timestamp,open,high,low,close,volume", "2023-01-01,0,0,0,0,0"];
|
|
private static readonly string[] LargeValuesData = ["timestamp,open,high,low,close,volume", "2023-01-01,999999999.99,1000000000.01,999999999.00,999999999.50,9999999999999"];
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
GC.SuppressFinalize(this);
|
|
|
|
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]
|
|
public void Constructor_NonExistentFile_ThrowsFileNotFoundException()
|
|
{
|
|
Assert.Throws<FileNotFoundException>(() => new CsvFeed("nonexistent.csv"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_NullPath_ThrowsArgumentException()
|
|
{
|
|
var ex = Assert.Throws<ArgumentException>(() => new CsvFeed(null!));
|
|
Assert.Equal("filePath", ex.ParamName);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_EmptyPath_ThrowsArgumentException()
|
|
{
|
|
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(HeaderOnlyData);
|
|
Assert.Throws<InvalidDataException>(() => new CsvFeed(tempCsv));
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_MalformedDate_ThrowsFormatException()
|
|
{
|
|
string tempCsv = CreateTempCsv(MalformedDateData);
|
|
Assert.Throws<FormatException>(() => new CsvFeed(tempCsv));
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_MalformedPrice_ThrowsFormatException()
|
|
{
|
|
string tempCsv = CreateTempCsv(MalformedPriceData);
|
|
Assert.Throws<FormatException>(() => new CsvFeed(tempCsv));
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_MissingColumns_ThrowsFormatException()
|
|
{
|
|
string tempCsv = CreateTempCsv(MissingColumnsData);
|
|
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(ExtraColumnsData);
|
|
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(SingleBarData);
|
|
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()
|
|
{
|
|
var feed = new CsvFeed(TestCsvPath);
|
|
|
|
// Get first bar
|
|
var bar1 = feed.Next(isNew: true);
|
|
Assert.True(bar1.Time > 0);
|
|
|
|
// Get second bar - should be later in time
|
|
var bar2 = feed.Next(isNew: true);
|
|
Assert.True(bar2.Time > bar1.Time);
|
|
|
|
// Get third bar
|
|
var bar3 = feed.Next(isNew: true);
|
|
Assert.True(bar3.Time > bar2.Time);
|
|
}
|
|
|
|
[Fact]
|
|
public void Next_WithRefParameter_StreamsCorrectly()
|
|
{
|
|
var feed = new CsvFeed(TestCsvPath);
|
|
|
|
bool isNew = true;
|
|
var bar1 = feed.Next(ref isNew);
|
|
Assert.True(isNew); // Should still be true
|
|
Assert.True(bar1.Time > 0);
|
|
|
|
isNew = true;
|
|
var bar2 = feed.Next(ref isNew);
|
|
Assert.True(isNew);
|
|
Assert.True(bar2.Time > bar1.Time);
|
|
}
|
|
|
|
[Fact]
|
|
public void Next_UpdateCurrentBar_ReturnsSameBar()
|
|
{
|
|
var feed = new CsvFeed(TestCsvPath);
|
|
|
|
// Get first bar
|
|
var bar1 = feed.Next(isNew: true);
|
|
|
|
// Update current bar (should return same bar)
|
|
var bar2 = feed.Next(isNew: false);
|
|
Assert.Equal(bar1.Time, bar2.Time);
|
|
Assert.Equal(bar1.Close, bar2.Close);
|
|
|
|
// Get next bar
|
|
var bar3 = feed.Next(isNew: true);
|
|
Assert.True(bar3.Time > bar1.Time);
|
|
}
|
|
|
|
[Fact]
|
|
public void Next_EndOfData_SignalsNoMoreData()
|
|
{
|
|
var feed = new CsvFeed(TestCsvPath);
|
|
|
|
// Stream through all data
|
|
TBar lastBar = default;
|
|
bool isNew = true;
|
|
int count = 0;
|
|
|
|
while (isNew && count < 200) // Safety limit
|
|
{
|
|
lastBar = feed.Next(ref isNew);
|
|
count++;
|
|
}
|
|
|
|
// Should have reached end and isNew should be false
|
|
Assert.False(isNew);
|
|
Assert.True(lastBar.Time > 0);
|
|
|
|
// Calling again should return same bar with isNew=false
|
|
isNew = true;
|
|
var finalBar = feed.Next(ref isNew);
|
|
Assert.False(isNew);
|
|
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(SingleBarData);
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public void CsvFeed_ObsoleteOverload_SilentlyMissesEndOfStream()
|
|
{
|
|
string tempCsv = CreateTempCsv(SingleBarData);
|
|
var feed = new CsvFeed(tempCsv);
|
|
|
|
// Value overload cannot detect end — silently returns last bar
|
|
feed.Next(isNew: true); // bar 1
|
|
var postEnd = feed.Next(isNew: true); // past end — no exception, no signal
|
|
|
|
// Must use HasMore to detect end when using value overload
|
|
Assert.False(feed.HasMore);
|
|
Assert.Equal(100.0, postEnd.Close); // last bar returned again
|
|
|
|
// Ref overload detects end correctly
|
|
bool isNew = true;
|
|
feed.Next(ref isNew);
|
|
Assert.False(isNew); // ref overload signals end
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Fetch Method Tests
|
|
|
|
[Fact]
|
|
public void Fetch_ReturnsCorrectNumberOfBars()
|
|
{
|
|
var feed = new CsvFeed(TestCsvPath);
|
|
|
|
var startTime = new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
|
var interval = TimeSpan.FromDays(1);
|
|
|
|
var series = feed.Fetch(10, startTime, interval);
|
|
|
|
Assert.True(series.Count > 0);
|
|
Assert.True(series.Count <= 10);
|
|
}
|
|
|
|
[Fact]
|
|
public void Fetch_ZeroCount_ThrowsArgumentException()
|
|
{
|
|
var feed = new CsvFeed(TestCsvPath);
|
|
var startTime = DateTime.UtcNow.Ticks;
|
|
var interval = TimeSpan.FromDays(1);
|
|
|
|
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]
|
|
public void Fetch_ResetsStreamingPosition()
|
|
{
|
|
var feed = new CsvFeed(TestCsvPath);
|
|
|
|
// Stream a few bars
|
|
feed.Next(isNew: true);
|
|
feed.Next(isNew: true);
|
|
feed.Next(isNew: true);
|
|
|
|
// Fetch from start
|
|
var startTime = new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
|
feed.Fetch(5, startTime, TimeSpan.FromDays(1));
|
|
|
|
// Next should now stream from fetched position
|
|
var bar = feed.Next(isNew: true);
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public void CsvFeed_FetchThenNext_StreamsFromFetchStartNotEnd()
|
|
{
|
|
var feed = new CsvFeed(TestCsvPath);
|
|
|
|
var startTime = new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
|
var interval = TimeSpan.FromDays(1);
|
|
var series = feed.Fetch(5, startTime, interval);
|
|
|
|
// After Fetch, streaming replays from the START of the fetched window
|
|
// (not from after the last returned bar)
|
|
for (int i = 0; i < series.Count; i++)
|
|
{
|
|
bool isNew = true;
|
|
var bar = feed.Next(ref isNew);
|
|
Assert.Equal(series[i].Time, bar.Time);
|
|
Assert.True(isNew);
|
|
}
|
|
}
|
|
|
|
#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
|
|
const 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()
|
|
{
|
|
var feed = new CsvFeed(TestCsvPath);
|
|
|
|
// Get first bar (oldest in chronological order)
|
|
var bar = feed.Next(isNew: true);
|
|
|
|
// Verify it has valid OHLCV data
|
|
Assert.True(bar.Open > 0);
|
|
Assert.True(bar.High >= bar.Open);
|
|
Assert.True(bar.High >= bar.Close);
|
|
Assert.True(bar.Low <= bar.Open);
|
|
Assert.True(bar.Low <= bar.Close);
|
|
Assert.True(bar.Close > 0);
|
|
Assert.True(bar.Volume > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadFromCsv_DataInChronologicalOrder()
|
|
{
|
|
var feed = new CsvFeed(TestCsvPath);
|
|
|
|
var bars = new List<TBar>();
|
|
bool isNew = true;
|
|
|
|
// Collect first 10 bars
|
|
for (int i = 0; i < 10 && isNew; i++)
|
|
{
|
|
bars.Add(feed.Next(ref isNew));
|
|
}
|
|
|
|
// Verify chronological order (each bar later than previous)
|
|
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})");
|
|
}
|
|
}
|
|
|
|
[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(DecimalPrecisionData);
|
|
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(NegativeValuesData);
|
|
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(ScientificNotationData);
|
|
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()
|
|
{
|
|
CsvFeed feed = new CsvFeed(TestCsvPath);
|
|
|
|
var bar1 = feed.Next(isNew: true);
|
|
Assert.True(bar1.Time > 0);
|
|
|
|
var bar2 = feed.Next(isNew: true);
|
|
Assert.True(bar2.Time > bar1.Time);
|
|
}
|
|
|
|
[Fact]
|
|
public void CsvFeed_IFeedRefOverload()
|
|
{
|
|
CsvFeed 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()
|
|
{
|
|
CsvFeed 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()
|
|
{
|
|
var feed = new CsvFeed(TestCsvPath);
|
|
|
|
var bar1 = feed.Next(isNew: true);
|
|
var bar1Update = feed.Next(isNew: false);
|
|
Assert.Equal(bar1.Time, bar1Update.Time);
|
|
|
|
var bar2 = feed.Next(isNew: true);
|
|
Assert.True(bar2.Time > bar1.Time);
|
|
|
|
var bar2Update = feed.Next(isNew: false);
|
|
Assert.Equal(bar2.Time, bar2Update.Time);
|
|
|
|
var bar3 = feed.Next(isNew: true);
|
|
Assert.True(bar3.Time > bar2.Time);
|
|
}
|
|
|
|
[Fact]
|
|
public void Fetch_WithEarlyStartTime_ReturnsData()
|
|
{
|
|
var feed = new CsvFeed(TestCsvPath);
|
|
|
|
// Start from very early date (before any data)
|
|
var startTime = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
|
var series = feed.Fetch(5, startTime, TimeSpan.FromDays(1));
|
|
|
|
// Should return data starting from first available bar
|
|
Assert.True(series.Count > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Fetch_WithFutureStartTime_ReturnsEmpty()
|
|
{
|
|
var feed = new CsvFeed(TestCsvPath);
|
|
|
|
// Start from future date (after all data)
|
|
var startTime = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
|
var series = feed.Fetch(5, startTime, TimeSpan.FromDays(1));
|
|
|
|
// Should return empty or minimal data
|
|
Assert.Empty(series);
|
|
}
|
|
|
|
[Fact]
|
|
public void Fetch_HandlesGapsCorrectly()
|
|
{
|
|
// Create CSV with gaps using helper
|
|
string tempCsv = CreateTempCsv(GapDataReversed);
|
|
|
|
var feed = new CsvFeed(tempCsv);
|
|
var startTime = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
|
var interval = TimeSpan.FromDays(1);
|
|
|
|
// 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 (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(SingleBarData);
|
|
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(WhitespaceData);
|
|
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(ZeroValuesData);
|
|
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(LargeValuesData);
|
|
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);
|
|
}
|
|
|
|
// Reset
|
|
feed.Reset();
|
|
|
|
// Second pass
|
|
var secondPass = new List<double>();
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
secondPass.Add(feed.Next(isNew: true).Close);
|
|
}
|
|
|
|
// Should be identical
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
Assert.Equal(firstPass[i], secondPass[i]);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void CsvFeed_Fetch_Tolerance_GapBarSkipped()
|
|
{
|
|
// Verify gap-skip behavior: Jan 2 is absent, Fetch re-aligns to Jan 3
|
|
string tempCsv = CreateTempCsv(TwoBarsWithGapData);
|
|
var feed = new CsvFeed(tempCsv);
|
|
var startTime = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
|
var series = feed.Fetch(5, startTime, TimeSpan.FromDays(1));
|
|
|
|
// Jan 1 and Jan 3 present; Jan 2 absent → Fetch includes both with gap
|
|
Assert.Equal(2, series.Count);
|
|
Assert.Equal(startTime, series[0].Time);
|
|
Assert.Equal(startTime + (2 * TimeSpan.FromDays(1).Ticks), series[1].Time);
|
|
}
|
|
|
|
#endregion
|
|
}
|