(* ------------------------------------------------------------------------------ Unit Name: Myc.OHLCCache Author: Delphi Algo Trading Assistant (Generated by AI) Date: May 21, 2025 Purpose: This unit provides a class, TOHLC, designed to aggregate raw tick data into OHLC (Open, High, Low, Close) candle data and manage this cached candle data. It supports various aggregation timeframes, including a generic callback mode using an interface, and provides helper functions for data interpretation. Main Features: - OHLC Candle Structure: Defines TCachedCandle record. - TOHLC Class: Encapsulates OHLC candle building and storage. * Aggregation Modes: - Fixed Timeframes: Aggregates ticks into candles of a specified duration. - Tick-by-Tick: Each incoming tick forms a separate OHLC candle. - Auto Aggregation: Calculates aggregation to fit display width. - Generic Builder: Uses a user-defined class implementing IGenericCandleBuilder to determine new candle breaks. * Data Caching, Global Price Range, Validity Status, Metadata, Informational Properties. - Time Formatting: Helper function FormatTimeSpanFromSeconds. Key Constants: - TF_AUTO_AGGREGATION, TF_TICK_BY_TICK, TF_GENERIC_CALLBACK. Key Types: - TCachedCandle, IGenericCandleBuilder, TOHLC. Key Methods in TOHLC: - Create, ClearCache, Build. Dependencies: (As before) ------------------------------------------------------------------------------ *) unit Myc.OHLCCache; interface uses System.Classes, System.SysUtils, System.Math, System.Generics.Collections, System.DateUtils, Myc.TickLoader; const TF_AUTO_AGGREGATION = 0; TF_TICK_BY_TICK = -1; TF_GENERIC_CALLBACK = -2; // Constant remains, signifies generic mode SecsPerDay_Double = 24.0 * 60.0 * 60.0; type TCachedCandle = record OpenPrice: Single; HighPrice: Single; LowPrice: Single; ClosePrice: Single; OriginalDataIndexStart: Integer; OriginalDataIndexEnd: Integer; TickVolume: Integer; end; // Interface for generic candle building logic IGenericCandleBuilder = interface ['{B4F6A9C1-0A7E-42D3-847B-170705A8CC8D}'] // Added GUID function Init(const ADataSet: TArray): Boolean; // Changed DataSet to ADataSet for clarity function IsNewBar(AIndex: Integer; const ATick: TTickData): Boolean; // Changed Idx to AIndex, Tick to ATick function GetCaption: string; // Method for the Caption property property Caption: string read GetCaption; end; TOHLC = class private FCachedCandles: TArray; FGlobalMinY: Single; FGlobalMaxY: Single; FIsValid: Boolean; FAggregationTimeframeSeconds: Int64; FAutoAggCandleDisplayWidth: Integer; FAutoAggPaintBoxClientWidth: Integer; FAutoAggNumCandlesInCache: Integer; FApproxTimePerCandleText: string; FTotalDataTimeSpanText: string; function GetCount: Integer; function GetCandle(Index: Integer): TCachedCandle; public constructor Create; procedure ClearCache; procedure Build(const ATickData: TArray; ASelectedTimeframeSeconds: Int64; AAutoAggCandleWidth: Integer; AAutoAggCandleSpacing: Integer; AAutoAggPaintBoxClientWidth: Integer; AMemoForLogging: TStrings; // Parameter changed from TGenericCandleProc to IGenericCandleBuilder AGenericCandleBuilder: IGenericCandleBuilder = nil); property Candles[Index: Integer]: TCachedCandle read GetCandle; default; property Count: Integer read GetCount; property IsValid: Boolean read FIsValid; property GlobalMinY: Single read FGlobalMinY; property GlobalMaxY: Single read FGlobalMaxY; property AggregationTimeframeSeconds: Int64 read FAggregationTimeframeSeconds; property ApproxTimePerCandleText: string read FApproxTimePerCandleText; property TotalDataTimeSpanText: string read FTotalDataTimeSpanText; property AutoAggNumCandlesInCache: Integer read FAutoAggNumCandlesInCache; end; implementation function FormatTimeSpanFromSeconds(const TotalSecondsInput: Double): string; const SecsPerMin = 60; SecsPerHour = SecsPerMin * 60; SecsPerDayConst = SecsPerHour * 24; var Days, Hours, Minutes, SecComponent: Int64; sTotalSeconds, FracSec: Double; ResultBuilder: TStringBuilder; begin // Standard implementation as previously provided sTotalSeconds := Abs(TotalSecondsInput); if sTotalSeconds < 0.001 then Result := 'less than 1 ms' else if sTotalSeconds < 1.0 then Result := Format('%.0f ms', [sTotalSeconds * 1000.0]) else if sTotalSeconds < SecsPerMin then Result := Format('%.2f seconds', [sTotalSeconds]) else if sTotalSeconds < SecsPerHour then Result := Format('%.1f minutes (approx. %d secs)', [sTotalSeconds / SecsPerMin, Floor(sTotalSeconds)]) else if sTotalSeconds < SecsPerDayConst then Result := Format('%.1f hours (approx. %d mins)', [sTotalSeconds / SecsPerHour, Floor(sTotalSeconds / SecsPerMin)]) else begin ResultBuilder := TStringBuilder.Create; try Days := Trunc(sTotalSeconds / SecsPerDayConst); sTotalSeconds := sTotalSeconds - (Days * SecsPerDayConst); Hours := Trunc(sTotalSeconds / SecsPerHour); sTotalSeconds := sTotalSeconds - (Hours * SecsPerHour); Minutes := Trunc(sTotalSeconds / SecsPerMin); sTotalSeconds := sTotalSeconds - (Minutes * SecsPerMin); SecComponent := Trunc(sTotalSeconds); FracSec := Frac(sTotalSeconds); if Days > 0 then ResultBuilder.Append(Format('%d days, ', [Days])); if Hours > 0 then ResultBuilder.Append(Format('%d hours, ', [Hours])); if Minutes > 0 then ResultBuilder.Append(Format('%d minutes, ', [Minutes])); ResultBuilder.Append(Format('%d', [SecComponent])); if FracSec > 0.001 then ResultBuilder.Append(Format('.%02d', [Round(FracSec * 100)])); ResultBuilder.Append(' seconds'); Result := ResultBuilder.ToString; finally ResultBuilder.Free; end; end; if TotalSecondsInput < 0.0 then Result := '-' + Result; end; { TOHLC } constructor TOHLC.Create; begin ClearCache; end; procedure TOHLC.ClearCache; begin FIsValid := False; SetLength(FCachedCandles, 0); FGlobalMinY := 0; FGlobalMaxY := 0; FAggregationTimeframeSeconds := 0; FAutoAggCandleDisplayWidth := 0; FAutoAggPaintBoxClientWidth := 0; FAutoAggNumCandlesInCache := 0; FApproxTimePerCandleText := 'N/A'; FTotalDataTimeSpanText := 'N/A'; end; function TOHLC.GetCandle(Index: Integer): TCachedCandle; begin if (Index >= 0) and (Index < Length(FCachedCandles)) then Result := FCachedCandles[Index] else Result := Default(TCachedCandle); end; function TOHLC.GetCount: Integer; begin Result := Length(FCachedCandles); end; procedure TOHLC.Build(const ATickData: TArray; ASelectedTimeframeSeconds: Int64; AAutoAggCandleWidth: Integer; AAutoAggCandleSpacing: Integer; AAutoAggPaintBoxClientWidth: Integer; AMemoForLogging: TStrings; AGenericCandleBuilder: IGenericCandleBuilder = nil); // Parameter changed var I, CurrentTickIndex, DataIndexStart, DataIndexEnd, J, NumDataPoints, ChartPixelWidthForAutoAgg, NumCandlesToCacheAuto, SlotWidthForAutoAgg: Integer; OpenPrice, HighPrice, LowPrice, ClosePrice: Single; BucketStartTimeOADate, BucketEndTimeOADate, TimeframeIntervalOADays: Double; CandleList: TList; TempCandle: TCachedCandle; LogTimePerCandleStr: string; CurrentTick: TTickData; begin if AMemoForLogging <> nil then AMemoForLogging.Add(FormatDateTime('yyyy-mm-dd hh:nn:ss', Now) + Format(' - TOHLC.Build: TF Secs: %d...', [ASelectedTimeframeSeconds])); ClearCache(); FAggregationTimeframeSeconds := ASelectedTimeframeSeconds; NumDataPoints := Length(ATickData); if NumDataPoints < 1 then begin if AMemoForLogging <> nil then AMemoForLogging.Add('TOHLC.Build: No tick data.'); Exit; end; FGlobalMinY := ATickData[0].Ask; FGlobalMaxY := ATickData[0].Ask; for I := 1 to High(ATickData) do begin if ATickData[I].Ask < FGlobalMinY then FGlobalMinY := ATickData[I].Ask; if ATickData[I].Ask > FGlobalMaxY then FGlobalMaxY := ATickData[I].Ask; end; if FGlobalMaxY = FGlobalMinY then begin FGlobalMaxY := FGlobalMinY + 0.0001; // Ensure a minimal range FGlobalMinY := FGlobalMinY - 0.0001; end; if FGlobalMaxY = FGlobalMinY then // Still equal (e.g. if original was 0) FGlobalMaxY := FGlobalMinY + 1; // Ensure a non-zero range if AMemoForLogging <> nil then AMemoForLogging.Add(Format('TOHLC.Build: Global Y-Range: %.5f to %.5f', [FGlobalMinY, FGlobalMaxY])); LogTimePerCandleStr := 'N/A'; // Generic Candle Building using IGenericCandleBuilder if (ASelectedTimeframeSeconds = TF_GENERIC_CALLBACK) and Assigned(AGenericCandleBuilder) then begin if AMemoForLogging <> nil then AMemoForLogging.Add(Format('TOHLC.Build (Generic Builder: %s): Aggregating %d ticks.', [AGenericCandleBuilder.Caption, NumDataPoints])); if not AGenericCandleBuilder.Init(ATickData) then begin if AMemoForLogging <> nil then AMemoForLogging.Add(Format('TOHLC.Build (Generic Builder: %s): Init method failed.', [AGenericCandleBuilder.Caption])); FIsValid := False; // Ensure cache is marked as invalid Exit; // Exit if Init fails end; CandleList := TList.Create; try if NumDataPoints > 0 then begin DataIndexStart := 0; OpenPrice := ATickData[0].Ask; HighPrice := ATickData[0].Ask; LowPrice := ATickData[0].Ask; ClosePrice := ATickData[0].Ask; for I := 0 to NumDataPoints - 1 do begin CurrentTick := ATickData[I]; // Use IGenericCandleBuilder.IsNewBar if (I > DataIndexStart) and AGenericCandleBuilder.IsNewBar(I, CurrentTick) then // Current tick starts a NEW candle begin // Finalize the PREVIOUS candle (ending at I-1) TempCandle.OpenPrice := OpenPrice; TempCandle.HighPrice := HighPrice; TempCandle.LowPrice := LowPrice; TempCandle.ClosePrice := ATickData[I - 1].Ask; // Close of previous candle is the tick before the new bar starts TempCandle.OriginalDataIndexStart := DataIndexStart; TempCandle.OriginalDataIndexEnd := I - 1; TempCandle.TickVolume := (I - 1) - DataIndexStart + 1; CandleList.Add(TempCandle); // Current tick 'I' starts a new candle DataIndexStart := I; OpenPrice := CurrentTick.Ask; HighPrice := CurrentTick.Ask; LowPrice := CurrentTick.Ask; ClosePrice := CurrentTick.Ask; end else // Current tick continues the existing candle OR is the first tick of the very first candle begin if I = DataIndexStart then // This is the first tick of the current (or overall first) candle begin OpenPrice := CurrentTick.Ask; // Open is set HighPrice := CurrentTick.Ask; // Initial High LowPrice := CurrentTick.Ask; // Initial Low end else // Update High/Low for ongoing candle begin if CurrentTick.Ask > HighPrice then HighPrice := CurrentTick.Ask; if CurrentTick.Ask < LowPrice then LowPrice := CurrentTick.Ask; end; ClosePrice := CurrentTick.Ask; // Always update close to current tick end; // If this is the last tick in the dataset, finalize the current candle if I = NumDataPoints - 1 then begin TempCandle.OpenPrice := OpenPrice; TempCandle.HighPrice := HighPrice; TempCandle.LowPrice := LowPrice; TempCandle.ClosePrice := ClosePrice; // Close price is this tick's price TempCandle.OriginalDataIndexStart := DataIndexStart; TempCandle.OriginalDataIndexEnd := I; TempCandle.TickVolume := I - DataIndexStart + 1; CandleList.Add(TempCandle); end; end; end; FCachedCandles := CandleList.ToArray; LogTimePerCandleStr := Format('(%s)', [AGenericCandleBuilder.Caption]); // Use Builder's Caption if AMemoForLogging <> nil then AMemoForLogging.Add(Format('TOHLC.Build (Generic Builder: %s): Created %d candles.', [AGenericCandleBuilder.Caption, Length(FCachedCandles)])); finally CandleList.Free; end; end else if FAggregationTimeframeSeconds = TF_AUTO_AGGREGATION then begin // Auto Aggregation logic FAutoAggCandleDisplayWidth := AAutoAggCandleWidth; FAutoAggPaintBoxClientWidth := AAutoAggPaintBoxClientWidth; ChartPixelWidthForAutoAgg := FAutoAggPaintBoxClientWidth; if ChartPixelWidthForAutoAgg <= 0 then begin if AMemoForLogging <> nil then AMemoForLogging.Add('TOHLC.Build (Auto): PaintBox width too small.'); Exit; end; SlotWidthForAutoAgg := FAutoAggCandleDisplayWidth + AAutoAggCandleSpacing; if SlotWidthForAutoAgg <= 0 then begin if AMemoForLogging <> nil then AMemoForLogging.Add('TOHLC.Build (Auto): SlotWidth zero or negative.'); Exit; end; NumCandlesToCacheAuto := ChartPixelWidthForAutoAgg div SlotWidthForAutoAgg; FAutoAggNumCandlesInCache := NumCandlesToCacheAuto; if AMemoForLogging <> nil then AMemoForLogging.Add(Format('TOHLC.Build (Auto): Aggregating with CW %dpx, Sp %dpx. SlotW %dpx. NumCandles: %d.', [FAutoAggCandleDisplayWidth, AAutoAggCandleSpacing, SlotWidthForAutoAgg, NumCandlesToCacheAuto])); if NumCandlesToCacheAuto <= 0 then begin if AMemoForLogging <> nil then AMemoForLogging.Add('TOHLC.Build (Auto): Not enough space for candles.'); Exit; end; SetLength(FCachedCandles, NumCandlesToCacheAuto); if (NumDataPoints > 1) and (NumCandlesToCacheAuto > 0) then begin var FirstTickTimeVal_A := ATickData[0].OADateTime; var LastTickTimeVal_A := ATickData[High(ATickData)].OADateTime; var TotalDataDurationDaysVal_A := LastTickTimeVal_A - FirstTickTimeVal_A; if NumCandlesToCacheAuto > 0 then // Avoid division by zero if NumCandlesToCacheAuto is somehow 0 here begin var AvgCandleDurationDaysVal_A := TotalDataDurationDaysVal_A / NumCandlesToCacheAuto; LogTimePerCandleStr := FormatTimeSpanFromSeconds(AvgCandleDurationDaysVal_A * SecsPerDay_Double); end else LogTimePerCandleStr := 'N/A (Auto)'; end; for I := 0 to NumCandlesToCacheAuto - 1 do begin DataIndexStart := Floor((I * SlotWidthForAutoAgg) * (NumDataPoints / ChartPixelWidthForAutoAgg)); DataIndexEnd := Floor(((I + 1) * SlotWidthForAutoAgg) * (NumDataPoints / ChartPixelWidthForAutoAgg)) - 1; DataIndexStart := Max(0, DataIndexStart); DataIndexEnd := Min(High(ATickData), Max(DataIndexStart, DataIndexEnd)); if (DataIndexStart > High(ATickData)) or (DataIndexStart > DataIndexEnd) then begin FCachedCandles[I] := Default(TCachedCandle); // Initialize with default values Continue; end; OpenPrice := ATickData[DataIndexStart].Ask; ClosePrice := ATickData[DataIndexEnd].Ask; HighPrice := OpenPrice; // Initialize with Open LowPrice := OpenPrice; // Initialize with Open for J := DataIndexStart to DataIndexEnd do begin if ATickData[J].Ask > HighPrice then HighPrice := ATickData[J].Ask; if ATickData[J].Ask < LowPrice then LowPrice := ATickData[J].Ask; end; // Ensure High/Low encompass Open/Close after loop HighPrice := Max(HighPrice, Max(OpenPrice, ClosePrice)); LowPrice := Min(LowPrice, Min(OpenPrice, ClosePrice)); FCachedCandles[I].OpenPrice := OpenPrice; FCachedCandles[I].HighPrice := HighPrice; FCachedCandles[I].LowPrice := LowPrice; FCachedCandles[I].ClosePrice := ClosePrice; FCachedCandles[I].OriginalDataIndexStart := DataIndexStart; FCachedCandles[I].OriginalDataIndexEnd := DataIndexEnd; FCachedCandles[I].TickVolume := DataIndexEnd - DataIndexStart + 1; end; end else if FAggregationTimeframeSeconds = TF_TICK_BY_TICK then begin // Tick-by-Tick logic if AMemoForLogging <> nil then AMemoForLogging.Add(Format('TOHLC.Build (Tick-by-Tick): Creating %d candles.', [NumDataPoints])); SetLength(FCachedCandles, NumDataPoints); for I := 0 to NumDataPoints - 1 do begin FCachedCandles[I].OpenPrice := ATickData[I].Ask; FCachedCandles[I].HighPrice := ATickData[I].Ask; FCachedCandles[I].LowPrice := ATickData[I].Ask; FCachedCandles[I].ClosePrice := ATickData[I].Ask; FCachedCandles[I].OriginalDataIndexStart := I; FCachedCandles[I].OriginalDataIndexEnd := I; FCachedCandles[I].TickVolume := 1; end; LogTimePerCandleStr := '(duration of one tick)'; end else // Fixed Timeframe (FAggregationTimeframeSeconds > 0) begin // Fixed Timeframe logic CandleList := TList.Create; try TimeframeIntervalOADays := FAggregationTimeframeSeconds / SecsPerDay_Double; if TimeframeIntervalOADays <= 1E-9 then // Avoid division by zero or too small interval begin if AMemoForLogging <> nil then AMemoForLogging.Add(Format('TOHLC.Build (Fixed TF): Error - Interval too small (%.10f).', [TimeframeIntervalOADays])); Exit; end; CurrentTickIndex := 0; while CurrentTickIndex <= High(ATickData) do begin BucketStartTimeOADate := Floor(ATickData[CurrentTickIndex].OADateTime / TimeframeIntervalOADays) * TimeframeIntervalOADays; BucketEndTimeOADate := BucketStartTimeOADate + TimeframeIntervalOADays; DataIndexStart := CurrentTickIndex; OpenPrice := ATickData[CurrentTickIndex].Ask; HighPrice := ATickData[CurrentTickIndex].Ask; LowPrice := ATickData[CurrentTickIndex].Ask; ClosePrice := ATickData[CurrentTickIndex].Ask; // Initialize close with open DataIndexEnd := CurrentTickIndex; // Initialize end with start // Iterate through ticks that fall into the current bucket while (CurrentTickIndex <= High(ATickData)) and (ATickData[CurrentTickIndex].OADateTime >= BucketStartTimeOADate) and (ATickData[CurrentTickIndex].OADateTime < BucketEndTimeOADate) do begin if ATickData[CurrentTickIndex].Ask > HighPrice then HighPrice := ATickData[CurrentTickIndex].Ask; if ATickData[CurrentTickIndex].Ask < LowPrice then LowPrice := ATickData[CurrentTickIndex].Ask; ClosePrice := ATickData[CurrentTickIndex].Ask; // Continuously update close price DataIndexEnd := CurrentTickIndex; // Mark the last tick included in this candle Inc(CurrentTickIndex); end; // If no ticks were processed in the inner loop (e.g., if the first tick was already >= BucketEndTimeOADate) // ensure we advance CurrentTickIndex to avoid an infinite loop if DataIndexStart remains unchanged. // This situation should ideally be rare if data is sorted and dense enough for the timeframe. // However, if DataIndexStart equals CurrentTickIndex here, it means the inner loop didn't run. // This could happen if ATickData[CurrentTickIndex].OADateTime was already >= BucketEndTimeOADate. // In such case, we still form a candle with the single tick at DataIndexStart, and then CurrentTickIndex must advance. // If the loop DID run, CurrentTickIndex is already pointing to the next tick outside the bucket. if (DataIndexStart = DataIndexEnd) And (CurrentTickIndex = DataIndexStart) then // Only one tick considered, and it might not have advanced CurrentTickIndex begin if CurrentTickIndex <= High(ATickData) then // If it wasn't the very last tick. Inc(CurrentTickIndex); // Ensure progress if loop condition makes it stick. end; // If the inner while loop exited because CurrentTickIndex > High(ATickData), DataIndexStart might still be a valid index // for the last candle. // Or, if the loop exited due to time boundary, CurrentTickIndex is correctly positioned for the next bucket. TempCandle.OpenPrice := OpenPrice; TempCandle.HighPrice := HighPrice; TempCandle.LowPrice := LowPrice; TempCandle.ClosePrice := ClosePrice; TempCandle.OriginalDataIndexStart := DataIndexStart; TempCandle.OriginalDataIndexEnd := DataIndexEnd; TempCandle.TickVolume := (DataIndexEnd - DataIndexStart) + 1; CandleList.Add(TempCandle); if CurrentTickIndex > High(ATickData) then Break; // All ticks processed end; FCachedCandles := CandleList.ToArray; LogTimePerCandleStr := FormatTimeSpanFromSeconds(FAggregationTimeframeSeconds); if AMemoForLogging <> nil then AMemoForLogging.Add(Format('TOHLC.Build (Fixed TF): Created %d candles.', [Length(FCachedCandles)])); finally CandleList.Free; end; end; if Length(ATickData) > 0 then begin var FirstTickTimeVal := ATickData[0].OADateTime; var LastTickTimeVal := ATickData[High(ATickData)].OADateTime; FTotalDataTimeSpanText := FormatTimeSpanFromSeconds((LastTickTimeVal - FirstTickTimeVal) * SecsPerDay_Double); if AMemoForLogging <> nil then AMemoForLogging.Add(Format('TOHLC.Build: Total data time span: %s.', [FTotalDataTimeSpanText])); end; FApproxTimePerCandleText := LogTimePerCandleStr; if AMemoForLogging <> nil then AMemoForLogging.Add(Format('TOHLC.Build: Time per cached candle: %s.', [FApproxTimePerCandleText])); FIsValid := True; if AMemoForLogging <> nil then AMemoForLogging.Add(FormatDateTime('yyyy-mm-dd hh:nn:ss', Now) + ' - TOHLC.Build: OHLC cache built.'); end; end.