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: IDataServer; 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; // Local copy of all available timeframe settings FCurrentPriceRect: TRect; FCurrentVolumeHistRect: TRect; FMouseLastChartPos: TPoint; FMouseCurrentlyInChartArea: Boolean; FBufferBitmap: TBitmap; FDataController: TChartDataController; FSymbolFiles: TArray; // Stores full paths from FindOldestFilesPerSymbol procedure PopulateTimeframeComboBox; procedure PopulateSymbolComboBox; 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>): Boolean; function IsNewBar(AIndex: Integer; const ATick: TDataRecord): 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>): Boolean; function IsNewBar(AIndex: Integer; const ATick: TDataRecord): 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>): Boolean; begin Result := True; end; function TEveryNTicksBuilder.IsNewBar(AIndex: Integer; const ATick: TDataRecord): 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>): Boolean; begin FCurrentTickInBarCount := 0; Result := True; end; function TVolumeAccumulationBuilder.IsNewBar(AIndex: Integer; const ATick: TDataRecord): 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 FDataServer := TAskBidServer.Create; 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; 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; SetLength(FSymbolFiles, 0); // Initialize FSymbolFiles array // Assuming we have a valid default value in the Path-Edit: PopulateSymbolComboBox; 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); PopulateSymbolComboBox; 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).TimeStamp; UTCTimeEnd := FDataController.GetTickDataItem(HoveredCandle.OriginalDataIndexEnd).TimeStamp; 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).TimeStamp; 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.PopulateSymbolComboBox; var fileName: string; pathValue, symbolValue: string; yearValue, monthValue: Integer; i: Integer; begin var currTxt := SymbolComboBox.Text; FSymbolFiles := FindOldestFilesPerSymbol(DataPathEdit.Text); // Find all relevant symbol files [cite: 238] 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]; // TryParseFileName extracts symbol from the filename. [cite: 237] if 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.