using System.Globalization; using System.Runtime.CompilerServices; namespace QuanTAlib; /// /// 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) /// public class CsvFeed : IFeed { private readonly TBarSeries _data; // Streaming state private int _currentIndex; private TBar _currentBar; private bool _hasCurrentBar; /// /// Loads CSV file and prepares data for streaming. /// Data is reversed to chronological order (oldest first). /// /// Path to CSV file 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; } /// /// 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). /// private static TBarSeries LoadFromCsv(string filePath) { var dataLines = new List(); 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; } /// /// Gets the next bar with full bidirectional control. /// When end of data reached, returns last bar and sets isNew=false. /// [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; } /// /// Gets the next bar with simple control. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public TBar Next(bool isNew = true) { return Next(ref isNew); } /// /// Returns a filtered subset of data matching the criteria. /// Resets streaming position to start of returned data. /// 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; } }