unit Myc.Trade.DataFeed; interface uses System.SysUtils, System.Classes, System.Generics.Collections, System.Generics.Defaults, System.IOUtils, Myc.Futures, Myc.Signals, Myc.Mutable, Myc.Data.Scalar, Myc.Data.Keyword, Myc.Data.Pipeline, Myc.Data.Pipeline.Impl, Myc.Core.FileCache; type TImportConverter = reference to procedure(const Src: Pointer; Dst: TScalar.PValue); IScalarRecordFeed = interface {$region 'private'} function GetTicker: TProducer; {$endregion} procedure SetImportOptions(RecordSize: Integer; const Converter: TImportConverter); function EnumerateSymbols: TFuture>; // Streaming processing: Reads data from disk and broadcasts it via Ticker function ProcessData(const Symbol: String; const Terminated: TState): TState; property Ticker: TProducer read GetTicker; end; TScalarRecordFeed = class(TInterfacedObject, IScalarRecordFeed) public type TBatch = record Data: TArray; RecCount: Integer; end; 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; var FPath: String; FDef: IScalarRecordDefinition; FLookback: Int64; FFileExtension: String; FIsCompressed: Boolean; FCachedFiles: IDataFileCache>; FSymbols: TFuture>>; // Producer implementation FTicker: TMycContainedProducer; class var FLoadGate: TLatch; function GetPath: String; // Loading Logic function DoLoad(const FileName: string; const Gate: TLatch): TFuture>; function LoadDataFile(const DataFile: TDataFile): TFuture>; function GetNextFile(const Curr: TDataFile): TDataFile; // Processing Logic function ProcessFile(FileInfo: TDataFile; const DataFile: TFuture>; const Terminated: TState): TState; function ProcessChunks(const Batches: TArray; Terminated: TState): TState; // Binary I/O function ReadCompressedData(const InputStream: TStream): TArray; function ReadUncompressedData(const InputStream: TStream): TArray; // Helpers function ParseFileName(const FileName: string): TDataFile; function FindFirstFile(const Symbol: string): TFuture; procedure UpdateSymbols; private FImportRecordSize: Integer; FImportConverter: TImportConverter; function GetTicker: TProducer; public constructor Create( const ACachedFiles: IDataFileCache>; const APath, AExtension: String; IsCompressed: Boolean; const ADef: IScalarRecordDefinition ); destructor Destroy; override; procedure AfterConstruction; override; procedure SetImportOptions(RecordSize: Integer; const Converter: TImportConverter); function EnumerateSymbols: TFuture>; // Streaming processing: Reads data from disk and broadcasts it via Ticker function ProcessData(const Symbol: String; const Terminated: TState): TState; property Path: String read GetPath; property FileExtension: String read FFileExtension; property IsCompressed: Boolean read FIsCompressed; property Lookback: Int64 read FLookback write FLookback; property Ticker: TProducer read GetTicker; end; implementation uses System.Zip, System.Math, System.StrUtils, Myc.TaskManager; const BatchSize = 1024; type // Private implementation of the view logic. // This object is reused (cursor pattern) to avoid allocations per tick. TScalarRecordView = class(TInterfacedObject, IScalarRecord) private FDef: IScalarRecordDefinition; FData: TScalar.PValue; // IKeywordMapping implementation function IndexOf(const Key: IKeyword): Integer; function GetCount: Integer; function GetItems(Idx: Integer): TPair; function GetFields(const Key: IKeyword): TScalar; public constructor Create(const ADef: IScalarRecordDefinition); procedure SetData(const AData: TScalar.PValue); inline; end; { TScalarRecordView } constructor TScalarRecordView.Create(const ADef: IScalarRecordDefinition); begin inherited Create; FDef := ADef; end; procedure TScalarRecordView.SetData(const AData: TScalar.PValue); begin FData := AData; end; function TScalarRecordView.GetCount: Integer; begin Result := FDef.Count; end; function TScalarRecordView.GetFields(const Key: IKeyword): TScalar; var idx: Integer; begin idx := FDef.IndexOf(Key); if idx < 0 then exit(Default(TScalar)); // Return the raw TValue at the offset Result.Kind := FDef[idx].Value; Result.Value := TScalar.PValue(NativeUInt(FData) + NativeUInt(idx * SizeOf(TScalar.TValue)))^; end; function TScalarRecordView.GetItems(Idx: Integer): TPair; begin Result.Key := FDef[Idx].Key; Result.Value.Kind := FDef[idx].Value; Result.Value.Value := TScalar.PValue(NativeUInt(FData) + NativeUInt(Idx * SizeOf(TScalar.TValue)))^; end; function TScalarRecordView.IndexOf(const Key: IKeyword): Integer; begin Result := FDef.IndexOf(Key); end; { TScalarRecordFeed.TDataFile } constructor TScalarRecordFeed.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 TScalarRecordFeed.TDataFile.GetBaseFileName: string; begin Result := FBasename; end; function TScalarRecordFeed.TDataFile.GetFullFileName: string; begin Result := TPath.Combine(FPath, GetBaseFileName + FExtension); end; function TScalarRecordFeed.TDataFile.GetIsValid: Boolean; begin Result := (FSymbol <> '') and (FYear > 0); end; { TScalarRecordFeed } constructor TScalarRecordFeed.Create( const ACachedFiles: IDataFileCache>; const APath, AExtension: String; IsCompressed: Boolean; const ADef: IScalarRecordDefinition ); begin inherited Create; FPath := APath; FDef := ADef; FCachedFiles := ACachedFiles; FFileExtension := AExtension; FIsCompressed := IsCompressed; FLookback := -1; FImportRecordSize := 0; FImportConverter := nil; FTicker := TMycContainedProducer.Create(Self); FCachedFiles := TDataFileCache>.Create; end; destructor TScalarRecordFeed.Destroy; begin FCachedFiles.Clear; FTicker.Free; inherited Destroy; end; procedure TScalarRecordFeed.AfterConstruction; begin inherited; UpdateSymbols; end; procedure TScalarRecordFeed.SetImportOptions(RecordSize: Integer; const Converter: TImportConverter); begin FImportRecordSize := RecordSize; FImportConverter := Converter; end; function TScalarRecordFeed.GetPath: String; begin Result := FPath; end; function TScalarRecordFeed.GetTicker: TProducer; begin Result := FTicker; end; procedure TScalarRecordFeed.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 TScalarRecordFeed.EnumerateSymbols: TFuture>; begin Result := FSymbols.Chain>( function(const Symbols: TDictionary>): TArray begin Result := Symbols.Keys.ToArray; end ); end; function TScalarRecordFeed.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 TScalarRecordFeed.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 TScalarRecordFeed.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); ext := TPath.GetExtension(fileNameNoPath); if not fileNameNoPath.EndsWith(FFileExtension, True) then exit; baseName := TPath.GetFileNameWithoutExtension(fileNameNoPath); parts := baseName.Split(['_']); if Length(parts) < 3 then exit; var startDatePart := parts[High(parts) - 1]; dateParts := startDatePart.Split(['-']); if Length(dateParts) <> 2 then exit; 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; Result := TDataFile.Create(path, symbol, ext, year, month, baseName); end; function TScalarRecordFeed.LoadDataFile(const DataFile: TDataFile): TFuture>; begin if DataFile.IsValid then Result := FCachedFiles.GetOrAdd( DataFile.GetFullFileName, function(const Filename: String): TFuture> begin Result := DoLoad(Filename, FLoadGate); end ) else Result := TFuture>.Construct(TArray(nil)); end; function TScalarRecordFeed.DoLoad(const FileName: string; const Gate: TLatch): TFuture>; begin Result := TFuture>.Null; if not TFile.Exists(FileName) then exit; var capFileName := FileName; if FIsCompressed then begin Result := TFuture .Construct(Gate.Enqueue, function: TBytes begin Result := TFile.ReadAllBytes(capFileName); end) .Chain>( function(bytes: TBytes): TArray begin var ms := TBytesStream.Create(bytes); try Result := ReadCompressedData(ms); finally ms.Free; end; end); end else begin Result := TFuture>.Construct( Gate.Enqueue, function: TArray begin var fs := TFileStream.Create(capFileName, fmOpenRead or fmShareDenyWrite); try Result := ReadUncompressedData(fs); finally fs.Free; end; end ); end; end; function TScalarRecordFeed.ReadCompressedData(const InputStream: TStream): TArray; var decompressionStream: TStream; localHeader: TZipHeader; entryIndex, i: Integer; zip: TZipFile; begin Result := nil; decompressionStream := nil; zip := nil; if not Assigned(InputStream) or (InputStream.Size = 0) then exit; try InputStream.Position := 0; zip := TZipFile.Create; zip.Open(InputStream, TZipMode.zmRead); if zip.FileCount = 0 then exit; entryIndex := -1; for i := 0 to zip.FileCount - 1 do begin if zip.FileNames[i].EndsWith(FFileExtension, True) then begin entryIndex := i; break; end; end; if entryIndex = -1 then entryIndex := 0; zip.Read(entryIndex, decompressionStream, localHeader); if not Assigned(decompressionStream) then exit; Result := ReadUncompressedData(decompressionStream); finally decompressionStream.Free; zip.Free; end; end; function TScalarRecordFeed.ReadUncompressedData(const InputStream: TStream): TArray; var fileSize, totalRecords: Int64; fieldsPerRecord: Integer; internalRecordSize, sourceRecordSize: Integer; numBatches, batchIdx, k: Integer; bytesToRead, valuesToWrite: Integer; tempBuffer: TBytes; pSrc: PByte; pDst: TScalar.PValue; begin Result := nil; InputStream.Position := 0; fileSize := InputStream.Size; fieldsPerRecord := FDef.Count; internalRecordSize := fieldsPerRecord * sizeof(TScalar.TValue); if (FImportRecordSize > 0) and Assigned(FImportConverter) then sourceRecordSize := FImportRecordSize else sourceRecordSize := internalRecordSize; if (fileSize = 0) or ((fileSize mod sourceRecordSize) <> 0) then exit; totalRecords := fileSize div sourceRecordSize; if totalRecords = 0 then exit; numBatches := (totalRecords + BatchSize - 1) div BatchSize; SetLength(Result, numBatches); if sourceRecordSize <> internalRecordSize then SetLength(tempBuffer, BatchSize * sourceRecordSize); for batchIdx := 0 to numBatches - 1 do begin var recordsInBatch := Min(BatchSize, totalRecords - (batchIdx * BatchSize)); Result[batchIdx].RecCount := recordsInBatch; valuesToWrite := recordsInBatch * fieldsPerRecord; SetLength(Result[batchIdx].Data, valuesToWrite); if sourceRecordSize = internalRecordSize then begin bytesToRead := valuesToWrite * sizeof(TScalar.TValue); if InputStream.Read(Result[batchIdx].Data[0], bytesToRead) <> bytesToRead then raise EReadError.Create('Unexpected end of stream'); end else begin bytesToRead := recordsInBatch * sourceRecordSize; if InputStream.Read(tempBuffer, 0, bytesToRead) <> bytesToRead then raise EReadError.Create('Unexpected end of stream'); pSrc := @tempBuffer[0]; pDst := @Result[batchIdx].Data[0]; for k := 0 to recordsInBatch - 1 do begin FImportConverter(pSrc, pDst); Inc(pSrc, sourceRecordSize); Inc(pDst, fieldsPerRecord); end; end; end; end; function TScalarRecordFeed.ProcessData(const Symbol: String; const Terminated: TState): TState; begin var cTerminated := Terminated; Result := FindFirstFile(Symbol) .Chain( function(const FirstInfo: TDataFile): TState begin Result := ProcessFile(FirstInfo, LoadDataFile(FirstInfo), cTerminated); end); end; function TScalarRecordFeed.ProcessFile(FileInfo: TDataFile; const DataFile: TFuture>; const Terminated: TState): TState; begin if not FileInfo.IsValid or Terminated.IsSet then exit(DataFile.Done); var nextInfo := GetNextFile(FileInfo); var nextFuture := LoadDataFile(nextInfo); var cTerminated := Terminated; Result := DataFile.Chain( function(const Batches: TArray): TState begin var done := ProcessChunks(Batches, Terminated); Result := TaskManager.RunTask(done, function: TState begin Result := ProcessFile(nextInfo, nextFuture, cTerminated); end); end ); end; function TScalarRecordFeed.ProcessChunks(const Batches: TArray; Terminated: TState): TState; begin var callProcess := function(Idx: Integer): TFunc begin var cData := Batches[Idx].Data; var cCount := Batches[Idx].RecCount; Result := function: TState begin if not Terminated.IsSet then begin if (Length(cData) > 0) and (cCount > 0) then begin var Stride := Length(cData) div cCount; var pCursor: TScalar.PValue := @cData[0]; // Initialize reused View object (Cursor Pattern) // This object implements IScalarRecord and is updated for each tick. var ViewObj := TScalarRecordView.Create(FDef); var View := ViewObj as IScalarRecord; for var i := 0 to cCount - 1 do begin ViewObj.SetData(pCursor); // Broadcast to all listeners FTicker.Broadcast(View); Inc(pCursor, Stride); end; end; Result := TState.Null; end; end; end; Result := TState.Null; for var i := 0 to High(Batches) do if not Terminated.IsSet then begin var queue := TaskManager.RunTask(Result, callProcess(i)); Result := queue; end; end; end.