unit Myc.Trade.DataStream; interface uses System.SysUtils, System.Classes, System.Generics.Collections, System.Generics.Defaults, System.IOUtils, Myc.Futures, Myc.Signals, Myc.Mutable, Myc.Trade.Types, Myc.Data.Pipeline, Myc.Core.FileCache; type // Interface for an instantiable data server. IDataServer = interface procedure ClearCache; function ProcessData(const Symbol: String; const Terminated: TState; const Processor: IConsumer>>): TState; function EnumerateSymbols: TFuture>; end; // Represents metadata for a single data file. TDataFile = record private FExtension: String; FPath: String; FSymbol: String; FYear: Integer; FMonth: Integer; FBasename: String; function GetIsValid: Boolean; public constructor Create(const APath, ASymbol, AExtension: String; AYear, AMonth: Integer; const ABasename: String); function GetBaseFileName: string; function GetFullFileName: string; property IsValid: Boolean read GetIsValid; property Extension: String read FExtension; property Path: String read FPath; property Symbol: String read FSymbol; property Year: Integer read FYear; property Month: Integer read FMonth; end; // Generic server for loading and managing sequential time-series data from files. TDataServer = class(TInterfacedObject, IDataServer) private FCachedFiles: TDataFileCache>>>; FPath: String; // The cache now holds a sorted array of all files for each symbol. FSymbols: TFuture>>; function GetPath: String; function ProcessChunks( const DataChunks: TArray>>; Terminated: TState; Processor: IConsumer>> ): TState; function ProcessFile( FileInfo: TDataFile; const DataFile: TFuture>>>; const Terminated: TState; Processor: IConsumer>> ): TState; strict private class var FLoadGate: TLatch; protected // Used by cache to actually load an uncached file. Now virtual and abstract. function DoLoad(const FileName: string; const Gate: TLatch): TFuture>>>; virtual; abstract; // Gets the next consecutive data file from the cached, sorted list. function GetNextFile(const Curr: TDataFile): TDataFile; virtual; public constructor Create(const APath: String); destructor Destroy; override; procedure AfterConstruction; override; procedure ClearCache; procedure UpdateSymbols; // Scans a directory to find the oldest file for a specific symbol. function FindFirstFile(const Symbol: string): TFuture; function ParseFileName(const FileName: string): TDataFile; virtual; abstract; function EnumerateSymbols: TFuture>; // Load data file and split content into chunks. function LoadDataFile(const DataFile: TDataFile): TFuture>>>; function ProcessData(const Symbol: String; const Terminated: TState; const Processor: IConsumer>>): TState; property Path: String read GetPath; end; TAskBidFileItem = packed record Ask: Single; Bid: Single; end; TAskBidFileServer = class(TDataServer) private class function ReadCompressedData(const InputStream: TStream): TArray>>; class function ReadUncompressedData(const InputStream: TStream): TArray>>; protected function DoLoad(const FileName: string; const Gate: TLatch): TFuture>>>; override; public function ParseFileName(const FileName: string): TDataFile; override; end; TM1FileItem = packed record OADateTime: Double; Open: Int64; High: Int64; Low: Int64; Close: Int64; Spread: Int64; TickVolume: Integer; Digits: Integer; end; // File server for cTrader M1 data. Note that the generic type is TOhlcItem, // as the server converts the file data into standard OHLC items. TM1FileServer = class(TDataServer) private class function ReadCompressedData(const InputStream: TStream): TArray>>; class function ReadUncompressedData(const InputStream: TStream): TArray>>; protected function DoLoad(const FileName: string; const Gate: TLatch): TFuture>>>; override; public function ParseFileName(const FileName: string): TDataFile; override; end; implementation uses System.Zip, System.ZLib, System.Math, System.StrUtils, Myc.TaskManager; const // The number of records per data chunk when loading files. ChunkSize = 512; { TDataFile } constructor TDataFile.Create(const APath, ASymbol, AExtension: String; AYear, AMonth: Integer; const ABasename: String); begin FPath := APath; FSymbol := ASymbol; FExtension := AExtension; FYear := AYear; FMonth := AMonth; FBasename := ABasename; end; function TDataFile.GetBaseFileName: string; begin Result := FBasename; end; function TDataFile.GetFullFileName: string; begin Result := TPath.Combine(FPath, GetBaseFileName + FExtension); end; function TDataFile.GetIsValid: Boolean; begin Result := (FSymbol <> '') and (FYear > 0); end; { TDataServer } constructor TDataServer.Create(const APath: String); begin inherited Create; FPath := APath; FCachedFiles := TDataFileCache>>>.Create( function(const Filename: String): TFuture>>> begin // Pass the private load gate to the virtual DoLoad method. Result := DoLoad(Filename, FLoadGate); end ); end; destructor TDataServer.Destroy; begin ClearCache; FCachedFiles.Free; inherited Destroy; end; procedure TDataServer.AfterConstruction; begin inherited; UpdateSymbols; end; procedure TDataServer.ClearCache; begin FCachedFiles.Clear; end; procedure TDataServer.UpdateSymbols; begin FSymbols := TFuture>>.Construct( FSymbols.Done, function: TDictionary> var fileNames: TArray; tempSymbolMap: TDictionary>; dataFile: TDataFile; symbolList: TList; begin Result := TDictionary>.Create; tempSymbolMap := TDictionary>.Create; try if not TDirectory.Exists(FPath) then exit; fileNames := TDirectory.GetFiles(FPath); for var currentFile in fileNames do begin dataFile := ParseFileName(currentFile); if dataFile.IsValid then begin if not tempSymbolMap.TryGetValue(dataFile.Symbol, symbolList) then begin symbolList := TList.Create; tempSymbolMap.Add(dataFile.Symbol, symbolList); end; symbolList.Add(dataFile); end; end; for var kvp in tempSymbolMap do begin symbolList := kvp.Value; symbolList.Sort( TComparer.Construct( function(const Left, Right: TDataFile): Integer begin if Left.Year < Right.Year then Result := -1 else if Left.Year > Right.Year then Result := 1 else Result := Left.Month - Right.Month; end ) ); Result.Add(kvp.Key, symbolList.ToArray); end; finally for var kvp in tempSymbolMap do kvp.Value.Free; tempSymbolMap.Free; end; end ); FSymbols.Manage; end; function TDataServer.EnumerateSymbols: TFuture>; begin Result := FSymbols.Chain>( function(const Symbols: TDictionary>): TArray begin Result := Symbols.Keys.ToArray; end ); end; function TDataServer.FindFirstFile(const Symbol: string): TFuture; begin var symName := Symbol; Result := FSymbols.Chain( function(const Symbols: TDictionary>): TDataFile var symbolFiles: TArray; begin if Symbols.TryGetValue(symName, symbolFiles) and (Length(symbolFiles) > 0) then Result := symbolFiles[0] else Result := Default(TDataFile); end ); end; function TDataServer.GetNextFile(const Curr: TDataFile): TDataFile; var allSymbolFiles: TArray; foundIndex: Integer; begin Result := Default(TDataFile); if not Curr.IsValid or not FSymbols.Done.IsSet then exit; if not FSymbols.Value.TryGetValue(Curr.Symbol, allSymbolFiles) then exit; foundIndex := -1; for var i := 0 to High(allSymbolFiles) do begin if allSymbolFiles[i].GetBaseFileName() = Curr.GetBaseFileName() then begin foundIndex := i; break; end; end; if (foundIndex <> -1) and (foundIndex < High(allSymbolFiles)) then begin Result := allSymbolFiles[foundIndex + 1]; end; end; function TDataServer.GetPath: String; begin Result := FPath; end; function TDataServer.ProcessData( const Symbol: String; const Terminated: TState; const Processor: IConsumer>> ): TState; begin var cProc := Processor; var cTerminated := Terminated; Result := FindFirstFile(Symbol) .Chain( function(const FirstFileInfo: TDataFile): TState begin Result := ProcessFile(FirstFileInfo, LoadDataFile(FirstFileInfo), cTerminated, cProc); end); end; function TDataServer.LoadDataFile(const DataFile: TDataFile): TFuture>>>; begin if DataFile.IsValid then Result := FCachedFiles.GetOrAdd(DataFile.GetFullFileName) else Result := TFuture>>>.Construct(TArray>>(nil)); end; function TDataServer.ProcessChunks( const DataChunks: TArray>>; Terminated: TState; Processor: IConsumer>> ): TState; begin var callProcess := function(Idx: Integer): TFunc begin var cChunk := DataChunks[Idx]; Result := function: TState begin if not Terminated.IsSet then Result := Processor.Consume(cChunk); end; end; for var i := 0 to High(DataChunks) do if not Terminated.IsSet then begin var q := TaskManager.RunTask(Result, callProcess(i)); Result := q; end end; function TDataServer.ProcessFile( FileInfo: TDataFile; const DataFile: TFuture>>>; const Terminated: TState; Processor: IConsumer>> ): TState; begin if not FileInfo.IsValid or Terminated.IsSet then exit(DataFile.Done); // Read ahead the next file, while processing the current one var nextFileInfo := GetNextFile(FileInfo); var nextFile := LoadDataFile(nextFileInfo); var cTerminated := Terminated; Result := DataFile.Chain( function(const DataChunks: TArray>>): TState begin // Process each chunk, checking for termination between chunks. var done := ProcessChunks(DataChunks, Terminated, Processor); // Move to next file (which is currently preloading) once all Processors are done Result := TaskManager .RunTask(done, function: TState begin Result := ProcessFile(nextFileInfo, nextFile, cTerminated, Processor); end); end ); end; { TAskBidFileServer } function TAskBidFileServer.DoLoad(const FileName: string; const Gate: TLatch): TFuture>>>; begin Result := TFuture>>>.Null; if not TFile.Exists(FileName) then exit; var capFileName := FileName; if FileName.EndsWith('_zip', True) then begin Result := TFuture .Construct(Gate.Enqueue, function: TBytes begin Result := TFile.ReadAllBytes(capFileName); end) .Chain>>>( function(bytes: TBytes): TArray>> begin var compMemoryStream := TBytesStream.Create(bytes); try compMemoryStream.Position := 0; Result := ReadCompressedData(compMemoryStream); finally compMemoryStream.Free; end; end); end else begin Result := TFuture>>>.Construct( Gate.Enqueue, function: TArray>> begin if TFile.Exists(capFileName) then begin var stream := TFileStream.Create(capFileName, fmOpenRead or fmShareDenyWrite); try Result := ReadUncompressedData(stream); finally stream.Free; end; end; end ); end; end; function TAskBidFileServer.ParseFileName(const FileName: string): TDataFile; var fileNameNoPath, nameForParsing, baseName, ext, path, symbol: string; year, month: Integer; parts: TArray; begin Result := Default(TDataFile); path := TPath.GetDirectoryName(FileName); fileNameNoPath := TPath.GetFileName(FileName); nameForParsing := fileNameNoPath; ext := ''; if nameForParsing.EndsWith('_zip', True) then begin ext := '_zip'; nameForParsing := nameForParsing.Substring(0, nameForParsing.Length - 4); end; var fileExt := TPath.GetExtension(nameForParsing) + ext; baseName := TPath.GetFileNameWithoutExtension(nameForParsing); parts := baseName.Split(['_']); if Length(parts) < 3 then exit; if not TryStrToInt(parts[High(parts)], month) then exit; if (month < 1) or (month > 12) then exit; if not TryStrToInt(parts[High(parts) - 1], year) then exit; if year <= 0 then exit; symbol := string.Join('_', Copy(parts, 0, Length(parts) - 2)); if symbol = '' then exit; Result := TDataFile.Create(path, symbol, fileExt, year, month, baseName); end; class function TAskBidFileServer.ReadCompressedData(const InputStream: TStream): TArray>>; var decompressionStream: TStream; localHeader: TZipHeader; entryIndex, i: Integer; zipFileInstance: TZipFile; begin Result := nil; decompressionStream := nil; zipFileInstance := nil; try InputStream.Position := 0; zipFileInstance := TZipFile.Create; zipFileInstance.Open(InputStream, TZipMode.zmRead); if zipFileInstance.FileCount = 0 then exit; entryIndex := -1; for i := 0 to zipFileInstance.FileCount - 1 do if zipFileInstance.FileNames[i].EndsWith('.tab', True) then begin entryIndex := i; break; end; if entryIndex = -1 then entryIndex := 0; zipFileInstance.Read(entryIndex, decompressionStream, localHeader); if not Assigned(decompressionStream) then exit; Result := ReadUncompressedData(decompressionStream); finally decompressionStream.Free; zipFileInstance.Free; end; end; class function TAskBidFileServer.ReadUncompressedData(const InputStream: TStream): TArray>>; type TFileRecord = packed record TimeStamp: TDateTime; Data: TAskBidFileItem; end; var fileSize: Int64; totalRecordCount, bytesRead, chunkIndex, indexInChunk, recordIndex: Integer; rec: TFileRecord; begin Result := nil; InputStream.Position := 0; fileSize := InputStream.Size; if (fileSize = 0) or ((fileSize mod SizeOf(TFileRecord)) <> 0) then exit; totalRecordCount := fileSize div SizeOf(TFileRecord); if totalRecordCount > 0 then begin var numChunks := (totalRecordCount + ChunkSize - 1) div ChunkSize; SetLength(Result, numChunks); chunkIndex := 0; indexInChunk := 0; SetLength(Result[chunkIndex], Min(ChunkSize, totalRecordCount)); for recordIndex := 0 to totalRecordCount - 1 do begin if indexInChunk >= ChunkSize then begin Inc(chunkIndex); indexInChunk := 0; var remainingRecords := totalRecordCount - (chunkIndex * ChunkSize); SetLength(Result[chunkIndex], Min(ChunkSize, remainingRecords)); end; bytesRead := InputStream.Read(rec, SizeOf(TFileRecord)); if bytesRead <> SizeOf(TFileRecord) then raise EReadError.CreateFmt('Read error. Expected %d bytes, read %d.', [SizeOf(TFileRecord), bytesRead]); // Corrected syntax: Assign the newly created record instance. Result[chunkIndex][indexInChunk] := TDataPoint.Create(rec.TimeStamp, rec.Data); Inc(indexInChunk); end; end; end; { TM1FileServer } function TM1FileServer.DoLoad(const FileName: string; const Gate: TLatch): TFuture>>>; begin Result := TFuture>>>.Null; if not TFile.Exists(FileName) then exit; var capFileName := FileName; // M1 files are always compressed with the .m1 extension if FileName.EndsWith('.m1', True) then begin Result := TFuture .Construct(Gate.Enqueue, function: TBytes begin Result := TFile.ReadAllBytes(capFileName); end) .Chain>>>( function(bytes: TBytes): TArray>> begin var compMemoryStream := TBytesStream.Create(bytes); try compMemoryStream.Position := 0; Result := ReadCompressedData(compMemoryStream); finally compMemoryStream.Free; end; end); end; end; function TM1FileServer.ParseFileName(const FileName: string): TDataFile; var path, fileNameNoPath, baseName, ext, symbol: string; year, month: Integer; parts, dateParts: TArray; begin Result := Default(TDataFile); path := TPath.GetDirectoryName(FileName); fileNameNoPath := TPath.GetFileName(FileName); // M1 files are compressed and have a .m1 extension if not fileNameNoPath.EndsWith('.m1', True) then exit; ext := TPath.GetExtension(fileNameNoPath); baseName := TPath.GetFileNameWithoutExtension(fileNameNoPath); // Expected format: SYMBOL_YYYY-MM_YYYY-MM parts := baseName.Split(['_']); if Length(parts) < 3 then exit; // Not enough parts for SYMBOL_START_END // The symbol can contain underscores, so we take everything before the last two parts. var startDatePart := parts[High(parts) - 1]; dateParts := startDatePart.Split(['-']); if Length(dateParts) <> 2 then exit; // Start date is not YYYY-MM if not TryStrToInt(dateParts[0], year) then exit; if not TryStrToInt(dateParts[1], month) then exit; if (month < 1) or (month > 12) or (year <= 0) then exit; symbol := string.Join('_', Copy(parts, 0, Length(parts) - 2)); if symbol = '' then exit; // The TDataFile only represents the start date of the file batch. Result := TDataFile.Create(path, symbol, ext, year, month, baseName); end; class function TM1FileServer.ReadCompressedData(const InputStream: TStream): TArray>>; var decompressionStream: TStream; localHeader: TZipHeader; entryIndex, i: Integer; zipFileInstance: TZipFile; begin Result := nil; decompressionStream := nil; zipFileInstance := nil; // M1 files are now zip archives containing the actual data file. if not Assigned(InputStream) or (InputStream.Size = 0) then exit; try InputStream.Position := 0; zipFileInstance := TZipFile.Create; zipFileInstance.Open(InputStream, TZipMode.zmRead); if zipFileInstance.FileCount = 0 then exit; // Find the data file entry within the archive. // The C# code creates an entry with the same name as the base filename, ending in .m1. entryIndex := -1; for i := 0 to zipFileInstance.FileCount - 1 do begin if zipFileInstance.FileNames[i].EndsWith('.m1', True) then begin entryIndex := i; break; end; end; // Fallback to the first entry if no specific .m1 file is found inside. if entryIndex = -1 then entryIndex := 0; zipFileInstance.Read(entryIndex, decompressionStream, localHeader); if not Assigned(decompressionStream) then exit; // The decompressed stream now contains the raw binary data. Result := ReadUncompressedData(decompressionStream); finally decompressionStream.Free; zipFileInstance.Free; end; end; class function TM1FileServer.ReadUncompressedData(const InputStream: TStream): TArray>>; type TFileRecord = TM1FileItem; var fileSize: Int64; totalRecordCount, bytesRead, chunkIndex, indexInChunk, recordIndex: Integer; rec: TFileRecord; ohlc: TOhlcItem; factor: Double; begin Result := nil; InputStream.Position := 0; fileSize := InputStream.Size; if (fileSize = 0) or ((fileSize mod SizeOf(TFileRecord)) <> 0) then exit; totalRecordCount := fileSize div SizeOf(TFileRecord); if totalRecordCount > 0 then begin var numChunks := (totalRecordCount + ChunkSize - 1) div ChunkSize; SetLength(Result, numChunks); chunkIndex := 0; indexInChunk := 0; SetLength(Result[chunkIndex], Min(ChunkSize, totalRecordCount)); for recordIndex := 0 to totalRecordCount - 1 do begin if indexInChunk >= ChunkSize then begin Inc(chunkIndex); indexInChunk := 0; var remainingRecords := totalRecordCount - (chunkIndex * ChunkSize); SetLength(Result[chunkIndex], Min(ChunkSize, remainingRecords)); end; bytesRead := InputStream.Read(rec, SizeOf(TFileRecord)); if bytesRead <> SizeOf(TFileRecord) then raise EReadError.CreateFmt('Read error. Expected %d bytes, read %d.', [SizeOf(TFileRecord), bytesRead]); // Convert from M1 file record to a standard OHLC item if rec.Digits > 0 then factor := Power(10, rec.Digits) else factor := 1.0; ohlc.Open := rec.Open / factor; ohlc.High := rec.High / factor; ohlc.Low := rec.Low / factor; ohlc.Close := rec.Close / factor; ohlc.Volume := rec.TickVolume; // Corrected syntax: Assign the newly created record instance. Result[chunkIndex][indexInChunk] := TDataPoint.Create(rec.OADateTime, ohlc); Inc(indexInChunk); end; end; end; end.