mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-21 20:18:05 +00:00
SIMD Refactor: Merge simd-dev into dev (#55)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat> Co-authored-by: Warp <agent@warp.dev>
This commit is contained in:
co-authored by
Claude Opus 4.5
aider
Warp
parent
5bcdf8d614
commit
86fe32a682
@@ -1,76 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
public class GbmFeed : TBarSeries
|
||||
{
|
||||
private readonly double _mu, _sigma;
|
||||
private readonly RandomNumberGenerator _rng;
|
||||
private double _lastClose;
|
||||
|
||||
public GbmFeed(double initialPrice = 100.0, double mu = 0.05, double sigma = 0.2)
|
||||
{
|
||||
_lastClose = initialPrice;
|
||||
_mu = mu;
|
||||
_sigma = sigma;
|
||||
_rng = RandomNumberGenerator.Create();
|
||||
this.Name = $"GBM({_sigma:F2})";
|
||||
}
|
||||
|
||||
public void Add(bool isNew = true) => Add(time: DateTime.Now, isNew: isNew);
|
||||
public void Add(DateTime time, bool isNew = true) => base.Add(Generate(time, isNew));
|
||||
public void Add(int count)
|
||||
{
|
||||
DateTime startTime = DateTime.UtcNow - TimeSpan.FromHours(count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Add(startTime, isNew: true);
|
||||
startTime = startTime.AddHours(1);
|
||||
}
|
||||
}
|
||||
|
||||
public TBar Generate(DateTime time, bool isNew = true)
|
||||
{
|
||||
double dt = 1.0 / 252;
|
||||
double drift = (_mu - (0.5 * _sigma * _sigma)) * dt;
|
||||
double diffusion = _sigma * Math.Sqrt(dt) * GenerateNormalRandom();
|
||||
|
||||
double open = _lastClose;
|
||||
double close = open * Math.Exp(drift + diffusion);
|
||||
|
||||
// Generate intra-bar price movements
|
||||
double maxMove = Math.Abs(close - open) * 1.5; // Allow for some extra movement within the bar
|
||||
double high = Math.Max(open, close) + (maxMove * GenerateRandomDouble());
|
||||
double low = Math.Min(open, close) - (maxMove * GenerateRandomDouble());
|
||||
|
||||
// Ensure high is always greater than or equal to both open and close
|
||||
high = Math.Max(high, Math.Max(open, close));
|
||||
|
||||
// Ensure low is always less than or equal to both open and close
|
||||
low = Math.Min(low, Math.Min(open, close));
|
||||
|
||||
double volume = 1000 + (GenerateRandomDouble() * 1000);
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_lastClose = close;
|
||||
}
|
||||
|
||||
return new TBar(time, open, high, low, close, volume, isNew);
|
||||
}
|
||||
|
||||
private double GenerateNormalRandom()
|
||||
{
|
||||
// Box-Muller transform to generate standard normal random variable
|
||||
double u1 = 1.0 - GenerateRandomDouble(); // Uniform(0,1] random doubles
|
||||
double u2 = 1.0 - GenerateRandomDouble();
|
||||
return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2);
|
||||
}
|
||||
|
||||
private double GenerateRandomDouble()
|
||||
{
|
||||
byte[] bytes = new byte[8];
|
||||
_rng.GetBytes(bytes);
|
||||
return (double)BitConverter.ToUInt64(bytes, 0) / ulong.MaxValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for data feeds that provide TBar (OHLCV) data.
|
||||
/// Implementations include synthetic generators (GBM), API-based feeds (AlphaVantage),
|
||||
/// file readers (CSV), and real-time streams (WebSocket).
|
||||
/// </summary>
|
||||
public interface IFeed
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the next bar from the feed with full bidirectional control.
|
||||
/// </summary>
|
||||
/// <param name="isNew">
|
||||
/// Input: Request for new bar (true) or update current bar (false).
|
||||
/// Output: Actual behavior - may differ if feed cannot honor request (e.g., end of data).
|
||||
/// </param>
|
||||
/// <returns>The bar (new or updated)</returns>
|
||||
TBar Next(ref bool isNew);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next bar from the feed with simple control.
|
||||
/// </summary>
|
||||
/// <param name="isNew">Request for new bar (true) or update current bar (false). Defaults to true.</param>
|
||||
/// <returns>The bar (new or updated)</returns>
|
||||
TBar Next(bool isNew = true);
|
||||
|
||||
/// <summary>
|
||||
/// Gets multiple bars in batch with explicit time parameters.
|
||||
/// </summary>
|
||||
/// <param name="count">Number of bars to retrieve</param>
|
||||
/// <param name="startTime">Starting timestamp for first bar (in ticks)</param>
|
||||
/// <param name="interval">Time interval between bars</param>
|
||||
/// <returns>Series containing the requested bars</returns>
|
||||
TBarSeries Fetch(int count, long startTime, TimeSpan interval);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# IFeed Interface
|
||||
|
||||
`IFeed` defines the standard contract for all data feeds in QuanTAlib, ensuring consistent behavior across different data sources (synthetic, file-based, or live API).
|
||||
|
||||
## Key Concepts
|
||||
|
||||
* **Bidirectional Control**: The `Next(ref bool isNew)` method allows the consumer to request a new bar (`isNew = true`) or an update to the current bar (`isNew = false`).
|
||||
* **Streaming**: Designed for bar-by-bar processing, simulating real-time data flow.
|
||||
* **Batching**: Supports fetching historical data ranges via `Fetch()`.
|
||||
|
||||
## Interface Definition
|
||||
|
||||
```csharp
|
||||
public interface IFeed
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the next bar with full control over new/update state.
|
||||
/// </summary>
|
||||
TBar Next(ref bool isNew);
|
||||
|
||||
/// <summary>
|
||||
/// Convenience overload for simple next-bar requests.
|
||||
/// </summary>
|
||||
TBar Next(bool isNew = true);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a batch of historical bars.
|
||||
/// </summary>
|
||||
TBarSeries Fetch(int count, long startTime, TimeSpan interval);
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
When implementing `IFeed`:
|
||||
|
||||
1. **State Management**: Maintain the current position in the data source.
|
||||
2. **End of Data**: When data is exhausted, `Next` should return the last valid bar and set `isNew` to `false`.
|
||||
3. **Intra-bar Updates**: If the source supports it (e.g., live ticks), `Next(isNew: false)` should return the updated state of the current bar. If not supported (e.g., CSV), it should return the current bar unchanged.
|
||||
4. **Thread Safety**: Implementations are generally not required to be thread-safe unless specified.
|
||||
|
||||
## Implementations
|
||||
|
||||
* **`GBM`**: Geometric Brownian Motion generator (Synthetic).
|
||||
* **`CsvFeed`**: Reads OHLCV data from CSV files (Historical).
|
||||
@@ -0,0 +1,885 @@
|
||||
|
||||
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[] 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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
#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]);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
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>
|
||||
[SkipLocalsInit]
|
||||
public sealed class CsvFeed : IFeed
|
||||
{
|
||||
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 { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file path of the loaded CSV.
|
||||
/// </summary>
|
||||
public string FilePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are more bars to stream.
|
||||
/// </summary>
|
||||
public bool HasMore => _currentIndex < 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))
|
||||
throw new ArgumentException("File path cannot be null or empty", nameof(filePath));
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
throw new FileNotFoundException($"CSV file not found: {filePath}", filePath);
|
||||
|
||||
FilePath = filePath;
|
||||
Data = LoadFromCsv(filePath);
|
||||
Count = Data.Count;
|
||||
_currentIndex = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses CSV file into TBarSeries.
|
||||
/// Expected format: timestamp,open,high,low,close,volume
|
||||
/// Memory-efficient: reads lines into list, reverses in-place (no LINQ allocations).
|
||||
/// </summary>
|
||||
private static TBarSeries LoadFromCsv(string filePath)
|
||||
{
|
||||
var dataLines = new List<string>();
|
||||
using (var reader = new StreamReader(filePath))
|
||||
{
|
||||
var header = reader.ReadLine();
|
||||
if (header is null)
|
||||
throw new InvalidDataException("CSV file is empty");
|
||||
|
||||
while (!reader.EndOfStream)
|
||||
{
|
||||
var line = reader.ReadLine();
|
||||
if (!string.IsNullOrWhiteSpace(line))
|
||||
dataLines.Add(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.Count == 0)
|
||||
throw new InvalidDataException("CSV file contains only header, no data");
|
||||
|
||||
// Reverse in-place to chronological order (oldest first)
|
||||
dataLines.Reverse();
|
||||
|
||||
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];
|
||||
// After Reverse(), index i corresponds to original index (dataLines.Count - 1 - i)
|
||||
int originalIndex = dataLines.Count - 1 - i;
|
||||
int originalLineNumber = originalIndex + 2; // +2 for header and 1-based line numbers
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TBar Next(ref bool isNew)
|
||||
{
|
||||
if (Count == 0)
|
||||
{
|
||||
isNew = false;
|
||||
return default;
|
||||
}
|
||||
|
||||
if (isNew || !_hasCurrentBar)
|
||||
{
|
||||
if (_currentIndex >= Count)
|
||||
{
|
||||
isNew = false;
|
||||
return _currentBar;
|
||||
}
|
||||
|
||||
_currentBar = Data[_currentIndex];
|
||||
_currentIndex++;
|
||||
_hasCurrentBar = true;
|
||||
}
|
||||
|
||||
return _currentBar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next bar with simple control.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TBar Next(bool isNew = true)
|
||||
{
|
||||
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)
|
||||
throw new ArgumentException("Count must be positive", nameof(count));
|
||||
|
||||
var result = new TBarSeries(count);
|
||||
|
||||
// Find starting index using binary search for better performance
|
||||
int startIndex = FindStartIndex(startTime);
|
||||
|
||||
if (startIndex == -1)
|
||||
return result;
|
||||
|
||||
// Collect bars matching interval
|
||||
long expectedTime = startTime;
|
||||
int collected = 0;
|
||||
long tolerance = interval.Ticks / 2;
|
||||
|
||||
for (int i = startIndex; i < Count && collected < count; i++)
|
||||
{
|
||||
var bar = Data[i];
|
||||
|
||||
// Check if bar time matches expected time (within tolerance)
|
||||
long timeDiff = Math.Abs(bar.Time - expectedTime);
|
||||
|
||||
if (timeDiff <= tolerance)
|
||||
{
|
||||
result.Add(bar, isNew: true);
|
||||
collected++;
|
||||
expectedTime += interval.Ticks;
|
||||
}
|
||||
else if (bar.Time > expectedTime)
|
||||
{
|
||||
// Gap in data - skip forward
|
||||
long gaps = (bar.Time - expectedTime) / interval.Ticks;
|
||||
expectedTime += gaps * interval.Ticks;
|
||||
|
||||
if (Math.Abs(bar.Time - expectedTime) <= tolerance)
|
||||
{
|
||||
result.Add(bar, isNew: true);
|
||||
collected++;
|
||||
expectedTime += interval.Ticks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset streaming to start of returned data
|
||||
_currentIndex = startIndex;
|
||||
_hasCurrentBar = false;
|
||||
|
||||
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 (Count == 0)
|
||||
return -1;
|
||||
|
||||
if (Data[0].Time >= startTime)
|
||||
return 0;
|
||||
|
||||
if (Data[Count - 1].Time < startTime)
|
||||
return -1;
|
||||
|
||||
int left = 0;
|
||||
int right = 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 > Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(index), index, $"Index must be between 0 and {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 >= Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(index), index, $"Index must be between 0 and {Count - 1}");
|
||||
|
||||
return Data[index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying data series (read-only access).
|
||||
/// </summary>
|
||||
public TBarSeries Data { get; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# CsvFeed Class
|
||||
|
||||
`CsvFeed` is a file-based feed implementation that loads historical OHLCV data from CSV files. It supports both streaming access (simulating real-time playback) and batch retrieval.
|
||||
|
||||
## Key Features
|
||||
|
||||
* **Historical Data Loading**: Reads standard OHLCV CSV files.
|
||||
* **Chronological Ordering**: Automatically reverses data if needed (assumes newest-first in file, provides oldest-first).
|
||||
* **Streaming Interface**: Implements `IFeed` for consistent usage with other feed types.
|
||||
* **Batch Retrieval**: Supports fetching specific time ranges via `Fetch()`.
|
||||
|
||||
## CSV Format Requirements
|
||||
|
||||
The file must have a header row and follow this column order:
|
||||
`timestamp, open, high, low, close, volume`
|
||||
|
||||
* **Timestamp**: `YYYY-MM-DD` (assumed UTC midnight)
|
||||
* **Prices/Volume**: Numeric values
|
||||
|
||||
Example:
|
||||
|
||||
```csv
|
||||
Date,Open,High,Low,Close,Volume
|
||||
2024-01-01,100.0,105.0,99.0,102.5,10000
|
||||
2024-01-02,102.5,103.0,101.0,101.5,8500
|
||||
```
|
||||
|
||||
## Class Definition
|
||||
|
||||
```csharp
|
||||
public class CsvFeed : IFeed
|
||||
{
|
||||
public CsvFeed(string filePath);
|
||||
public TBar Next(bool isNew = true);
|
||||
public TBarSeries Fetch(int count, long startTime, TimeSpan interval);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Loading Data
|
||||
|
||||
```csharp
|
||||
var feed = new CsvFeed("path/to/data.csv");
|
||||
```
|
||||
|
||||
### 2. Streaming Data (Simulation)
|
||||
|
||||
```csharp
|
||||
// Get first bar
|
||||
var bar = feed.Next(isNew: true);
|
||||
|
||||
// Loop through all data
|
||||
while (true)
|
||||
{
|
||||
// Process bar...
|
||||
Console.WriteLine(bar);
|
||||
|
||||
// Get next bar
|
||||
bool isNew = true;
|
||||
bar = feed.Next(ref isNew);
|
||||
|
||||
// Stop if no more new data
|
||||
if (!isNew) break;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Fetching a Batch
|
||||
|
||||
```csharp
|
||||
long startTime = new DateTime(2024, 1, 1).Ticks;
|
||||
var batch = feed.Fetch(10, startTime, TimeSpan.FromDays(1));
|
||||
@@ -0,0 +1,101 @@
|
||||
timestamp,open,high,low,close,volume
|
||||
2025-11-25,304.1250,306.0000,297.0600,304.4800,2825322
|
||||
2025-11-24,299.1800,307.1800,297.5100,304.1200,6050640
|
||||
2025-11-21,293.4800,300.4800,291.8900,297.4400,5710903
|
||||
2025-11-20,294.6400,300.7100,290.1600,290.4000,5597028
|
||||
2025-11-19,290.5000,291.1099,288.0700,288.5300,3595912
|
||||
2025-11-18,297.0000,297.0000,289.9200,289.9500,4861928
|
||||
2025-11-17,305.5900,306.0000,296.5100,297.1700,3909741
|
||||
2025-11-14,300.0000,307.7200,297.5900,305.6900,3592455
|
||||
2025-11-13,312.2900,314.6000,303.6800,304.8600,5310150
|
||||
2025-11-12,319.8900,324.9000,314.5324,314.9800,6042686
|
||||
2025-11-11,309.0000,317.9100,308.4300,313.7200,4381913
|
||||
2025-11-10,306.8200,309.9400,304.2300,309.1300,2975188
|
||||
2025-11-07,309.6800,310.0000,302.6301,306.3800,5070773
|
||||
2025-11-06,306.7500,315.4400,301.0900,312.4200,6818521
|
||||
2025-11-05,301.3800,307.2000,299.7100,306.7700,4633195
|
||||
2025-11-04,300.0000,303.1700,296.0000,300.8500,5677330
|
||||
2025-11-03,308.0000,312.1411,304.2300,304.7300,4957958
|
||||
2025-10-31,312.0000,313.5000,301.6300,307.4100,7697499
|
||||
2025-10-30,306.6500,313.7500,305.0200,310.0600,4694275
|
||||
2025-10-29,312.7900,314.3300,307.5200,308.2100,4135948
|
||||
2025-10-28,312.6000,319.3500,311.4100,312.5700,6044770
|
||||
2025-10-27,307.8000,313.5000,302.8800,313.0900,9868151
|
||||
2025-10-24,283.7700,310.7500,282.2100,307.4600,16914243
|
||||
2025-10-23,264.9500,285.5791,263.5623,285.0000,16676394
|
||||
2025-10-22,281.9900,289.1700,281.3500,287.5100,10538480
|
||||
2025-10-21,283.3100,285.3100,281.6000,282.0500,4080981
|
||||
2025-10-20,281.2500,285.5000,280.9600,283.6500,3494336
|
||||
2025-10-17,276.1500,283.4000,275.3500,281.2800,5309565
|
||||
2025-10-16,281.1100,282.5600,275.6000,275.9700,2956923
|
||||
2025-10-15,278.3800,285.4500,277.0000,280.7500,3346753
|
||||
2025-10-14,275.5200,277.5300,272.5469,276.1500,3058149
|
||||
2025-10-13,279.7900,282.4399,274.6400,277.2200,4333836
|
||||
2025-10-10,288.9700,290.3850,277.5000,277.8200,4508506
|
||||
2025-10-09,289.8200,290.1300,283.3200,288.2300,4912375
|
||||
2025-10-08,294.1600,294.2000,286.4730,289.4600,5297030
|
||||
2025-10-07,295.5500,301.0425,293.2850,293.8700,7190126
|
||||
2025-10-06,288.6100,291.4500,287.8000,289.4200,2881947
|
||||
2025-10-03,287.5000,293.3200,287.3000,288.3700,4375082
|
||||
2025-10-02,285.7900,288.5400,282.7900,286.7200,3814232
|
||||
2025-10-01,280.2000,286.5900,280.1500,286.4900,4381338
|
||||
2025-09-30,280.8800,286.0250,280.5200,282.1600,5926924
|
||||
2025-09-29,286.0000,286.0000,279.6600,279.8000,6022125
|
||||
2025-09-26,280.5100,288.8500,280.1100,284.3100,9063938
|
||||
2025-09-25,272.9350,284.2300,271.1480,281.4400,11506192
|
||||
2025-09-24,272.6200,273.6499,267.3000,267.5300,3159924
|
||||
2025-09-23,272.7000,273.2962,269.2650,272.2400,5394121
|
||||
2025-09-22,266.6200,272.3100,266.0000,271.3700,5030540
|
||||
2025-09-19,266.0500,267.8700,263.6400,266.4000,9858112
|
||||
2025-09-18,258.8600,265.2300,256.8004,265.0000,4988421
|
||||
2025-09-17,257.4950,260.9644,257.0100,259.0800,3974785
|
||||
2025-09-16,256.2600,258.0000,254.4100,257.5200,2719918
|
||||
2025-09-15,254.0200,259.0500,254.0000,256.2400,4028365
|
||||
2025-09-12,256.9500,257.2500,252.4250,253.4400,3433300
|
||||
2025-09-11,257.5600,258.5450,255.6550,257.0100,3576048
|
||||
2025-09-10,259.6500,260.0800,254.5600,256.8800,5185420
|
||||
2025-09-09,256.1200,260.6600,254.8800,259.1100,4931105
|
||||
2025-09-08,248.6300,257.1500,247.0200,256.0900,6940270
|
||||
2025-09-05,248.2300,249.0300,245.4500,248.5300,3147478
|
||||
2025-09-04,245.4200,249.2800,242.8500,247.1800,4765087
|
||||
2025-09-03,240.0200,244.2500,239.4100,244.1000,3156289
|
||||
2025-09-02,240.9000,241.5500,238.2500,241.5000,3469501
|
||||
2025-08-29,245.2300,245.4599,241.7200,243.4900,2967558
|
||||
2025-08-28,245.4300,245.8800,243.3600,245.7300,2820817
|
||||
2025-08-27,242.8700,245.9600,242.0000,244.8400,3698372
|
||||
2025-08-26,241.0200,244.9800,240.3800,242.6300,5386582
|
||||
2025-08-25,242.5650,242.5650,239.4300,239.4300,3513327
|
||||
2025-08-22,240.7400,243.6800,240.2200,242.0900,3134882
|
||||
2025-08-21,242.2100,242.5000,238.6500,239.4000,2991902
|
||||
2025-08-20,242.1100,242.8800,240.3400,242.5500,3240064
|
||||
2025-08-19,240.0000,242.8300,239.4900,241.2800,3328305
|
||||
2025-08-18,239.5700,241.4200,239.1158,239.4500,3569594
|
||||
2025-08-15,237.6100,240.6200,236.7700,239.7200,4344322
|
||||
2025-08-14,238.2500,239.0000,235.6200,237.1100,4556725
|
||||
2025-08-13,236.2000,240.8411,236.2000,240.0700,5663562
|
||||
2025-08-12,236.5300,237.9600,233.3600,234.7700,8800597
|
||||
2025-08-11,242.2400,243.1500,234.7000,236.3000,9381960
|
||||
2025-08-08,248.8800,249.4800,241.6500,242.2700,6828390
|
||||
2025-08-07,252.8100,255.0000,248.8750,250.1600,6251285
|
||||
2025-08-06,251.5300,254.3200,249.2800,252.2800,3692105
|
||||
2025-08-05,252.0000,252.8000,248.9950,250.6700,5823016
|
||||
2025-08-04,251.0500,252.0800,248.1100,251.9800,5280588
|
||||
2025-08-01,251.4050,251.4791,245.6100,250.0500,9683404
|
||||
2025-07-31,259.5700,259.9900,252.2200,253.1500,6739092
|
||||
2025-07-30,261.6000,262.0000,258.9000,260.2600,3718290
|
||||
2025-07-29,264.3000,265.7999,261.0200,262.4100,4627265
|
||||
2025-07-28,260.3000,264.0000,259.6100,263.2100,5192516
|
||||
2025-07-25,260.0200,260.8000,256.3500,259.7200,7758653
|
||||
2025-07-24,261.2500,262.0486,252.7500,260.5100,22647720
|
||||
2025-07-23,284.3000,288.0800,281.4400,282.0100,8105906
|
||||
2025-07-22,284.7400,284.8800,281.2500,281.9600,4824219
|
||||
2025-07-21,286.2900,287.7300,284.3800,284.7100,3051791
|
||||
2025-07-18,283.3800,287.1600,282.2200,285.8700,4478165
|
||||
2025-07-17,281.5000,283.4566,280.9000,282.0000,3337168
|
||||
2025-07-16,282.7500,283.8700,279.8700,281.9200,2804831
|
||||
2025-07-15,283.7700,284.1550,280.7301,282.7000,2864106
|
||||
2025-07-14,282.8300,284.9250,281.7100,283.7900,2857401
|
||||
2025-07-11,285.0100,287.4300,282.9200,283.5900,3790679
|
||||
2025-07-10,288.9000,288.9000,282.2100,287.4300,3489068
|
||||
2025-07-09,291.3900,291.6000,288.6300,290.1400,2971309
|
||||
2025-07-08,293.1000,295.6100,289.4900,290.4200,2925329
|
||||
|
@@ -0,0 +1,71 @@
|
||||
# GBM Class
|
||||
|
||||
`GBM` (Geometric Brownian Motion) is a synthetic data generator that simulates realistic financial price movements. It is useful for testing indicators, strategies, and system performance without relying on external data files.
|
||||
|
||||
## Key Features
|
||||
|
||||
* **Geometric Brownian Motion**: Uses the standard mathematical model for asset price dynamics.
|
||||
* **Configurable Parameters**: Control drift (trend) and volatility (noise).
|
||||
* **Stateless Design**: Minimal memory footprint; only maintains state needed for continuity.
|
||||
* **Dual Modes**: Supports both streaming (bar-by-bar) and batch generation.
|
||||
* **Intra-bar Updates**: Can simulate real-time price updates within a single bar.
|
||||
|
||||
## Mathematical Model
|
||||
|
||||
The price evolution follows the stochastic differential equation:
|
||||
|
||||
$$ dS_t = \mu S_t dt + \sigma S_t dW_t $$
|
||||
|
||||
Where:
|
||||
|
||||
* $S_t$: Asset price at time $t$
|
||||
* $\mu$: Drift (expected return)
|
||||
* $\sigma$: Volatility (standard deviation of returns)
|
||||
* $W_t$: Wiener process (Brownian motion)
|
||||
|
||||
## Class Definition
|
||||
|
||||
```csharp
|
||||
public class GBM : IFeed
|
||||
{
|
||||
public GBM(double startPrice = 100.0, double mu = 0.05, double sigma = 0.2, TimeSpan? defaultTimeframe = null);
|
||||
|
||||
public TBar Next(bool isNew = true);
|
||||
public TBarSeries Fetch(int count, long startTime, TimeSpan interval);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Initialization
|
||||
|
||||
```csharp
|
||||
// Default: Start at 100, 5% drift, 20% volatility
|
||||
var gbm = new GBM();
|
||||
|
||||
// Custom: Start at 50, 10% drift, 50% volatility
|
||||
var volatileGbm = new GBM(startPrice: 50.0, mu: 0.10, sigma: 0.50);
|
||||
```
|
||||
|
||||
### 2. Streaming Generation
|
||||
|
||||
```csharp
|
||||
// Generate a new bar
|
||||
var bar = gbm.Next(isNew: true);
|
||||
|
||||
// Simulate intra-bar updates (e.g., real-time ticks)
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var updatedBar = gbm.Next(isNew: false);
|
||||
Console.WriteLine($"Update: {updatedBar.Close}");
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Batch Generation
|
||||
|
||||
```csharp
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
// Generate 1000 bars
|
||||
var history = gbm.Fetch(1000, startTime, interval);
|
||||
@@ -0,0 +1,703 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class GBMTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultParameters_CreatesValidInstance()
|
||||
{
|
||||
var gbm = new GBM();
|
||||
|
||||
Assert.Equal(100.0, gbm.StartPrice);
|
||||
Assert.Equal(0.05, gbm.Mu);
|
||||
Assert.Equal(0.2, gbm.Sigma);
|
||||
Assert.Equal(100.0, gbm.CurrentPrice);
|
||||
Assert.False(gbm.HasCurrentBar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CustomParameters_SetsCorrectly()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 50.0, mu: 0.1, sigma: 0.3, seed: 42);
|
||||
|
||||
Assert.Equal(50.0, gbm.StartPrice);
|
||||
Assert.Equal(0.1, gbm.Mu);
|
||||
Assert.Equal(0.3, gbm.Sigma);
|
||||
Assert.Equal(50.0, gbm.CurrentPrice);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(-100)]
|
||||
public void Constructor_InvalidStartPrice_ThrowsArgumentOutOfRangeException(double startPrice)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(startPrice: startPrice));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NaNStartPrice_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(startPrice: double.NaN));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InfinityStartPrice_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(startPrice: double.PositiveInfinity));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(startPrice: double.NegativeInfinity));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-0.01)]
|
||||
[InlineData(-1)]
|
||||
public void Constructor_NegativeSigma_ThrowsArgumentOutOfRangeException(double sigma)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(sigma: sigma));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NaNSigma_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(sigma: double.NaN));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InfinitySigma_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(sigma: double.PositiveInfinity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NaNMu_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(mu: double.NaN));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InfinityMu_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(mu: double.PositiveInfinity));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(mu: double.NegativeInfinity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroTimeframe_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(defaultTimeframe: TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeTimeframe_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(defaultTimeframe: TimeSpan.FromMinutes(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroSigma_IsValid()
|
||||
{
|
||||
var gbm = new GBM(sigma: 0);
|
||||
Assert.Equal(0, gbm.Sigma);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeMu_IsValid()
|
||||
{
|
||||
var gbm = new GBM(mu: -0.1);
|
||||
Assert.Equal(-0.1, gbm.Mu);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Next Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Next_DefaultParameter_GeneratesNewBar()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var bar1 = gbm.Next();
|
||||
var bar2 = gbm.Next();
|
||||
|
||||
Assert.NotEqual(bar1.Time, bar2.Time);
|
||||
Assert.True(bar2.Time > bar1.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_IsNewTrue_AdvancesToNewBar()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var bar1 = gbm.Next(isNew: true);
|
||||
var bar2 = gbm.Next(isNew: true);
|
||||
|
||||
Assert.NotEqual(bar1.Time, bar2.Time);
|
||||
Assert.True(bar2.Time > bar1.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_IsNewFalse_UpdatesCurrentBar()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var bar1 = gbm.Next(isNew: true);
|
||||
long initialTime = bar1.Time;
|
||||
|
||||
var bar2 = gbm.Next(isNew: false);
|
||||
|
||||
Assert.Equal(initialTime, bar2.Time);
|
||||
Assert.Equal(bar1.Open, bar2.Open);
|
||||
// High/Low/Close/Volume may change
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_RefBool_HonorsRequest()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
bool isNew1 = true;
|
||||
var bar1 = gbm.Next(ref isNew1);
|
||||
Assert.True(isNew1, "GBM should honor isNew=true request");
|
||||
|
||||
bool isNew2 = false;
|
||||
long time1 = bar1.Time;
|
||||
var bar2 = gbm.Next(ref isNew2);
|
||||
Assert.False(isNew2, "GBM should honor isNew=false request");
|
||||
Assert.Equal(time1, bar2.Time);
|
||||
|
||||
bool isNew3 = true;
|
||||
var bar3 = gbm.Next(ref isNew3);
|
||||
Assert.True(isNew3, "GBM should honor isNew=true request");
|
||||
Assert.NotEqual(time1, bar3.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_FirstCallWithIsNewFalse_GeneratesBar()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
// First call with isNew=false should still generate a bar
|
||||
var bar = gbm.Next(isNew: false);
|
||||
|
||||
Assert.True(bar.Time > 0);
|
||||
Assert.True(bar.Open > 0);
|
||||
Assert.True(gbm.HasCurrentBar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_MultipleUpdates_AccumulatesVolume()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var bar1 = gbm.Next(isNew: true);
|
||||
double initialVolume = bar1.Volume;
|
||||
|
||||
var bar2 = gbm.Next(isNew: false);
|
||||
|
||||
Assert.True(bar2.Volume > initialVolume, "Volume should accumulate on intra-bar updates");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_IntraBarUpdates_ExpandsHighLow()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, sigma: 0.5, seed: 42);
|
||||
|
||||
var bar1 = gbm.Next(isNew: true);
|
||||
double initialHigh = bar1.High;
|
||||
double initialLow = bar1.Low;
|
||||
|
||||
// Multiple updates should potentially expand the range
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: false);
|
||||
Assert.True(bar.High >= initialHigh || bar.Low <= initialLow || i > 50,
|
||||
"High-Low range should expand or stay same with updates");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fetch Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Fetch_GeneratesCorrectCount()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
const int count = 10;
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
var series = gbm.Fetch(count, startTime, interval);
|
||||
|
||||
Assert.Equal(count, series.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_GeneratesSequentialBars()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
var series = gbm.Fetch(5, startTime, interval);
|
||||
|
||||
for (int i = 1; i < series.Count; i++)
|
||||
{
|
||||
Assert.True(series[i].Time > series[i - 1].Time);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_RespectsInterval()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
var interval = TimeSpan.FromHours(1);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
|
||||
var series = gbm.Fetch(5, startTime, interval);
|
||||
|
||||
for (int i = 1; i < series.Count; i++)
|
||||
{
|
||||
long expectedDiff = interval.Ticks;
|
||||
long actualDiff = series[i].Time - series[i - 1].Time;
|
||||
Assert.Equal(expectedDiff, actualDiff);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_StartsAtSpecifiedTime()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
var startTime = new DateTime(2024, 1, 1, 9, 30, 0, DateTimeKind.Utc).Ticks;
|
||||
var interval = TimeSpan.FromMinutes(5);
|
||||
|
||||
var series = gbm.Fetch(3, startTime, interval);
|
||||
|
||||
Assert.Equal(startTime, series[0].Time);
|
||||
Assert.Equal(startTime + interval.Ticks, series[1].Time);
|
||||
Assert.Equal(startTime + 2 * interval.Ticks, series[2].Time);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(-100)]
|
||||
public void Fetch_InvalidCount_ThrowsArgumentException(int count)
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => gbm.Fetch(count, startTime, interval));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_ZeroInterval_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => gbm.Fetch(10, startTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_NegativeInterval_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => gbm.Fetch(10, startTime, TimeSpan.FromMinutes(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_WithDifferentIntervals_WorksCorrectly()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
|
||||
var intervals = new[] {
|
||||
TimeSpan.FromMinutes(1),
|
||||
TimeSpan.FromMinutes(5),
|
||||
TimeSpan.FromHours(1)
|
||||
};
|
||||
|
||||
foreach (var interval in intervals)
|
||||
{
|
||||
var series = gbm.Fetch(3, startTime, interval);
|
||||
|
||||
for (int i = 1; i < series.Count; i++)
|
||||
{
|
||||
long expectedDiff = interval.Ticks;
|
||||
long actualDiff = series[i].Time - series[i - 1].Time;
|
||||
Assert.Equal(expectedDiff, actualDiff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_LargeCount_WorksCorrectly()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
var series = gbm.Fetch(10000, startTime, interval);
|
||||
|
||||
Assert.Equal(10000, series.Count);
|
||||
Assert.All(Enumerable.Range(0, series.Count), i =>
|
||||
{
|
||||
Assert.True(series[i].Open > 0);
|
||||
Assert.True(series[i].High > 0);
|
||||
Assert.True(series[i].Low > 0);
|
||||
Assert.True(series[i].Close > 0);
|
||||
Assert.True(series[i].Volume > 0);
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region OHLCV Validity Tests
|
||||
|
||||
[Fact]
|
||||
public void GeneratesRealisticOHLCV()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
var series = gbm.Fetch(100, startTime, interval);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
var bar = series[i];
|
||||
|
||||
// High should be >= max(Open, Close)
|
||||
Assert.True(bar.High >= Math.Max(bar.Open, bar.Close),
|
||||
$"Bar {i}: High ({bar.High}) should be >= max(Open, Close) ({Math.Max(bar.Open, bar.Close)})");
|
||||
|
||||
// Low should be <= min(Open, Close)
|
||||
Assert.True(bar.Low <= Math.Min(bar.Open, bar.Close),
|
||||
$"Bar {i}: Low ({bar.Low}) should be <= min(Open, Close) ({Math.Min(bar.Open, bar.Close)})");
|
||||
|
||||
// High should be >= Low
|
||||
Assert.True(bar.High >= bar.Low,
|
||||
$"Bar {i}: High ({bar.High}) should be >= Low ({bar.Low})");
|
||||
|
||||
// Volume should be positive
|
||||
Assert.True(bar.Volume > 0, $"Bar {i}: Volume should be positive");
|
||||
|
||||
// All prices should be positive and finite
|
||||
Assert.True(double.IsFinite(bar.Open) && bar.Open > 0, $"Bar {i}: Open should be positive and finite");
|
||||
Assert.True(double.IsFinite(bar.High) && bar.High > 0, $"Bar {i}: High should be positive and finite");
|
||||
Assert.True(double.IsFinite(bar.Low) && bar.Low > 0, $"Bar {i}: Low should be positive and finite");
|
||||
Assert.True(double.IsFinite(bar.Close) && bar.Close > 0, $"Bar {i}: Close should be positive and finite");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsecutiveCalls_MaintainContinuity()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var previousBar = gbm.Next();
|
||||
var currentBar = gbm.Next();
|
||||
|
||||
// currentBar.Open should equal previousBar.Close (continuity)
|
||||
Assert.Equal(previousBar.Close, currentBar.Open);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_MaintainsContinuity()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
var series = gbm.Fetch(10, startTime, interval);
|
||||
|
||||
for (int i = 1; i < series.Count; i++)
|
||||
{
|
||||
Assert.True(Math.Abs(series[i - 1].Close - series[i].Open) < 1e-10,
|
||||
$"Bar {i}: Open should equal previous bar's Close for continuity");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset Tests
|
||||
|
||||
[Fact]
|
||||
public void Reset_RestoresInitialState()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
// Generate some bars
|
||||
gbm.Next();
|
||||
gbm.Next();
|
||||
gbm.Next();
|
||||
|
||||
Assert.NotEqual(100.0, gbm.CurrentPrice);
|
||||
Assert.True(gbm.HasCurrentBar);
|
||||
|
||||
// Reset
|
||||
gbm.Reset();
|
||||
|
||||
Assert.Equal(100.0, gbm.CurrentPrice);
|
||||
Assert.False(gbm.HasCurrentBar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_WithStartTime_SetsSpecificTime()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
long specificTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
||||
|
||||
gbm.Next();
|
||||
gbm.Reset(specificTime);
|
||||
|
||||
var bar = gbm.Next();
|
||||
|
||||
// The bar time should be based on the reset time
|
||||
Assert.True(bar.Time > specificTime);
|
||||
Assert.Equal(100.0, bar.Open); // Should start from initial price
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Seeded Reproducibility Tests
|
||||
|
||||
[Fact]
|
||||
public void SeededGenerator_ProducesReproducibleResults()
|
||||
{
|
||||
var gbm1 = new GBM(startPrice: 100.0, seed: 42);
|
||||
var gbm2 = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
var series1 = gbm1.Fetch(10, startTime, interval);
|
||||
var series2 = gbm2.Fetch(10, startTime, interval);
|
||||
|
||||
for (int i = 0; i < series1.Count; i++)
|
||||
{
|
||||
Assert.Equal(series1[i].Open, series2[i].Open);
|
||||
Assert.Equal(series1[i].High, series2[i].High);
|
||||
Assert.Equal(series1[i].Low, series2[i].Low);
|
||||
Assert.Equal(series1[i].Close, series2[i].Close);
|
||||
Assert.Equal(series1[i].Volume, series2[i].Volume);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentSeeds_ProduceDifferentResults()
|
||||
{
|
||||
var gbm1 = new GBM(startPrice: 100.0, seed: 42);
|
||||
var gbm2 = new GBM(startPrice: 100.0, seed: 123);
|
||||
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
var series1 = gbm1.Fetch(10, startTime, interval);
|
||||
var series2 = gbm2.Fetch(10, startTime, interval);
|
||||
|
||||
bool anyDifferent = false;
|
||||
for (int i = 0; i < series1.Count; i++)
|
||||
{
|
||||
if (Math.Abs(series1[i].Close - series2[i].Close) > 1e-14)
|
||||
{
|
||||
anyDifferent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(anyDifferent, "Different seeds should produce different results");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnseededGenerator_ProducesVariableResults()
|
||||
{
|
||||
var gbm1 = new GBM(startPrice: 100.0);
|
||||
var gbm2 = new GBM(startPrice: 100.0);
|
||||
|
||||
// Note: This test may occasionally fail due to randomness, but is extremely unlikely
|
||||
var bar1 = gbm1.Next();
|
||||
var bar2 = gbm2.Next();
|
||||
|
||||
// At least one value should be different (use tolerance for floating-point comparison)
|
||||
const double tolerance = 1e-14;
|
||||
bool anyDifferent = Math.Abs(bar1.Close - bar2.Close) > tolerance ||
|
||||
Math.Abs(bar1.High - bar2.High) > tolerance ||
|
||||
Math.Abs(bar1.Low - bar2.Low) > tolerance ||
|
||||
Math.Abs(bar1.Volume - bar2.Volume) > tolerance;
|
||||
|
||||
Assert.True(anyDifferent, "Unseeded generators should produce different results");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Drift and Volatility Tests
|
||||
|
||||
[Fact]
|
||||
public void DriftAndVolatility_AffectPriceMovement()
|
||||
{
|
||||
var gbmLowVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.01, seed: 42);
|
||||
var gbmHighVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.5, seed: 42);
|
||||
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
var seriesLow = gbmLowVol.Fetch(100, startTime, interval);
|
||||
var seriesHigh = gbmHighVol.Fetch(100, startTime, interval);
|
||||
|
||||
// Calculate standard deviation of returns
|
||||
double[] returnsLow = new double[99];
|
||||
double[] returnsHigh = new double[99];
|
||||
|
||||
for (int i = 1; i < 100; i++)
|
||||
{
|
||||
returnsLow[i - 1] = Math.Log(seriesLow[i].Close / seriesLow[i - 1].Close);
|
||||
returnsHigh[i - 1] = Math.Log(seriesHigh[i].Close / seriesHigh[i - 1].Close);
|
||||
}
|
||||
|
||||
double stdLow = CalculateStdDev(returnsLow);
|
||||
double stdHigh = CalculateStdDev(returnsHigh);
|
||||
|
||||
Assert.True(stdHigh > stdLow, "High volatility should produce larger return dispersion");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroVolatility_ProducesConstantPrices()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.0, seed: 42);
|
||||
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
var series = gbm.Fetch(10, startTime, interval);
|
||||
|
||||
// With zero volatility and zero drift, price should stay constant
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
Assert.Equal(100.0, series[i].Close, 10);
|
||||
}
|
||||
}
|
||||
|
||||
private static double CalculateStdDev(double[] values)
|
||||
{
|
||||
double mean = 0;
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
mean += values[i];
|
||||
mean /= values.Length;
|
||||
|
||||
double sumSquares = 0;
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
sumSquares += (values[i] - mean) * (values[i] - mean);
|
||||
|
||||
return Math.Sqrt(sumSquares / values.Length);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region State Management Tests
|
||||
|
||||
[Fact]
|
||||
public void IntraBarUpdates_ModifyCurrentBar()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var bar1 = gbm.Next(isNew: true);
|
||||
long initialTime = bar1.Time;
|
||||
double initialClose = bar1.Close;
|
||||
|
||||
bool changed = false;
|
||||
const double tolerance = 1e-14;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: false);
|
||||
Assert.Equal(initialTime, bar.Time);
|
||||
if (Math.Abs(bar.Close - initialClose) > tolerance)
|
||||
{
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(changed, "Price should change during intra-bar updates");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixedStreamingAndBatch_WorksCorrectly()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
_ = gbm.Next();
|
||||
var bar2 = gbm.Next();
|
||||
|
||||
long startTime = bar2.Time + TimeSpan.FromMinutes(1).Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
var series = gbm.Fetch(3, startTime, interval);
|
||||
|
||||
Assert.True(series[0].Time > bar2.Time);
|
||||
Assert.Equal(3, series.Count);
|
||||
|
||||
var bar3 = gbm.Next();
|
||||
Assert.True(bar3.Time > series[2].Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_ResetsStreamingState()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
// Create a bar with intra-bar updates
|
||||
gbm.Next(isNew: true);
|
||||
gbm.Next(isNew: false);
|
||||
Assert.True(gbm.HasCurrentBar);
|
||||
|
||||
// Fetch should reset streaming state
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
gbm.Fetch(5, startTime, TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.False(gbm.HasCurrentBar);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IFeed Interface Tests
|
||||
|
||||
[Fact]
|
||||
public void ImplementsIFeed()
|
||||
{
|
||||
GBM feed = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var bar1 = feed.Next(isNew: true);
|
||||
Assert.True(bar1.Time > 0);
|
||||
|
||||
var bar2 = feed.Next(isNew: true);
|
||||
Assert.True(bar2.Time > bar1.Time);
|
||||
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var series = feed.Fetch(5, startTime, TimeSpan.FromMinutes(1));
|
||||
Assert.Equal(5, series.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Statelessness Tests
|
||||
|
||||
[Fact]
|
||||
public void Stateless_NoHistoryStorage()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
_ = gbm.Next();
|
||||
}
|
||||
|
||||
var type = typeof(GBM);
|
||||
var barsProperty = type.GetProperty("Bars");
|
||||
|
||||
Assert.Null(barsProperty);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Provides validation utilities for comparing indicator results against external libraries.
|
||||
/// Contains tolerance constants and verification methods for cross-library validation.
|
||||
/// </summary>
|
||||
public static class ValidationHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Default tolerance for floating-point comparisons (1e-7).
|
||||
/// Suitable for most indicator comparisons.
|
||||
/// </summary>
|
||||
public const double DefaultTolerance = 1e-7;
|
||||
|
||||
/// <summary>
|
||||
/// Tolerance for Ooples Finance library comparisons (1e-7).
|
||||
/// May need adjustment for specific indicators with different internal precision.
|
||||
/// </summary>
|
||||
public const double OoplesTolerance = 1e-7;
|
||||
|
||||
/// <summary>
|
||||
/// Tolerance for Skender.Stock.Indicators library comparisons (1e-7).
|
||||
/// Skender uses decimal internally, so some precision loss is expected.
|
||||
/// </summary>
|
||||
public const double SkenderTolerance = 1e-7;
|
||||
|
||||
/// <summary>
|
||||
/// Tolerance for TA-Lib (TALib.NETCore) library comparisons (1e-7).
|
||||
/// TA-Lib uses double precision throughout.
|
||||
/// </summary>
|
||||
public const double TalibTolerance = 1e-7;
|
||||
|
||||
/// <summary>
|
||||
/// Tolerance for Tulip library comparisons (1e-7).
|
||||
/// Note: Tulip may have 1-bar shifts due to different initialization strategies.
|
||||
/// </summary>
|
||||
public const double TulipTolerance = 1e-7;
|
||||
|
||||
/// <summary>
|
||||
/// Relative tolerance for percentage-based comparisons (0.5%).
|
||||
/// Use when absolute tolerance is not appropriate.
|
||||
/// </summary>
|
||||
public const double RelativeTolerance = 0.005;
|
||||
|
||||
/// <summary>
|
||||
/// Default number of bars to verify from the end of the series.
|
||||
/// Using 100 bars ensures we're comparing converged values.
|
||||
/// </summary>
|
||||
public const int DefaultVerificationCount = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies TSeries results against an external library's results.
|
||||
/// Compares the last 'skip' values by default.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type of results from the external library</typeparam>
|
||||
/// <param name="qSeries">QuanTAlib TSeries results</param>
|
||||
/// <param name="sSeries">External library results</param>
|
||||
/// <param name="selector">Function to extract the comparable value from external results</param>
|
||||
/// <param name="skip">Number of values to verify from the end (default: 100)</param>
|
||||
/// <param name="tolerance">Tolerance for floating-point comparison</param>
|
||||
public static void VerifyData<TResult>(
|
||||
TSeries qSeries,
|
||||
IReadOnlyList<TResult> sSeries,
|
||||
Func<TResult, double?> selector,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
Assert.Equal(qSeries.Count, sSeries.Count);
|
||||
|
||||
int count = qSeries.Count;
|
||||
int start = Math.Max(0, count - skip);
|
||||
|
||||
for (int i = start; i < count; i++)
|
||||
{
|
||||
double qValue = qSeries[i].Value;
|
||||
double? sValue = selector(sSeries[i]);
|
||||
|
||||
if (!sValue.HasValue) continue;
|
||||
|
||||
Assert.True(
|
||||
Math.Abs(qValue - sValue.Value) <= tolerance,
|
||||
$"Mismatch at index {i}: QuanTAlib={qValue:G17}, External={sValue.Value:G17}, Diff={Math.Abs(qValue - sValue.Value):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies IReadOnlyList results against an external library's results.
|
||||
/// </summary>
|
||||
public static void VerifyData<TResult>(
|
||||
IReadOnlyList<double> qResults,
|
||||
IReadOnlyList<TResult> sSeries,
|
||||
Func<TResult, double?> selector,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
Assert.Equal(qResults.Count, sSeries.Count);
|
||||
|
||||
int count = qResults.Count;
|
||||
int start = Math.Max(0, count - skip);
|
||||
|
||||
for (int i = start; i < count; i++)
|
||||
{
|
||||
double qValue = qResults[i];
|
||||
double? sValue = selector(sSeries[i]);
|
||||
|
||||
if (!sValue.HasValue) continue;
|
||||
|
||||
Assert.True(
|
||||
Math.Abs(qValue - sValue.Value) <= tolerance,
|
||||
$"Mismatch at index {i}: QuanTAlib={qValue:G17}, External={sValue.Value:G17}, Diff={Math.Abs(qValue - sValue.Value):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies double array results against an external library's results.
|
||||
/// </summary>
|
||||
public static void VerifyData<TResult>(
|
||||
double[] qOutput,
|
||||
IReadOnlyList<TResult> sSeries,
|
||||
Func<TResult, double?> selector,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
Assert.Equal(qOutput.Length, sSeries.Count);
|
||||
|
||||
int count = qOutput.Length;
|
||||
int start = Math.Max(0, count - skip);
|
||||
|
||||
for (int i = start; i < count; i++)
|
||||
{
|
||||
double qValue = qOutput[i];
|
||||
double? sValue = selector(sSeries[i]);
|
||||
|
||||
if (!sValue.HasValue) continue;
|
||||
|
||||
Assert.True(
|
||||
Math.Abs(qValue - sValue.Value) <= tolerance,
|
||||
$"Mismatch at index {i}: QuanTAlib={qValue:G17}, External={sValue.Value:G17}, Diff={Math.Abs(qValue - sValue.Value):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies TSeries results against TA-Lib style output with lookback offset.
|
||||
/// </summary>
|
||||
/// <param name="qSeries">QuanTAlib TSeries results</param>
|
||||
/// <param name="tOutput">TA-Lib output array</param>
|
||||
/// <param name="lookback">TA-Lib lookback period (output is shifted by this amount)</param>
|
||||
/// <param name="skip">Number of values to verify from the end</param>
|
||||
/// <param name="tolerance">Tolerance for floating-point comparison</param>
|
||||
public static void VerifyData(
|
||||
TSeries qSeries,
|
||||
double[] tOutput,
|
||||
int lookback,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
int count = qSeries.Count;
|
||||
int start = Math.Max(0, count - skip);
|
||||
|
||||
for (int i = start; i < count; i++)
|
||||
{
|
||||
double qValue = qSeries[i].Value;
|
||||
|
||||
if (i < lookback) continue;
|
||||
|
||||
int tIndex = i - lookback;
|
||||
if (tIndex >= tOutput.Length) continue;
|
||||
|
||||
double tValue = tOutput[tIndex];
|
||||
|
||||
Assert.True(
|
||||
Math.Abs(qValue - tValue) <= tolerance,
|
||||
$"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies IReadOnlyList results against TA-Lib style output with lookback offset.
|
||||
/// </summary>
|
||||
public static void VerifyData(
|
||||
IReadOnlyList<double> qResults,
|
||||
double[] tOutput,
|
||||
int lookback,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
int count = qResults.Count;
|
||||
int start = Math.Max(0, count - skip);
|
||||
|
||||
for (int i = start; i < count; i++)
|
||||
{
|
||||
double qValue = qResults[i];
|
||||
|
||||
if (i < lookback) continue;
|
||||
|
||||
int tIndex = i - lookback;
|
||||
if (tIndex >= tOutput.Length) continue;
|
||||
|
||||
double tValue = tOutput[tIndex];
|
||||
|
||||
Assert.True(
|
||||
Math.Abs(qValue - tValue) <= tolerance,
|
||||
$"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies double array results against TA-Lib style output with lookback offset.
|
||||
/// </summary>
|
||||
public static void VerifyData(
|
||||
double[] qOutput,
|
||||
double[] tOutput,
|
||||
int lookback,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
int count = qOutput.Length;
|
||||
int start = Math.Max(0, count - skip);
|
||||
|
||||
for (int i = start; i < count; i++)
|
||||
{
|
||||
double qValue = qOutput[i];
|
||||
|
||||
if (i < lookback) continue;
|
||||
|
||||
int tIndex = i - lookback;
|
||||
if (tIndex >= tOutput.Length) continue;
|
||||
|
||||
double tValue = tOutput[tIndex];
|
||||
|
||||
Assert.True(
|
||||
Math.Abs(qValue - tValue) <= tolerance,
|
||||
$"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies TSeries results against TA-Lib style output with range and lookback.
|
||||
/// </summary>
|
||||
public static void VerifyData(
|
||||
TSeries qSeries,
|
||||
double[] tOutput,
|
||||
Range outRange,
|
||||
int lookback,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
int count = qSeries.Count;
|
||||
int start = Math.Max(0, count - skip);
|
||||
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
|
||||
|
||||
for (int i = start; i < count; i++)
|
||||
{
|
||||
double qValue = qSeries[i].Value;
|
||||
|
||||
if (i < lookback) continue;
|
||||
|
||||
int tIndex = i - offset;
|
||||
if (tIndex < 0 || tIndex >= length) continue;
|
||||
|
||||
double tValue = tOutput[tIndex];
|
||||
|
||||
Assert.True(
|
||||
Math.Abs(qValue - tValue) <= tolerance,
|
||||
$"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies IReadOnlyList results against TA-Lib style output with range and lookback.
|
||||
/// </summary>
|
||||
public static void VerifyData(
|
||||
IReadOnlyList<double> qResults,
|
||||
double[] tOutput,
|
||||
Range outRange,
|
||||
int lookback,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
int count = qResults.Count;
|
||||
int start = Math.Max(0, count - skip);
|
||||
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
|
||||
|
||||
for (int i = start; i < count; i++)
|
||||
{
|
||||
double qValue = qResults[i];
|
||||
|
||||
if (i < lookback) continue;
|
||||
|
||||
int tIndex = i - offset;
|
||||
if (tIndex < 0 || tIndex >= length) continue;
|
||||
|
||||
double tValue = tOutput[tIndex];
|
||||
|
||||
Assert.True(
|
||||
Math.Abs(qValue - tValue) <= tolerance,
|
||||
$"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies double array results against TA-Lib style output with range and lookback.
|
||||
/// </summary>
|
||||
public static void VerifyData(
|
||||
double[] qOutput,
|
||||
double[] tOutput,
|
||||
Range outRange,
|
||||
int lookback,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
int count = qOutput.Length;
|
||||
int start = Math.Max(0, count - skip);
|
||||
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
|
||||
|
||||
for (int i = start; i < count; i++)
|
||||
{
|
||||
double qValue = qOutput[i];
|
||||
|
||||
if (i < lookback) continue;
|
||||
|
||||
int tIndex = i - offset;
|
||||
if (tIndex < 0 || tIndex >= length) continue;
|
||||
|
||||
double tValue = tOutput[tIndex];
|
||||
|
||||
Assert.True(
|
||||
Math.Abs(qValue - tValue) <= tolerance,
|
||||
$"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that all values in the series are finite (not NaN or Infinity).
|
||||
/// </summary>
|
||||
/// <param name="series">The series to verify</param>
|
||||
/// <param name="startIndex">Starting index for verification (default: 0)</param>
|
||||
public static void VerifyAllFinite(TSeries series, int startIndex = 0)
|
||||
{
|
||||
for (int i = startIndex; i < series.Count; i++)
|
||||
{
|
||||
Assert.True(
|
||||
double.IsFinite(series[i].Value),
|
||||
$"Non-finite value at index {i}: {series[i].Value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that all values in the array are finite (not NaN or Infinity).
|
||||
/// </summary>
|
||||
/// <param name="values">The array to verify</param>
|
||||
/// <param name="startIndex">Starting index for verification (default: 0)</param>
|
||||
public static void VerifyAllFinite(double[] values, int startIndex = 0)
|
||||
{
|
||||
for (int i = startIndex; i < values.Length; i++)
|
||||
{
|
||||
Assert.True(
|
||||
double.IsFinite(values[i]),
|
||||
$"Non-finite value at index {i}: {values[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that two series produce the same results (for consistency testing).
|
||||
/// </summary>
|
||||
/// <param name="series1">First series</param>
|
||||
/// <param name="series2">Second series</param>
|
||||
/// <param name="tolerance">Tolerance for floating-point comparison</param>
|
||||
public static void VerifySeriesEqual(TSeries series1, TSeries series2, double tolerance = DefaultTolerance)
|
||||
{
|
||||
Assert.Equal(series1.Count, series2.Count);
|
||||
|
||||
for (int i = 0; i < series1.Count; i++)
|
||||
{
|
||||
Assert.True(
|
||||
Math.Abs(series1[i].Value - series2[i].Value) <= tolerance,
|
||||
$"Mismatch at index {i}: Series1={series1[i].Value:G17}, Series2={series2[i].Value:G17}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the maximum absolute difference between two series.
|
||||
/// Useful for debugging tolerance issues.
|
||||
/// </summary>
|
||||
public static double MaxAbsoluteDifference<TResult>(
|
||||
TSeries qSeries,
|
||||
IReadOnlyList<TResult> sSeries,
|
||||
Func<TResult, double?> selector)
|
||||
{
|
||||
if (qSeries.Count != sSeries.Count)
|
||||
throw new ArgumentException("Series must have the same count", nameof(sSeries));
|
||||
|
||||
double maxDiff = 0;
|
||||
|
||||
for (int i = 0; i < qSeries.Count; i++)
|
||||
{
|
||||
double? sValue = selector(sSeries[i]);
|
||||
if (!sValue.HasValue) continue;
|
||||
|
||||
double diff = Math.Abs(qSeries[i].Value - sValue.Value);
|
||||
if (diff > maxDiff)
|
||||
maxDiff = diff;
|
||||
}
|
||||
|
||||
return maxDiff;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the maximum relative difference between two series.
|
||||
/// Useful for percentage-based tolerance testing.
|
||||
/// </summary>
|
||||
public static double MaxRelativeDifference<TResult>(
|
||||
TSeries qSeries,
|
||||
IReadOnlyList<TResult> sSeries,
|
||||
Func<TResult, double?> selector)
|
||||
{
|
||||
if (qSeries.Count != sSeries.Count)
|
||||
throw new ArgumentException("Series must have the same count", nameof(sSeries));
|
||||
|
||||
double maxDiff = 0;
|
||||
|
||||
for (int i = 0; i < qSeries.Count; i++)
|
||||
{
|
||||
double? sValue = selector(sSeries[i]);
|
||||
if (!sValue.HasValue || Math.Abs(sValue.Value) < double.Epsilon) continue;
|
||||
|
||||
double relDiff = Math.Abs((qSeries[i].Value - sValue.Value) / sValue.Value);
|
||||
if (relDiff > maxDiff)
|
||||
maxDiff = relDiff;
|
||||
}
|
||||
|
||||
return maxDiff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using Skender.Stock.Indicators;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Provides standardized test data for validation tests.
|
||||
/// Uses GBM (Geometric Brownian Motion) to generate realistic price data
|
||||
/// and converts it to formats required by external validation libraries.
|
||||
/// </summary>
|
||||
public sealed class ValidationTestData : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Default number of bars for validation tests.
|
||||
/// 5000 bars ensures sufficient convergence for most indicators.
|
||||
/// </summary>
|
||||
public const int DefaultCount = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// Default starting price for generated data.
|
||||
/// </summary>
|
||||
public const double DefaultStartPrice = 1000.0;
|
||||
|
||||
/// <summary>
|
||||
/// Default annual drift for GBM (5%).
|
||||
/// </summary>
|
||||
public const double DefaultMu = 0.05;
|
||||
|
||||
/// <summary>
|
||||
/// Default annual volatility for GBM (200%).
|
||||
/// High volatility ensures diverse price scenarios.
|
||||
/// </summary>
|
||||
public const double DefaultSigma = 2.0;
|
||||
|
||||
/// <summary>
|
||||
/// Default random seed for reproducibility.
|
||||
/// </summary>
|
||||
public const int DefaultSeed = 123;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generated bar series.
|
||||
/// </summary>
|
||||
public TBarSeries Bars { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the close price series.
|
||||
/// </summary>
|
||||
public TSeries Data { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the quotes in Skender.Stock.Indicators format.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Quote> SkenderQuotes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw close price data as a ReadOnlyMemory for span-based APIs.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<double> RawData { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw open prices as read-only memory.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<double> OpenPrices { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw high prices as read-only memory.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<double> HighPrices { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw low prices as read-only memory.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<double> LowPrices { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw close prices as read-only memory.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<double> ClosePrices { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw volume data as read-only memory.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<double> VolumeData { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamps as read-only memory.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<long> Timestamps { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of bars in the dataset.
|
||||
/// </summary>
|
||||
public int Count => Bars.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Creates validation test data with default parameters.
|
||||
/// </summary>
|
||||
public ValidationTestData()
|
||||
: this(DefaultCount, DefaultStartPrice, DefaultMu, DefaultSigma, DefaultSeed)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates validation test data with specified parameters.
|
||||
/// </summary>
|
||||
/// <param name="count">Number of bars to generate</param>
|
||||
/// <param name="startPrice">Starting price</param>
|
||||
/// <param name="mu">Annual drift rate</param>
|
||||
/// <param name="sigma">Annual volatility</param>
|
||||
/// <param name="seed">Random seed for reproducibility</param>
|
||||
public ValidationTestData(
|
||||
int count,
|
||||
double startPrice = DefaultStartPrice,
|
||||
double mu = DefaultMu,
|
||||
double sigma = DefaultSigma,
|
||||
int seed = DefaultSeed)
|
||||
{
|
||||
var gbm = new GBM(startPrice, mu, sigma, seed: seed);
|
||||
Bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
Data = Bars.Close;
|
||||
|
||||
// Extract raw arrays efficiently (avoid LINQ in hot path)
|
||||
int barCount = Bars.Count;
|
||||
var openPrices = new double[barCount];
|
||||
var highPrices = new double[barCount];
|
||||
var lowPrices = new double[barCount];
|
||||
var closePrices = new double[barCount];
|
||||
var volumeData = new double[barCount];
|
||||
var timestamps = new long[barCount];
|
||||
|
||||
// Use span-based access for efficiency
|
||||
var openSpan = Bars.OpenValues;
|
||||
var highSpan = Bars.HighValues;
|
||||
var lowSpan = Bars.LowValues;
|
||||
var closeSpan = Bars.CloseValues;
|
||||
var volumeSpan = Bars.VolumeValues;
|
||||
var timeSpan = Bars.Times;
|
||||
|
||||
openSpan.CopyTo(openPrices);
|
||||
highSpan.CopyTo(highPrices);
|
||||
lowSpan.CopyTo(lowPrices);
|
||||
closeSpan.CopyTo(closePrices);
|
||||
volumeSpan.CopyTo(volumeData);
|
||||
timeSpan.CopyTo(timestamps);
|
||||
|
||||
// Expose as ReadOnlyMemory to prevent external modification
|
||||
OpenPrices = openPrices;
|
||||
HighPrices = highPrices;
|
||||
LowPrices = lowPrices;
|
||||
ClosePrices = closePrices;
|
||||
VolumeData = volumeData;
|
||||
Timestamps = timestamps;
|
||||
RawData = closePrices;
|
||||
|
||||
// Build Skender quotes without LINQ
|
||||
var quotes = new Quote[barCount];
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
quotes[i] = new Quote
|
||||
{
|
||||
Date = new DateTime(timestamps[i], DateTimeKind.Utc),
|
||||
Open = (decimal)openPrices[i],
|
||||
High = (decimal)highPrices[i],
|
||||
Low = (decimal)lowPrices[i],
|
||||
Close = (decimal)closePrices[i],
|
||||
Volume = (decimal)volumeData[i],
|
||||
};
|
||||
}
|
||||
SkenderQuotes = quotes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ValidationTestData instance with the specified bar count.
|
||||
/// Note: This regenerates data using the same seed rather than slicing existing data,
|
||||
/// ensuring deterministic results but not reusing the parent's generated bars.
|
||||
/// </summary>
|
||||
/// <param name="count">Number of bars to generate (must be between 1 and current Count)</param>
|
||||
/// <returns>A new ValidationTestData instance with freshly generated data</returns>
|
||||
public ValidationTestData CreateSubset(int count)
|
||||
{
|
||||
if (count <= 0 || count > Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(count), count, $"Count must be between 1 and {Count}");
|
||||
|
||||
return new ValidationTestData(count, DefaultStartPrice, DefaultMu, DefaultSigma, DefaultSeed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the close price span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> GetCloseSpan() => ClosePrices.Span;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the high price span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> GetHighSpan() => HighPrices.Span;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the low price span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> GetLowSpan() => LowPrices.Span;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the open price span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> GetOpenSpan() => OpenPrices.Span;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the volume span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> GetVolumeSpan() => VolumeData.Span;
|
||||
|
||||
/// <summary>
|
||||
/// Disposes of resources (no-op, but implements pattern for test fixtures).
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
// No unmanaged resources to dispose
|
||||
// Implemented for IDisposable pattern compatibility with test fixtures
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// Geometric Brownian Motion (GBM) generator for simulating OHLCV data.
|
||||
/// Generates realistic price data for testing indicators and strategies.
|
||||
/// Stateless design - only maintains minimal state needed for price continuity.
|
||||
/// </summary>
|
||||
[SkipLocalsInit]
|
||||
#pragma warning disable S101 // Rename class 'GBM' to match pascal case naming rules
|
||||
#pragma warning disable S2245 // Random is acceptable for simulation/testing purposes
|
||||
public sealed class GBM : IFeed
|
||||
#pragma warning restore S101
|
||||
{
|
||||
private readonly Random? _rnd;
|
||||
|
||||
private double _lastPrice;
|
||||
private long _lastTime;
|
||||
|
||||
private readonly double _drift;
|
||||
private readonly double _vol;
|
||||
private readonly long _defaultTimeStep;
|
||||
|
||||
private TBar _currentBar;
|
||||
private bool _hasCurrentBar;
|
||||
|
||||
private double _cachedZ;
|
||||
private bool _hasCachedZ;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the annual drift/return rate.
|
||||
/// </summary>
|
||||
public double Mu { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the annual volatility.
|
||||
/// </summary>
|
||||
public double Sigma { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the starting price.
|
||||
/// </summary>
|
||||
public double StartPrice { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current price state.
|
||||
/// </summary>
|
||||
public double CurrentPrice => _lastPrice;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the generator has a current bar in progress.
|
||||
/// </summary>
|
||||
public bool HasCurrentBar => _hasCurrentBar;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new GBM generator.
|
||||
/// </summary>
|
||||
/// <param name="startPrice">Initial price (default: 100.0, must be positive and finite)</param>
|
||||
/// <param name="mu">Annual drift/return rate (default: 0.05 = 5%, must be finite)</param>
|
||||
/// <param name="sigma">Annual volatility (default: 0.2 = 20%, must be non-negative and finite)</param>
|
||||
/// <param name="defaultTimeframe">Default timeframe for bars (default: 1 minute, must be positive)</param>
|
||||
/// <param name="seed">Optional random seed for reproducibility (default: null for non-deterministic)</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// Thrown when startPrice is not positive/finite, sigma is negative/non-finite,
|
||||
/// mu is non-finite, or defaultTimeframe is non-positive.
|
||||
/// </exception>
|
||||
public GBM(
|
||||
double startPrice = 100.0,
|
||||
double mu = 0.05,
|
||||
double sigma = 0.2,
|
||||
TimeSpan? defaultTimeframe = null,
|
||||
int? seed = null)
|
||||
{
|
||||
// Validate startPrice
|
||||
if (startPrice <= 0 || !double.IsFinite(startPrice))
|
||||
throw new ArgumentOutOfRangeException(nameof(startPrice), startPrice, "Start price must be positive and finite");
|
||||
|
||||
// Validate mu
|
||||
if (!double.IsFinite(mu))
|
||||
throw new ArgumentOutOfRangeException(nameof(mu), mu, "Drift (mu) must be finite");
|
||||
|
||||
// Validate sigma
|
||||
if (sigma < 0 || !double.IsFinite(sigma))
|
||||
throw new ArgumentOutOfRangeException(nameof(sigma), sigma, "Volatility (sigma) must be non-negative and finite");
|
||||
|
||||
// Use provided timeframe or default to 1 minute
|
||||
var timeframe = defaultTimeframe ?? TimeSpan.FromMinutes(1);
|
||||
|
||||
// Validate timeframe
|
||||
if (timeframe <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(defaultTimeframe), defaultTimeframe, "Timeframe must be positive");
|
||||
|
||||
_rnd = seed.HasValue ? new Random(seed.Value) : null;
|
||||
StartPrice = startPrice;
|
||||
_lastPrice = startPrice;
|
||||
_lastTime = DateTime.UtcNow.Ticks;
|
||||
|
||||
Mu = mu;
|
||||
Sigma = sigma;
|
||||
|
||||
_defaultTimeStep = timeframe.Ticks;
|
||||
|
||||
const double minutesPerYear = 252.0 * 6.5 * 60.0;
|
||||
double dt = timeframe.TotalMinutes / minutesPerYear;
|
||||
|
||||
_drift = (mu - 0.5 * sigma * sigma) * dt;
|
||||
_vol = sigma * Math.Sqrt(dt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the generator to its initial state.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_lastPrice = StartPrice;
|
||||
_lastTime = DateTime.UtcNow.Ticks;
|
||||
_currentBar = default;
|
||||
_hasCurrentBar = false;
|
||||
_cachedZ = 0;
|
||||
_hasCachedZ = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the generator to its initial state with a specific start time.
|
||||
/// </summary>
|
||||
/// <param name="startTime">The start time in ticks.</param>
|
||||
public void Reset(long startTime)
|
||||
{
|
||||
_lastPrice = StartPrice;
|
||||
_lastTime = startTime;
|
||||
_currentBar = default;
|
||||
_hasCurrentBar = false;
|
||||
_cachedZ = 0;
|
||||
_hasCachedZ = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a random double in [0, 1) using either the seeded Random or RandomNumberGenerator.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private double NextDouble()
|
||||
{
|
||||
if (_rnd != null)
|
||||
{
|
||||
return _rnd.NextDouble();
|
||||
}
|
||||
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
ulong ul = BitConverter.ToUInt64(buffer);
|
||||
return (ul >> 11) * (1.0 / (1ul << 53));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates next standard normal using Box-Muller transform with caching.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private double NextNormal()
|
||||
{
|
||||
if (_hasCachedZ)
|
||||
{
|
||||
_hasCachedZ = false;
|
||||
return _cachedZ;
|
||||
}
|
||||
|
||||
double u1 = 1.0 - NextDouble();
|
||||
double u2 = 1.0 - NextDouble();
|
||||
|
||||
// Guard against log(0) which produces -Infinity
|
||||
if (u1 <= double.Epsilon)
|
||||
u1 = double.Epsilon;
|
||||
|
||||
double mag = Math.Sqrt(-2.0 * Math.Log(u1));
|
||||
double angle = 2.0 * Math.PI * u2;
|
||||
|
||||
_cachedZ = mag * Math.Sin(angle);
|
||||
_hasCachedZ = true;
|
||||
|
||||
return mag * Math.Cos(angle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next bar with full bidirectional control.
|
||||
/// GBM always honors the request - isNew parameter unchanged on return.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TBar Next(ref bool isNew)
|
||||
{
|
||||
// GBM always honors request - parameter unchanged
|
||||
|
||||
if (isNew || !_hasCurrentBar)
|
||||
{
|
||||
// Generate new bar
|
||||
long currentTime = _lastTime + _defaultTimeStep;
|
||||
|
||||
double z = NextNormal();
|
||||
double price = _lastPrice * Math.Exp(Math.FusedMultiplyAdd(_vol, z, _drift));
|
||||
|
||||
// Ensure price stays positive and finite
|
||||
if (!double.IsFinite(price) || price <= 0)
|
||||
price = _lastPrice;
|
||||
|
||||
double volume = 1000 + NextDouble() * 1000;
|
||||
|
||||
double open = _lastPrice;
|
||||
double close = price;
|
||||
|
||||
double rnd1 = NextDouble();
|
||||
double rnd2 = NextDouble();
|
||||
|
||||
double high = Math.Max(open, close) * (1.0 + rnd1 * 0.01);
|
||||
double low = Math.Min(open, close) * (1.0 - rnd2 * 0.01);
|
||||
|
||||
// Ensure valid OHLC constraints
|
||||
high = Math.Max(high, Math.Max(open, close));
|
||||
low = Math.Min(low, Math.Min(open, close));
|
||||
low = Math.Max(double.Epsilon, low); // Ensure positive
|
||||
|
||||
_currentBar = new TBar(currentTime, open, high, low, close, volume);
|
||||
_hasCurrentBar = true;
|
||||
|
||||
_lastPrice = close;
|
||||
_lastTime = currentTime;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update current bar (intra-bar tick)
|
||||
double z = NextNormal();
|
||||
double price = _lastPrice * Math.Exp(Math.FusedMultiplyAdd(_vol, z, _drift));
|
||||
|
||||
// Ensure price stays positive and finite
|
||||
if (!double.IsFinite(price) || price <= 0)
|
||||
price = _lastPrice;
|
||||
|
||||
double additionalVolume = 1000 + NextDouble() * 1000;
|
||||
|
||||
var bar = _currentBar;
|
||||
double newClose = price;
|
||||
double newHigh = Math.Max(bar.High, newClose);
|
||||
double newLow = Math.Min(bar.Low, newClose);
|
||||
newLow = Math.Max(double.Epsilon, newLow); // Ensure positive
|
||||
double newVolume = bar.Volume + additionalVolume;
|
||||
|
||||
_currentBar = new TBar(bar.Time, bar.Open, newHigh, newLow, newClose, newVolume);
|
||||
_lastPrice = newClose;
|
||||
}
|
||||
|
||||
return _currentBar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next bar with simple control.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TBar Next(bool isNew = true)
|
||||
{
|
||||
// Delegate to ref version
|
||||
return Next(ref isNew);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a batch of bars using optimized batch processing with explicit time parameters.
|
||||
/// </summary>
|
||||
/// <param name="count">Number of bars to generate (must be positive)</param>
|
||||
/// <param name="startTime">Starting timestamp in ticks</param>
|
||||
/// <param name="interval">Time interval between bars (must be positive)</param>
|
||||
/// <returns>A TBarSeries containing the generated bars</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when count is not positive</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when interval is not positive</exception>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TBarSeries Fetch(int count, long startTime, TimeSpan interval)
|
||||
{
|
||||
if (count <= 0)
|
||||
throw new ArgumentException("Count must be positive", nameof(count));
|
||||
if (interval <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(interval), interval, "Interval must be positive");
|
||||
|
||||
var series = new TBarSeries(count);
|
||||
|
||||
// Pre-allocate arrays for SoA layout
|
||||
long[] t = new long[count];
|
||||
double[] o = new double[count];
|
||||
double[] h = new double[count];
|
||||
double[] l = new double[count];
|
||||
double[] c = new double[count];
|
||||
double[] v = new double[count];
|
||||
|
||||
const double minutesPerYear = 252.0 * 6.5 * 60.0;
|
||||
double dt = interval.TotalMinutes / minutesPerYear;
|
||||
double drift = (Mu - 0.5 * Sigma * Sigma) * dt;
|
||||
double vol = Sigma * Math.Sqrt(dt);
|
||||
|
||||
long timeStep = interval.Ticks;
|
||||
double currentPrice = _lastPrice;
|
||||
long currentTime = startTime;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
double z = NextNormal();
|
||||
double price = currentPrice * Math.Exp(Math.FusedMultiplyAdd(vol, z, drift));
|
||||
|
||||
// Ensure price stays positive and finite
|
||||
if (!double.IsFinite(price) || price <= 0)
|
||||
price = currentPrice;
|
||||
|
||||
double open = currentPrice;
|
||||
double close = price;
|
||||
|
||||
double rnd1 = NextDouble();
|
||||
double rnd2 = NextDouble();
|
||||
double rnd3 = NextDouble();
|
||||
|
||||
t[i] = currentTime;
|
||||
o[i] = open;
|
||||
c[i] = close;
|
||||
|
||||
double high = Math.Max(open, close) * (1.0 + rnd1 * 0.01);
|
||||
double low = Math.Min(open, close) * (1.0 - rnd2 * 0.01);
|
||||
|
||||
// Ensure valid OHLC constraints
|
||||
high = Math.Max(high, Math.Max(open, close));
|
||||
low = Math.Min(low, Math.Min(open, close));
|
||||
low = Math.Max(double.Epsilon, low); // Ensure positive
|
||||
|
||||
h[i] = high;
|
||||
l[i] = low;
|
||||
v[i] = 1000 + rnd3 * 1000;
|
||||
|
||||
currentPrice = price;
|
||||
currentTime += timeStep;
|
||||
}
|
||||
|
||||
// Update internal state to continue from end of batch
|
||||
_lastPrice = currentPrice;
|
||||
_lastTime = currentTime - timeStep; // Last bar time, not next bar time
|
||||
|
||||
// Bulk add to series
|
||||
series.Add(t, o, h, l, c, v);
|
||||
|
||||
// Reset streaming state after batch
|
||||
_hasCurrentBar = false;
|
||||
|
||||
return series;
|
||||
}
|
||||
}
|
||||
#pragma warning restore S2245
|
||||
Reference in New Issue
Block a user