From 4d67d9acbe44e8e39673f72db533ef0f110532e3 Mon Sep 17 00:00:00 2001 From: Michael Schimmel Date: Sun, 13 Jul 2025 21:49:42 +0200 Subject: [PATCH] Chart X grid, standard timeframes --- AuraTrader/AuraTrader.dproj | 2 +- AuraTrader/FirstStrategy.pas | 57 ++++- AuraTrader/MainForm.pas | 65 +++-- Src/Myc.Core.Notifier.pas | 50 +++- Src/Myc.Fmx.Chart.Series.pas | 94 +++++-- Src/Myc.Fmx.Chart.pas | 470 ++++++++++++++++++++++++++++------- Src/Myc.Trade.Indicators.pas | 42 ++-- Src/Myc.Trade.Types.pas | 4 + 8 files changed, 619 insertions(+), 165 deletions(-) diff --git a/AuraTrader/AuraTrader.dproj b/AuraTrader/AuraTrader.dproj index b01b2ad..5c1bb79 100644 --- a/AuraTrader/AuraTrader.dproj +++ b/AuraTrader/AuraTrader.dproj @@ -4,7 +4,7 @@ 20.3 FMX True - Debug + Release Win64 AuraTrader 3 diff --git a/AuraTrader/FirstStrategy.pas b/AuraTrader/FirstStrategy.pas index 16f8b47..35defbf 100644 --- a/AuraTrader/FirstStrategy.pas +++ b/AuraTrader/FirstStrategy.pas @@ -13,8 +13,6 @@ uses Myc.Trade.DataArray; type - TTimeframe = (M1, M5, H1, D); - TMycGenericConverter = class(TMycConverter) type TConvertFunc = reference to function(const Value: S): T; @@ -54,11 +52,11 @@ type TGenericIndicator = class(TIndicator) private - FFunc: TFunc; + FFunc: TIndicatorFunc; protected function Calculate(const Value: S): T; override; final; public - constructor Create(const AFunc: TFunc); + constructor Create(const AFunc: TIndicatorFunc); end; implementation @@ -76,14 +74,49 @@ begin end; function TTicksToTimeframe.GetBarStartTime(const TimeStamp: TDateTime; const Timeframe: TTimeframe): TDateTime; +var + baseTime: TDateTime; begin // Align the time grid to UTC 0:00 using functions from System.DateUtils + baseTime := RecodeMilliSecond(TimeStamp, 0); + case Timeframe of - M1: Result := RecodeSecond(RecodeMilliSecond(TimeStamp, 0), 0); - M5: Result := RecodeMinute(RecodeSecond(RecodeMilliSecond(TimeStamp, 0), 0), MinuteOf(TimeStamp) - MinuteOf(TimeStamp) mod 5); - H1: Result := RecodeMinute(RecodeSecond(RecodeMilliSecond(TimeStamp, 0), 0), 0); + S: Result := baseTime; + S5: Result := RecodeSecond(baseTime, SecondOf(TimeStamp) - (SecondOf(TimeStamp) mod 5)); + S15: Result := RecodeSecond(baseTime, SecondOf(TimeStamp) - (SecondOf(TimeStamp) mod 15)); + S30: Result := RecodeSecond(baseTime, SecondOf(TimeStamp) - (SecondOf(TimeStamp) mod 30)); + + M: Result := RecodeSecond(baseTime, 0); + M2: Result := RecodeMinute(RecodeSecond(baseTime, 0), MinuteOf(TimeStamp) - (MinuteOf(TimeStamp) mod 2)); + M3: Result := RecodeMinute(RecodeSecond(baseTime, 0), MinuteOf(TimeStamp) - (MinuteOf(TimeStamp) mod 3)); + M5: Result := RecodeMinute(RecodeSecond(baseTime, 0), MinuteOf(TimeStamp) - (MinuteOf(TimeStamp) mod 5)); + M10: Result := RecodeMinute(RecodeSecond(baseTime, 0), MinuteOf(TimeStamp) - (MinuteOf(TimeStamp) mod 10)); + M15: Result := RecodeMinute(RecodeSecond(baseTime, 0), MinuteOf(TimeStamp) - (MinuteOf(TimeStamp) mod 15)); + M30: Result := RecodeMinute(RecodeSecond(baseTime, 0), MinuteOf(TimeStamp) - (MinuteOf(TimeStamp) mod 30)); + + H: Result := RecodeMinute(RecodeSecond(baseTime, 0), 0); + H2: Result := RecodeHour(RecodeMinute(RecodeSecond(baseTime, 0), 0), HourOf(TimeStamp) - (HourOf(TimeStamp) mod 2)); + H3: Result := RecodeHour(RecodeMinute(RecodeSecond(baseTime, 0), 0), HourOf(TimeStamp) - (HourOf(TimeStamp) mod 3)); + H4: Result := RecodeHour(RecodeMinute(RecodeSecond(baseTime, 0), 0), HourOf(TimeStamp) - (HourOf(TimeStamp) mod 4)); + H8: Result := RecodeHour(RecodeMinute(RecodeSecond(baseTime, 0), 0), HourOf(TimeStamp) - (HourOf(TimeStamp) mod 8)); + H12: Result := RecodeHour(RecodeMinute(RecodeSecond(baseTime, 0), 0), HourOf(TimeStamp) - (HourOf(TimeStamp) mod 12)); + D: Result := StartOfTheDay(TimeStamp); + // D2, D3 are uncommon; this is a simple modulo-based approach relative to TDateTime's epoch. + D2: Result := Floor(TimeStamp) - (Floor(TimeStamp) mod 2); + D3: Result := Floor(TimeStamp) - (Floor(TimeStamp) mod 3); + + W: Result := TimeStamp.StartOfTheWeek; + + MN: Result := TimeStamp.StartOfTheMonth; + // Quarter alignment + MN3: Result := RecodeMonth(TimeStamp.StartOfTheMonth, (MonthOf(TimeStamp) - 1) div 3 * 3 + 1); + // Half-year alignment + MN6: Result := RecodeMonth(TimeStamp.StartOfTheMonth, (MonthOf(TimeStamp) - 1) div 6 * 6 + 1); + + Y: Result := TimeStamp.StartOfTheYear; else + // Fallback for any undefined timeframe Result := 0; end; end; @@ -122,7 +155,9 @@ begin begin // A new bar starts, so the previous one is now complete. if (lastBarTime > 0) then + begin states.Add(Broadcast(FCurrentBar)); + end; // Start a new bar, Volume is 1 because this is the first tick. currentBar := TOhlcItem.Create(midPrice, midPrice, midPrice, midPrice, 1); @@ -161,12 +196,16 @@ begin Result := Broadcast(FFunc(Value)); end; +{ TIndicator } + function TIndicator.ProcessData(const Value: S): TState; begin - Result := FQueue.Enqueue(function: TState begin Result := Broadcast(Calculate(Value)); end); + Result := Broadcast(Calculate(Value)); end; -constructor TGenericIndicator.Create(const AFunc: TFunc); +{ TGenericIndicator } + +constructor TGenericIndicator.Create(const AFunc: TIndicatorFunc); begin inherited Create; FFunc := AFunc; diff --git a/AuraTrader/MainForm.pas b/AuraTrader/MainForm.pas index 40b78f4..5409a9b 100644 --- a/AuraTrader/MainForm.pas +++ b/AuraTrader/MainForm.pas @@ -165,17 +165,25 @@ begin ///// - var OhlcPoint: IMycConverter>, TDataPoint> := TTicksToTimeframe.Create(M1); + var timeframe := TTimeframe.S15; + + var OhlcPoint: IMycConverter>, TDataPoint> := TTicksToTimeframe.Create(timeframe); var Timestamps: IMycConverter, TDateTime> := TMycGenericConverter, TDateTime> .Create(function(const Ohlc: TDataPoint): TDateTime begin Result := Ohlc.Time; end); + OhlcPoint.Sender.Link(TimeStamps); + chart.SetXAxisSeries(timeframe, Timestamps.Sender); + + var Panel := chart.Panels[0]; + var Ohlc: IMycConverter, TOhlcItem> := TMycGenericConverter, TOhlcItem> .Create(function(const Ohlc: TDataPoint): TOhlcItem begin Result := Ohlc.Data; end); OhlcPoint.Sender.Link(Ohlc); + Panel.AddOhlcSeries(Ohlc.Sender); var Closes: IMycConverter := TMycGenericConverter.Create(function(const Ohlc: TOhlcItem): Double begin Result := Ohlc.Close; end); @@ -184,17 +192,17 @@ begin var Hull: IMycConverter := TGenericIndicator.Create(TIndicators.CreateHMA(150)); Closes.Sender.Link(Hull); - chart.Panels[0].AddDoubleSeries(Hull.Sender, TAlphaColors.Aliceblue); + Panel.AddDoubleSeries(Hull.Sender, TAlphaColors.Aliceblue); // Add SMA (Simple Moving Average) var Sma: IMycConverter := TGenericIndicator.Create(TIndicators.CreateSMA(50)); Closes.Sender.Link(Sma); - chart.Panels[0].AddDoubleSeries(Sma.Sender, TAlphaColors.Yellow); + Panel.AddDoubleSeries(Sma.Sender, TAlphaColors.Yellow); // Add EMA (Exponential Moving Average) var Ema: IMycConverter := TGenericIndicator.Create(TIndicators.CreateEMA(21)); Closes.Sender.Link(Ema); - chart.Panels[0].AddDoubleSeries(Ema.Sender, TAlphaColors.Aqua); + Panel.AddDoubleSeries(Ema.Sender, TAlphaColors.Aqua); // Add Bollinger Bands (20, 2.0) var Boli: IMycConverter := @@ -205,59 +213,64 @@ begin TMycGenericConverter .Create(function(const Item: TBollingerBandsResult): Double begin Result := Item.UpperBand; end); Boli.Sender.Link(BoliUpper); - chart.Panels[0].AddDoubleSeries(BoliUpper.Sender, TAlphaColors.Gray); + Panel.AddDoubleSeries(BoliUpper.Sender, TAlphaColors.Gray); var BoliMiddle: IMycConverter := TMycGenericConverter .Create(function(const Item: TBollingerBandsResult): Double begin Result := Item.MiddleBand; end); Boli.Sender.Link(BoliMiddle); - chart.Panels[0].AddDoubleSeries(BoliMiddle.Sender, TAlphaColors.Darkgray, 1.0); + Panel.AddDoubleSeries(BoliMiddle.Sender, TAlphaColors.Darkgray, 1.0); var BoliLower: IMycConverter := TMycGenericConverter .Create(function(const Item: TBollingerBandsResult): Double begin Result := Item.LowerBand; end); Boli.Sender.Link(BoliLower); - chart.Panels[0].AddDoubleSeries(BoliLower.Sender, TAlphaColors.Gray); + Panel.AddDoubleSeries(BoliLower.Sender, TAlphaColors.Gray); - chart.AddPanel; + Panel := chart.AddPanel; // Add RSI (Relative Strength Index) var Rsi: IMycConverter := TGenericIndicator.Create(TIndicators.CreateRSI(14)); Closes.Sender.Link(Rsi); - chart.Panels[1].AddDoubleSeries(Rsi.Sender, TAlphaColors.Fuchsia); - { + Panel.AddDoubleSeries(Rsi.Sender, TAlphaColors.Fuchsia); + // Add MACD (12, 26, 9) var Macd: IMycConverter := TGenericIndicator.Create(TIndicators.CreateMACD(12, 26, 9)); Closes.Sender.Link(Macd); - var MacdLine: IMycConverter := TMycGenericConverter.Create(function(const Item: TMacdResult): Double begin Result := Item.MacdLine; end); + Panel := chart.AddPanel; + + var MacdLine: IMycConverter := + TMycGenericConverter.Create(function(const Item: TMacdResult): Double begin Result := Item.MacdLine; end); Macd.Sender.Link(MacdLine); - chart.AddDoubleSeries(MacdLine.Sender, TAlphaColors.Orange); + Panel.AddDoubleSeries(MacdLine.Sender, TAlphaColors.Orange); - var MacdSignal: IMycConverter := TMycGenericConverter.Create(function(const Item: TMacdResult): Double begin Result := Item.SignalLine; end); + var MacdSignal: IMycConverter := + TMycGenericConverter.Create(function(const Item: TMacdResult): Double begin Result := Item.SignalLine; end); Macd.Sender.Link(MacdSignal); - chart.AddDoubleSeries(MacdSignal.Sender, TAlphaColors.Dodgerblue); + Panel.AddDoubleSeries(MacdSignal.Sender, TAlphaColors.Dodgerblue); - var MacdHist: IMycConverter := TMycGenericConverter.Create(function(const Item: TMacdResult): Double begin Result := Item.Histogram; end); + var MacdHist: IMycConverter := + TMycGenericConverter.Create(function(const Item: TMacdResult): Double begin Result := Item.Histogram; end); Macd.Sender.Link(MacdHist); - chart.AddDoubleSeries(MacdHist.Sender, TAlphaColors.Lightgreen, 1.0); + Panel.AddDoubleSeries(MacdHist.Sender, TAlphaColors.Lightgreen, 1.0); + + Panel := chart.AddPanel; // Add Stochastic Oscillator (14, 3) - This needs OHLC data, not just Close prices. - var Stoch: IMycConverter := TGenericIndicator.Create(TIndicators.CreateStochastic(14, 3)); + var Stoch: IMycConverter := + TGenericIndicator.Create(TIndicators.CreateStochastic(14, 3)); Ohlc.Sender.Link(Stoch); - var StochK: IMycConverter := TMycGenericConverter.Create(function(const Item: TStochasticResult): Double begin Result := Item.K; end); + var StochK: IMycConverter := + TMycGenericConverter.Create(function(const Item: TStochasticResult): Double begin Result := Item.K; end); Stoch.Sender.Link(StochK); - chart.AddDoubleSeries(StochK.Sender, TAlphaColors.Green); + Panel.AddDoubleSeries(StochK.Sender, TAlphaColors.Green); - var StochD: IMycConverter := TMycGenericConverter.Create(function(const Item: TStochasticResult): Double begin Result := Item.D; end); + var StochD: IMycConverter := + TMycGenericConverter.Create(function(const Item: TStochasticResult): Double begin Result := Item.D; end); Stoch.Sender.Link(StochD); - chart.AddDoubleSeries(StochD.Sender, TAlphaColors.Red); -} - OhlcPoint.Sender.Link(TimeStamps); - - chart.SetXAxisSeries(Timestamps.Sender); - chart.Panels[0].AddOhlcSeries(Ohlc.Sender); + Panel.AddDoubleSeries(StochD.Sender, TAlphaColors.Red); var done := ExecuteStrategy(Symbol, OhlcPoint); diff --git a/Src/Myc.Core.Notifier.pas b/Src/Myc.Core.Notifier.pas index 4401406..789ca37 100644 --- a/Src/Myc.Core.Notifier.pas +++ b/Src/Myc.Core.Notifier.pas @@ -130,11 +130,56 @@ end; procedure TMycNotifyList.Notify(const Func: TNotifyProc); var Item: PItem; - Last: PItem; - tmp: PItem; begin Assert(IsLocked); + var rev: PItem := nil; + var tmp: PItem := nil; + + while (FList <> nil) and Assigned(FList.Receiver) do + begin + Item := FList; + FList := Item.Next; + + if Func(Item.Receiver) then + begin + Item.Next := rev; + rev := Item; + end + else + begin + // release the receiver + Item.Receiver := nil; + + Item.Next := tmp; + tmp := Item; + end; + end; + + while tmp <> nil do + begin + Item := tmp; + tmp := Item.Next; + + Item.Prev := nil; + Item.Next := FList; + FList := Item; + if Item.Next <> nil then + Item.Next.Prev := Item; + end; + + while rev <> nil do + begin + Item := rev; + rev := Item.Next; + + Item.Prev := nil; + Item.Next := FList; + FList := Item; + if Item.Next <> nil then + Item.Next.Prev := Item; + end; + { tmp := nil; Last := nil; Item := FList; @@ -189,6 +234,7 @@ begin if Item.Next <> nil then Item.Next.Prev := Item; end; + } end; procedure TMycNotifyList.Release; diff --git a/Src/Myc.Fmx.Chart.Series.pas b/Src/Myc.Fmx.Chart.Series.pas index 3dbfe24..3f97bba 100644 --- a/Src/Myc.Fmx.Chart.Series.pas +++ b/Src/Myc.Fmx.Chart.Series.pas @@ -1,4 +1,4 @@ -unit Myc.FMX.Chart.Series; +unit Myc.Fmx.Chart.Series; interface @@ -58,14 +58,14 @@ type end; { TChartCustomLayer } - TChartCustomLayer = class(TMycChart.TLayer) + TChartCustomLayer = class abstract(TMycChart.TDataLayer) private FSeries: TChartSeriesProcessor; protected function GetSeries: TMycChart.TSeries; override; - function GetValueRange(First, Last: Int64; out MinValue, MaxValue: Double): Boolean; override; + function GetValueRange(First, Last: Int64; out MinValue, MaxValue: Double): Boolean; override; abstract; procedure Update; override; - procedure Paint(const Canvas: TCanvas; First, Last: Int64; const XForm, YForm: TFunc); override; + procedure Paint(const Canvas: TCanvas; First, Last: Int64; const XForm, YForm: TFunc); override; abstract; public constructor Create(AParent: TMycChart.TPanel; const ADataProvider: IMycDataProvider); destructor Destroy; override; @@ -104,6 +104,30 @@ type ); end; + { TChartXAxisLayer } + TChartXAxisLayer = class(TMycChart.TXAxisLayer) + private + FSeries: TChartSeriesProcessor; + protected + function GetSeries: TMycChart.TSeries; override; + procedure Update; override; + function GetCaption(Idx: Int64): String; override; abstract; + public + constructor Create(AOwner: TMycChart; const ADataProvider: IMycDataProvider); + destructor Destroy; override; + end; + + { TChartXAxisTimestampLayer } + TChartXAxisTimestampLayer = class(TChartXAxisLayer) + private + FTimeframe: TTimeframe; + protected + // Provide a formatted timestamp string for a given data index. + function GetCaption(Idx: Int64): String; override; final; + public + constructor Create(AOwner: TMycChart; ATimeframe: TTimeframe; const ADataProvider: IMycDataProvider); + end; + implementation uses @@ -320,19 +344,61 @@ begin Result := FSeries; end; -function TChartCustomLayer.GetValueRange(First, Last: Int64; out MinValue, MaxValue: Double): Boolean; -begin - Result := false; -end; - -procedure TChartCustomLayer.Paint(const Canvas: TCanvas; First, Last: Int64; const XForm, YForm: TFunc); -begin - // nope -end; - procedure TChartCustomLayer.Update; begin FSeries.Update; end; +{ TChartXAxisLayer } + +constructor TChartXAxisLayer.Create(AOwner: TMycChart; const ADataProvider: IMycDataProvider); +begin + inherited Create(AOwner); + FSeries := TChartSeriesProcessor.Create(ADataProvider, AOwner.Lookback.AsMutable); +end; + +destructor TChartXAxisLayer.Destroy; +begin + FSeries.Free; + inherited; +end; + +function TChartXAxisLayer.GetSeries: TMycChart.TSeries; +begin + Result := FSeries; +end; + +procedure TChartXAxisLayer.Update; +begin + FSeries.Update; +end; + +constructor TChartXAxisTimestampLayer.Create(AOwner: TMycChart; ATimeframe: TTimeframe; const ADataProvider: IMycDataProvider); +begin + inherited Create(AOwner, ADataProvider); + FTimeframe := ATimeframe; +end; + +function TChartXAxisTimestampLayer.GetCaption(Idx: Int64): String; +var + dt: TDateTime; + formatStr: string; +begin + Result := ''; + if (Idx >= 0) and (Idx < FSeries.Count) then + begin + dt := FSeries.Data[Idx]; + + // Choose format based on the series timeframe. + if FTimeframe >= D then // Daily, Weekly, Monthly, Yearly + formatStr := 'dd.mm.yyyy' + else if FTimeframe >= M then // Hourly + Minutes + formatStr := 'dd.mm.yyyy hh:nn' + else // Seconds + formatStr := 'dd.mm.yyyy hh:nn:ss'; + + Result := FormatDateTime(formatStr, dt); + end; +end; + end. diff --git a/Src/Myc.Fmx.Chart.pas b/Src/Myc.Fmx.Chart.pas index 30d432f..35c1350 100644 --- a/Src/Myc.Fmx.Chart.pas +++ b/Src/Myc.Fmx.Chart.pas @@ -38,29 +38,66 @@ type // Abstract base for a drawable layer in a panel. TLayer = class abstract(TObject) - private - FParent: TPanel; - function GetOwner: TMycChart; protected + function GetOwner: TMycChart; virtual; abstract; function GetSeries: TSeries; virtual; abstract; procedure Update; virtual; abstract; - function GetValueRange(First, Last: Int64; out Min, Max: Double): Boolean; virtual; abstract; - procedure Paint(const Canvas: TCanvas; First, Last: Int64; const XForm, YForm: TFunc); virtual; abstract; public - constructor Create(AOwner: TPanel); procedure Repaint; property Owner: TMycChart read GetOwner; property Series: TSeries read GetSeries; end; + TDataLayer = class abstract(TLayer) + private + FParent: TPanel; + protected + function GetOwner: TMycChart; override; final; + function GetValueRange(First, Last: Int64; out Min, Max: Double): Boolean; virtual; abstract; + procedure Paint(const Canvas: TCanvas; First, Last: Int64; const XForm, YForm: TFunc); virtual; abstract; + public + constructor Create(AParent: TPanel); + end; + + TAxisLayer = class(TLayer) + private + FOwner: TMycChart; + protected + function GetOwner: TMycChart; override; final; + // Paint crosshair and other axis related overlays + procedure Paint( + const Canvas: TCanvas; + const Viewport: TRectF; + ViewStartIndex, ViewCount: Int64; + const MousePos: TPointF + ); virtual; + public + constructor Create(AOwner: TMycChart); + end; + + TXAxisLayer = class(TAxisLayer) + protected + // Paint crosshair and time caption + procedure Paint( + const Canvas: TCanvas; + const Viewport: TRectF; + ViewStartIndex, ViewCount: Int64; + const MousePos: TPointF + ); override; + // This function delivers the text for the caption. + function GetCaption(Idx: Int64): String; virtual; abstract; + end; + // A panel is a rectangular area in the chart with its own Y-axis, which can contain multiple layers. TPanel = class(TObject) private FOwner: TMycChart; - FSeriesList: TObjectList; + FSeriesList: TObjectList; + FWeight: Single; + procedure SetWeight(const Value: Single); protected procedure Paint(const Canvas: TCanvas; const Viewport: TRectF; MasterTotalCount: Int64; ViewStartIndex, ViewCount: Int64); - procedure UpdateAndCheckChanges(MasterTotalCount: Int64; ViewStartIndex: Int64; out SeriesChanged, SeriesRepaint: Boolean); + procedure Update(MasterTotalCount: Int64; ViewStartIndex: Int64; out SeriesChanged, SeriesRepaint: Boolean); public constructor Create(AOwner: TMycChart); @@ -70,35 +107,42 @@ type const DataProvider: IMycDataProvider; const AUpColor: TAlphaColor = TAlphaColors.Green; const ADownColor: TAlphaColor = TAlphaColors.Red - ): TMycChart.TLayer; + ): TMycChart.TDataLayer; function AddDoubleSeries( const DataProvider: IMycDataProvider; const ALineColor: TAlphaColor = TAlphaColors.Cornflowerblue; const ALineWidth: Single = 1.5 - ): TMycChart.TLayer; + ): TMycChart.TDataLayer; property Owner: TMycChart read FOwner; + property Weight: Single read FWeight write SetWeight; end; private FPanelList: TObjectList; - FXAxisSeries: TMycChart.TLayer; + FXAxisSeries: TMycChart.TXAxisLayer; FLookback: TWriteable; FIdleSubscrId: TMessageSubscriptionId; FViewStartIndex: Int64; FViewCount: Int64; FIsDragging: Boolean; FDragStartPoint: TPointF; + FMousePos: TPointF; + FIsMouseInControl: Boolean; FJumpButtonRect: TRectF; FJumpButtonHot: Boolean; FJumpButtonPressed: Boolean; FNeedRepaint: TFlag; + FIsResizing: Boolean; + FResizePanelIndex: Integer; + FHotResizePanelIndex: Integer; function GetPanel(Index: Integer): TPanel; function GetPanelCount: Integer; protected procedure Paint; override; procedure DoIdle; + procedure DoMouseLeave; override; procedure MouseWheel(Shift: TShiftState; WheelDelta: Integer; var Handled: Boolean); override; procedure MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Single); override; procedure MouseMove(Shift: TShiftState; X, Y: Single); override; @@ -110,13 +154,15 @@ type // Adds a new, empty panel to the bottom of the chart. function AddPanel: TPanel; - // Sets the master series that defines the time scale (X-axis). This series is not drawn. - function SetXAxisSeries(const DataProvider: IMycDataProvider): TMycChart.TLayer; + // Sets the master series that defines the time scale (X-axis). + function SetXAxisSeries(Timeframe: TTimeframe; const DataProvider: IMycDataProvider): TMycChart.TXAxisLayer; property Lookback: TWriteable read FLookback write FLookback; property NeedRepaint: TFlag read FNeedRepaint; property PanelCount: Integer read GetPanelCount; property Panels[Index: Integer]: TPanel read GetPanel; default; + // The rectangle occupied by the jump-to-latest button, used for hit testing. + property JumpButtonRect: TRectF read FJumpButtonRect; end; implementation @@ -125,6 +171,11 @@ uses System.Math, Myc.FMX.Chart.Series; +const + RESIZE_HOT_ZONE = 4; + MIN_PANEL_HEIGHT = 30; + MIN_PANEL_WEIGHT = 0.05; + { TMycChart } constructor TMycChart.Create(AOwner: TComponent); @@ -136,7 +187,14 @@ begin FLookback := TWriteable.CreateWriteable(1000); FViewStartIndex := 0; FViewCount := 100; + FIsDragging := false; + FIsResizing := false; + FResizePanelIndex := -1; + FHotResizePanelIndex := -1; + FIsMouseInControl := false; + + FNeedRepaint := TFlag.CreateFlag(); FIdleSubscrId := TMessageManager @@ -144,7 +202,7 @@ begin .SubscribeToMessage(TIdleMessage, procedure(const Sender: TObject; const M: TMessage) begin DoIdle; end); // Create the default panel. - AddPanel; + AddPanel.Weight := 1.0; end; destructor TMycChart.Destroy; @@ -173,16 +231,6 @@ begin Result := FPanelList.Count; end; -function TMycChart.SetXAxisSeries(const DataProvider: IMycDataProvider): TMycChart.TLayer; -begin - FXAxisSeries.Free; - // Note: TChartCustomLayer needs its owner to be a TPanel. We use the first panel. - var counter := TChartSeriesCounter.Create; - DataProvider.Link(counter); - FXAxisSeries := TChartCustomLayer.Create(Self.Panels[0], counter.Sender); - Result := FXAxisSeries; -end; - procedure TMycChart.DoIdle; var prevTotalCount, newTotalCount: Int64; @@ -202,11 +250,12 @@ begin for var panel in FPanelList do begin var panelChanged, panelRepaint: Boolean; - panel.UpdateAndCheckChanges(prevTotalCount, FViewStartIndex, panelChanged, panelRepaint); + panel.Update(prevTotalCount, FViewStartIndex, panelChanged, panelRepaint); seriesChanged := seriesChanged or panelChanged; seriesRepaint := seriesRepaint or panelRepaint; end; + // When we are not displaying live data, adjust start index accordingly if (FViewStartIndex > 0) then begin countDelta := newTotalCount - prevTotalCount; @@ -224,6 +273,16 @@ begin Repaint; end; +procedure TMycChart.DoMouseLeave; +begin + inherited; + if FIsMouseInControl then + begin + FIsMouseInControl := false; + Repaint; + end; +end; + procedure TMycChart.Paint; var rect, panelRect: TRectF; @@ -231,7 +290,7 @@ var buttonColor: TAlphaColor; path: TPathData; masterTotalCount: Int64; - panelHeight: Single; + panelHeight, totalWeight, currentY: Single; begin inherited; rect := Self.LocalRect; @@ -244,12 +303,20 @@ begin end; masterTotalCount := FXAxisSeries.Series.TotalCount; - panelHeight := rect.Height / PanelCount; + // Calculate total weight for proportional panel height + totalWeight := 0; + for var panel in FPanelList do + totalWeight := totalWeight + panel.Weight; + if (totalWeight <= 0) then + totalWeight := PanelCount; // Fallback to equal distribution + + currentY := rect.Top; for var i := 0 to PanelCount - 1 do begin var panel := Panels[i]; - panelRect := TRectF.Create(rect.Left, rect.Top + (i * panelHeight), rect.Right, rect.Top + ((i + 1) * panelHeight)); + panelHeight := (panel.Weight / totalWeight) * rect.Height; + panelRect := TRectF.Create(rect.Left, currentY, rect.Right, currentY + panelHeight); // Draw panel content panel.Paint(Self.Canvas, panelRect, masterTotalCount, FViewStartIndex, FViewCount); @@ -261,8 +328,14 @@ begin Canvas.Stroke.Thickness := 1; Canvas.DrawLine(PointF(panelRect.Left, panelRect.Top), PointF(panelRect.Right, panelRect.Top), 1); end; + + currentY := currentY + panelHeight; end; + // Draw crosshair if mouse is in control + if FIsMouseInControl and Assigned(FXAxisSeries) and not FIsDragging and not FIsResizing then + FXAxisSeries.Paint(Self.Canvas, rect, FViewStartIndex, FViewCount, FMousePos); + // --- Draw the "jump to latest" button (code unchanged) --- isButtonVisible := (FViewStartIndex > 0); @@ -312,7 +385,17 @@ begin begin FJumpButtonPressed := true; Repaint; - exit; // Prevent chart dragging + exit; + end; + + // Check for starting a resize + if (Button = TMouseButton.mbLeft) and (FHotResizePanelIndex >= 0) then + begin + FIsResizing := true; + FResizePanelIndex := FHotResizePanelIndex; + FDragStartPoint := TPointF.Create(X, Y); + Capture; + exit; end; inherited MouseDown(Button, Shift, X, Y); @@ -320,61 +403,111 @@ begin begin FIsDragging := true; FDragStartPoint := TPointF.Create(X, Y); + Capture; end; end; procedure TMycChart.MouseUp(Button: TMouseButton; Shift: TShiftState; X, Y: Single); begin - // Check if a button press was active - if FJumpButtonPressed then - begin - FJumpButtonPressed := false; - // If mouse is released over the button, trigger the action - if (FViewStartIndex > 0) and FJumpButtonRect.Contains(PointF(X, Y)) then - begin - FViewStartIndex := 0; // Jump to the latest candle - end; - Repaint; - exit; - end; - inherited MouseUp(Button, Shift, X, Y); + if (Button = TMouseButton.mbLeft) then begin - FIsDragging := false; + // Stop resizing + if FIsResizing then + begin + FIsResizing := false; + FResizePanelIndex := -1; + // Force cursor update on next move by resetting hot index + if (FHotResizePanelIndex <> -1) then + begin + FHotResizePanelIndex := -1; + Self.Cursor := crDefault; + end; + end; + + // Stop panning + if FIsDragging then + begin + FIsDragging := false; + end; + + // Stop button press + if FJumpButtonPressed then + begin + FJumpButtonPressed := false; + // If mouse is released over the button, trigger the action + if (FViewStartIndex > 0) and FJumpButtonRect.Contains(PointF(X, Y)) then + begin + FViewStartIndex := 0; // Jump to the latest candle + end; + Repaint; + end; + + // Release mouse capture if it was held by this control + ReleaseCapture; end; end; procedure TMycChart.MouseMove(Shift: TShiftState; X, Y: Single); var - isHot: Boolean; - dx: Single; + dx, dy: Single; indexDelta: Int64; maxIndex: Int64; begin - // Update button hot state - if (FViewStartIndex > 0) then - begin - isHot := FJumpButtonRect.Contains(PointF(X, Y)); - if (isHot <> FJumpButtonHot) then - begin - FJumpButtonHot := isHot; - Repaint; - end; - end - else - begin - // Ensure hot state is off when button is not visible - if FJumpButtonHot then - begin - FJumpButtonHot := false; - Repaint; - end; - end; - - // Panning logic inherited MouseMove(Shift, X, Y); + if not FIsMouseInControl then + FIsMouseInControl := true; + FMousePos := TPointF.Create(X, Y); + + // --- Panel Resizing Logic --- + if FIsResizing then + begin + dy := Y - FDragStartPoint.Y; + + var panel1 := Panels[FResizePanelIndex]; + var panel2 := Panels[FResizePanelIndex + 1]; + + // Get total weight and height of the two panels being resized + var totalWeight := 0.0; + for var panel in FPanelList do + totalWeight := totalWeight + panel.Weight; + if (totalWeight <= 0) then + totalWeight := PanelCount; + + var h1 := (panel1.Weight / totalWeight) * Self.Height; + var h2 := (panel2.Weight / totalWeight) * Self.Height; + var twoPanelWeight := panel1.Weight + panel2.Weight; + + var newH1 := h1 + dy; + var newH2 := h2 - dy; + + // Clamp to minimum height + if (newH1 < MIN_PANEL_HEIGHT) then + begin + newH2 := newH2 + (newH1 - MIN_PANEL_HEIGHT); + newH1 := MIN_PANEL_HEIGHT; + end; + if (newH2 < MIN_PANEL_HEIGHT) then + begin + newH1 := newH1 + (newH2 - MIN_PANEL_HEIGHT); + newH2 := MIN_PANEL_HEIGHT; + end; + + // Recalculate weights based on new clamped heights + if (newH1 + newH2 > 0) then + begin + panel1.Weight := (newH1 / (newH1 + newH2)) * twoPanelWeight; + panel2.Weight := (newH2 / (newH1 + newH2)) * twoPanelWeight; + end; + + FDragStartPoint.Y := Y; // Update start point for next move + Repaint; + exit; // Do not continue with other mouse move logic + end; + + // --- Chart Panning Logic --- if FIsDragging then begin dx := X - FDragStartPoint.X; @@ -383,24 +516,16 @@ begin exit; end; - // An X-axis series is required for panning if not Assigned(FXAxisSeries) or (FViewCount <= 0) then - begin exit; - end; indexDelta := Round(dx / (Self.Width / FViewCount)); - - // To move content left (natural), view must shift to newer data (lower index). - // Drag left (dx < 0) -> FViewStartIndex must decrease. - // The formula for that is FViewStartIndex := FViewStartIndex + indexDelta; -> StartIndex + (negative) = decrease. FViewStartIndex := FViewStartIndex + indexDelta; // Clamp values maxIndex := FXAxisSeries.Series.Count - FViewCount; if (maxIndex < 0) then maxIndex := 0; - if (FViewStartIndex < 0) then FViewStartIndex := 0; if (FViewStartIndex > maxIndex) then @@ -408,9 +533,43 @@ begin FDragStartPoint.X := X; FDragStartPoint.Y := Y; - Repaint; + exit; end; + + // --- Separator Hit-Testing and Cursor Change --- + var currentHotIndex := -1; + if (PanelCount > 1) then + begin + var totalWeight := 0.0; + for var panel in FPanelList do + totalWeight := totalWeight + panel.Weight; + if (totalWeight <= 0) then + totalWeight := PanelCount; + + var currentY: Single := 0; + for var i := 0 to PanelCount - 2 do + begin + currentY := currentY + (Panels[i].Weight / totalWeight) * Self.Height; + if (Abs(Y - currentY) < RESIZE_HOT_ZONE) then + begin + currentHotIndex := i; + break; + end; + end; + end; + + if (currentHotIndex <> FHotResizePanelIndex) then + begin + FHotResizePanelIndex := currentHotIndex; + if (FHotResizePanelIndex <> -1) then + Self.Cursor := crVSplit + else + Self.Cursor := crDefault; + end; + + // --- Jump Button Hot State Logic and final repaint for crosshair --- + Repaint; end; procedure TMycChart.MouseWheel(Shift: TShiftState; WheelDelta: Integer; var Handled: Boolean); @@ -471,22 +630,17 @@ begin Repaint; end; -{ TMycChart.TLayer } - -constructor TMycChart.TLayer.Create(AOwner: TPanel); +function TMycChart.SetXAxisSeries(Timeframe: TTimeframe; const DataProvider: IMycDataProvider): TMycChart.TXAxisLayer; begin - inherited Create; - FParent := AOwner; -end; + FXAxisSeries.Free; -function TMycChart.TLayer.GetOwner: TMycChart; -begin - Result := FParent.Owner; + FXAxisSeries := TChartXAxisTimestampLayer.Create(Self, Timeframe, DataProvider); + Result := FXAxisSeries; end; procedure TMycChart.TLayer.Repaint; begin - FParent.Owner.Repaint; + Owner.Repaint; end; { TMycChart.TPanel } @@ -495,7 +649,8 @@ constructor TMycChart.TPanel.Create(AOwner: TMycChart); begin inherited Create; FOwner := AOwner; - FSeriesList := TObjectList.Create(true); + FSeriesList := TObjectList.Create(true); + FWeight := 0.2; end; destructor TMycChart.TPanel.Destroy; @@ -506,9 +661,9 @@ end; function TMycChart.TPanel.AddDoubleSeries( const DataProvider: IMycDataProvider; - const ALineColor: TAlphaColor; - const ALineWidth: Single -): TMycChart.TLayer; + const ALineColor: TAlphaColor = TAlphaColors.Cornflowerblue; + const ALineWidth: Single = 1.5 +): TMycChart.TDataLayer; begin Result := TChartLineLayer.Create(Self, DataProvider, ALineColor, ALineWidth); FSeriesList.Add(Result); @@ -516,8 +671,9 @@ end; function TMycChart.TPanel.AddOhlcSeries( const DataProvider: IMycDataProvider; - const AUpColor, ADownColor: TAlphaColor -): TMycChart.TLayer; + const AUpColor: TAlphaColor = TAlphaColors.Green; + const ADownColor: TAlphaColor = TAlphaColors.Red +): TMycChart.TDataLayer; begin Result := TChartOhlcLayer.Create(Self, DataProvider, TMutable.Constant(AUpColor), TMutable.Constant(ADownColor)); @@ -603,7 +759,20 @@ begin end; end; -procedure TMycChart.TPanel.UpdateAndCheckChanges(MasterTotalCount, ViewStartIndex: Int64; out SeriesChanged, SeriesRepaint: Boolean); +procedure TMycChart.TPanel.SetWeight(const Value: Single); +begin + // Ensure weight does not become too small or negative + var clampedValue := Max(MIN_PANEL_WEIGHT, Value); + if FWeight <> clampedValue then + begin + FWeight := clampedValue; + // Do not call Repaint here, it will be handled by the mouse move logic + if Assigned(FOwner) and (not FOwner.FIsResizing) then + FOwner.Repaint; + end; +end; + +procedure TMycChart.TPanel.Update(MasterTotalCount: Int64; ViewStartIndex: Int64; out SeriesChanged, SeriesRepaint: Boolean); var seriesCount: Int64; begin @@ -623,4 +792,121 @@ begin end; end; +{ TMycChart.TDataLayer } + +constructor TMycChart.TDataLayer.Create(AParent: TPanel); +begin + inherited Create; + FParent := AParent; +end; + +function TMycChart.TDataLayer.GetOwner: TMycChart; +begin + Result := FParent.Owner; +end; + +{ TMycChart.TAxisLayer } + +constructor TMycChart.TAxisLayer.Create(AOwner: TMycChart); +begin + inherited Create; + FOwner := AOwner; +end; + +function TMycChart.TAxisLayer.GetOwner: TMycChart; +begin + Result := FOwner; +end; + +procedure TMycChart.TAxisLayer.Paint( + const Canvas: TCanvas; + const Viewport: TRectF; + ViewStartIndex, ViewCount: Int64; + const MousePos: TPointF +); +begin + // Do nothing in base class +end; + +{ TMycChart.TXAxisLayer } + +procedure TMycChart.TXAxisLayer.Paint( + const Canvas: TCanvas; + const Viewport: TRectF; + ViewStartIndex, ViewCount: Int64; + const MousePos: TPointF +); +var + idx: Int64; + caption: string; + captionRect: TRectF; + padding: Single; + indexFromMouse: Double; + candleX: Single; + textSize: TRectF; +begin + // Do not draw crosshair if mouse is over the jump button + if Owner.JumpButtonRect.Contains(MousePos) then + exit; + + // Do not draw if no data or view is invalid + if (not Assigned(Series)) or (Series.Count = 0) or (ViewCount <= 1) then + exit; + + // 1. Calculate the data index from the mouse X position + indexFromMouse := ViewStartIndex + (Viewport.Right - MousePos.X) * (ViewCount - 1) / Viewport.Width; + idx := Round(indexFromMouse); + + // Check if the calculated index is valid for the series + if (idx < 0) or (idx >= Series.Count) then + exit; + + // Snap X to the candle's center + candleX := Viewport.Right - ((idx - ViewStartIndex) * (ViewCount - 1)) / (ViewCount - 1) * (Viewport.Width / (ViewCount - 1)); + + // 2. Draw the vertical line at the snapped position + Canvas.Stroke.Kind := TBrushKind.Solid; + Canvas.Stroke.Color := TAlphaColors.Gray; + Canvas.Stroke.Thickness := 1; + Canvas.DrawLine(PointF(candleX, Viewport.Top), PointF(candleX, Viewport.Bottom), 1.0); + + // 3. Get the caption for the index + caption := GetCaption(idx); + if caption.IsEmpty then + exit; + + // 4. Draw the caption with a background + padding := 1; + Canvas.Font.Size := 9; + + // Measure text size + textSize := TRectF.Create(0, 0, 10000, 10000); + Canvas.MeasureText(textSize, caption, False, [], TTextAlign.Leading); + + // Position the caption box, centered on the snapped candle X + captionRect.Left := candleX - (textSize.Width / 2) - padding; + captionRect.Width := textSize.Width + (padding * 2); + captionRect.Top := Viewport.Bottom - textSize.Height - (padding * 2); + captionRect.Height := textSize.Height + (padding * 2); + + // Adjust position to not go outside the viewport + if captionRect.Left < Viewport.Left then + captionRect.SetLocation(Viewport.Left, captionRect.Top); + if captionRect.Right > Viewport.Right then + captionRect.SetLocation(Viewport.Right - captionRect.Width, captionRect.Top); + + // Draw the styled box + Canvas.Fill.Color := TAlphaColors.White; + Canvas.Fill.Kind := TBrushKind.Solid; + Canvas.Stroke.Kind := TBrushKind.Solid; + Canvas.Stroke.Color := TAlphaColors.Gray; + Canvas.Stroke.Thickness := 1; + Canvas.FillRect(captionRect, 2, 2, AllCorners, 1.0); + Canvas.DrawRect(captionRect, 2, 2, AllCorners, 1.0); + + // Draw the text + Canvas.Fill.Color := TAlphaColors.Black; + Canvas.FillText(captionRect, caption, false, 1.0, [], TTextAlign.Center, TTextAlign.Center); +end; + end. diff --git a/Src/Myc.Trade.Indicators.pas b/Src/Myc.Trade.Indicators.pas index 7c0c342..71f67ae 100644 --- a/Src/Myc.Trade.Indicators.pas +++ b/Src/Myc.Trade.Indicators.pas @@ -36,19 +36,19 @@ type class function CalculateWMA(const Series: TMycDataArray; const Period: Integer): Double; static; public // Simple Moving Average - class function CreateSMA(Period: Integer): TFunc; static; + class function CreateSMA(Period: Integer): TIndicatorFunc; static; // Exponential Moving Average - class function CreateEMA(Period: Integer): TFunc; static; + class function CreateEMA(Period: Integer): TIndicatorFunc; static; // Hull Moving Average - class function CreateHMA(Period: Integer): TFunc; static; + class function CreateHMA(Period: Integer): TIndicatorFunc; static; // Relative Strength Index - class function CreateRSI(Period: Integer): TFunc; static; + class function CreateRSI(Period: Integer): TIndicatorFunc; static; // Moving Average Convergence Divergence - class function CreateMACD(FastPeriod, SlowPeriod, SignalPeriod: Integer): TFunc; static; + class function CreateMACD(FastPeriod, SlowPeriod, SignalPeriod: Integer): TIndicatorFunc; static; // Stochastic Oscillator - class function CreateStochastic(KPeriod, DPeriod: Integer): TFunc; static; + class function CreateStochastic(KPeriod, DPeriod: Integer): TIndicatorFunc; static; // Bollinger Bands - class function CreateBollingerBands(Period: Integer; Multiplier: Double): TFunc; static; + class function CreateBollingerBands(Period: Integer; Multiplier: Double): TIndicatorFunc; static; end; implementation @@ -112,11 +112,11 @@ begin Result := numerator / denominator; end; -class function TIndicators.CreateBollingerBands(Period: Integer; Multiplier: Double): TFunc; +class function TIndicators.CreateBollingerBands(Period: Integer; Multiplier: Double): TIndicatorFunc; begin var sourceData := TMycDataArray.CreateEmpty; Result := - function(Value: Double): TBollingerBandsResult + function(const Value: Double): TBollingerBandsResult var stdDev: Double; begin @@ -135,14 +135,14 @@ begin end; end; -class function TIndicators.CreateEMA(Period: Integer): TFunc; +class function TIndicators.CreateEMA(Period: Integer): TIndicatorFunc; begin var lastEma: Double := Double.NaN; var sourceData := TMycDataArray.CreateEmpty; var multiplier := 2 / (Period + 1); Result := - function(Value: Double): Double + function(const Value: Double): Double begin sourceData := sourceData.Add(Value, Period); @@ -166,7 +166,7 @@ begin end; end; -class function TIndicators.CreateHMA(Period: Integer): TFunc; +class function TIndicators.CreateHMA(Period: Integer): TIndicatorFunc; begin var periodHalf := Period div 2; var periodSqrt := Round(Sqrt(Period)); @@ -174,7 +174,7 @@ begin var diffSeries := TMycDataArray.CreateEmpty; Result := - function(Value: Double): Double + function(const Value: Double): Double var price: Double; wmaHalf, wmaFull, diff: Double; @@ -208,14 +208,14 @@ begin end; end; -class function TIndicators.CreateMACD(FastPeriod, SlowPeriod, SignalPeriod: Integer): TFunc; +class function TIndicators.CreateMACD(FastPeriod, SlowPeriod, SignalPeriod: Integer): TIndicatorFunc; begin var emaFast := CreateEMA(FastPeriod); var emaSlow := CreateEMA(SlowPeriod); var emaSignal := CreateEMA(SignalPeriod); Result := - function(Value: Double): TMacdResult + function(const Value: Double): TMacdResult var fastVal, slowVal: Double; begin @@ -240,14 +240,14 @@ begin end; end; -class function TIndicators.CreateRSI(Period: Integer): TFunc; +class function TIndicators.CreateRSI(Period: Integer): TIndicatorFunc; begin var avgGain: Double := Double.NaN; var avgLoss: Double := Double.NaN; var sourceData := TMycDataArray.CreateEmpty; Result := - function(Value: Double): Double + function(const Value: Double): Double var change, gain, loss, rs: Double; gainSum, lossSum: Double; @@ -299,11 +299,11 @@ begin end; end; -class function TIndicators.CreateSMA(Period: Integer): TFunc; +class function TIndicators.CreateSMA(Period: Integer): TIndicatorFunc; begin var sourceData := TMycDataArray.CreateEmpty; Result := - function(Value: Double): Double + function(const Value: Double): Double begin sourceData := sourceData.Add(Value, Period); if (sourceData.Count >= Period) then @@ -313,13 +313,13 @@ begin end; end; -class function TIndicators.CreateStochastic(KPeriod, DPeriod: Integer): TFunc; +class function TIndicators.CreateStochastic(KPeriod, DPeriod: Integer): TIndicatorFunc; begin var sourceData := TMycDataArray.CreateEmpty; var smaD := CreateSMA(DPeriod); Result := - function(Value: TOhlcItem): TStochasticResult + function(const Value: TOhlcItem): TStochasticResult var i: Integer; highestHigh, lowestLow: Double; diff --git a/Src/Myc.Trade.Types.pas b/Src/Myc.Trade.Types.pas index e123c7d..a7a4874 100644 --- a/Src/Myc.Trade.Types.pas +++ b/Src/Myc.Trade.Types.pas @@ -3,6 +3,10 @@ unit Myc.Trade.Types; interface type + TTimeframe = (S, S5, S15, S30, M, M2, M3, M5, M10, M15, M30, H, H2, H3, H4, H8, H12, D, D2, D3, W, MN, MN3, MN6, Y); + + TIndicatorFunc = reference to function(const Value: S): T; + // A data record for an Ask/Bid price pair. TAskBidItem = packed record Ask: Double;