From 7f6672db24bea5e3c735b65fa10e8cd6b20182f0 Mon Sep 17 00:00:00 2001 From: Michael Schimmel Date: Wed, 11 Jun 2025 13:33:46 +0200 Subject: [PATCH] TSeries --- Src/Myc.Test.Trade.DataPoint.pas | 264 ++++++++++++++++++++++++++++++ Src/Myc.Test.Trade.DataStream.pas | 2 +- Src/Myc.Trade.DataPoint.pas | 135 ++++++++++++++- Src/Myc.Trade.DataStream.pas | 4 +- Test/MycTests.dpr | 3 +- Test/MycTests.dproj | 1 + 6 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 Src/Myc.Test.Trade.DataPoint.pas diff --git a/Src/Myc.Test.Trade.DataPoint.pas b/Src/Myc.Test.Trade.DataPoint.pas new file mode 100644 index 0000000..ec1089d --- /dev/null +++ b/Src/Myc.Test.Trade.DataPoint.pas @@ -0,0 +1,264 @@ +unit Myc.Test.Trade.DataPoint; + +interface + +uses + System.SysUtils, + System.Classes, + DUnitX.TestFramework, + Myc.Trade.DataPoint; + +type + + [TestFixture] + TTestTDataSeries = class(TObject) + private + FSeries: TDataSeries; + procedure SetupSeriesWithData; + public + [Setup] + procedure Setup; + [Teardown] + procedure Teardown; + + [Test] + procedure TestSetupSeriesWithDataVerification; + [Test] + procedure TestAddAndCount; + [Test] + [IgnoreMemoryLeaks] + procedure TestAddOrderAssertion; + [Test] + procedure TestGetItemsIndexing; + [Test] + procedure TestIndexOfExistingTimeStamp; + [Test] + procedure TestIndexOfNonExistingBetween; + [Test] + procedure TestIndexOfBeforeFirstItem; + [Test] + procedure TestIndexOfAfterLastItem; + [Test] + procedure TestIndexOfExactOldestItem; + [Test] + procedure TestIndexOfExactNewestItem; + [Test] + procedure TestIndexOfEmptySeries; + [Test] + procedure TestIndexOfSingleItemSeries; + end; + +implementation + +{ TTestTDataSeries } + +procedure TTestTDataSeries.Setup; +begin + // Managed record is implicitly initialized. +end; + +procedure TTestTDataSeries.Teardown; +begin + // Ensure managed record is finalized to prevent false leak reports. + FSeries := Default(TDataSeries); +end; + +procedure TTestTDataSeries.SetupSeriesWithData; +var + i: Integer; + DataPoint: TDataPoint; + baseTime: TDateTime; +begin + FSeries := Default(TDataSeries); + baseTime := EncodeDate(2020, 7, 7); + + // Add 10 data points with increasing timestamps. + for i := 0 to 9 do + begin + DataPoint := TDataPoint.Create(baseTime + i, TAskBidItem.Create(i * 1.0, i * 1.0 + 0.1)); + FSeries.Add(DataPoint); + end; + // Resulting state: + // Newest item: Time = baseTime + 9, Logical Index = 0 + // Oldest item: Time = baseTime + 0, Logical Index = 9 +end; + +// Verifies that the test data setup helper works as expected. +procedure TTestTDataSeries.TestSetupSeriesWithDataVerification; +var + i: Integer; + baseTime, expectedTime: TDateTime; + expectedAsk: Single; +begin + SetupSeriesWithData; + baseTime := EncodeDate(2020, 7, 7); + + Assert.AreEqual(Int64(10), FSeries.Count, 'Setup should create exactly 10 items'); + + // Check all items to ensure correct reverse chronological order. + for i := 0 to FSeries.Count - 1 do + begin + expectedTime := baseTime + (9 - i); + expectedAsk := Single(9 - i); + Assert.AreEqual(expectedTime, FSeries[i].Time, 'Item at logical index should have correct reversed timestamp'); + Assert.AreEqual(expectedAsk, FSeries[i].Data.Ask, 'Item at logical index should have correct reversed data'); + end; +end; + +// Tests adding new items and the resulting count. +procedure TTestTDataSeries.TestAddAndCount; +var + DataPoint: TDataPoint; + newTime: TDateTime; +begin + SetupSeriesWithData; + newTime := EncodeDate(2020, 7, 17); + + Assert.AreEqual(Int64(10), FSeries.Count, 'Initial count should be 10'); + + DataPoint := TDataPoint.Create(newTime, TAskBidItem.Create(100.0, 100.1)); + FSeries.Add(DataPoint); + + Assert.AreEqual(Int64(11), FSeries.Count, 'Count should be 11 after adding one more'); + Assert.AreEqual(newTime, FSeries.Items[0].Time, 'Newest item should be at index 0'); +end; + +// Ensures that adding an item with an older timestamp raises an assertion. +procedure TTestTDataSeries.TestAddOrderAssertion; +var + olderTime: TDateTime; +begin + SetupSeriesWithData; // Newest item is at 2020-07-16 + olderTime := EncodeDate(2020, 7, 15); + + Assert.WillRaise( + procedure + begin + var DataPoint := TDataPoint.Create(olderTime, TAskBidItem.Create(0.0, 0.0)); + FSeries.Add(DataPoint); + end, + EAssertionFailed, + 'Adding item with older timestamp should raise an assert error' + ); +end; + +// Verifies that the logical-to-physical index mapping is correct. +procedure TTestTDataSeries.TestGetItemsIndexing; +var + i: Integer; + expectedTime: TDateTime; + baseTime: TDateTime; +begin + SetupSeriesWithData; + baseTime := EncodeDate(2020, 7, 7); + + for i := 0 to 9 do + begin + expectedTime := baseTime + (9 - i); + Assert.AreEqual(expectedTime, FSeries.Items[i].Time, 'Item at logical index should have reversed chronological time'); + Assert.AreEqual(Single(9 - i), FSeries.Items[i].Data.Ask, 'Item data at logical index should match reversed insertion order'); + end; +end; + +// Tests finding an existing item (in this case, the oldest). +procedure TTestTDataSeries.TestIndexOfExistingTimeStamp; +var + ATimeStamp: TDateTime; + expectedIndex: Int64; +begin + SetupSeriesWithData; + ATimeStamp := EncodeDate(2020, 7, 7); // Oldest item + expectedIndex := 9; + + Assert.AreEqual(expectedIndex, FSeries.IndexOf(ATimeStamp), 'IndexOf for the oldest existing item timestamp should return its correct index'); +end; + +// Tests finding the index for a timestamp that falls between two existing items. +procedure TTestTDataSeries.TestIndexOfNonExistingBetween; +var + ATimeStamp: TDateTime; + expectedIndex: Int64; +begin + SetupSeriesWithData; + ATimeStamp := EncodeDate(2020, 7, 7) + 0.5; // 12:00 on the day of the oldest item + expectedIndex := Int64(9); // Should find the item from 00:00 + + Assert.AreEqual(expectedIndex, FSeries.IndexOf(ATimeStamp), 'IndexOf for a non-existing timestamp should return the index of the immediately preceding item'); +end; + +// Tests finding a timestamp that is older than any item in the series. +procedure TTestTDataSeries.TestIndexOfBeforeFirstItem; +var + ATimeStamp: TDateTime; + baseTime: TDateTime; +begin + SetupSeriesWithData; + baseTime := EncodeDate(2020, 7, 7); + ATimeStamp := baseTime - 1; // A day before the oldest item + + Assert.AreEqual(Int64(-1), FSeries.IndexOf(ATimeStamp), 'IndexOf for a timestamp before the oldest item should return -1'); +end; + +// Tests finding a timestamp that is newer than any item in the series. +procedure TTestTDataSeries.TestIndexOfAfterLastItem; +var + ATimeStamp: TDateTime; + baseTime: TDateTime; +begin + SetupSeriesWithData; + baseTime := EncodeDate(2020, 7, 7); + ATimeStamp := baseTime + 10; // A day after the newest item + + Assert.AreEqual(Int64(0), FSeries.IndexOf(ATimeStamp), 'IndexOf for a timestamp after the newest item should return the index of the newest item'); +end; + +// Specifically tests finding the exact oldest item. +procedure TTestTDataSeries.TestIndexOfExactOldestItem; +var + ATimeStamp: TDateTime; +begin + SetupSeriesWithData; + ATimeStamp := EncodeDate(2020, 7, 7); + + Assert.AreEqual(Int64(9), FSeries.IndexOf(ATimeStamp), 'IndexOf for the exact oldest timestamp should return the last index'); +end; + +// Specifically tests finding the exact newest item. +procedure TTestTDataSeries.TestIndexOfExactNewestItem; +var + ATimeStamp: TDateTime; +begin + SetupSeriesWithData; + ATimeStamp := EncodeDate(2020, 7, 16); + + Assert.AreEqual(Int64(0), FSeries.IndexOf(ATimeStamp), 'IndexOf for the exact newest timestamp should return index 0'); +end; + +// Tests IndexOf on an empty series. +procedure TTestTDataSeries.TestIndexOfEmptySeries; +begin + FSeries := Default(TDataSeries); + Assert.AreEqual(Int64(0), FSeries.Count); + Assert.AreEqual(Int64(-1), FSeries.IndexOf(Now), 'IndexOf on empty series should return -1'); +end; + +// Tests IndexOf on a series with only one item. +procedure TTestTDataSeries.TestIndexOfSingleItemSeries; +var + DataPoint: TDataPoint; + testTime: TDateTime; +begin + FSeries := Default(TDataSeries); + testTime := EncodeDate(2025, 1, 1); + DataPoint := TDataPoint.Create(testTime, TAskBidItem.Create(1.0, 2.0)); + FSeries.Add(DataPoint); + + Assert.AreEqual(Int64(1), FSeries.Count); + Assert.AreEqual(Int64(0), FSeries.IndexOf(testTime), 'IndexOf for exact single item should be 0'); + Assert.AreEqual(Int64(0), FSeries.IndexOf(testTime + 0.5), 'IndexOf for time after single item should be 0'); + Assert.AreEqual(Int64(-1), FSeries.IndexOf(testTime - 1), 'IndexOf for time before single item should be -1'); +end; + +initialization + TDUnitX.RegisterTestFixture(TTestTDataSeries); +end. diff --git a/Src/Myc.Test.Trade.DataStream.pas b/Src/Myc.Test.Trade.DataStream.pas index cc2a8a5..ef752be 100644 --- a/Src/Myc.Test.Trade.DataStream.pas +++ b/Src/Myc.Test.Trade.DataStream.pas @@ -114,7 +114,7 @@ begin for n := 0 to High(ExpectedData) div 1000 do begin i := n * 1000; - Assert.AreEqual(ExpectedData[i].TimeStamp, Dst[i].Time, 'Timestamp mismatch'); + Assert.AreEqual(ExpectedData[i].Time, Dst[i].Time, 'Timestamp mismatch'); Assert.AreEqual(ExpectedData[i].Data.Ask, Dst[i].Data.Ask, 'Ask price mismatch'); Assert.AreEqual(ExpectedData[i].Data.Bid, Dst[i].Data.Bid, 'Bid price mismatch'); end; diff --git a/Src/Myc.Trade.DataPoint.pas b/Src/Myc.Trade.DataPoint.pas index a5be20b..1fd6c36 100644 --- a/Src/Myc.Trade.DataPoint.pas +++ b/Src/Myc.Trade.DataPoint.pas @@ -7,23 +7,57 @@ uses type // A specific data record structure for Ask and Bid prices. + // Contains Ask and Bid prices as Single-precision floating-point numbers. TAskBidItem = packed record Ask: Single; Bid: Single; + constructor Create(AAsk, ABid: Single); end; + // Represents a single data point in a time series. + // Combines a timestamp with generic data. TDataPoint = record Time: TDateTime; Data: T; constructor Create(ATime: TDateTime; const AData: T); end; - IMycStream = interface + // A time-ordered array whose most recently added element has index 0. + // This managed record stores a series of TDataPoint elements. + TDataSeries = record + const + ChunkSize = 1024; + type + TChunk = TArray>; + private + FChunks: TArray; + FCount: Int64; + function GetCount: Int64; + function GetItems(Idx: Int64): TDataPoint; + public + // Adds a new data point to the series. + // Asserts that the new data point's time is not older than the current newest item. procedure Add(const Data: TDataPoint); + // Searches for a data point by its timestamp. + // Returns the logical index of the matching item. + // If no exact match, returns the index of the item immediately preceding the timestamp. + // Returns -1 if the timestamp is before the oldest item in the series. + function IndexOf(TimeStamp: TDateTime): Int64; + // Initializes the TDataSeries record. + class operator Initialize(out Dest: TDataSeries); + class operator Finalize(var Dest: TDataSeries); + // The total number of data points in the series. + property Count: Int64 read GetCount; + // Accesses data points by their logical index. + // Index 0 is the newest element, Index (Count - 1) is the oldest. + property Items[Idx: Int64]: TDataPoint read GetItems; default; end; implementation +uses + Winapi.Windows; + { TDataPoint } constructor TDataPoint.Create(ATime: TDateTime; const AData: T); @@ -32,4 +66,103 @@ begin Data := AData; end; +{ TDataSeries } + +procedure TDataSeries.Add(const Data: TDataPoint); +begin + // Assert that the new data point's time is NOT OLDER than the current newest item. + // This means data must be added in chronologically ascending order (or equal timestamp). + Assert((Length(FChunks) = 0) or (Data.Time >= GetItems(0).Time), 'Time stamp older than last item'); + + var ci := FCount div ChunkSize; + var di := FCount mod ChunkSize; + + if di = 0 then + begin + Assert(ci = Length(FChunks)); + Assert(di = 0); + SetLength(FChunks, ci + 1); + SetLength(FChunks[ci], ChunkSize); + end; + + FChunks[ci][di] := Data; + inc(FCount); +end; + +function TDataSeries.GetCount: Int64; +begin + Result := FCount; +end; + +function TDataSeries.GetItems(Idx: Int64): TDataPoint; +begin + Assert((Idx >= 0) and (Idx < FCount)); + Idx := FCount - Idx - 1; + Result := FChunks[Idx div ChunkSize][Idx mod ChunkSize]; +end; + +function TDataSeries.IndexOf(TimeStamp: TDateTime): Int64; +var + low, high, mid: Int64; + dataPointTime: TDateTime; + debugStr: string; +begin + Result := -1; + if FCount = 0 then + Exit; + + low := 0; + high := FCount - 1; + + // Start der Debug-Ausgabe + OutputDebugString(PChar(Format('--- IndexOf Search for %.15f ---', [TimeStamp]))); + + while (low <= high) do + begin + mid := low + (high - low) div 2; + dataPointTime := GetItems(mid).Time; + + // Ausgabe der Werte in jeder Iteration + debugStr := Format('low: %d, high: %d, mid: %d, dataPointTime: %.15f', + [low, high, mid, dataPointTime]); + OutputDebugString(PChar(debugStr)); + + if (dataPointTime = TimeStamp) then + begin + OutputDebugString(' -> Match found!'); + Result := mid; + Exit; + end + else if (dataPointTime < TimeStamp) then + begin + OutputDebugString(' -> dataPointTime < TimeStamp'); + Result := mid; + high := mid - 1; + end + else // dataPointTime > TimeStamp + begin + OutputDebugString(' -> dataPointTime > TimeStamp'); + low := mid + 1; + end; + end; + OutputDebugString(PChar('--- Search Finished ---')); +end; + +class operator TDataSeries.Finalize(var Dest: TDataSeries); +begin + Dest.FChunks := nil; + Dest.FCount := 0; +end; + +class operator TDataSeries.Initialize(out Dest: TDataSeries); +begin + Dest.FCount := 0; +end; + +constructor TAskBidItem.Create(AAsk, ABid: Single); +begin + Ask := AAsk; + Bid := ABid; +end; + end. diff --git a/Src/Myc.Trade.DataStream.pas b/Src/Myc.Trade.DataStream.pas index 74cbe0d..ffb53b8 100644 --- a/Src/Myc.Trade.DataStream.pas +++ b/Src/Myc.Trade.DataStream.pas @@ -563,12 +563,12 @@ begin if recordCount > 0 then begin SetLength(Result, recordCount); - for var i:=0 to High(Result) do + for var i := 0 to High(Result) do begin bytesRead := InputStream.Read(rec, SizeOf(TAuraFileDataRecord)); if bytesRead <> SizeOf(TAuraFileDataRecord) then raise EReadError.CreateFmt('Read error. Expected %d bytes, read %d.', [fileSize, bytesRead]); - Result[i].Create( rec.TimeStamp, rec.Data ); + Result[i].Create(rec.TimeStamp, rec.Data); end; end; end; diff --git a/Test/MycTests.dpr b/Test/MycTests.dpr index e2e9493..5e3a00d 100644 --- a/Test/MycTests.dpr +++ b/Test/MycTests.dpr @@ -36,7 +36,8 @@ uses Myc.Trade.DataPoint in '..\Src\Myc.Trade.DataPoint.pas', Myc.Trade.Node in '..\Src\Myc.Trade.Node.pas', Myc.Trade.DataStream in '..\Src\Myc.Trade.DataStream.pas', - Myc.Test.Trade.DataStream in '..\Src\Myc.Test.Trade.DataStream.pas'; + Myc.Test.Trade.DataStream in '..\Src\Myc.Test.Trade.DataStream.pas', + Myc.Test.Trade.DataPoint in '..\Src\Myc.Test.Trade.DataPoint.pas'; { keep comment here to protect the following conditional from being removed by the IDE when adding a unit } {$IFNDEF TESTINSIGHT} diff --git a/Test/MycTests.dproj b/Test/MycTests.dproj index 3d6e0c2..e6dc8ec 100644 --- a/Test/MycTests.dproj +++ b/Test/MycTests.dproj @@ -137,6 +137,7 @@ $(PreBuildEvent)]]> + Base