Refactor code formatting and improve consistency across various test files

- Removed unnecessary blank lines in multiple test files to enhance readability.
- Ensured consistent spacing and formatting in the `Trima`, `Usf`, `Vidya`, `Wma`, and `Atr` test classes.
- Updated comments for clarity and consistency in the `Atr` and `Adl` classes.
- Adjusted project files for better structure and maintainability.
This commit is contained in:
Miha Kralj
2025-12-28 17:44:08 -08:00
parent ad6eebf812
commit 13d7c1215d
169 changed files with 10815 additions and 10814 deletions
+35 -35
View File
@@ -1,35 +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);
}
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);
}
+45 -45
View File
@@ -1,45 +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).
# 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).
+296 -296
View File
@@ -1,296 +1,296 @@
namespace QuanTAlib.Tests;
public class CsvFeedTests
{
private const string TestCsvPath = "daily_IBM.csv";
[Fact]
public void Constructor_ValidFile_LoadsData()
{
var feed = new CsvFeed(TestCsvPath);
Assert.NotNull(feed);
}
[Fact]
public void Constructor_NonExistentFile_ThrowsFileNotFoundException()
{
Assert.Throws<FileNotFoundException>(() => new CsvFeed("nonexistent.csv"));
}
[Fact]
public void Constructor_NullPath_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new CsvFeed(null!));
}
[Fact]
public void Constructor_EmptyPath_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new CsvFeed(""));
}
[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 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_InvalidCount_ThrowsArgumentException()
{
var feed = new CsvFeed(TestCsvPath);
var startTime = DateTime.UtcNow.Ticks;
var interval = TimeSpan.FromDays(1);
Assert.Throws<ArgumentException>(() => feed.Fetch(0, startTime, interval));
Assert.Throws<ArgumentException>(() => feed.Fetch(-1, startTime, interval));
}
[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 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 CsvFeed_WorksWithIFeedInterface()
{
IFeed 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 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.True(series.Count == 0);
}
[Fact]
public void Fetch_HandlesGapsCorrectly()
{
string tempCsv = Path.GetTempFileName() + ".csv";
try
{
// Create CSV with gaps
// Date, Open, High, Low, Close, Volume
// 2023-01-01 (Sunday)
// 2023-01-02 (Monday)
// 2023-01-04 (Wednesday) - Gap of Tuesday
// 2023-01-05 (Thursday)
var lines = new[]
{
"Date,Open,High,Low,Close,Volume",
"2023-01-05,103,104,102,103,1000",
"2023-01-04,102,103,101,102,1000",
"2023-01-02,101,102,100,101,1000",
"2023-01-01,100,101,99,100,1000"
};
File.WriteAllLines(tempCsv, lines);
var feed = new CsvFeed(tempCsv);
var startTime = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
var interval = TimeSpan.FromDays(1);
// Fetch 5 bars. Should get 4 bars (Jan 1, 2, 4, 5).
var series = feed.Fetch(10, startTime, interval);
Assert.Equal(4, series.Count);
Assert.Equal(startTime, series[0].Time); // Jan 1
Assert.Equal(startTime + interval.Ticks, series[1].Time); // Jan 2
// Gap here
Assert.Equal(startTime + 3 * interval.Ticks, series[2].Time); // Jan 4
Assert.Equal(startTime + 4 * interval.Ticks, series[3].Time); // Jan 5
}
finally
{
if (File.Exists(tempCsv))
File.Delete(tempCsv);
}
}
}
namespace QuanTAlib.Tests;
public class CsvFeedTests
{
private const string TestCsvPath = "daily_IBM.csv";
[Fact]
public void Constructor_ValidFile_LoadsData()
{
var feed = new CsvFeed(TestCsvPath);
Assert.NotNull(feed);
}
[Fact]
public void Constructor_NonExistentFile_ThrowsFileNotFoundException()
{
Assert.Throws<FileNotFoundException>(() => new CsvFeed("nonexistent.csv"));
}
[Fact]
public void Constructor_NullPath_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new CsvFeed(null!));
}
[Fact]
public void Constructor_EmptyPath_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new CsvFeed(""));
}
[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 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_InvalidCount_ThrowsArgumentException()
{
var feed = new CsvFeed(TestCsvPath);
var startTime = DateTime.UtcNow.Ticks;
var interval = TimeSpan.FromDays(1);
Assert.Throws<ArgumentException>(() => feed.Fetch(0, startTime, interval));
Assert.Throws<ArgumentException>(() => feed.Fetch(-1, startTime, interval));
}
[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 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 CsvFeed_WorksWithIFeedInterface()
{
IFeed 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 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.True(series.Count == 0);
}
[Fact]
public void Fetch_HandlesGapsCorrectly()
{
string tempCsv = Path.GetTempFileName() + ".csv";
try
{
// Create CSV with gaps
// Date, Open, High, Low, Close, Volume
// 2023-01-01 (Sunday)
// 2023-01-02 (Monday)
// 2023-01-04 (Wednesday) - Gap of Tuesday
// 2023-01-05 (Thursday)
var lines = new[]
{
"Date,Open,High,Low,Close,Volume",
"2023-01-05,103,104,102,103,1000",
"2023-01-04,102,103,101,102,1000",
"2023-01-02,101,102,100,101,1000",
"2023-01-01,100,101,99,100,1000"
};
File.WriteAllLines(tempCsv, lines);
var feed = new CsvFeed(tempCsv);
var startTime = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
var interval = TimeSpan.FromDays(1);
// Fetch 5 bars. Should get 4 bars (Jan 1, 2, 4, 5).
var series = feed.Fetch(10, startTime, interval);
Assert.Equal(4, series.Count);
Assert.Equal(startTime, series[0].Time); // Jan 1
Assert.Equal(startTime + interval.Ticks, series[1].Time); // Jan 2
// Gap here
Assert.Equal(startTime + 3 * interval.Ticks, series[2].Time); // Jan 4
Assert.Equal(startTime + 4 * interval.Ticks, series[3].Time); // Jan 5
}
finally
{
if (File.Exists(tempCsv))
File.Delete(tempCsv);
}
}
}
+207 -207
View File
@@ -1,207 +1,207 @@
using System.Globalization;
using System.Runtime.CompilerServices;
namespace QuanTAlib;
/// <summary>
/// CSV file feed for loading historical OHLCV data.
/// Loads data in constructor and streams through it with Next() or returns batches with Fetch().
/// CSV format: timestamp,open,high,low,close,volume (header required)
/// Timestamp format: YYYY-MM-DD (UTC midnight assumed)
/// </summary>
public class CsvFeed : IFeed
{
private readonly TBarSeries _data;
// Streaming state
private int _currentIndex;
private TBar _currentBar;
private bool _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>
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);
_data = LoadFromCsv(filePath);
_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);
for (int i = 0; i < dataLines.Count; i++)
{
var line = dataLines[i];
var parts = line.Split(',');
int originalLineNumber = dataLines.Count - i + 1;
if (parts.Length != 6)
throw new FormatException($"Invalid CSV format at line {originalLineNumber}. Expected 6 columns, found {parts.Length}");
// Parse timestamp (YYYY-MM-DD format, assume UTC midnight)
if (!DateTime.TryParseExact(parts[0].Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var timestamp))
{
throw new FormatException($"Failed to parse timestamp at line {originalLineNumber}: {line}");
}
// Parse OHLCV values
if (!double.TryParse(parts[1].Trim(), CultureInfo.InvariantCulture, out double open) ||
!double.TryParse(parts[2].Trim(), CultureInfo.InvariantCulture, out double high) ||
!double.TryParse(parts[3].Trim(), CultureInfo.InvariantCulture, out double low) ||
!double.TryParse(parts[4].Trim(), CultureInfo.InvariantCulture, out double close) ||
!double.TryParse(parts[5].Trim(), CultureInfo.InvariantCulture, out double volume))
{
throw new FormatException($"Failed to parse CSV line {originalLineNumber}: {line}");
}
series.Add(timestamp, open, high, low, close, volume, isNew: true);
}
return series;
}
/// <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 (_data.Count == 0)
{
isNew = false;
return default;
}
if (isNew || !_hasCurrentBar)
{
// Request for new bar
if (_currentIndex >= _data.Count)
{
// End of data - return last bar and signal no more data
isNew = false;
return _currentBar;
}
_currentBar = _data[_currentIndex];
_currentIndex++;
_hasCurrentBar = true;
}
else
{
// Update current bar - CSV has no intra-bar updates, return same bar
// No change to _currentBar or _currentIndex
}
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>
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
int startIndex = -1;
for (int i = 0; i < _data.Count; i++)
{
if (_data[i].Time >= startTime)
{
startIndex = i;
break;
}
}
if (startIndex == -1)
return result;
// Collect bars matching interval
long expectedTime = startTime;
int collected = 0;
for (int i = startIndex; i < _data.Count && collected < count; i++)
{
var bar = _data[i];
// Check if bar time matches expected time (within tolerance)
long timeDiff = Math.Abs(bar.Time - expectedTime);
long tolerance = interval.Ticks / 2; // Allow 50% tolerance
if (timeDiff <= tolerance)
{
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;
}
}
using System.Globalization;
using System.Runtime.CompilerServices;
namespace QuanTAlib;
/// <summary>
/// CSV file feed for loading historical OHLCV data.
/// Loads data in constructor and streams through it with Next() or returns batches with Fetch().
/// CSV format: timestamp,open,high,low,close,volume (header required)
/// Timestamp format: YYYY-MM-DD (UTC midnight assumed)
/// </summary>
public class CsvFeed : IFeed
{
private readonly TBarSeries _data;
// Streaming state
private int _currentIndex;
private TBar _currentBar;
private bool _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>
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);
_data = LoadFromCsv(filePath);
_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);
for (int i = 0; i < dataLines.Count; i++)
{
var line = dataLines[i];
var parts = line.Split(',');
int originalLineNumber = dataLines.Count - i + 1;
if (parts.Length != 6)
throw new FormatException($"Invalid CSV format at line {originalLineNumber}. Expected 6 columns, found {parts.Length}");
// Parse timestamp (YYYY-MM-DD format, assume UTC midnight)
if (!DateTime.TryParseExact(parts[0].Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var timestamp))
{
throw new FormatException($"Failed to parse timestamp at line {originalLineNumber}: {line}");
}
// Parse OHLCV values
if (!double.TryParse(parts[1].Trim(), CultureInfo.InvariantCulture, out double open) ||
!double.TryParse(parts[2].Trim(), CultureInfo.InvariantCulture, out double high) ||
!double.TryParse(parts[3].Trim(), CultureInfo.InvariantCulture, out double low) ||
!double.TryParse(parts[4].Trim(), CultureInfo.InvariantCulture, out double close) ||
!double.TryParse(parts[5].Trim(), CultureInfo.InvariantCulture, out double volume))
{
throw new FormatException($"Failed to parse CSV line {originalLineNumber}: {line}");
}
series.Add(timestamp, open, high, low, close, volume, isNew: true);
}
return series;
}
/// <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 (_data.Count == 0)
{
isNew = false;
return default;
}
if (isNew || !_hasCurrentBar)
{
// Request for new bar
if (_currentIndex >= _data.Count)
{
// End of data - return last bar and signal no more data
isNew = false;
return _currentBar;
}
_currentBar = _data[_currentIndex];
_currentIndex++;
_hasCurrentBar = true;
}
else
{
// Update current bar - CSV has no intra-bar updates, return same bar
// No change to _currentBar or _currentIndex
}
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>
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
int startIndex = -1;
for (int i = 0; i < _data.Count; i++)
{
if (_data[i].Time >= startTime)
{
startIndex = i;
break;
}
}
if (startIndex == -1)
return result;
// Collect bars matching interval
long expectedTime = startTime;
int collected = 0;
for (int i = startIndex; i < _data.Count && collected < count; i++)
{
var bar = _data[i];
// Check if bar time matches expected time (within tolerance)
long timeDiff = Math.Abs(bar.Time - expectedTime);
long tolerance = interval.Ticks / 2; // Allow 50% tolerance
if (timeDiff <= tolerance)
{
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;
}
}
+72 -72
View File
@@ -1,72 +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));
# 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));
+71 -71
View File
@@ -1,71 +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);
# 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);
+281 -281
View File
@@ -1,281 +1,281 @@
namespace QuanTAlib.Tests;
public class GBMTests
{
[Fact]
public void Next_DefaultParameter_GeneratesNewBar()
{
var gbm = new GBM(startPrice: 100.0);
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);
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);
var bar1 = gbm.Next(isNew: true);
long initialTime = bar1.Time;
var bar2 = gbm.Next(isNew: false);
Assert.Equal(initialTime, bar2.Time);
// Price likely changed (GBM random walk)
Assert.NotEqual(bar1.Close, bar2.Close);
}
[Fact]
public void Next_RefBool_HonorsRequest()
{
var gbm = new GBM(startPrice: 100.0);
// GBM always honors isNew - parameter should remain unchanged
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 Fetch_GeneratesCorrectCount()
{
var gbm = new GBM(startPrice: 100.0);
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);
long startTime = DateTime.UtcNow.Ticks;
var interval = TimeSpan.FromMinutes(1);
var series = gbm.Fetch(5, startTime, interval);
// Verify time sequence
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);
var interval = TimeSpan.FromHours(1);
long startTime = DateTime.UtcNow.Ticks;
var series = gbm.Fetch(5, startTime, interval);
// Verify interval spacing
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);
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);
}
[Fact]
public void Fetch_WithDifferentIntervals_WorksCorrectly()
{
var gbm = new GBM(startPrice: 100.0);
long startTime = DateTime.UtcNow.Ticks;
// Test different intervals
var intervals = new[] {
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(5),
TimeSpan.FromHours(1)
};
foreach (var interval in intervals)
{
var series = gbm.Fetch(3, startTime, interval);
// Verify spacing
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 GeneratesRealisticOHLCV()
{
var gbm = new GBM(startPrice: 100.0);
long startTime = DateTime.UtcNow.Ticks;
var interval = TimeSpan.FromMinutes(1);
var series = gbm.Fetch(10, 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));
// Low should be <= min(Open, Close)
Assert.True(bar.Low <= Math.Min(bar.Open, bar.Close));
// Volume should be positive
Assert.True(bar.Volume > 0);
// All prices should be positive
Assert.True(bar.Open > 0);
Assert.True(bar.High > 0);
Assert.True(bar.Low > 0);
Assert.True(bar.Close > 0);
}
}
[Fact]
public void IntraBarUpdates_ModifyCurrentBar()
{
var gbm = new GBM(startPrice: 100.0);
var bar1 = gbm.Next(isNew: true);
long initialTime = bar1.Time;
double initialClose = bar1.Close;
// Loop until price changes (random walk might stay same but unlikely)
bool changed = false;
for (int i = 0; i < 10; i++)
{
var bar = gbm.Next(isNew: false);
Assert.Equal(initialTime, bar.Time);
if (Math.Abs(bar.Close - initialClose) > double.Epsilon)
{
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);
// Start with streaming
_ = gbm.Next();
var bar2 = gbm.Next();
// Batch generation with explicit time
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);
// Continue streaming after batch (uses internal state)
var bar3 = gbm.Next();
Assert.True(bar3.Time > series[2].Time);
}
[Fact]
public void DriftAndVolatility_AffectPriceMovement()
{
// High volatility should produce more price variation
var gbmLowVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.01);
var gbmHighVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.5);
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 price ranges
double rangeLow = seriesLow[99].Close - seriesLow[0].Open;
double rangeHigh = seriesHigh[99].Close - seriesHigh[0].Open;
// High volatility should generally produce larger absolute movements
Assert.True(Math.Abs(rangeHigh) > Math.Abs(rangeLow) * 0.5);
}
[Fact]
public void ConsecutiveCalls_MaintainContinuity()
{
var gbm = new GBM(startPrice: 100.0);
var previousBar = gbm.Next();
var currentBar = gbm.Next();
// currentBar.Open should equal previousBar.Close (continuity)
Assert.Equal(previousBar.Close, currentBar.Open);
}
[Fact]
public void Stateless_NoHistoryStorage()
{
var gbm = new GBM(startPrice: 100.0);
// Generate multiple bars
for (int i = 0; i < 100; i++)
{
_ = gbm.Next();
}
// GBM should not expose any history storage
// Use typeof() instead of GetType() to satisfy trimming analyzer
var type = typeof(GBM);
var barsProperty = type.GetProperty("Bars");
Assert.Null(barsProperty);
}
}
namespace QuanTAlib.Tests;
public class GBMTests
{
[Fact]
public void Next_DefaultParameter_GeneratesNewBar()
{
var gbm = new GBM(startPrice: 100.0);
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);
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);
var bar1 = gbm.Next(isNew: true);
long initialTime = bar1.Time;
var bar2 = gbm.Next(isNew: false);
Assert.Equal(initialTime, bar2.Time);
// Price likely changed (GBM random walk)
Assert.NotEqual(bar1.Close, bar2.Close);
}
[Fact]
public void Next_RefBool_HonorsRequest()
{
var gbm = new GBM(startPrice: 100.0);
// GBM always honors isNew - parameter should remain unchanged
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 Fetch_GeneratesCorrectCount()
{
var gbm = new GBM(startPrice: 100.0);
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);
long startTime = DateTime.UtcNow.Ticks;
var interval = TimeSpan.FromMinutes(1);
var series = gbm.Fetch(5, startTime, interval);
// Verify time sequence
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);
var interval = TimeSpan.FromHours(1);
long startTime = DateTime.UtcNow.Ticks;
var series = gbm.Fetch(5, startTime, interval);
// Verify interval spacing
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);
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);
}
[Fact]
public void Fetch_WithDifferentIntervals_WorksCorrectly()
{
var gbm = new GBM(startPrice: 100.0);
long startTime = DateTime.UtcNow.Ticks;
// Test different intervals
var intervals = new[] {
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(5),
TimeSpan.FromHours(1)
};
foreach (var interval in intervals)
{
var series = gbm.Fetch(3, startTime, interval);
// Verify spacing
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 GeneratesRealisticOHLCV()
{
var gbm = new GBM(startPrice: 100.0);
long startTime = DateTime.UtcNow.Ticks;
var interval = TimeSpan.FromMinutes(1);
var series = gbm.Fetch(10, 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));
// Low should be <= min(Open, Close)
Assert.True(bar.Low <= Math.Min(bar.Open, bar.Close));
// Volume should be positive
Assert.True(bar.Volume > 0);
// All prices should be positive
Assert.True(bar.Open > 0);
Assert.True(bar.High > 0);
Assert.True(bar.Low > 0);
Assert.True(bar.Close > 0);
}
}
[Fact]
public void IntraBarUpdates_ModifyCurrentBar()
{
var gbm = new GBM(startPrice: 100.0);
var bar1 = gbm.Next(isNew: true);
long initialTime = bar1.Time;
double initialClose = bar1.Close;
// Loop until price changes (random walk might stay same but unlikely)
bool changed = false;
for (int i = 0; i < 10; i++)
{
var bar = gbm.Next(isNew: false);
Assert.Equal(initialTime, bar.Time);
if (Math.Abs(bar.Close - initialClose) > double.Epsilon)
{
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);
// Start with streaming
_ = gbm.Next();
var bar2 = gbm.Next();
// Batch generation with explicit time
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);
// Continue streaming after batch (uses internal state)
var bar3 = gbm.Next();
Assert.True(bar3.Time > series[2].Time);
}
[Fact]
public void DriftAndVolatility_AffectPriceMovement()
{
// High volatility should produce more price variation
var gbmLowVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.01);
var gbmHighVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.5);
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 price ranges
double rangeLow = seriesLow[99].Close - seriesLow[0].Open;
double rangeHigh = seriesHigh[99].Close - seriesHigh[0].Open;
// High volatility should generally produce larger absolute movements
Assert.True(Math.Abs(rangeHigh) > Math.Abs(rangeLow) * 0.5);
}
[Fact]
public void ConsecutiveCalls_MaintainContinuity()
{
var gbm = new GBM(startPrice: 100.0);
var previousBar = gbm.Next();
var currentBar = gbm.Next();
// currentBar.Open should equal previousBar.Close (continuity)
Assert.Equal(previousBar.Close, currentBar.Open);
}
[Fact]
public void Stateless_NoHistoryStorage()
{
var gbm = new GBM(startPrice: 100.0);
// Generate multiple bars
for (int i = 0; i < 100; i++)
{
_ = gbm.Next();
}
// GBM should not expose any history storage
// Use typeof() instead of GetType() to satisfy trimming analyzer
var type = typeof(GBM);
var barsProperty = type.GetProperty("Bars");
Assert.Null(barsProperty);
}
}
+254 -254
View File
@@ -1,254 +1,254 @@
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 class GBM : IFeed
#pragma warning restore S101
{
private readonly Random? _rnd;
private double _lastPrice;
private long _lastTime;
private readonly double _mu;
private readonly double _sigma;
// Precomputed GBM constants
private readonly double _drift;
private readonly double _vol;
private readonly long _defaultTimeStep;
// State for streaming bar formation (only when isNew=false)
private TBar _currentBar;
private bool _hasCurrentBar;
// Box-Muller optimization: cache second normal
private double _cachedZ;
private bool _hasCachedZ;
/// <summary>
/// Creates a new GBM generator.
/// </summary>
/// <param name="startPrice">Initial price (default: 100.0, must be positive)</param>
/// <param name="mu">Annual drift/return rate (default: 0.05 = 5%)</param>
/// <param name="sigma">Annual volatility (default: 0.2 = 20%, must be non-negative)</param>
/// <param name="defaultTimeframe">Default timeframe for bars (default: 1 minute)</param>
/// <param name="seed">Optional random seed for reproducibility (default: null for non-deterministic)</param>
public GBM(
double startPrice = 100.0,
double mu = 0.05,
double sigma = 0.2,
TimeSpan? defaultTimeframe = null,
int? seed = null)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(startPrice);
ArgumentOutOfRangeException.ThrowIfNegative(sigma);
_rnd = seed.HasValue ? new Random(seed.Value) : null;
_lastPrice = startPrice;
_lastTime = DateTime.UtcNow.Ticks;
_mu = mu;
_sigma = sigma;
// Use provided timeframe or default to 1 minute
var timeframe = defaultTimeframe ?? TimeSpan.FromMinutes(1);
_defaultTimeStep = timeframe.Ticks;
// Calculate dt based on timeframe (assuming 252 trading days/year, 6.5 hours/day)
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>
/// 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();
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(_drift + _vol * z);
double volume = 1000 + NextDouble() * 1000;
double open = _lastPrice;
double close = price;
double high = Math.Max(open, close) * (1.0 + Math.Abs(NextDouble()) * 0.01);
double low = Math.Min(open, close) * (1.0 - Math.Abs(NextDouble()) * 0.01);
// Ensure valid OHLC
high = Math.Max(high, Math.Max(open, close));
low = Math.Min(low, Math.Min(open, close));
low = Math.Max(0.0, low);
_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(_drift + _vol * z);
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);
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>
[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 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];
// Calculate dt for this specific interval
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(drift + vol * z);
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 + Math.Abs(rnd1) * 0.01);
double low = Math.Min(open, close) * (1.0 - Math.Abs(rnd2) * 0.01);
// Ensure valid OHLC
high = Math.Max(high, Math.Max(open, close));
low = Math.Min(low, Math.Min(open, close));
low = Math.Max(0.0, low);
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;
}
}
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 class GBM : IFeed
#pragma warning restore S101
{
private readonly Random? _rnd;
private double _lastPrice;
private long _lastTime;
private readonly double _mu;
private readonly double _sigma;
// Precomputed GBM constants
private readonly double _drift;
private readonly double _vol;
private readonly long _defaultTimeStep;
// State for streaming bar formation (only when isNew=false)
private TBar _currentBar;
private bool _hasCurrentBar;
// Box-Muller optimization: cache second normal
private double _cachedZ;
private bool _hasCachedZ;
/// <summary>
/// Creates a new GBM generator.
/// </summary>
/// <param name="startPrice">Initial price (default: 100.0, must be positive)</param>
/// <param name="mu">Annual drift/return rate (default: 0.05 = 5%)</param>
/// <param name="sigma">Annual volatility (default: 0.2 = 20%, must be non-negative)</param>
/// <param name="defaultTimeframe">Default timeframe for bars (default: 1 minute)</param>
/// <param name="seed">Optional random seed for reproducibility (default: null for non-deterministic)</param>
public GBM(
double startPrice = 100.0,
double mu = 0.05,
double sigma = 0.2,
TimeSpan? defaultTimeframe = null,
int? seed = null)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(startPrice);
ArgumentOutOfRangeException.ThrowIfNegative(sigma);
_rnd = seed.HasValue ? new Random(seed.Value) : null;
_lastPrice = startPrice;
_lastTime = DateTime.UtcNow.Ticks;
_mu = mu;
_sigma = sigma;
// Use provided timeframe or default to 1 minute
var timeframe = defaultTimeframe ?? TimeSpan.FromMinutes(1);
_defaultTimeStep = timeframe.Ticks;
// Calculate dt based on timeframe (assuming 252 trading days/year, 6.5 hours/day)
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>
/// 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();
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(_drift + _vol * z);
double volume = 1000 + NextDouble() * 1000;
double open = _lastPrice;
double close = price;
double high = Math.Max(open, close) * (1.0 + Math.Abs(NextDouble()) * 0.01);
double low = Math.Min(open, close) * (1.0 - Math.Abs(NextDouble()) * 0.01);
// Ensure valid OHLC
high = Math.Max(high, Math.Max(open, close));
low = Math.Min(low, Math.Min(open, close));
low = Math.Max(0.0, low);
_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(_drift + _vol * z);
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);
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>
[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 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];
// Calculate dt for this specific interval
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(drift + vol * z);
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 + Math.Abs(rnd1) * 0.01);
double low = Math.Min(open, close) * (1.0 - Math.Abs(rnd2) * 0.01);
// Ensure valid OHLC
high = Math.Max(high, Math.Max(open, close));
low = Math.Min(low, Math.Min(open, close));
low = Math.Max(0.0, low);
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;
}
}