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

1524 lines
66 KiB
ObjectPascal

unit ChartDisplayMain;
// ------------------------------------------------------------------------------
// Unit Name: ChartDisplayMain
// Author: Delphi Algo Trading Assistant
// Date: June 05, 2025 // Updated Date
// Purpose: Main form for chart display. Uses TChartDataController for data
// management and OHLC aggregation, including IGenericCandleBuilder.
// Data is loaded based on a selected directory and symbol.
// Timeframes are selected via unique captions (keys), while user-friendly
// descriptions are shown in the UI.
// ------------------------------------------------------------------------------
interface
uses
Winapi.Windows,
Winapi.Messages,
System.SysUtils,
System.Variants,
System.Classes,
Vcl.Graphics,
Vcl.Controls,
Vcl.Forms,
Vcl.Dialogs,
Vcl.StdCtrls,
Vcl.ExtCtrls,
System.Generics.Collections,
System.DateUtils,
System.Math,
System.IOUtils,
System.UITypes,
System.Types,
Vcl.FileCtrl, // Added for SelectDirectory
Myc.OHLCCache, // For TOHLC, IGenericCandleBuilder, TF_ constants
Myc.Trade.DataPoint, Myc.Trade.DataStream,
Myc.ChartDataController, // Uses the updated DataController
Vcl.Buttons;
type
// TTimeframeSetting is defined in Myc.ChartDataController.pas
// IGenericCandleBuilder is defined in Myc.OHLCCache.pas
TChartForm = class(TForm)
DataPathEdit: TEdit; // Used to store and display the selected data directory
LoadButton: TButton;
Memo: TMemo;
RefreshButton: TButton;
TimeframeComboBox: TComboBox;
GoToCurrentButton: TButton;
PathSelectButton: TSpeedButton;
MouseHoverDataLabel: TLabel;
ChartScrollBox: TScrollBox;
PaintBox: TPaintBox;
SymbolComboBox: TComboBox; // ComboBox for selecting the symbol
procedure LoadButtonClick(Sender: TObject);
procedure PaintBoxPaint(Sender: TObject);
procedure RefreshButtonClick(Sender: TObject);
procedure FormCreate(Sender: TObject);
procedure FormDestroy(Sender: TObject);
procedure FormMouseWheel(Sender: TObject; Shift: TShiftState; WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
procedure PaintBoxMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
procedure PaintBoxMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
procedure PaintBoxMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
procedure TimeframeComboBoxChange(Sender: TObject);
procedure GoToCurrentButtonClick(Sender: TObject);
procedure PathSelectButtonClick(Sender: TObject);
procedure PaintBoxMouseLeave(Sender: TObject);
procedure SymbolComboBoxChange(Sender: TObject); // Event handler for symbol selection change
private
FDataServer: IAuraDataServer<TAskBidItem>;
FActiveOHLCCache: TOHLC;
FCandleWidthInPixels: Integer;
FCandleSpacing: Integer;
FDisplayStartIndex: Integer;
FIsDragging: Boolean;
FDragStartX: Integer;
FDragStartDisplayIndex: Integer;
FSelectedTimeframeSeconds: Int64; // Stores the .Seconds value of the currently selected timeframe setting
FTimeframeSettings: TArray<Myc.ChartDataController.TTimeframeSetting>; // Local copy of all available timeframe settings
FCurrentPriceRect: TRect;
FCurrentVolumeHistRect: TRect;
FMouseLastChartPos: TPoint;
FMouseCurrentlyInChartArea: Boolean;
FBufferBitmap: TBitmap;
FDataController: TChartDataController;
FSymbolFiles: TArray<string>; // Stores full paths from FindOldestFilesPerSymbol
procedure PopulateTimeframeComboBox;
procedure SetupDataServer;
procedure EnsureCacheForCurrentTimeframe(ARebuildIfPresent: Boolean = False);
procedure ClearAllCachesInForm;
procedure DrawChart(ACanvas: TCanvas; ARect: TRect);
public
end;
var
ChartForm: TChartForm;
implementation
{$R *.dfm}
const
DefaultChartMargin = 20;
DojiPenWidth = 2;
InitialCandleWidth = 5;
InitialCandleSpacing = 2;
DefaultEveryNTicksCount = 20;
DefaultVolumeAccumulationThreshold = 10;
// --- Generic Candle Builder Implementations ---
type
TEveryNTicksBuilder = class(TInterfacedObject, IGenericCandleBuilder)
private
FTickCountForNewBar: Integer;
FDescriptionText: string; // User-friendly description
public
constructor Create(ATickCount: Integer);
function Init(const ADataSet: TArray<TDataPoint<TAskBidItem>>): Boolean;
function IsNewBar(AIndex: Integer; const ATick: TDataPoint<TAskBidItem>): Boolean;
function GetCaption: string; // Returns the Description for UI
property Caption: string read GetCaption; // Interface property uses GetCaption
end;
TVolumeAccumulationBuilder = class(TInterfacedObject, IGenericCandleBuilder)
private
FTargetTickVolumePerBar: Integer;
FCurrentTickInBarCount: Integer;
FDescriptionText: string; // User-friendly description
public
constructor Create(ATargetTickVolume: Integer);
function Init(const ADataSet: TArray<TDataPoint<TAskBidItem>>): Boolean;
function IsNewBar(AIndex: Integer; const ATick: TDataPoint<TAskBidItem>): Boolean;
function GetCaption: string; // Returns the Description for UI
property Caption: string read GetCaption; // Interface property uses GetCaption
end;
{ TEveryNTicksBuilder }
constructor TEveryNTicksBuilder.Create(ATickCount: Integer);
begin
inherited Create;
FTickCountForNewBar := ATickCount;
if FTickCountForNewBar <= 0 then
FTickCountForNewBar := 1;
// The Caption property of IGenericCandleBuilder is used as the Description when registering
FDescriptionText := Format('Every %d Ticks', [FTickCountForNewBar]);
end;
function TEveryNTicksBuilder.Init(const ADataSet: TArray<TDataPoint<TAskBidItem>>): Boolean;
begin
Result := True;
end;
function TEveryNTicksBuilder.IsNewBar(AIndex: Integer; const ATick: TDataPoint<TAskBidItem>): Boolean;
begin
Result := (AIndex mod FTickCountForNewBar = 0);
end;
function TEveryNTicksBuilder.GetCaption: string;
begin
Result := FDescriptionText; // This is what TChartDataController gets if it calls ABuilder.Caption
end;
{ TVolumeAccumulationBuilder }
constructor TVolumeAccumulationBuilder.Create(ATargetTickVolume: Integer);
begin
inherited Create;
FTargetTickVolumePerBar := ATargetTickVolume;
if FTargetTickVolumePerBar <= 0 then
FTargetTickVolumePerBar := 1;
FDescriptionText := Format('Ticks/Bar: %d', [FTargetTickVolumePerBar]);
FCurrentTickInBarCount := 0;
end;
function TVolumeAccumulationBuilder.Init(const ADataSet: TArray<TDataPoint<TAskBidItem>>): Boolean;
begin
FCurrentTickInBarCount := 0;
Result := True;
end;
function TVolumeAccumulationBuilder.IsNewBar(AIndex: Integer; const ATick: TDataPoint<TAskBidItem>): Boolean;
begin
Inc(FCurrentTickInBarCount);
if FCurrentTickInBarCount >= FTargetTickVolumePerBar then
begin
FCurrentTickInBarCount := 0;
Result := True;
end
else
Result := False;
end;
function TVolumeAccumulationBuilder.GetCaption: string;
begin
Result := FDescriptionText; // This is what TChartDataController gets if it calls ABuilder.Caption
end;
{ TChartForm }
procedure TChartForm.PopulateTimeframeComboBox;
var
i: Integer;
DefaultItemIndex: Integer;
PreferredDefaultCaptionKey: string; // The short KEY caption for the preferred default
begin
TimeframeComboBox.Items.Clear;
if not Assigned(FDataController) then
begin
Memo.Lines.Add('Error: DataController not initialized for PopulateTimeframeComboBox.');
exit;
end;
// Get all available timeframes, including base and registered generic ones
FTimeframeSettings := FDataController.GetAvailableTimeframes();
DefaultItemIndex := -1;
PreferredDefaultCaptionKey := 'Auto'; // Short key for "Auto (Screen Fit)"
for i := Low(FTimeframeSettings) to High(FTimeframeSettings) do
begin
// Add the user-friendly Description to the ComboBox
TimeframeComboBox.Items.Add(FTimeframeSettings[i].Description);
// Check against the short Caption (key) for default selection
if CompareText(FTimeframeSettings[i].Caption, PreferredDefaultCaptionKey) = 0 then
DefaultItemIndex := i;
end;
if TimeframeComboBox.Items.Count > 0 then
begin
if (DefaultItemIndex = -1) then
DefaultItemIndex := 0; // Fallback to the first item
TimeframeComboBox.ItemIndex := DefaultItemIndex;
if (TimeframeComboBox.ItemIndex >= 0) and (TimeframeComboBox.ItemIndex < Length(FTimeframeSettings)) then
FSelectedTimeframeSeconds := FTimeframeSettings[TimeframeComboBox.ItemIndex].Seconds
else
FSelectedTimeframeSeconds := TF_AUTO_AGGREGATION; // Should ideally not happen
end
else
begin
FSelectedTimeframeSeconds := TF_AUTO_AGGREGATION; // Default if no timeframes
Memo.Lines.Add('Warning: No timeframes available to populate ComboBox.');
end;
if TimeframeComboBox.Items.Count > 0 then // Log current selection
Memo.Lines.Add(
'Timeframes populated. Default UI: "'
+ TimeframeComboBox.Text
+ Format('", Key: "%s", AggSecs: %d', [FTimeframeSettings[TimeframeComboBox.ItemIndex].Caption, FSelectedTimeframeSeconds])
)
else
Memo.Lines.Add('Timeframes populated, but ComboBox is empty. No default set.');
end;
procedure TChartForm.FormCreate(Sender: TObject);
var
BuilderN: IGenericCandleBuilder;
BuilderV: IGenericCandleBuilder;
BuilderNCaptionKey, BuilderVCaptionKey: string; // These will be the unique keys
BuilderNDescription, BuilderVDescription: string;
begin
IsMemoryLow :=
function: Boolean
var
memStatusEx: TMemoryStatusEx;
begin
memStatusEx.dwLength := SizeOf(TMemoryStatusEx); // Initialize the structure size
if GlobalMemoryStatusEx(memStatusEx) then
begin
Result := memStatusEx.dwMemoryLoad >= 80;
end
else
begin
// Handle potential error if GlobalMemoryStatusEx fails
RaiseLastOSError;
Result := False; // Or handle error as appropriate
end;
end;
// Assuming we have a valid default value in the Path-Edit:
SetupDataServer;
FCandleWidthInPixels := InitialCandleWidth;
FCandleSpacing := InitialCandleSpacing;
FDisplayStartIndex := 0;
FIsDragging := False;
FDragStartX := 0;
FDragStartDisplayIndex := 0;
FDataController := TChartDataController.Create(FDataServer, Memo.Lines);
// Create and register generic builders
BuilderN := TEveryNTicksBuilder.Create(DefaultEveryNTicksCount);
BuilderNCaptionKey := Format('NTICKS_%d', [DefaultEveryNTicksCount]); // e.g., "NTICKS_20" - this is the unique key
BuilderNDescription := BuilderN.Caption; // BuilderN.Caption returns "Every 20 Ticks"
FDataController.RegisterGenericBuilder(BuilderNCaptionKey, BuilderNDescription, BuilderN);
BuilderV := TVolumeAccumulationBuilder.Create(DefaultVolumeAccumulationThreshold);
BuilderVCaptionKey := Format('VOLACC_%d', [DefaultVolumeAccumulationThreshold]); // e.g., "VOLACC_10" - unique key
BuilderVDescription := BuilderV.Caption; // BuilderV.Caption returns "Ticks/Bar: 10"
FDataController.RegisterGenericBuilder(BuilderVCaptionKey, BuilderVDescription, BuilderV);
PopulateTimeframeComboBox; // Populates from FDataController, now includes generic builders
if TimeframeComboBox.ItemIndex <> -1 then
TimeframeComboBoxChange(TimeframeComboBox) // Trigger initial cache build
else if Length(FTimeframeSettings) > 0 then
begin // If ComboBox empty but settings exist (e.g. Populate didn't find default)
TimeframeComboBox.ItemIndex := 0;
TimeframeComboBoxChange(TimeframeComboBox);
end
else
begin
Memo.Lines.Add('FormCreate: No timeframes to select after population. Ensuring cache for a default (likely empty).');
EnsureCacheForCurrentTimeframe(True); // Attempt to build something
end;
FActiveOHLCCache := nil;
FCurrentPriceRect := Rect(0, 0, 0, 0);
FCurrentVolumeHistRect := Rect(0, 0, 0, 0);
MouseHoverDataLabel.Caption := '';
FMouseLastChartPos := Point(-1, -1);
FMouseCurrentlyInChartArea := False;
PaintBox.OnMouseLeave := PaintBoxMouseLeave;
FBufferBitmap := TBitmap.Create;
end;
procedure TChartForm.FormDestroy(Sender: TObject);
begin
FreeAndNil(FDataController);
FreeAndNil(FBufferBitmap);
FActiveOHLCCache := nil;
FDataServer.ClearCache;
FDataServer := nil;
end;
procedure TChartForm.ClearAllCachesInForm;
begin
Memo.Lines.Add('TChartForm: Clearing all caches via DataController...');
if Assigned(FDataController) then
FDataController.ClearAllDataAndCaches();
FActiveOHLCCache := nil;
end;
procedure TChartForm.EnsureCacheForCurrentTimeframe(ARebuildIfPresent: Boolean = False);
var
Index: Integer;
EffectivePaintBoxClientWidthForAuto: Integer;
SelectedTFKeyCaption: string; // The short key caption (e.g., "M1", "NTICKS_20")
SelectedTFDescription: string; // The UI description (e.g., "1 Minute (M1)")
begin
Index := TimeframeComboBox.ItemIndex;
if (Index < Low(FTimeframeSettings)) or (Index > High(FTimeframeSettings)) then
begin
Memo.Lines.Add('EnsureCache: Invalid TF ComboBox Index: ' + IntToStr(Index));
FActiveOHLCCache := nil;
PaintBox.Invalidate;
exit;
end;
if not Assigned(FDataController) then
begin
Memo.Lines.Add('EnsureCache: FDataController not assigned.');
FActiveOHLCCache := nil;
PaintBox.Invalidate;
exit;
end;
SelectedTFKeyCaption := FTimeframeSettings[Index].Caption; // This is the short key
SelectedTFDescription := FTimeframeSettings[Index].Description; // This is for UI/logging
FSelectedTimeframeSeconds := FTimeframeSettings[Index].Seconds; // Still useful for some logic
EffectivePaintBoxClientWidthForAuto := PaintBox.ClientWidth - (2 * DefaultChartMargin);
if EffectivePaintBoxClientWidthForAuto < 0 then
EffectivePaintBoxClientWidthForAuto := 0;
// Call GetOrBuildCache using the selected timeframe's short KEY CAPTION.
// The ASelectedGenericBuilderCaption parameter was removed from GetOrBuildCache.
FActiveOHLCCache :=
FDataController.GetOrBuildCache(
SelectedTFKeyCaption,
FCandleWidthInPixels,
FCandleSpacing,
EffectivePaintBoxClientWidthForAuto,
ARebuildIfPresent
);
if Assigned(FActiveOHLCCache) and FActiveOHLCCache.IsValid then
Memo.Lines.Add(
Format(
'Active Cache UI: "%s" (Key: %s). Candles: %d. AggSecs: %d. ApproxTime/Candle: %s',
[
SelectedTFDescription,
SelectedTFKeyCaption,
FActiveOHLCCache.Count,
FActiveOHLCCache.AggregationTimeframeSeconds,
FActiveOHLCCache.ApproxTimePerCandleText
]
)
)
else
Memo.Lines.Add(
Format(
'Active Cache for UI: "%s" (Key: %s) not valid or empty after EnsureCache.',
[SelectedTFDescription, SelectedTFKeyCaption]
)
);
end;
procedure TChartForm.TimeframeComboBoxChange(Sender: TObject);
var
CurrentTFKeyCaption: string;
begin
if TimeframeComboBox.ItemIndex < 0 then
begin
Memo.Lines.Add('TimeframeComboBoxChange: Invalid ItemIndex (-1).');
exit;
end;
if (TimeframeComboBox.ItemIndex >= Low(FTimeframeSettings)) and (TimeframeComboBox.ItemIndex <= High(FTimeframeSettings)) then
begin
FSelectedTimeframeSeconds := FTimeframeSettings[TimeframeComboBox.ItemIndex].Seconds;
CurrentTFKeyCaption := FTimeframeSettings[TimeframeComboBox.ItemIndex].Caption;
Memo.Lines.Add(
FormatDateTime('yyyy-mm-dd hh:nn:ss', Now)
+ Format(
' - TF Change Event: UI shows "%s" (Key: %s, Resolved AggSecs: %d)',
[
TimeframeComboBox.Text, // This is FTimeframeSettings[Index].Description
CurrentTFKeyCaption,
FSelectedTimeframeSeconds
])
);
end
else
begin
Memo.Lines.Add(
Format(
'Error: Invalid ItemIndex %d in TimeframeComboBoxChange. FTimeframeSettings valid range %d to %d.',
[TimeframeComboBox.ItemIndex, Low(FTimeframeSettings), High(FTimeframeSettings)]
)
);
if Length(FTimeframeSettings) > 0 then
FSelectedTimeframeSeconds := FTimeframeSettings[0].Seconds
else
FSelectedTimeframeSeconds := TF_AUTO_AGGREGATION;
end;
FDisplayStartIndex := 0;
EnsureCacheForCurrentTimeframe(True);
PaintBox.Invalidate;
end;
procedure TChartForm.LoadButtonClick(Sender: TObject);
begin
// This button now triggers the loading based on the SymbolComboBox selection
SymbolComboBoxChange(SymbolComboBox);
end;
procedure TChartForm.RefreshButtonClick(Sender: TObject);
var
TargetStartTickIndex: Integer;
VisibleStartIndexInOldCache: Integer;
ActualNumVisibleCandlesOld: Integer;
ChartDrawWidth, OldDisplaySlotWidth, NumCandlesDrawableOld: Integer;
NewOptimalStartIndex, i: Integer;
begin
Memo.Lines.Add(FormatDateTime('yyyy-mm-dd hh:nn:ss', Now) + ' - Refresh Button clicked...');
if Assigned(FActiveOHLCCache)
and FActiveOHLCCache.IsValid
and (FActiveOHLCCache.Count > 0)
and Assigned(FDataController)
and FDataController.HasData then
begin
VisibleStartIndexInOldCache := FDisplayStartIndex;
if FCurrentPriceRect.Width > 0 then
ChartDrawWidth := FCurrentPriceRect.Width
else
ChartDrawWidth := PaintBox.ClientWidth - (2 * DefaultChartMargin);
OldDisplaySlotWidth := FCandleWidthInPixels + FCandleSpacing;
NumCandlesDrawableOld := 0;
if OldDisplaySlotWidth > 0 then
NumCandlesDrawableOld := ChartDrawWidth div OldDisplaySlotWidth;
ActualNumVisibleCandlesOld := 0;
if (VisibleStartIndexInOldCache >= 0) and (VisibleStartIndexInOldCache < FActiveOHLCCache.Count) then
ActualNumVisibleCandlesOld := Min(NumCandlesDrawableOld, FActiveOHLCCache.Count - VisibleStartIndexInOldCache);
if ActualNumVisibleCandlesOld > 0 then
begin
TargetStartTickIndex := FActiveOHLCCache.Candles[VisibleStartIndexInOldCache].OriginalDataIndexStart;
Memo.Lines.Add(
Format(
'Refresh S1: Old Cache Index %d maps to Original Tick Index: %d',
[VisibleStartIndexInOldCache, TargetStartTickIndex]
)
);
end
else
begin
Memo.Lines.Add('Refresh S1: No candles visible in old cache or invalid start index. Defaulting to Tick Index 0.');
TargetStartTickIndex := 0;
end;
end
else
begin
Memo.Lines.Add('Refresh S1: No active cache or no raw data. Defaulting Target Tick Index to 0.');
TargetStartTickIndex := 0;
end;
if Assigned(FDataController) and FDataController.HasData then
begin
TargetStartTickIndex := Max(0, TargetStartTickIndex);
if FDataController.GetTickDataLength > 0 then // Use GetTickDataLength to get the count of ticks from the controller
TargetStartTickIndex := Min(TargetStartTickIndex, FDataController.GetTickDataLength - 1)
else
TargetStartTickIndex := 0;
end
else
TargetStartTickIndex := -1; // Or handle as appropriate if no data
Memo.Lines.Add(Format('Refresh S1a: Final Target Original Tick Index: %d', [TargetStartTickIndex]));
FCandleWidthInPixels := InitialCandleWidth;
FCandleSpacing := InitialCandleSpacing;
Memo.Lines.Add(Format('Refresh S2: Visuals (Candle Width/Spacing) reset to W:%dpx, S:%dpx.', [FCandleWidthInPixels, FCandleSpacing]));
EnsureCacheForCurrentTimeframe(True);
NewOptimalStartIndex := 0;
if Assigned(FActiveOHLCCache)
and FActiveOHLCCache.IsValid
and (FActiveOHLCCache.Count > 0)
and (TargetStartTickIndex >= 0) // Ensure TargetStartTickIndex is valid
and Assigned(FDataController) // Ensure FDataController is assigned
and FDataController.HasData then // Ensure FDataController has data
begin
for i := 0 to FActiveOHLCCache.Count - 1 do
begin
if FActiveOHLCCache.Candles[i].OriginalDataIndexStart >= TargetStartTickIndex then
begin
NewOptimalStartIndex := i;
Break;
end
else if FActiveOHLCCache.Candles[i].OriginalDataIndexEnd >= TargetStartTickIndex then // Check end index as well
begin
NewOptimalStartIndex := i;
Break;
end;
if i = FActiveOHLCCache.Count - 1 then // If no candle starts at or after, select the last one
NewOptimalStartIndex := i;
end;
Memo.Lines.Add(
Format(
'Refresh S4a: New Optimal Start Index in new cache = %d (for Original Tick Index %d).',
[NewOptimalStartIndex, TargetStartTickIndex]
)
);
FDisplayStartIndex := NewOptimalStartIndex;
if FActiveOHLCCache.Count > 0 then // Clamp FDisplayStartIndex
begin
FDisplayStartIndex := Max(0, FDisplayStartIndex);
FDisplayStartIndex := Min(FDisplayStartIndex, FActiveOHLCCache.Count - 1);
end
else
FDisplayStartIndex := 0; // Reset if cache is empty
Memo.Lines.Add(
Format('Refresh S4b: Final FDisplayStartIndex: %d (New Cache Candle Count: %d).', [FDisplayStartIndex, FActiveOHLCCache.Count])
);
end
else
begin
FDisplayStartIndex := 0; // Reset FDisplayStartIndex if no valid cache or data
Memo.Lines.Add('Refresh S4c: New cache invalid/empty, or no target tick. FDisplayStartIndex reset to 0.');
end;
PaintBox.Invalidate;
end;
procedure TChartForm.PathSelectButtonClick(Sender: TObject);
var
selectedDirectory: string;
begin
selectedDirectory := DataPathEdit.Text; // Start with current path, if any
// Use Vcl.FileCtrl.SelectDirectory function for directory selection.
// The third parameter (selectedDirectory) is an "out" parameter that receives the selected path.
if SelectDirectory('Select Data Directory', '', selectedDirectory) then
begin
DataPathEdit.Text := selectedDirectory; // Update the edit box with the selected directory
Memo.Lines.Add(FormatDateTime('yyyy-mm-dd hh:nn:ss', Now) + ' - Data directory selected: ' + selectedDirectory);
SetupDataServer;
end
else
begin
Memo.Lines.Add(FormatDateTime('yyyy-mm-dd hh:nn:ss', Now) + ' - Directory selection cancelled by user.');
end;
end;
procedure TChartForm.PaintBoxMouseLeave(Sender: TObject);
begin
if FMouseCurrentlyInChartArea then
begin
FMouseCurrentlyInChartArea := False;
MouseHoverDataLabel.Caption := '';
PaintBox.Invalidate;
end;
FMouseLastChartPos := Point(-1, -1);
end;
procedure TChartForm.PaintBoxMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
var
IsOverPrice, IsOverVolume: Boolean;
begin
if Button = mbLeft then
begin
FMouseLastChartPos := Point(X, Y);
IsOverPrice := (FCurrentPriceRect.Width > 0) and PtInRect(FCurrentPriceRect, FMouseLastChartPos);
IsOverVolume :=
(FCurrentVolumeHistRect.Width > 0)
and (FCurrentVolumeHistRect.Height > 0)
and PtInRect(FCurrentVolumeHistRect, FMouseLastChartPos);
FMouseCurrentlyInChartArea := IsOverPrice or IsOverVolume;
if FMouseCurrentlyInChartArea and Assigned(FActiveOHLCCache) and FActiveOHLCCache.IsValid and (FActiveOHLCCache.Count > 0) then
begin
FIsDragging := True;
FDragStartX := X;
FDragStartDisplayIndex := FDisplayStartIndex;
PaintBox.Cursor := crSizeWE;
Memo.Lines.Add(
FormatDateTime('yyyy-mm-dd hh:nn:ss', Now) + Format(' - Drag Start: MouseX=%d, StartDisplayIdx=%d', [X, FDisplayStartIndex])
);
end
else if Assigned(FActiveOHLCCache) and FActiveOHLCCache.IsValid and (FActiveOHLCCache.Count > 0) then
begin
Memo.Lines.Add(
FormatDateTime('yyyy-mm-dd hh:nn:ss', Now)
+ Format(' - MouseDown at X=%d, Y=%d (outside chart drawing areas). No drag initiated.', [X, Y])
);
end;
if FMouseCurrentlyInChartArea then
PaintBox.Invalidate;
end;
end;
procedure TChartForm.PaintBoxMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
var
DeltaX,
NumCandlesToScroll,
PrevDisplayStartIndex,
CurrentFixedSlotWidth,
MaxPossibleStartIndex,
ChartDrawWidthForDrag,
DrawableCandlesInViewPort,
RelativeX,
CandleIndexInViewPort,
ActualCacheIndex,
CurrentDrawingSlotWidthForHover: Integer;
HoveredCandle: TCachedCandle;
UTCTimeStart, UTCTimeEnd, LocalTimeStart, LocalTimeEnd: TDateTime;
StartTimeStr, EndTimeStr, TimeZoneStr, HoverInfo: string;
IsOverPrice, IsOverVolume, NeedsInvalidate, OldMouseCurrentlyInChartArea: Boolean;
begin
OldMouseCurrentlyInChartArea := FMouseCurrentlyInChartArea;
FMouseLastChartPos := Point(X, Y);
if FIsDragging then
begin
DeltaX := X - FDragStartX;
CurrentFixedSlotWidth := FCandleWidthInPixels + FCandleSpacing;
if CurrentFixedSlotWidth > 0 then
begin
NumCandlesToScroll := Round(-DeltaX / CurrentFixedSlotWidth);
PrevDisplayStartIndex := FDisplayStartIndex;
FDisplayStartIndex := FDragStartDisplayIndex + NumCandlesToScroll;
if FCurrentPriceRect.Width > 0 then
ChartDrawWidthForDrag := FCurrentPriceRect.Width
else
ChartDrawWidthForDrag := PaintBox.ClientWidth - (2 * DefaultChartMargin);
DrawableCandlesInViewPort := Max(1, ChartDrawWidthForDrag div CurrentFixedSlotWidth);
if Assigned(FActiveOHLCCache) and (FActiveOHLCCache.Count > 0) then
begin
MaxPossibleStartIndex := FActiveOHLCCache.Count - DrawableCandlesInViewPort;
if MaxPossibleStartIndex < 0 then
MaxPossibleStartIndex := 0;
FDisplayStartIndex := Max(0, FDisplayStartIndex);
FDisplayStartIndex := Min(FDisplayStartIndex, MaxPossibleStartIndex);
end
else
FDisplayStartIndex := 0;
if FDisplayStartIndex <> PrevDisplayStartIndex then
begin
Memo.Lines.Add(
FormatDateTime('yyyy-mm-dd hh:nn:ss', Now)
+ Format(' - Dragging: DeltaX=%d, NumScroll=%d, NewDisplayIdx=%d', [DeltaX, NumCandlesToScroll, FDisplayStartIndex])
);
PaintBox.Invalidate;
end;
end;
IsOverPrice := (FCurrentPriceRect.Width > 0) and PtInRect(FCurrentPriceRect, FMouseLastChartPos);
IsOverVolume := (FCurrentVolumeHistRect.Width > 0) and PtInRect(FCurrentVolumeHistRect, FMouseLastChartPos);
FMouseCurrentlyInChartArea := IsOverPrice or IsOverVolume;
if FMouseCurrentlyInChartArea <> OldMouseCurrentlyInChartArea then
PaintBox.Invalidate;
MouseHoverDataLabel.Caption := ''; // Clear hover label while dragging
exit;
end;
NeedsInvalidate := False;
IsOverPrice := (FCurrentPriceRect.Width > 0) and PtInRect(FCurrentPriceRect, FMouseLastChartPos);
IsOverVolume := (FCurrentVolumeHistRect.Width > 0) and PtInRect(FCurrentVolumeHistRect, FMouseLastChartPos);
FMouseCurrentlyInChartArea := IsOverPrice or IsOverVolume;
if FMouseCurrentlyInChartArea <> OldMouseCurrentlyInChartArea then
NeedsInvalidate := True;
// Always invalidate if mouse is over chart area to draw crosshair or update hover info
if FMouseCurrentlyInChartArea then
NeedsInvalidate := True;
if FMouseCurrentlyInChartArea
and Assigned(FActiveOHLCCache)
and FActiveOHLCCache.IsValid
and (FActiveOHLCCache.Count > 0)
and Assigned(FDataController)
and FDataController.HasData then
begin
CurrentDrawingSlotWidthForHover := FCandleWidthInPixels + FCandleSpacing;
if CurrentDrawingSlotWidthForHover > 0 then
begin
RelativeX := FMouseLastChartPos.X - FCurrentPriceRect.Left; // Relative to price chart area
CandleIndexInViewPort := RelativeX div CurrentDrawingSlotWidthForHover;
ActualCacheIndex := FDisplayStartIndex + CandleIndexInViewPort;
if (ActualCacheIndex >= 0) and (ActualCacheIndex < FActiveOHLCCache.Count) then
begin
HoveredCandle := FActiveOHLCCache.Candles[ActualCacheIndex];
if (HoveredCandle.OriginalDataIndexStart >= 0)
and (HoveredCandle.OriginalDataIndexStart < FDataController.GetTickDataLength) // Use GetTickDataLength
and (HoveredCandle.OriginalDataIndexEnd >= 0)
and (HoveredCandle.OriginalDataIndexEnd < FDataController.GetTickDataLength) then // Use GetTickDataLength
begin
UTCTimeStart := FDataController.GetTickDataItem(HoveredCandle.OriginalDataIndexStart).Time;
UTCTimeEnd := FDataController.GetTickDataItem(HoveredCandle.OriginalDataIndexEnd).Time;
LocalTimeStart := TTimeZone.Local.ToLocalTime(UTCTimeStart);
LocalTimeEnd := TTimeZone.Local.ToLocalTime(UTCTimeEnd);
TimeZoneStr := TTimeZone.Local.GetAbbreviation(LocalTimeStart); // Get abbreviation for the local time zone
StartTimeStr := FormatDateTime('yy-mm-dd hh:nn:ss', LocalTimeStart);
if System.DateUtils.DateOf(LocalTimeStart) <> System.DateUtils.DateOf(LocalTimeEnd) then
EndTimeStr := FormatDateTime('yy-mm-dd hh:nn:ss', LocalTimeEnd)
else
EndTimeStr := FormatDateTime('hh:nn:ss', LocalTimeEnd);
HoverInfo :=
Format(
'S: %s E: %s (%s) O:%.5f H:%.5f L:%.5f C:%.5f Vol:%d',
[
StartTimeStr,
EndTimeStr,
TimeZoneStr,
HoveredCandle.OpenPrice,
HoveredCandle.HighPrice,
HoveredCandle.LowPrice,
HoveredCandle.ClosePrice,
HoveredCandle.TickVolume
]
);
MouseHoverDataLabel.Caption := HoverInfo;
end
else
MouseHoverDataLabel.Caption := 'Candle (Original Tick Index Error)';
end
else
MouseHoverDataLabel.Caption := ''; // Mouse is outside valid candle range
end
else
MouseHoverDataLabel.Caption := ''; // Slot width is zero, cannot determine candle
end
else
begin
MouseHoverDataLabel.Caption := ''; // Not over chart, no cache, or no data
if not FMouseCurrentlyInChartArea and OldMouseCurrentlyInChartArea then // If mouse just left the chart area
NeedsInvalidate := True; // Ensure crosshair is cleared
end;
if NeedsInvalidate then
PaintBox.Invalidate;
end;
procedure TChartForm.PaintBoxMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
begin
if Button = mbLeft then
begin
if FIsDragging then
begin
FIsDragging := False;
PaintBox.Cursor := crDefault;
Memo.Lines.Add(FormatDateTime('yyyy-mm-dd hh:nn:ss', Now) + ' - Drag End. Final DisplayIdx=' + IntToStr(FDisplayStartIndex));
FMouseLastChartPos := Point(X, Y); // Update last mouse position
PaintBoxMouseMove(Sender, Shift, X, Y); // Trigger MouseMove to update hover info and crosshair based on final position
end;
end;
end;
procedure TChartForm.PaintBoxPaint(Sender: TObject);
begin
if not Assigned(FBufferBitmap) then
begin
Memo.Lines.Add('Error: FBufferBitmap not assigned in PaintBoxPaint.');
exit;
end;
if (PaintBox.ClientWidth <= 0) or (PaintBox.ClientHeight <= 0) then
begin
if Assigned(PaintBox.Canvas) then // Ensure canvas is assigned before using
PaintBox.Canvas.FillRect(PaintBox.ClientRect); // Clear with default color if size is invalid
exit;
end;
// Resize buffer bitmap if PaintBox size changed
if (FBufferBitmap.Width <> PaintBox.ClientWidth) or (FBufferBitmap.Height <> PaintBox.ClientHeight) then
begin
FBufferBitmap.SetSize(PaintBox.ClientWidth, PaintBox.ClientHeight);
end;
// Draw chart onto the buffer
DrawChart(FBufferBitmap.Canvas, Rect(0, 0, FBufferBitmap.Width, FBufferBitmap.Height));
// Copy buffer to screen
PaintBox.Canvas.Draw(0, 0, FBufferBitmap);
end;
procedure TChartForm.SymbolComboBoxChange(Sender: TObject);
var
selectedIndex: Integer;
selectedFileName: string;
begin
Memo.Clear; // Clear memo for new symbol load information
Memo.Lines.Add(FormatDateTime('yyyy-mm-dd hh:nn:ss', Now) + ' - Symbol selection changed or load triggered.');
ClearAllCachesInForm(); // Clear existing caches and data
FDisplayStartIndex := 0; // Reset display start index
if SymbolComboBox.ItemIndex = -1 then
begin
Memo.Lines.Add('Error: No symbol selected in SymbolComboBox.');
PaintBox.Invalidate; // Refresh chart (will show no data message)
exit;
end;
// Retrieve stored index from the ComboBox item's Object property
selectedIndex := Integer(SymbolComboBox.Items.Objects[SymbolComboBox.ItemIndex]);
// Validate the retrieved index against the FSymbolFiles array bounds
if (selectedIndex >= Low(FSymbolFiles)) and (selectedIndex <= High(FSymbolFiles)) then
begin
selectedFileName := FSymbolFiles[selectedIndex]; // Get the full path from our stored array
end
else
begin
Memo.Lines.Add(Format('Error: Invalid stored index %d in SymbolComboBox.', [selectedIndex]));
PaintBox.Invalidate;
exit;
end;
if Trim(selectedFileName) = '' then
begin
Memo.Lines.Add('Error: File name for the selected symbol is empty.');
MessageDlg('File name for the selected symbol is empty.', mtWarning, [mbOK], 0);
PaintBox.Invalidate;
exit;
end;
Memo.Lines.Add('Loading data for symbol "' + SymbolComboBox.Text + '" from file: ' + selectedFileName);
if Assigned(FDataController) then
begin
FDataController.LoadTickDataSeries(selectedFileName); // Load data using the controller
if FDataController.HasData then
begin
Memo.Lines.Add(
FormatDateTime('yyyy-mm-dd hh:nn:ss', Now)
+ Format(
' - Successfully loaded %d ticks for symbol "%s" by controller.',
[FDataController.GetTickDataCount, SymbolComboBox.Text])
);
EnsureCacheForCurrentTimeframe(True); // Rebuild cache for the new data and current timeframe
end
else
begin
Memo.Lines.Add(
FormatDateTime('yyyy-mm-dd hh:nn:ss', Now)
+ ' - No tick data was loaded for symbol "'
+ SymbolComboBox.Text
+ '" by controller, or an error occurred.'
);
FActiveOHLCCache := nil; // Ensure active cache is nil if no data
end;
end
else
begin
Memo.Lines.Add('Error: DataController is not initialized. Cannot load data for symbol "' + SymbolComboBox.Text + '".');
end;
PaintBox.Invalidate; // Refresh the chart display
end;
procedure TChartForm.DrawChart(ACanvas: TCanvas; ARect: TRect);
const
DefaultVolumeAreaHeightRatio = 0.20;
MinVolumeAreaPixelHeight = 30;
MaxVolumeAreaPixelHeight = 80;
PriceToVolumeSeparatorPixels = 5;
CrosshairPriceTimeMargin = 3;
var
LoopIdx, CacheIdx: Integer;
ScaleY: Double;
Y_open, Y_high, Y_low, Y_close: Integer;
NumCachedCandlesInActiveCache: Integer;
CurrentDrawingSlotWidth: Integer;
ActualNumCandlesToDraw: Integer;
CurrentCandleXStart: Integer;
X_wick_center: Integer;
BodyRect: TRect;
CachedCandleRec: TCachedCandle;
NumCandlesDrawableInViewPort: Integer;
LocalMinYValue, LocalMaxYValue: Single;
VisibleCandleForScale: TCachedCandle;
k_scale: Integer;
CurrentMinYToUse, CurrentMaxYToUse: Single;
ExpansionValue: Single;
DrawableContentRect, PriceRect_Local, VolumeHistRect_Local: TRect;
ActualVolumeHistAreaHeight: Integer;
MaxVisibleTickVolume: Integer;
VolumeScaleY: Double;
VolumeBarHeight: Integer;
VolBarRect: TRect;
RelativeX, CandleIndexInViewPortForCrosshair, VerticalLineX: Integer;
ShowVolumeBars: Boolean;
PriceAtMouseY: Double;
PriceStr, TimeStr: string;
TextX, TextY: Integer;
CrosshairCandle: TCachedCandle;
UTCTimeStart, LocalTimeStart: TDateTime;
OriginalPenStyle: TPenStyle;
OriginalPenWidth: Integer;
OriginalFontColor: TColor;
OriginalBrushStyle: TBrushStyle;
OriginalFontSize: Integer; // For saving and restoring font size
HorzYClamped: Integer; // For crosshair Y position clamping
ActualCacheIdxForCrosshair: Integer; // For crosshair candle index
begin
ACanvas.Brush.Color := clWhite; // Set background color
ACanvas.FillRect(ARect); // Fill the entire canvas area
FCurrentPriceRect := Rect(0, 0, 0, 0); // Reset current price chart area
FCurrentVolumeHistRect := Rect(0, 0, 0, 0); // Reset current volume histogram area
// Check if there's valid data to draw
if not Assigned(FActiveOHLCCache) or not FActiveOHLCCache.IsValid or (FActiveOHLCCache.Count = 0) then
begin
ACanvas.Font.Color := clGray;
ACanvas.TextOut(ARect.Left + 10, ARect.Top + 10, 'No data in active cache or cache is invalid.');
exit;
end;
// Define the main drawable content area with margins
DrawableContentRect :=
Rect(
ARect.Left + DefaultChartMargin,
ARect.Top + DefaultChartMargin,
ARect.Right - DefaultChartMargin,
ARect.Bottom - DefaultChartMargin
);
// Check if drawable area is too small
if (DrawableContentRect.Width <= 0) or (DrawableContentRect.Height <= 0) then
begin
ACanvas.Font.Color := clRed;
ACanvas.TextOut(ARect.Left + 10, ARect.Top + 10, 'Drawable area too small.');
exit;
end;
// Determine if volume bars should be shown
ShowVolumeBars :=
(FActiveOHLCCache.AggregationTimeframeSeconds <> TF_AUTO_AGGREGATION) // Not auto aggregation
and (FActiveOHLCCache.AggregationTimeframeSeconds <> TF_TICK_BY_TICK); // Not tick-by-tick
ActualVolumeHistAreaHeight := 0;
if ShowVolumeBars then
begin
// Calculate initial height for volume area
ActualVolumeHistAreaHeight := Round(DrawableContentRect.Height * DefaultVolumeAreaHeightRatio);
// Clamp volume area height to min/max pixel values
ActualVolumeHistAreaHeight := Max(MinVolumeAreaPixelHeight, Min(ActualVolumeHistAreaHeight, MaxVolumeAreaPixelHeight));
// Adjust if total height is insufficient for price, volume, and separator
if DrawableContentRect.Height < (ActualVolumeHistAreaHeight + PriceToVolumeSeparatorPixels + 50) then // 50 is arbitrary minimum for price chart
begin
ActualVolumeHistAreaHeight := Max(0, DrawableContentRect.Height - PriceToVolumeSeparatorPixels - 50);
if ActualVolumeHistAreaHeight < MinVolumeAreaPixelHeight then // If it becomes too small, don't show it
ActualVolumeHistAreaHeight := 0;
end;
end;
if ActualVolumeHistAreaHeight <= 0 then // If height is zero or less, disable volume bars
ShowVolumeBars := False;
// Define drawing rectangles for price and volume areas
if ShowVolumeBars then
begin
VolumeHistRect_Local :=
Rect(
DrawableContentRect.Left,
DrawableContentRect.Bottom - ActualVolumeHistAreaHeight, // Positioned at the bottom
DrawableContentRect.Right,
DrawableContentRect.Bottom
);
PriceRect_Local :=
Rect(
DrawableContentRect.Left,
DrawableContentRect.Top,
DrawableContentRect.Right,
VolumeHistRect_Local.Top - PriceToVolumeSeparatorPixels // Price area is above volume area
);
end
else // If not showing volume bars, price area takes full drawable content height
begin
PriceRect_Local := DrawableContentRect;
VolumeHistRect_Local := Rect(0, 0, 0, 0); // No volume area
end;
// Check if price chart area is too small
if (PriceRect_Local.Height <= 0) then
begin
ACanvas.Font.Color := clRed;
ACanvas.TextOut(ARect.Left + 10, ARect.Top + 10, 'Price chart area too small to draw.');
exit;
end;
// Store the calculated drawing rectangles globally for mouse interactions
FCurrentPriceRect := PriceRect_Local;
FCurrentVolumeHistRect := VolumeHistRect_Local;
NumCachedCandlesInActiveCache := FActiveOHLCCache.Count;
CurrentDrawingSlotWidth := FCandleWidthInPixels + FCandleSpacing; // Total width for one candle + spacing
if CurrentDrawingSlotWidth <= 0 then // Prevent division by zero or infinite loop
exit;
// Calculate how many candles can be drawn in the viewport
NumCandlesDrawableInViewPort := PriceRect_Local.Width div CurrentDrawingSlotWidth;
ActualNumCandlesToDraw := 0;
// Determine the actual number of candles to draw based on start index and available cache
if (FDisplayStartIndex >= 0) and (FDisplayStartIndex < NumCachedCandlesInActiveCache) then
ActualNumCandlesToDraw := Min(NumCandlesDrawableInViewPort, NumCachedCandlesInActiveCache - FDisplayStartIndex);
MaxVisibleTickVolume := 0; // Initialize max volume for scaling
// Determine Min/Max Y for scaling price chart and Max Volume for volume chart
if ActualNumCandlesToDraw > 0 then
begin
VisibleCandleForScale := FActiveOHLCCache.Candles[FDisplayStartIndex]; // First visible candle
LocalMinYValue := VisibleCandleForScale.LowPrice;
LocalMaxYValue := VisibleCandleForScale.HighPrice;
if ShowVolumeBars then
MaxVisibleTickVolume := VisibleCandleForScale.TickVolume;
// Iterate through visible candles to find overall min/max prices and max volume
for k_scale := 1 to ActualNumCandlesToDraw - 1 do
begin
CacheIdx := FDisplayStartIndex + k_scale;
VisibleCandleForScale := FActiveOHLCCache.Candles[CacheIdx];
if VisibleCandleForScale.LowPrice < LocalMinYValue then
LocalMinYValue := VisibleCandleForScale.LowPrice;
if VisibleCandleForScale.HighPrice > LocalMaxYValue then
LocalMaxYValue := VisibleCandleForScale.HighPrice;
if ShowVolumeBars and (VisibleCandleForScale.TickVolume > MaxVisibleTickVolume) then
MaxVisibleTickVolume := VisibleCandleForScale.TickVolume;
end;
// Expand range slightly if min and max are too close (or equal) to prevent division by zero in scaling
if Abs(LocalMaxYValue - LocalMinYValue) < 1E-6 then // Using a small epsilon for float comparison
begin
ExpansionValue := Max(Abs(LocalMinYValue) * 0.001, 0.0001); // Expand by 0.1% or a tiny fixed amount
LocalMaxYValue := LocalMinYValue + ExpansionValue;
LocalMinYValue := LocalMinYValue - ExpansionValue;
end;
CurrentMinYToUse := LocalMinYValue;
CurrentMaxYToUse := LocalMaxYValue;
if ShowVolumeBars and (MaxVisibleTickVolume = 0) then // Ensure max volume is at least 1 for scaling
MaxVisibleTickVolume := 1;
end
else // If no candles to draw, use global min/max from cache or default values
begin
CurrentMinYToUse := FActiveOHLCCache.GlobalMinY; // These should be pre-calculated in TOHLC
CurrentMaxYToUse := FActiveOHLCCache.GlobalMaxY;
if Abs(CurrentMaxYToUse - CurrentMinYToUse) < 1E-6 then // Handle flat data
begin
CurrentMaxYToUse := CurrentMinYToUse + 0.0002; // Some small default range
CurrentMinYToUse := CurrentMinYToUse - 0.0002;
end;
end;
// Draw Min/Max Price and Max Volume labels
OriginalFontSize := ACanvas.Font.Size; // Save original font size
ACanvas.Font.Size := 8; // Use a smaller font for these labels
ACanvas.Font.Color := clGray;
ACanvas.TextOut(PriceRect_Local.Left + 3, PriceRect_Local.Top + 3, Format('%.5f', [CurrentMaxYToUse]));
ACanvas.TextOut(PriceRect_Local.Left + 3, PriceRect_Local.Bottom - ACanvas.TextHeight('0') - 3, Format('%.5f', [CurrentMinYToUse]));
if ShowVolumeBars and (VolumeHistRect_Local.Height > 0) then // Only draw if volume area is visible
ACanvas.TextOut(VolumeHistRect_Local.Left + 3, VolumeHistRect_Local.Top + 3, Format('%d', [MaxVisibleTickVolume]));
ACanvas.Font.Size := OriginalFontSize; // Restore original font size
// Calculate Y scaling factor for price
if Abs(CurrentMaxYToUse - CurrentMinYToUse) < 1E-7 then // Prevent division by zero
ScaleY := 1.0 // Default scale if range is too small
else
ScaleY := PriceRect_Local.Height / (CurrentMaxYToUse - CurrentMinYToUse);
if ScaleY <= 0 then // Ensure ScaleY is positive
ScaleY := 1.0;
// Calculate Y scaling factor for volume
VolumeScaleY := 0;
if ShowVolumeBars and (VolumeHistRect_Local.Height > 0) and (MaxVisibleTickVolume > 0) then
VolumeScaleY := VolumeHistRect_Local.Height / MaxVisibleTickVolume;
// --- Draw Candles and Volume Bars ---
CurrentCandleXStart := PriceRect_Local.Left; // Starting X position for the first candle
if ActualNumCandlesToDraw > 0 then
begin
for LoopIdx := 0 to ActualNumCandlesToDraw - 1 do
begin
CacheIdx := FDisplayStartIndex + LoopIdx;
CachedCandleRec := FActiveOHLCCache.Candles[CacheIdx];
// Calculate Y coordinates for Open, High, Low, Close
Y_high := PriceRect_Local.Bottom - Round((CachedCandleRec.HighPrice - CurrentMinYToUse) * ScaleY);
Y_low := PriceRect_Local.Bottom - Round((CachedCandleRec.LowPrice - CurrentMinYToUse) * ScaleY);
Y_open := PriceRect_Local.Bottom - Round((CachedCandleRec.OpenPrice - CurrentMinYToUse) * ScaleY);
Y_close := PriceRect_Local.Bottom - Round((CachedCandleRec.ClosePrice - CurrentMinYToUse) * ScaleY);
// Clamp Y coordinates to the price chart area
Y_high := Max(PriceRect_Local.Top, Min(PriceRect_Local.Bottom, Y_high));
Y_low := Max(PriceRect_Local.Top, Min(PriceRect_Local.Bottom, Y_low));
Y_open := Max(PriceRect_Local.Top, Min(PriceRect_Local.Bottom, Y_open));
Y_close := Max(PriceRect_Local.Top, Min(PriceRect_Local.Bottom, Y_close));
// Draw wick (High-Low line)
X_wick_center := CurrentCandleXStart + (FCandleWidthInPixels div 2);
ACanvas.Pen.Color := clBlack;
ACanvas.Pen.Width := 1;
ACanvas.MoveTo(X_wick_center, Y_high);
ACanvas.LineTo(X_wick_center, Y_low);
// Define candle body rectangle
BodyRect.Left := CurrentCandleXStart;
BodyRect.Right := CurrentCandleXStart + FCandleWidthInPixels;
BodyRect.Top := Min(Y_open, Y_close); // Top of body is the higher of Open/Close in screen coords
BodyRect.Bottom := Max(Y_open, Y_close); // Bottom of body is the lower of Open/Close in screen coords
// Handle Doji (Open equals Close)
if Abs(Y_open - Y_close) < 1 then // If body height is less than 1 pixel, draw as a line
begin
ACanvas.Pen.Width := DojiPenWidth; // Thicker pen for Doji line
ACanvas.MoveTo(BodyRect.Left, Y_open);
ACanvas.LineTo(BodyRect.Right, Y_open);
ACanvas.Pen.Width := 1; // Reset pen width
end
else // Draw normal candle body
begin
if BodyRect.Bottom <= BodyRect.Top then // Ensure body has at least 1px height
BodyRect.Bottom := BodyRect.Top + 1;
if CachedCandleRec.ClosePrice > CachedCandleRec.OpenPrice then // Bullish candle
ACanvas.Brush.Color := clWhite // Or a bullish color like clLime
else // Bearish candle
ACanvas.Brush.Color := clBlack; // Or a bearish color like clRed
ACanvas.Pen.Color := clBlack; // Outline color
ACanvas.Rectangle(BodyRect); // Draw the body
end;
// Draw Volume Bar if enabled
if ShowVolumeBars and (VolumeScaleY > 0) then
begin
VolumeBarHeight := Round(CachedCandleRec.TickVolume * VolumeScaleY);
VolumeBarHeight := Max(0, Min(VolumeBarHeight, VolumeHistRect_Local.Height)); // Clamp height
VolBarRect :=
Rect(
CurrentCandleXStart,
VolumeHistRect_Local.Bottom - VolumeBarHeight, // From bottom of volume area upwards
CurrentCandleXStart + FCandleWidthInPixels,
VolumeHistRect_Local.Bottom
);
// Color volume bar based on candle type
if CachedCandleRec.ClosePrice > CachedCandleRec.OpenPrice then
ACanvas.Brush.Color := TColor($00B0F0B0) // Light green for bullish volume
else if CachedCandleRec.ClosePrice < CachedCandleRec.OpenPrice then
ACanvas.Brush.Color := TColor($00B0B0F0) // Light red for bearish volume
else
ACanvas.Brush.Color := clSilver; // Neutral color
ACanvas.Pen.Color := clGray; // Outline for volume bar
ACanvas.Rectangle(VolBarRect);
end;
CurrentCandleXStart := CurrentCandleXStart + CurrentDrawingSlotWidth; // Move to next candle position
end;
end;
// Draw borders around price and volume areas
ACanvas.Pen.Style := psSolid;
ACanvas.Pen.Width := 1;
ACanvas.Pen.Color := clBlack;
ACanvas.Brush.Style := bsClear; // Transparent brush for drawing rectangles
ACanvas.Rectangle(PriceRect_Local);
if ShowVolumeBars and (VolumeHistRect_Local.Width > 0) and (VolumeHistRect_Local.Height > 0) then
ACanvas.Rectangle(VolumeHistRect_Local);
// --- Draw Crosshair and Hover Info ---
if FMouseCurrentlyInChartArea // Mouse is within the general paintbox area designated for chart
and Assigned(FActiveOHLCCache)
and FActiveOHLCCache.IsValid
and (FActiveOHLCCache.Count > 0)
and (FMouseLastChartPos.X >= 0) // Valid mouse position
and Assigned(FDataController)
and FDataController.HasData then
begin
// Save current canvas settings
OriginalPenStyle := ACanvas.Pen.Style;
OriginalPenWidth := ACanvas.Pen.Width;
OriginalFontColor := ACanvas.Font.Color;
OriginalBrushStyle := ACanvas.Brush.Style;
OriginalFontSize := ACanvas.Font.Size;
// Set crosshair style
ACanvas.Pen.Color := clGray;
ACanvas.Pen.Style := psDot;
ACanvas.Pen.Width := 1;
ACanvas.Font.Color := clWindowText; // Color for text labels
ACanvas.Brush.Style := bsClear; // Transparent background for text
ACanvas.Font.Size := 8; // Small font for labels
// Horizontal Crosshair Line and Price Label
// Clamp Y position of horizontal line to the drawable content area (Price + Volume if shown)
HorzYClamped := Max(DrawableContentRect.Top, Min(FMouseLastChartPos.Y, DrawableContentRect.Bottom));
ACanvas.MoveTo(DrawableContentRect.Left, HorzYClamped);
ACanvas.LineTo(DrawableContentRect.Right, HorzYClamped);
// Draw price label only if mouse is within the price chart area for Y
if PtInRect(PriceRect_Local, Point(FMouseLastChartPos.X, HorzYClamped)) and (ScaleY > 1E-6) and (PriceRect_Local.Height > 0) then
begin
PriceAtMouseY := CurrentMinYToUse + ((PriceRect_Local.Bottom - HorzYClamped) / ScaleY);
PriceStr := Format('%.5f', [PriceAtMouseY]);
TextX := DrawableContentRect.Right + CrosshairPriceTimeMargin; // Position to the right of chart
TextY := HorzYClamped - (ACanvas.TextHeight(PriceStr) div 2); // Vertically centered
ACanvas.TextOut(TextX, TextY, PriceStr);
end;
// Vertical Crosshair Line and Time Label (only if mouse X is within drawable content)
if (CurrentDrawingSlotWidth > 0)
and (FMouseLastChartPos.X >= DrawableContentRect.Left)
and (FMouseLastChartPos.X <= DrawableContentRect.Right) then
begin
// Calculate candle index under mouse, relative to the *price chart area* specifically for X
RelativeX := FMouseLastChartPos.X - PriceRect_Local.Left;
if RelativeX >= 0 then // Ensure mouse is at or to the right of the price area's left edge
begin
CandleIndexInViewPortForCrosshair := RelativeX div CurrentDrawingSlotWidth;
// Calculate center X of the candle for the vertical line
VerticalLineX :=
PriceRect_Local.Left + (CandleIndexInViewPortForCrosshair * CurrentDrawingSlotWidth) + (FCandleWidthInPixels div 2);
// Ensure vertical line is within the overall drawable content area
if (VerticalLineX >= DrawableContentRect.Left) and (VerticalLineX <= DrawableContentRect.Right) then
begin
ACanvas.MoveTo(VerticalLineX, DrawableContentRect.Top);
ACanvas.LineTo(VerticalLineX, DrawableContentRect.Bottom);
// Time Label
ActualCacheIdxForCrosshair := FDisplayStartIndex + CandleIndexInViewPortForCrosshair;
if (ActualCacheIdxForCrosshair >= 0) and (ActualCacheIdxForCrosshair < FActiveOHLCCache.Count) then
begin
CrosshairCandle := FActiveOHLCCache.Candles[ActualCacheIdxForCrosshair];
if (CrosshairCandle.OriginalDataIndexStart >= 0)
and (CrosshairCandle.OriginalDataIndexStart < FDataController.GetTickDataLength) then // Use GetTickDataLength
begin
UTCTimeStart := FDataController.GetTickDataItem(CrosshairCandle.OriginalDataIndexStart).Time;
LocalTimeStart := TTimeZone.Local.ToLocalTime(UTCTimeStart);
TimeStr := FormatDateTime('hh:nn:ss', LocalTimeStart); // Display time part
TextX := VerticalLineX - (ACanvas.TextWidth(TimeStr) div 2); // Horizontally centered on line
TextY :=
DrawableContentRect.Top - ACanvas.TextHeight(TimeStr) - CrosshairPriceTimeMargin; // Position above chart
if TextY < ARect.Top then // Ensure it's within the paintbox bounds
TextY := ARect.Top;
ACanvas.TextOut(TextX, TextY, TimeStr);
end;
end;
end;
end;
end;
// Restore original canvas settings
ACanvas.Pen.Style := OriginalPenStyle;
ACanvas.Pen.Width := OriginalPenWidth;
ACanvas.Font.Color := OriginalFontColor;
ACanvas.Brush.Style := OriginalBrushStyle;
ACanvas.Font.Size := OriginalFontSize;
end;
end;
procedure TChartForm.FormMouseWheel(Sender: TObject; Shift: TShiftState; WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
const
MinDisplayCandleWidth = 1;
MaxDisplayCandleWidth = 100;
ZoomStep = 1; // Number of pixels to change candle width per wheel step
var
LMousePosInPaintBox: TPoint;
OldCandleWidthInPixels, OriginalFDisplayStartIndex: Integer;
MouseX_InChartArea_Relative: Integer;
OldSlotWidth, NewSlotWidth: Integer;
GlobalCandleIndexUnderMouse_Float: Double; // For precise calculation of index under cursor
ChartDrawWidth, DrawableCandlesInViewPort, MaxPossibleStartIndex: Integer;
ZoomToCursorActive: Boolean;
begin
Handled := False; // Default to not handled
LMousePosInPaintBox := PaintBox.ScreenToClient(MousePos); // Convert mouse pos to PaintBox client coordinates
// Only handle if mouse is over the PaintBox
if not PtInRect(PaintBox.ClientRect, LMousePosInPaintBox) then
exit;
// Zoom to cursor is active if there's a valid cache, data, and mouse is over the price chart area
ZoomToCursorActive :=
Assigned(FActiveOHLCCache)
and FActiveOHLCCache.IsValid
and (FActiveOHLCCache.Count > 0)
and (FCurrentPriceRect.Width > 0) // Ensure price rect is valid
and PtInRect(FCurrentPriceRect, LMousePosInPaintBox); // Mouse must be within price chart area
OriginalFDisplayStartIndex := FDisplayStartIndex;
OldCandleWidthInPixels := FCandleWidthInPixels;
// Adjust candle width based on wheel delta
if WheelDelta > 0 then // Zoom in
FCandleWidthInPixels := Min(MaxDisplayCandleWidth, FCandleWidthInPixels + ZoomStep)
else if WheelDelta < 0 then // Zoom out
FCandleWidthInPixels := Max(MinDisplayCandleWidth, FCandleWidthInPixels - ZoomStep)
else
exit; // No change in wheel delta
// If candle width didn't change (e.g., at min/max limit), exit
if FCandleWidthInPixels = OldCandleWidthInPixels then
exit;
NewSlotWidth := FCandleWidthInPixels + FCandleSpacing;
if NewSlotWidth <= 0 then // Safety check for new slot width
begin
FCandleWidthInPixels := OldCandleWidthInPixels; // Revert to old width
exit;
end;
// Adjust FDisplayStartIndex to keep the candle under the mouse cursor stationary
if ZoomToCursorActive then
begin
OldSlotWidth := OldCandleWidthInPixels + FCandleSpacing;
if OldSlotWidth <= 0 then // Safety check for old slot width
ZoomToCursorActive := False // Disable zoom to cursor if old slot width is invalid
else
begin
MouseX_InChartArea_Relative := LMousePosInPaintBox.X - FCurrentPriceRect.Left;
// Calculate the fractional global index of the candle under the mouse before zoom
GlobalCandleIndexUnderMouse_Float := OriginalFDisplayStartIndex + (MouseX_InChartArea_Relative / OldSlotWidth);
// Calculate the new FDisplayStartIndex to keep this global index at the same relative mouse position
FDisplayStartIndex := Round(GlobalCandleIndexUnderMouse_Float - (MouseX_InChartArea_Relative / NewSlotWidth));
end;
end;
// Recalculate drawing parameters and clamp FDisplayStartIndex
if FCurrentPriceRect.Width > 0 then
ChartDrawWidth := FCurrentPriceRect.Width
else // Fallback if FCurrentPriceRect is not yet calculated or invalid
ChartDrawWidth := PaintBox.ClientWidth - (2 * DefaultChartMargin);
DrawableCandlesInViewPort := Max(1, ChartDrawWidth div NewSlotWidth); // At least 1 candle drawable
MaxPossibleStartIndex := 0;
if Assigned(FActiveOHLCCache) and (FActiveOHLCCache.Count > 0) then
MaxPossibleStartIndex := FActiveOHLCCache.Count - DrawableCandlesInViewPort;
if MaxPossibleStartIndex < 0 then // Ensure MaxPossibleStartIndex is not negative
MaxPossibleStartIndex := 0;
FDisplayStartIndex := Max(0, FDisplayStartIndex); // Clamp to minimum 0
FDisplayStartIndex := Min(FDisplayStartIndex, MaxPossibleStartIndex); // Clamp to maximum possible start index
// If cache is empty, reset display start index to 0
if Assigned(FActiveOHLCCache) and (FActiveOHLCCache.Count = 0) then
FDisplayStartIndex := 0;
Memo.Lines.Add(
Format(
'MouseWheel: CW: %d->%d. NewStartIdx: %d. ZoomToCursor: %s',
[OldCandleWidthInPixels, FCandleWidthInPixels, FDisplayStartIndex, BoolToStr(ZoomToCursorActive, True)]
)
);
PaintBox.Invalidate; // Redraw the chart
Handled := True; // Indicate that the event was handled
// Update mouse hover information as zoom might change what's under the cursor
PaintBoxMouseMove(Sender, Shift, LMousePosInPaintBox.X, LMousePosInPaintBox.Y);
end;
procedure TChartForm.GoToCurrentButtonClick(Sender: TObject);
var
ChartDrawWidth, CurrentDrawingSlotWidth, NumCandlesDrawableInViewPort, NewDisplayStartIndex: Integer;
begin
Memo.Lines.Add(FormatDateTime('yyyy-mm-dd hh:nn:ss', Now) + ' - GoToCurrentButton clicked.');
if not Assigned(FActiveOHLCCache) or not FActiveOHLCCache.IsValid or (FActiveOHLCCache.Count = 0) then
begin
Memo.Lines.Add('GoToCurrent: No data or cache invalid.');
exit;
end;
// Determine the chart drawing width
if FCurrentPriceRect.Width > 0 then
ChartDrawWidth := FCurrentPriceRect.Width
else // Fallback if FCurrentPriceRect is not yet calculated
ChartDrawWidth := PaintBox.ClientWidth - (2 * DefaultChartMargin);
CurrentDrawingSlotWidth := FCandleWidthInPixels + FCandleSpacing;
if CurrentDrawingSlotWidth <= 0 then
begin
Memo.Lines.Add('GoToCurrent: Invalid candle slot width.');
exit;
end;
// Calculate how many candles can be displayed in the viewport
NumCandlesDrawableInViewPort := Max(1, ChartDrawWidth div CurrentDrawingSlotWidth);
// Calculate the start index to show the last N candles
NewDisplayStartIndex := Max(0, FActiveOHLCCache.Count - NumCandlesDrawableInViewPort);
// Ensure NewDisplayStartIndex is within valid bounds if cache has items
if FActiveOHLCCache.Count > 0 then // Should be true based on earlier check, but good for safety
NewDisplayStartIndex := Min(NewDisplayStartIndex, FActiveOHLCCache.Count - 1);
if FDisplayStartIndex <> NewDisplayStartIndex then
begin
FDisplayStartIndex := NewDisplayStartIndex;
Memo.Lines.Add(Format('GoToCurrent: Scrolled to display last candles. New StartIdx: %d', [FDisplayStartIndex]));
PaintBox.Invalidate; // Redraw the chart at the new position
end
else
Memo.Lines.Add('GoToCurrent: Already showing the latest candles.');
end;
procedure TChartForm.SetupDataServer;
var
fileName: string;
pathValue, symbolValue: string;
yearValue, monthValue: Integer;
i: Integer;
begin
FDataServer := TAuraTABFileServer.Create( DataPathEdit.Text );
var currTxt := SymbolComboBox.Text;
FSymbolFiles := FDataServer.EnumerateAssetFiles;
SymbolComboBox.Items.Clear;
SymbolComboBox.ClearSelection; // Ensure no previous selection remains
SymbolComboBox.Text := '';
if Length(FSymbolFiles) = 0 then
begin
Memo.Lines.Add('PathSelectButton: No symbol files found in the selected directory.');
ClearAllCachesInForm; // Clear any existing data
PaintBox.Invalidate; // Refresh chart (will show no data)
exit;
end;
Memo.Lines.Add(Format('PathSelectButton: Found %d symbol files.', [Length(FSymbolFiles)]));
var n := -1;
for i := Low(FSymbolFiles) to High(FSymbolFiles) do
begin
fileName := FSymbolFiles[i];
if TAuraTABFileServer.TryParseFileName(fileName, pathValue, symbolValue, yearValue, monthValue) then
begin
SymbolComboBox.Items.AddObject(
Format('%s %.2d-%.4d', [symbolValue, monthValue, yearValue]),
TObject(i)
); // Store index to FSymbolFiles in Object
if symbolValue = currTxt then
n := i;
Memo.Lines.Add(Format('PathSelectButton: Added symbol "%s" from file "%s".', [symbolValue, TPath.GetFileName(fileName)]));
end
else
begin
Memo.Lines.Add(Format('PathSelectButton: Could not parse symbol from filename "%s".', [TPath.GetFileName(fileName)]));
end;
end;
if n >= 0 then
SymbolComboBox.ItemIndex := n;
end;
end.