Files
cTrader-DataExport/common/Myc.OHLCCache.pas
T
Michael Schimmel b03d02449a TDataPoint<T> review
2025-06-11 08:54:04 +02:00

512 lines
25 KiB
ObjectPascal

(* ------------------------------------------------------------------------------
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.Trade.DataStream, Myc.Trade.DataPoint;
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
function Init(const ADataSet: TArray<TDataPoint<TAskBidItem>>): Boolean; // Changed DataSet to ADataSet for clarity
function IsNewBar(AIndex: Integer; const ATick: TDataPoint<TAskBidItem>): 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<TCachedCandle>;
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<TDataPoint<TAskBidItem>>;
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<TDataPoint<TAskBidItem>>;
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<TCachedCandle>;
TempCandle: TCachedCandle;
LogTimePerCandleStr: string;
CurrentTick: TDataPoint<TAskBidItem>;
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].Data.Ask; // Changed
FGlobalMaxY := ATickData[0].Data.Ask; // Changed
for I := 1 to High(ATickData) do
begin
if ATickData[I].Data.Ask < FGlobalMinY then // Changed
FGlobalMinY := ATickData[I].Data.Ask; // Changed
if ATickData[I].Data.Ask > FGlobalMaxY then // Changed
FGlobalMaxY := ATickData[I].Data.Ask; // Changed
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<TCachedCandle>.Create;
try
if NumDataPoints > 0 then
begin
DataIndexStart := 0;
OpenPrice := ATickData[0].Data.Ask; // Changed
HighPrice := ATickData[0].Data.Ask; // Changed
LowPrice := ATickData[0].Data.Ask; // Changed
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].Data.Ask; // Close of previous candle is the tick before the new bar starts // Changed
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.Data.Ask; // Changed
HighPrice := CurrentTick.Data.Ask; // Changed
LowPrice := CurrentTick.Data.Ask; // Changed
ClosePrice := CurrentTick.Data.Ask; // Changed
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.Data.Ask; // Open is set // Changed
HighPrice := CurrentTick.Data.Ask; // Initial High // Changed
LowPrice := CurrentTick.Data.Ask; // Initial Low // Changed
end
else // Update High/Low for ongoing candle
begin
if CurrentTick.Data.Ask > HighPrice then // Changed
HighPrice := CurrentTick.Data.Ask; // Changed
if CurrentTick.Data.Ask < LowPrice then // Changed
LowPrice := CurrentTick.Data.Ask; // Changed
end;
ClosePrice := CurrentTick.Data.Ask; // Always update close to current tick // Changed
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].Time;
var LastTickTimeVal_A := ATickData[High(ATickData)].Time;
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].Data.Ask; // Changed
ClosePrice := ATickData[DataIndexEnd].Data.Ask; // Changed
HighPrice := OpenPrice; // Initialize with Open
LowPrice := OpenPrice; // Initialize with Open
for J := DataIndexStart to DataIndexEnd do
begin
if ATickData[J].Data.Ask > HighPrice then HighPrice := ATickData[J].Data.Ask; // Changed
if ATickData[J].Data.Ask < LowPrice then LowPrice := ATickData[J].Data.Ask; // Changed
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].Data.Ask; // Changed
FCachedCandles[I].HighPrice := ATickData[I].Data.Ask; // Changed
FCachedCandles[I].LowPrice := ATickData[I].Data.Ask; // Changed
FCachedCandles[I].ClosePrice := ATickData[I].Data.Ask; // Changed
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<TCachedCandle>.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].Time / TimeframeIntervalOADays) * TimeframeIntervalOADays;
BucketEndTimeOADate := BucketStartTimeOADate + TimeframeIntervalOADays;
DataIndexStart := CurrentTickIndex;
OpenPrice := ATickData[CurrentTickIndex].Data.Ask; // Changed
HighPrice := ATickData[CurrentTickIndex].Data.Ask; // Changed
LowPrice := ATickData[CurrentTickIndex].Data.Ask; // Changed
ClosePrice := ATickData[CurrentTickIndex].Data.Ask; // Initialize close with open // Changed
DataIndexEnd := CurrentTickIndex; // Initialize end with start
// Iterate through ticks that fall into the current bucket
while (CurrentTickIndex <= High(ATickData)) and
(ATickData[CurrentTickIndex].Time >= BucketStartTimeOADate) and
(ATickData[CurrentTickIndex].Time < BucketEndTimeOADate) do
begin
if ATickData[CurrentTickIndex].Data.Ask > HighPrice then HighPrice := ATickData[CurrentTickIndex].Data.Ask; // Changed
if ATickData[CurrentTickIndex].Data.Ask < LowPrice then LowPrice := ATickData[CurrentTickIndex].Data.Ask; // Changed
ClosePrice := ATickData[CurrentTickIndex].Data.Ask; // Continuously update close price // Changed
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].Time;
var LastTickTimeVal := ATickData[High(ATickData)].Time;
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.