mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-15 09:08:04 +00:00
Refactor tests and implementations for various indicators
- Updated RsiIndicatorTests to ensure proper initialization and state checks. - Added new tests for Rsx, Vel, and Adosc indicators to validate behavior under iterative corrections and edge cases (NaN, Infinity). - Enhanced Bessel indicator tests and implementation with consistent formatting. - Improved Ema and Pwma implementations by ensuring proper handling of values. - Introduced mock classes for charting to facilitate testing without dependencies. - Ensured all indicators produce consistent results across different modes of operation. - Cleaned up code formatting and added missing commas for better readability.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,902 @@
|
||||
# Momentum Indicators Test Implementation Plan
|
||||
|
||||
> **Objective:** Bring all 13 momentum indicators to full compliance with testprotocol.md
|
||||
|
||||
## Executive Summary
|
||||
|
||||
- **Total Missing Tests:** ~72 tests across 12 indicators
|
||||
- **Estimated Effort:** 4-6 hours
|
||||
- **Priority:** Start with MACD (most deficient), end with VEL (closest to compliant)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Critical Deficiencies (MACD, BOP)
|
||||
|
||||
### 1.1 MACD - Add 10 Tests
|
||||
|
||||
**File:** `lib/momentum/macd/Macd.Tests.cs`
|
||||
|
||||
```csharp
|
||||
// ADD THESE TESTS:
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidParameters_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Macd(0, 26, 9));
|
||||
Assert.Throws<ArgumentException>(() => new Macd(12, 0, 9));
|
||||
Assert.Throws<ArgumentException>(() => new Macd(12, 26, 0));
|
||||
Assert.Throws<ArgumentException>(() => new Macd(26, 12, 9)); // fast >= slow
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_AcceptsParameter()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 49; i++)
|
||||
macd.Update(series.Close[i], isNew: true);
|
||||
|
||||
var val1 = macd.Update(series.Close[49], isNew: true);
|
||||
var val2 = macd.Update(new TValue(DateTime.UtcNow, series.Close[49].Value + 1), isNew: true);
|
||||
|
||||
Assert.NotEqual(val1.Value, val2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_False_UpdatesValue()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 49; i++)
|
||||
macd.Update(series.Close[i]);
|
||||
|
||||
var val1 = macd.Update(series.Close[49], isNew: true);
|
||||
var val2 = macd.Update(new TValue(series.Close[49].Time, series.Close[49].Value + 5), isNew: false);
|
||||
|
||||
Assert.Equal(val1.Time, val2.Time);
|
||||
Assert.NotEqual(val1.Value, val2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
macd.Update(series.Close[i]);
|
||||
|
||||
var originalValue = macd.Last;
|
||||
|
||||
for (int m = 0; m < 5; m++)
|
||||
{
|
||||
var modified = new TValue(series.Close[49].Time, series.Close[49].Value + m);
|
||||
macd.Update(modified, isNew: false);
|
||||
}
|
||||
|
||||
var restored = macd.Update(series.Close[49], isNew: false);
|
||||
Assert.Equal(originalValue.Value, restored.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
macd.Update(series.Close[i]);
|
||||
|
||||
macd.Reset();
|
||||
|
||||
Assert.Equal(0, macd.Last.Value);
|
||||
Assert.False(macd.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.False(macd.IsHot);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
macd.Update(series.Close[i]);
|
||||
if (i >= 40) break; // Should be hot by warmup
|
||||
}
|
||||
|
||||
Assert.True(macd.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 40; i++)
|
||||
macd.Update(series.Close[i]);
|
||||
|
||||
var result = macd.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 40; i++)
|
||||
macd.Update(series.Close[i]);
|
||||
|
||||
var result = macd.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
var gbm = new GBM(seed: 123);
|
||||
var series = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode
|
||||
var batchMacd = new Macd(12, 26, 9);
|
||||
var batchResult = batchMacd.Update(series.Close);
|
||||
double expected = batchResult.Last.Value;
|
||||
|
||||
// 2. Span Mode
|
||||
var spanOutput = new double[series.Count];
|
||||
Macd.Calculate(series.Close.Values, spanOutput, 12, 26);
|
||||
double spanResult = spanOutput[^1];
|
||||
|
||||
// 3. Streaming Mode
|
||||
var streamMacd = new Macd(12, 26, 9);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
streamMacd.Update(series.Close[i]);
|
||||
double streamResult = streamMacd.Last.Value;
|
||||
|
||||
// 4. Eventing Mode
|
||||
var pubSource = new TSeries();
|
||||
var eventMacd = new Macd(pubSource, 12, 26, 9);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
pubSource.Add(series.Close[i]);
|
||||
double eventResult = eventMacd.Last.Value;
|
||||
|
||||
Assert.Equal(expected, spanResult, 9);
|
||||
Assert.Equal(expected, streamResult, 9);
|
||||
Assert.Equal(expected, eventResult, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_ValidatesInput()
|
||||
{
|
||||
double[] source = [1, 2, 3, 4, 5];
|
||||
double[] output = new double[5];
|
||||
double[] wrongSize = new double[3];
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Macd.Calculate(source, wrongSize, 12, 26));
|
||||
Assert.Throws<ArgumentException>(() => Macd.Calculate(source, output, 0, 26));
|
||||
Assert.Throws<ArgumentException>(() => Macd.Calculate(source, output, 12, 0));
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 BOP - Add 9 Tests
|
||||
|
||||
**File:** `lib/momentum/bop/Bop.Tests.cs`
|
||||
|
||||
```csharp
|
||||
// ADD THESE TESTS:
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_AcceptsParameter()
|
||||
{
|
||||
var bop = new Bop();
|
||||
var bar1 = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100);
|
||||
var bar2 = new TBar(DateTime.UtcNow, 15, 25, 10, 20, 100);
|
||||
|
||||
bop.Update(bar1, isNew: true);
|
||||
var val1 = bop.Last.Value;
|
||||
|
||||
bop.Update(bar2, isNew: true);
|
||||
var val2 = bop.Last.Value;
|
||||
|
||||
Assert.NotEqual(val1, val2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_False_UpdatesValue()
|
||||
{
|
||||
var bop = new Bop();
|
||||
var bar1 = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100);
|
||||
var bar2 = new TBar(DateTime.UtcNow, 10, 25, 5, 20, 100);
|
||||
|
||||
var val1 = bop.Update(bar1, isNew: true);
|
||||
var val2 = bop.Update(bar2, isNew: false);
|
||||
|
||||
Assert.Equal(val1.Time, val2.Time);
|
||||
Assert.NotEqual(val1.Value, val2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var bop = new Bop();
|
||||
var bar = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100);
|
||||
|
||||
var originalValue = bop.Update(bar, isNew: true);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var modified = new TBar(bar.Time, bar.Open, bar.High + i, bar.Low, bar.Close, bar.Volume);
|
||||
bop.Update(modified, isNew: false);
|
||||
}
|
||||
|
||||
var restored = bop.Update(bar, isNew: false);
|
||||
Assert.Equal(originalValue.Value, restored.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var bop = new Bop();
|
||||
var bar = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100);
|
||||
|
||||
bop.Update(bar);
|
||||
bop.Reset();
|
||||
|
||||
Assert.Equal(0, bop.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var bop = new Bop();
|
||||
|
||||
Assert.False(bop.IsHot);
|
||||
|
||||
var bar = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100);
|
||||
bop.Update(bar);
|
||||
|
||||
Assert.True(bop.IsHot); // BOP is hot immediately (no warmup needed)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var bop = new Bop();
|
||||
var bar1 = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100);
|
||||
var barNaN = new TBar(DateTime.UtcNow, double.NaN, 20, 5, 15, 100);
|
||||
|
||||
bop.Update(bar1);
|
||||
var result = bop.Update(barNaN);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var bop = new Bop();
|
||||
var bar1 = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100);
|
||||
var barInf = new TBar(DateTime.UtcNow, double.PositiveInfinity, 20, 5, 15, 100);
|
||||
|
||||
bop.Update(bar1);
|
||||
var result = bop.Update(barInf);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
var gbm = new GBM(seed: 123);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode
|
||||
var batchResult = Bop.Batch(bars);
|
||||
double expected = batchResult.Last.Value;
|
||||
|
||||
// 2. Span Mode
|
||||
var spanOutput = new double[bars.Count];
|
||||
Bop.Calculate(bars.Open.Values, bars.High.Values, bars.Low.Values, bars.Close.Values, spanOutput);
|
||||
double spanResult = spanOutput[^1];
|
||||
|
||||
// 3. Streaming Mode
|
||||
var streamBop = new Bop();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
streamBop.Update(bars[i]);
|
||||
double streamResult = streamBop.Last.Value;
|
||||
|
||||
Assert.Equal(expected, spanResult, 9);
|
||||
Assert.Equal(expected, streamResult, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_ValidatesInput()
|
||||
{
|
||||
double[] open = [1, 2, 3];
|
||||
double[] high = [2, 3, 4];
|
||||
double[] low = [0, 1, 2];
|
||||
double[] close = [1.5, 2.5, 3.5];
|
||||
double[] output = new double[3];
|
||||
double[] wrongSize = new double[2];
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Bop.Calculate(open, high, low, close, wrongSize));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Medium Deficiencies (DMX, CFB)
|
||||
|
||||
### 2.1 DMX - Add 7 Tests
|
||||
|
||||
**File:** `lib/momentum/dmx/Dmx.Tests.cs`
|
||||
|
||||
```csharp
|
||||
// ADD THESE TESTS:
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidParameters_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Dmx(0));
|
||||
Assert.Throws<ArgumentException>(() => new Dmx(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var dmx = new Dmx(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
dmx.Update(bars[i]);
|
||||
|
||||
var originalValue = dmx.Last;
|
||||
|
||||
for (int m = 0; m < 5; m++)
|
||||
{
|
||||
var modified = new TBar(bars[49].Time, bars[49].Open, bars[49].High + m, bars[49].Low - m, bars[49].Close, bars[49].Volume);
|
||||
dmx.Update(modified, isNew: false);
|
||||
}
|
||||
|
||||
var restored = dmx.Update(bars[49], isNew: false);
|
||||
Assert.Equal(originalValue.Value, restored.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var dmx = new Dmx(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.False(dmx.IsHot);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
dmx.Update(bars[i]);
|
||||
if (dmx.IsHot) break;
|
||||
}
|
||||
|
||||
Assert.True(dmx.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var dmx = new Dmx(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
dmx.Update(bars[i]);
|
||||
|
||||
var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 100);
|
||||
var result = dmx.Update(nanBar);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var dmx = new Dmx(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
dmx.Update(bars[i]);
|
||||
|
||||
var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, 0, 100, 100);
|
||||
var result = dmx.Update(infBar);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
var gbm = new GBM(seed: 123);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode
|
||||
var batchResult = Dmx.Batch(bars, 14);
|
||||
double expected = batchResult.Last.Value;
|
||||
|
||||
// 2. Streaming Mode
|
||||
var streamDmx = new Dmx(14);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
streamDmx.Update(bars[i]);
|
||||
double streamResult = streamDmx.Last.Value;
|
||||
|
||||
Assert.Equal(expected, streamResult, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_ValidatesInput()
|
||||
{
|
||||
// Add if DMX has span API
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 CFB - Add 5 Tests
|
||||
|
||||
**File:** `lib/momentum/cfb/Cfb.Tests.cs`
|
||||
|
||||
```csharp
|
||||
// ADD THESE TESTS:
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidParameters_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Cfb(Array.Empty<int>()));
|
||||
Assert.Throws<ArgumentException>(() => new Cfb(new[] { 0, 10 }));
|
||||
Assert.Throws<ArgumentException>(() => new Cfb(new[] { -1, 10 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var cfb = new Cfb();
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i]));
|
||||
|
||||
var originalValue = cfb.Last;
|
||||
|
||||
for (int m = 0; m < 5; m++)
|
||||
{
|
||||
var modified = new TValue(bars.Close.Times[49], bars.Close.Values[49] + m);
|
||||
cfb.Update(modified, isNew: false);
|
||||
}
|
||||
|
||||
var restored = cfb.Update(new TValue(bars.Close.Times[49], bars.Close.Values[49]), isNew: false);
|
||||
Assert.Equal(originalValue.Value, restored.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var cfb = new Cfb(new[] { 5, 10 });
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i]));
|
||||
if (cfb.IsHot) break;
|
||||
}
|
||||
|
||||
Assert.True(cfb.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var cfb = new Cfb();
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i]));
|
||||
|
||||
var result = cfb.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var cfb = new Cfb();
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i]));
|
||||
|
||||
var result = cfb.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
var gbm = new GBM(seed: 123);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode
|
||||
var batchResult = Cfb.Batch(bars.Close);
|
||||
double expected = batchResult.Last.Value;
|
||||
|
||||
// 2. Span Mode
|
||||
var spanOutput = new double[bars.Count];
|
||||
Cfb.Batch(bars.Close.Values.ToArray(), spanOutput);
|
||||
double spanResult = spanOutput[^1];
|
||||
|
||||
// 3. Streaming Mode
|
||||
var streamCfb = new Cfb();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
streamCfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i]));
|
||||
double streamResult = streamCfb.Last.Value;
|
||||
|
||||
Assert.Equal(expected, spanResult, 9);
|
||||
Assert.Equal(expected, streamResult, 9);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Standard Deficiencies (ADX, ADXR, AO, APO, Aroon, AroonOsc)
|
||||
|
||||
These 6 indicators all have the same pattern of missing tests. Create a template:
|
||||
|
||||
### Template for TBar-based Indicators (ADX, ADXR, AO, Aroon, AroonOsc)
|
||||
|
||||
```csharp
|
||||
// ADD THESE 6 TESTS TO EACH:
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var indicator = new [IndicatorName](period);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
indicator.Update(bars[i]);
|
||||
|
||||
var originalValue = indicator.Last;
|
||||
|
||||
for (int m = 0; m < 5; m++)
|
||||
{
|
||||
var modified = new TBar(bars[49].Time, bars[49].Open, bars[49].High + m, bars[49].Low - m, bars[49].Close, bars[49].Volume);
|
||||
indicator.Update(modified, isNew: false);
|
||||
}
|
||||
|
||||
var restored = indicator.Update(bars[49], isNew: false);
|
||||
Assert.Equal(originalValue.Value, restored.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var indicator = new [IndicatorName](period);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.False(indicator.IsHot);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
indicator.Update(bars[i]);
|
||||
if (indicator.IsHot) break;
|
||||
}
|
||||
|
||||
Assert.True(indicator.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var indicator = new [IndicatorName](period);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 40; i++)
|
||||
indicator.Update(bars[i]);
|
||||
|
||||
var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 100);
|
||||
var result = indicator.Update(nanBar);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var indicator = new [IndicatorName](period);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 40; i++)
|
||||
indicator.Update(bars[i]);
|
||||
|
||||
var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, 0, 100, 100);
|
||||
var result = indicator.Update(infBar);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
var gbm = new GBM(seed: 123);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode
|
||||
var batchResult = [IndicatorName].Batch(bars, period);
|
||||
double expected = batchResult.Last.Value;
|
||||
|
||||
// 2. Streaming Mode
|
||||
var streamIndicator = new [IndicatorName](period);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
streamIndicator.Update(bars[i]);
|
||||
double streamResult = streamIndicator.Last.Value;
|
||||
|
||||
Assert.Equal(expected, streamResult, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_ValidatesInput()
|
||||
{
|
||||
// Implement if indicator has Span API
|
||||
}
|
||||
```
|
||||
|
||||
### Template for TValue-based Indicator (APO)
|
||||
|
||||
Similar pattern but uses `series.Close[i]` instead of `bars[i]`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Minor Deficiencies (RSX, VEL)
|
||||
|
||||
### 4.1 RSX - Add 4 Tests
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var rsx = new Rsx(14);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
rsx.Update(new TValue(series.Close.Times[i], series.Close.Values[i]));
|
||||
|
||||
var originalValue = rsx.Last;
|
||||
|
||||
for (int m = 0; m < 5; m++)
|
||||
{
|
||||
var modified = new TValue(series.Close.Times[49], series.Close.Values[49] + m);
|
||||
rsx.Update(modified, isNew: false);
|
||||
}
|
||||
|
||||
var restored = rsx.Update(new TValue(series.Close.Times[49], series.Close.Values[49]), isNew: false);
|
||||
Assert.Equal(originalValue.Value, restored.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var rsx = new Rsx(14);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.False(rsx.IsHot);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
rsx.Update(new TValue(series.Close.Times[i], series.Close.Values[i]));
|
||||
if (rsx.IsHot) break;
|
||||
}
|
||||
|
||||
Assert.True(rsx.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var rsx = new Rsx(14);
|
||||
rsx.Update(new TValue(DateTime.UtcNow, 100));
|
||||
var result = rsx.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
|
||||
Assert.False(double.IsInfinity(result.Value));
|
||||
Assert.InRange(result.Value, 0, 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
int period = 14;
|
||||
var gbm = new GBM(seed: 123);
|
||||
var series = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode
|
||||
var batchResult = Rsx.Batch(series.Close, period);
|
||||
double expected = batchResult.Last.Value;
|
||||
|
||||
// 2. Span Mode
|
||||
var spanOutput = new double[series.Count];
|
||||
Rsx.Batch(series.Close.Values.ToArray(), spanOutput, period);
|
||||
double spanResult = spanOutput[^1];
|
||||
|
||||
// 3. Streaming Mode
|
||||
var streamRsx = new Rsx(period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
streamRsx.Update(new TValue(series.Close.Times[i], series.Close.Values[i]));
|
||||
double streamResult = streamRsx.Last.Value;
|
||||
|
||||
// 4. Eventing Mode
|
||||
var pubSource = new TSeries();
|
||||
var eventRsx = new Rsx(pubSource, period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
pubSource.Add(new TValue(series.Close.Times[i], series.Close.Values[i]));
|
||||
double eventResult = eventRsx.Last.Value;
|
||||
|
||||
Assert.Equal(expected, spanResult, 9);
|
||||
Assert.Equal(expected, streamResult, 9);
|
||||
Assert.Equal(expected, eventResult, 9);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 VEL - Add 4 Tests
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var vel = new Vel(10);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
vel.Update(series.Close[i]);
|
||||
|
||||
var originalValue = vel.Last;
|
||||
|
||||
for (int m = 0; m < 5; m++)
|
||||
{
|
||||
var modified = new TValue(series.Close[49].Time, series.Close[49].Value + m);
|
||||
vel.Update(modified, isNew: false);
|
||||
}
|
||||
|
||||
var restored = vel.Update(series.Close[49], isNew: false);
|
||||
Assert.Equal(originalValue.Value, restored.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var vel = new Vel(10);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 15; i++)
|
||||
vel.Update(series.Close[i]);
|
||||
|
||||
var result = vel.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var vel = new Vel(10);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 15; i++)
|
||||
vel.Update(series.Close[i]);
|
||||
|
||||
var result = vel.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
int period = 10;
|
||||
var gbm = new GBM(seed: 123);
|
||||
var series = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode
|
||||
var batchResult = Vel.Batch(series.Close, period);
|
||||
double expected = batchResult.Last.Value;
|
||||
|
||||
// 2. Span Mode
|
||||
var spanOutput = new double[series.Count];
|
||||
Vel.Batch(series.Close.Values.ToArray().AsSpan(), spanOutput.AsSpan(), period);
|
||||
double spanResult = spanOutput[^1];
|
||||
|
||||
// 3. Streaming Mode
|
||||
var streamVel = new Vel(period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
streamVel.Update(series.Close[i]);
|
||||
double streamResult = streamVel.Last.Value;
|
||||
|
||||
// 4. Eventing Mode
|
||||
var pubSource = new TSeries();
|
||||
var eventVel = new Vel(pubSource, period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
pubSource.Add(series.Close[i]);
|
||||
double eventResult = eventVel.Last.Value;
|
||||
|
||||
Assert.Equal(expected, spanResult, 9);
|
||||
Assert.Equal(expected, streamResult, 9);
|
||||
Assert.Equal(expected, eventResult, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_ValidatesInput()
|
||||
{
|
||||
double[] source = [1, 2, 3, 4, 5];
|
||||
double[] output = new double[5];
|
||||
double[] wrongSize = new double[3];
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Vel.Batch(source.AsSpan(), wrongSize.AsSpan(), 3));
|
||||
Assert.Throws<ArgumentException>(() => Vel.Batch(source.AsSpan(), output.AsSpan(), 0));
|
||||
Assert.Throws<ArgumentException>(() => Vel.Batch(source.AsSpan(), output.AsSpan(), -1));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Phase 1 (Priority: Critical)
|
||||
- [ ] MACD.Tests.cs - Add 10 tests
|
||||
- [ ] BOP.Tests.cs - Add 9 tests
|
||||
|
||||
### Phase 2 (Priority: High)
|
||||
- [ ] DMX.Tests.cs - Add 7 tests
|
||||
- [ ] CFB.Tests.cs - Add 5 tests
|
||||
|
||||
### Phase 3 (Priority: Medium)
|
||||
- [ ] ADX.Tests.cs - Add 6 tests
|
||||
- [ ] ADXR.Tests.cs - Add 6 tests
|
||||
- [ ] AO.Tests.cs - Add 6 tests
|
||||
- [ ] APO.Tests.cs - Add 6 tests
|
||||
- [ ] Aroon.Tests.cs - Add 6 tests
|
||||
- [ ] AroonOsc.Tests.cs - Add 6 tests
|
||||
|
||||
### Phase 4 (Priority: Low)
|
||||
- [ ] RSX.Tests.cs - Add 4 tests
|
||||
- [ ] VEL.Tests.cs - Add 4 tests
|
||||
|
||||
---
|
||||
|
||||
## Verification Steps
|
||||
|
||||
After implementing all tests:
|
||||
|
||||
1. Run all tests: `dotnet test lib/QuanTAlib.Tests.csproj`
|
||||
2. Verify no regressions in existing tests
|
||||
3. Check test coverage meets targets
|
||||
4. Update docs/validation.md with compliance status
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
@@ -36,6 +37,7 @@ public abstract class AbstractBase : ITValuePublisher
|
||||
/// <summary>
|
||||
/// Helper to invoke the Pub event.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected void PubEvent(TValue value, bool isNew = true)
|
||||
{
|
||||
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
|
||||
|
||||
@@ -719,4 +719,175 @@ public class RingBufferTests
|
||||
Assert.Equal(21.666666666666668, buffer.Average, 1e-10);
|
||||
Assert.NotEqual(avgBeforeCorrection, buffer.Average);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_CapturesCurrentState()
|
||||
{
|
||||
var buffer = new RingBuffer(5);
|
||||
|
||||
buffer.Add(10.0);
|
||||
buffer.Add(20.0);
|
||||
buffer.Add(30.0);
|
||||
|
||||
buffer.Snapshot();
|
||||
|
||||
// Modify buffer after snapshot
|
||||
buffer.Add(40.0);
|
||||
|
||||
Assert.Equal(4, buffer.Count);
|
||||
Assert.Equal(100.0, buffer.Sum); // 10 + 20 + 30 + 40
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Restore_ReturnsToSnapshotState()
|
||||
{
|
||||
var buffer = new RingBuffer(5);
|
||||
|
||||
buffer.Add(10.0);
|
||||
buffer.Add(20.0);
|
||||
buffer.Add(30.0);
|
||||
|
||||
buffer.Snapshot();
|
||||
double sumBeforeModification = buffer.Sum;
|
||||
int countBeforeModification = buffer.Count;
|
||||
|
||||
// Modify buffer after snapshot
|
||||
buffer.Add(40.0);
|
||||
Assert.Equal(4, buffer.Count);
|
||||
|
||||
// Restore to snapshot state
|
||||
buffer.Restore();
|
||||
|
||||
Assert.Equal(countBeforeModification, buffer.Count);
|
||||
Assert.Equal(sumBeforeModification, buffer.Sum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_Restore_WithWrapping()
|
||||
{
|
||||
var buffer = new RingBuffer(3);
|
||||
|
||||
buffer.Add(10.0);
|
||||
buffer.Add(20.0);
|
||||
buffer.Add(30.0);
|
||||
|
||||
buffer.Snapshot();
|
||||
|
||||
// Add value that causes wrap
|
||||
buffer.Add(40.0);
|
||||
Assert.Equal(90.0, buffer.Sum); // 20 + 30 + 40
|
||||
|
||||
buffer.Restore();
|
||||
|
||||
Assert.Equal(60.0, buffer.Sum); // 10 + 20 + 30
|
||||
Assert.Equal(30.0, buffer.Newest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecalculateSum_CorrectsDrift()
|
||||
{
|
||||
var buffer = new RingBuffer(5);
|
||||
|
||||
buffer.Add(10.0);
|
||||
buffer.Add(20.0);
|
||||
buffer.Add(30.0);
|
||||
|
||||
double recalculated = buffer.RecalculateSum();
|
||||
|
||||
Assert.Equal(60.0, recalculated);
|
||||
Assert.Equal(60.0, buffer.Sum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecalculateSum_AfterMultipleOperations()
|
||||
{
|
||||
var buffer = new RingBuffer(3);
|
||||
|
||||
// Simulate many operations that could accumulate floating-point drift
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
buffer.Add(i * 0.1);
|
||||
}
|
||||
|
||||
double recalculated = buffer.RecalculateSum();
|
||||
|
||||
// Should be equal (or very close) since we're using exact values
|
||||
Assert.Equal(recalculated, buffer.Sum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartIndex_EmptyBuffer_ReturnsZero()
|
||||
{
|
||||
var buffer = new RingBuffer(5);
|
||||
|
||||
Assert.Equal(0, buffer.StartIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartIndex_PartiallyFilled_ReturnsZero()
|
||||
{
|
||||
var buffer = new RingBuffer(5);
|
||||
|
||||
buffer.Add(10.0);
|
||||
buffer.Add(20.0);
|
||||
|
||||
Assert.Equal(0, buffer.StartIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartIndex_FullBuffer_ReturnsHead()
|
||||
{
|
||||
var buffer = new RingBuffer(3);
|
||||
|
||||
buffer.Add(10.0);
|
||||
buffer.Add(20.0);
|
||||
buffer.Add(30.0);
|
||||
buffer.Add(40.0); // Wraps
|
||||
|
||||
// StartIndex should point to oldest element
|
||||
Assert.True(buffer.StartIndex >= 0 && buffer.StartIndex < buffer.Capacity);
|
||||
Assert.Equal(20.0, buffer.Oldest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_NegativeIndexViaFromEnd_ThrowsWhenOutOfBounds()
|
||||
{
|
||||
var buffer = new RingBuffer(5);
|
||||
|
||||
buffer.Add(10.0);
|
||||
buffer.Add(20.0);
|
||||
buffer.Add(30.0);
|
||||
|
||||
// ^4 when count=3 should throw
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _ = buffer[^4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CopyTo_InsufficientDestinationBuffer_Behavior()
|
||||
{
|
||||
var buffer = new RingBuffer(5);
|
||||
|
||||
buffer.Add(10.0);
|
||||
buffer.Add(20.0);
|
||||
buffer.Add(30.0);
|
||||
|
||||
var dest = new double[2]; // Too small
|
||||
|
||||
// This will throw IndexOutOfRangeException since we're copying 3 elements to size-2 array
|
||||
Assert.Throws<ArgumentException>(() => buffer.CopyTo(dest, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CopyTo_StartIndexOutOfRange_Behavior()
|
||||
{
|
||||
var buffer = new RingBuffer(5);
|
||||
|
||||
buffer.Add(10.0);
|
||||
buffer.Add(20.0);
|
||||
|
||||
var dest = new double[5];
|
||||
|
||||
// Starting at index 4 with 2 elements should fail
|
||||
Assert.Throws<ArgumentException>(() => buffer.CopyTo(dest, 4));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,4 +805,191 @@ public class SimdScalarFallbackTests
|
||||
Assert.Equal(42.5, min);
|
||||
Assert.Equal(42.5, max);
|
||||
}
|
||||
|
||||
// Additional edge case tests
|
||||
[Fact]
|
||||
public void DotProduct_ContainsNaN_PropagatesNaN()
|
||||
{
|
||||
double[] a = [1.0, double.NaN, 3.0];
|
||||
double[] b = [4.0, 5.0, 6.0];
|
||||
double result = SimdExtensions.DotProduct(a, b);
|
||||
Assert.True(double.IsNaN(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DotProduct_ContainsInfinity_PropagatesCorrectly()
|
||||
{
|
||||
double[] a = [1.0, double.PositiveInfinity, 3.0];
|
||||
double[] b = [4.0, 5.0, 6.0];
|
||||
double result = SimdExtensions.DotProduct(a, b);
|
||||
Assert.True(double.IsPositiveInfinity(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_ContainsNaN_PropagatesNaN()
|
||||
{
|
||||
double[] left = [1.0, double.NaN, 3.0];
|
||||
double[] right = [4.0, 5.0, 6.0];
|
||||
double[] result = new double[3];
|
||||
|
||||
SimdExtensions.Add(left, right, result);
|
||||
|
||||
Assert.Equal(5.0, result[0]);
|
||||
Assert.True(double.IsNaN(result[1]));
|
||||
Assert.Equal(9.0, result[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subtract_ContainsNaN_PropagatesNaN()
|
||||
{
|
||||
double[] left = [10.0, double.NaN, 30.0];
|
||||
double[] right = [1.0, 2.0, 3.0];
|
||||
double[] result = new double[3];
|
||||
|
||||
SimdExtensions.Subtract(left, right, result);
|
||||
|
||||
Assert.Equal(9.0, result[0]);
|
||||
Assert.True(double.IsNaN(result[1]));
|
||||
Assert.Equal(27.0, result[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsNonFinite_NegativeInfinityAtStart_ReturnsTrue()
|
||||
{
|
||||
double[] data = [double.NegativeInfinity, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
|
||||
var span = new ReadOnlySpan<double>(data);
|
||||
Assert.True(span.ContainsNonFinite());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsNonFinite_NegativeInfinityAtEnd_ReturnsTrue()
|
||||
{
|
||||
double[] data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, double.NegativeInfinity];
|
||||
var span = new ReadOnlySpan<double>(data);
|
||||
Assert.True(span.ContainsNonFinite());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VarianceSIMD_SingleElement_ReturnsNaN()
|
||||
{
|
||||
double[] data = [42.5];
|
||||
var span = new ReadOnlySpan<double>(data);
|
||||
Assert.True(double.IsNaN(span.VarianceSIMD()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StdDevSIMD_SingleElement_ReturnsNaN()
|
||||
{
|
||||
double[] data = [42.5];
|
||||
var span = new ReadOnlySpan<double>(data);
|
||||
Assert.True(double.IsNaN(span.StdDevSIMD()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StdDevSIMD_EmptySpan_ReturnsNaN()
|
||||
{
|
||||
var span = ReadOnlySpan<double>.Empty;
|
||||
Assert.True(double.IsNaN(span.StdDevSIMD()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SumSIMD_SingleElement_ReturnsElement()
|
||||
{
|
||||
double[] data = [42.5];
|
||||
var span = new ReadOnlySpan<double>(data);
|
||||
Assert.Equal(42.5, span.SumSIMD());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AverageSIMD_SingleElement_ReturnsElement()
|
||||
{
|
||||
double[] data = [42.5];
|
||||
var span = new ReadOnlySpan<double>(data);
|
||||
Assert.Equal(42.5, span.AverageSIMD());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DotProduct_SingleElement_ReturnsProduct()
|
||||
{
|
||||
double[] a = [3.0];
|
||||
double[] b = [4.0];
|
||||
Assert.Equal(12.0, SimdExtensions.DotProduct(a, b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DotProduct_TwoElements_ReturnsCorrect()
|
||||
{
|
||||
double[] a = [2.0, 3.0];
|
||||
double[] b = [4.0, 5.0];
|
||||
// 2*4 + 3*5 = 8 + 15 = 23
|
||||
Assert.Equal(23.0, SimdExtensions.DotProduct(a, b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_SingleElement_Works()
|
||||
{
|
||||
double[] left = [5.0];
|
||||
double[] right = [3.0];
|
||||
double[] result = new double[1];
|
||||
|
||||
SimdExtensions.Add(left, right, result);
|
||||
|
||||
Assert.Equal(8.0, result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subtract_SingleElement_Works()
|
||||
{
|
||||
double[] left = [5.0];
|
||||
double[] right = [3.0];
|
||||
double[] result = new double[1];
|
||||
|
||||
SimdExtensions.Subtract(left, right, result);
|
||||
|
||||
Assert.Equal(2.0, result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_EmptyArrays_Works()
|
||||
{
|
||||
double[] left = [];
|
||||
double[] right = [];
|
||||
double[] result = [];
|
||||
|
||||
SimdExtensions.Add(left, right, result); // Should not throw
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subtract_EmptyArrays_Works()
|
||||
{
|
||||
double[] left = [];
|
||||
double[] right = [];
|
||||
double[] result = [];
|
||||
|
||||
SimdExtensions.Subtract(left, right, result); // Should not throw
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_ResultTooSmall_ThrowsArgumentException()
|
||||
{
|
||||
double[] left = [1.0, 2.0, 3.0];
|
||||
double[] right = [4.0, 5.0, 6.0];
|
||||
double[] result = new double[2]; // Too small
|
||||
|
||||
Assert.Throws<ArgumentException>(() => SimdExtensions.Add(left, right, result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subtract_ResultTooSmall_ThrowsArgumentException()
|
||||
{
|
||||
double[] left = [1.0, 2.0, 3.0];
|
||||
double[] right = [4.0, 5.0, 6.0];
|
||||
double[] result = new double[2]; // Too small
|
||||
|
||||
Assert.Throws<ArgumentException>(() => SimdExtensions.Subtract(left, right, result));
|
||||
}
|
||||
}
|
||||
|
||||
+152
-1
@@ -345,5 +345,156 @@ public class TBarTests
|
||||
var bar2 = new TBar(12346, 100, 110, 90, 105, 1000);
|
||||
|
||||
Assert.True(bar1 != bar2);
|
||||
}
|
||||
}
|
||||
|
||||
// Additional edge case tests
|
||||
[Fact]
|
||||
public void Constructor_WithLocalDateTime_ConvertsToUtc()
|
||||
{
|
||||
var localDateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Local);
|
||||
var bar = new TBar(localDateTime, 100, 110, 90, 105, 1000);
|
||||
|
||||
// AsDateTime should return UTC
|
||||
Assert.Equal(DateTimeKind.Utc, bar.AsDateTime.Kind);
|
||||
Assert.Equal(localDateTime.ToUniversalTime().Ticks, bar.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithUnspecifiedDateTime_ConvertsToUtc()
|
||||
{
|
||||
var unspecifiedDateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Unspecified);
|
||||
var bar = new TBar(unspecifiedDateTime, 100, 110, 90, 105, 1000);
|
||||
|
||||
// Should be converted to UTC
|
||||
Assert.Equal(DateTimeKind.Utc, bar.AsDateTime.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultTBar_HasZeroValues()
|
||||
{
|
||||
var bar = default(TBar);
|
||||
|
||||
Assert.Equal(0, bar.Time);
|
||||
Assert.Equal(0.0, bar.Open);
|
||||
Assert.Equal(0.0, bar.High);
|
||||
Assert.Equal(0.0, bar.Low);
|
||||
Assert.Equal(0.0, bar.Close);
|
||||
Assert.Equal(0.0, bar.Volume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TBar_WithNaN_HandlesGracefully()
|
||||
{
|
||||
var bar = new TBar(12345, double.NaN, 110, 90, 105, 1000);
|
||||
|
||||
Assert.True(double.IsNaN(bar.Open));
|
||||
Assert.True(double.IsNaN(bar.O.Value));
|
||||
Assert.True(double.IsNaN(bar.OHL3)); // Uses Open
|
||||
Assert.True(double.IsNaN(bar.OC2)); // Uses Open
|
||||
Assert.True(double.IsNaN(bar.OHLC4)); // Uses Open
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TBar_WithInfinity_HandlesGracefully()
|
||||
{
|
||||
var bar = new TBar(12345, 100, double.PositiveInfinity, 90, 105, 1000);
|
||||
|
||||
Assert.True(double.IsPositiveInfinity(bar.High));
|
||||
Assert.True(double.IsPositiveInfinity(bar.H.Value));
|
||||
Assert.True(double.IsPositiveInfinity(bar.HL2)); // Uses High
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TBar_WithMaxValue_HandlesGracefully()
|
||||
{
|
||||
var bar = new TBar(12345, double.MaxValue, double.MaxValue, double.MinValue, 105, 1000);
|
||||
|
||||
Assert.Equal(double.MaxValue, bar.Open);
|
||||
Assert.Equal(double.MaxValue, bar.High);
|
||||
Assert.Equal(double.MinValue, bar.Low);
|
||||
// HL2 calculation with extreme values
|
||||
Assert.True(double.IsFinite(bar.HL2) || double.IsInfinity(bar.HL2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TBar_WithEpsilon_HandlesGracefully()
|
||||
{
|
||||
var bar = new TBar(12345, double.Epsilon, double.Epsilon, double.Epsilon, double.Epsilon, double.Epsilon);
|
||||
|
||||
Assert.Equal(double.Epsilon, bar.Open);
|
||||
Assert.Equal(double.Epsilon, bar.Close);
|
||||
Assert.True(bar.HL2 > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HL2_WithNegativeValues_CalculatesCorrectly()
|
||||
{
|
||||
var bar = new TBar(0, -100, -90, -110, -95, 1000);
|
||||
|
||||
Assert.Equal(-100.0, bar.HL2); // (-90 + -110) / 2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OHLC4_WithNegativeValues_CalculatesCorrectly()
|
||||
{
|
||||
var bar = new TBar(0, -100, -90, -110, -100, 1000);
|
||||
|
||||
Assert.Equal(-100.0, bar.OHLC4); // (-100 + -90 + -110 + -100) / 4
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImplicitConversion_ToTValue_PreservesTimeAndClose()
|
||||
{
|
||||
long time = 12_345_678_901_234_567;
|
||||
var bar = new TBar(time, 100, 110, 90, 105.5, 1000);
|
||||
|
||||
TValue tv = bar;
|
||||
|
||||
Assert.Equal(time, tv.Time);
|
||||
Assert.Equal(105.5, tv.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_WithNaN_DoesNotThrow()
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.Ticks, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
|
||||
|
||||
string result = bar.ToString();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("NaN", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void O_H_L_C_V_AllHaveSameTime()
|
||||
{
|
||||
long time = DateTime.UtcNow.Ticks;
|
||||
var bar = new TBar(time, 100, 110, 90, 105, 1000);
|
||||
|
||||
Assert.Equal(time, bar.O.Time);
|
||||
Assert.Equal(time, bar.H.Time);
|
||||
Assert.Equal(time, bar.L.Time);
|
||||
Assert.Equal(time, bar.C.Time);
|
||||
Assert.Equal(time, bar.V.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HLCC4_DoubleWeightsClose()
|
||||
{
|
||||
// HLCC4 = (High + Low + Close + Close) / 4
|
||||
var bar = new TBar(0, 100, 120, 80, 100, 1000);
|
||||
|
||||
// (120 + 80 + 100 + 100) / 4 = 400 / 4 = 100
|
||||
Assert.Equal(100.0, bar.HLCC4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OHL3_ExcludesClose()
|
||||
{
|
||||
// OHL3 = (Open + High + Low) / 3
|
||||
var bar = new TBar(0, 90, 120, 60, 999, 1000);
|
||||
|
||||
// (90 + 120 + 60) / 3 = 270 / 3 = 90
|
||||
Assert.Equal(90.0, bar.OHL3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,7 +344,8 @@ public class TBarSeriesTests
|
||||
series.Add(200, 20, 25, 15, 22, 200, isNew: true);
|
||||
|
||||
var list = new List<object>();
|
||||
|
||||
|
||||
#pragma warning disable S4158
|
||||
foreach (var item in (IEnumerable)series)
|
||||
{
|
||||
list.Add(item);
|
||||
@@ -408,4 +409,146 @@ public class TBarSeriesTests
|
||||
Assert.Equal(200, series[1].Time);
|
||||
Assert.Equal(300, series[2].Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_WithEnumerables_MismatchedLengths_ThrowsArgumentException()
|
||||
{
|
||||
var series = new TBarSeries();
|
||||
var times = new long[] { 100, 200, 300 };
|
||||
var opens = new double[] { 10, 20 }; // Mismatched length
|
||||
var highs = new double[] { 15, 25, 35 };
|
||||
var lows = new double[] { 5, 15, 25 };
|
||||
var closes = new double[] { 12, 22, 32 };
|
||||
var volumes = new double[] { 100, 200, 300 };
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
series.Add(times, opens, highs, lows, closes, volumes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_OutOfBounds_ThrowsException()
|
||||
{
|
||||
var series = new TBarSeries();
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _ = series[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_NegativeIndex_ThrowsException()
|
||||
{
|
||||
var series = new TBarSeries();
|
||||
series.Add(100, 10, 15, 5, 12, 100);
|
||||
|
||||
int invalidIndex = -1;
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _ = series[invalidIndex]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_BeyondCount_ThrowsException()
|
||||
{
|
||||
var series = new TBarSeries();
|
||||
series.Add(100, 10, 15, 5, 12, 100);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _ = series[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_WithNaN_PreservesNaN()
|
||||
{
|
||||
var series = new TBarSeries();
|
||||
var bar = new TBar(DateTime.UtcNow.Ticks, double.NaN, 110, 90, 105, 1000);
|
||||
|
||||
series.Add(bar, isNew: true);
|
||||
|
||||
Assert.True(double.IsNaN(series.Last.Open));
|
||||
Assert.True(double.IsNaN(series.Open.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_WithInfinity_PreservesInfinity()
|
||||
{
|
||||
var series = new TBarSeries();
|
||||
var bar = new TBar(DateTime.UtcNow.Ticks, 100, double.PositiveInfinity, 90, 105, 1000);
|
||||
|
||||
series.Add(bar, isNew: true);
|
||||
|
||||
Assert.True(double.IsPositiveInfinity(series.Last.High));
|
||||
Assert.True(double.IsPositiveInfinity(series.High.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubSeries_EmptySeries_HaveZeroCount()
|
||||
{
|
||||
var series = new TBarSeries();
|
||||
|
||||
Assert.Empty(series.Open);
|
||||
Assert.Empty(series.High);
|
||||
Assert.Empty(series.Low);
|
||||
Assert.Empty(series.Close);
|
||||
Assert.Empty(series.Volume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubSeries_ValuesSpan_ReturnsCorrectData()
|
||||
{
|
||||
var series = new TBarSeries();
|
||||
series.Add(100, 10, 15, 5, 12, 100);
|
||||
series.Add(200, 20, 25, 15, 22, 200);
|
||||
|
||||
ReadOnlySpan<double> closeValues = series.Close.Values;
|
||||
|
||||
Assert.Equal(2, closeValues.Length);
|
||||
Assert.Equal(12.0, closeValues[0]);
|
||||
Assert.Equal(22.0, closeValues[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubSeries_TimesSpan_ReturnsCorrectData()
|
||||
{
|
||||
var series = new TBarSeries();
|
||||
series.Add(100, 10, 15, 5, 12, 100);
|
||||
series.Add(200, 20, 25, 15, 22, 200);
|
||||
|
||||
ReadOnlySpan<long> times = series.Close.Times;
|
||||
|
||||
Assert.Equal(2, times.Length);
|
||||
Assert.Equal(100, times[0]);
|
||||
Assert.Equal(200, times[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pub_EventArgs_ContainsIsNewFlag()
|
||||
{
|
||||
var series = new TBarSeries();
|
||||
bool? receivedIsNew = null;
|
||||
series.Pub += (object? sender, in TBarEventArgs args) => receivedIsNew = args.IsNew;
|
||||
|
||||
series.Add(new TBar(100, 10, 15, 5, 12, 100), isNew: true);
|
||||
|
||||
Assert.True(receivedIsNew);
|
||||
|
||||
series.Add(new TBar(100, 10, 18, 5, 15, 150), isNew: false);
|
||||
|
||||
Assert.False(receivedIsNew);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_WithEnumerables_EmptyArrays_AddsNothing()
|
||||
{
|
||||
var series = new TBarSeries();
|
||||
var empty = Array.Empty<long>();
|
||||
var emptyD = Array.Empty<double>();
|
||||
|
||||
series.Add(empty, emptyD, emptyD, emptyD, emptyD, emptyD);
|
||||
|
||||
Assert.Empty(series);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithCapacity_DoesNotAffectCount()
|
||||
{
|
||||
var series = new TBarSeries(1000);
|
||||
|
||||
Assert.Empty(series);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
@@ -16,6 +17,74 @@ public readonly struct TBarEventArgs
|
||||
public bool IsNew { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// High-performance enumerator for TBarSeries.
|
||||
/// </summary>
|
||||
public struct TBarSeriesEnumerator : IEnumerator<TBar>, IEquatable<TBarSeriesEnumerator>
|
||||
{
|
||||
private readonly List<long> _t;
|
||||
private readonly List<double> _o;
|
||||
private readonly List<double> _h;
|
||||
private readonly List<double> _l;
|
||||
private readonly List<double> _c;
|
||||
private readonly List<double> _v;
|
||||
private readonly int _count;
|
||||
private int _index;
|
||||
private TBar _current;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
internal TBarSeriesEnumerator(List<long> t, List<double> o, List<double> h, List<double> l, List<double> c, List<double> v)
|
||||
{
|
||||
_t = t;
|
||||
_o = o;
|
||||
_h = h;
|
||||
_l = l;
|
||||
_c = c;
|
||||
_v = v;
|
||||
_count = c.Count;
|
||||
_index = -1;
|
||||
_current = default;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_index + 1 >= _count)
|
||||
return false;
|
||||
|
||||
_index++;
|
||||
_current = new TBar(_t[_index], _o[_index], _h[_index], _l[_index], _c[_index], _v[_index]);
|
||||
return true;
|
||||
}
|
||||
|
||||
public readonly TBar Current => _current;
|
||||
readonly object IEnumerator.Current => Current;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Reset()
|
||||
{
|
||||
_index = -1;
|
||||
_current = default;
|
||||
}
|
||||
|
||||
public readonly void Dispose() { }
|
||||
|
||||
public readonly bool Equals(TBarSeriesEnumerator other) =>
|
||||
ReferenceEquals(_t, other._t) &&
|
||||
ReferenceEquals(_c, other._c) &&
|
||||
_count == other._count &&
|
||||
_index == other._index;
|
||||
|
||||
public override readonly bool Equals(object? obj) =>
|
||||
obj is TBarSeriesEnumerator other && Equals(other);
|
||||
|
||||
public override readonly int GetHashCode() =>
|
||||
HashCode.Combine(RuntimeHelpers.GetHashCode(_t), RuntimeHelpers.GetHashCode(_c), _count, _index);
|
||||
|
||||
public static bool operator ==(TBarSeriesEnumerator left, TBarSeriesEnumerator right) => left.Equals(right);
|
||||
public static bool operator !=(TBarSeriesEnumerator left, TBarSeriesEnumerator right) => !left.Equals(right);
|
||||
}
|
||||
|
||||
// Performance-focused event args struct; not derived from EventArgs by design.
|
||||
// We intentionally deviate from the standard EventArgs pattern here for perf.
|
||||
#pragma warning disable MA0046 // The second parameter must be of type 'System.EventArgs' or a derived type
|
||||
@@ -94,6 +163,60 @@ public class TBarSeries : IReadOnlyList<TBar>
|
||||
public double LastClose { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _c.Count > 0 ? _c[^1] : double.NaN; }
|
||||
public double LastVolume { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _v.Count > 0 ? _v[^1] : double.NaN; }
|
||||
|
||||
/// <summary>
|
||||
/// Direct access to the underlying Time array as a Span.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<long> Times
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
get => CollectionsMarshal.AsSpan(_t);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direct access to the underlying Open array as a Span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> OpenValues
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
get => CollectionsMarshal.AsSpan(_o);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direct access to the underlying High array as a Span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> HighValues
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
get => CollectionsMarshal.AsSpan(_h);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direct access to the underlying Low array as a Span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> LowValues
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
get => CollectionsMarshal.AsSpan(_l);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direct access to the underlying Close array as a Span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> CloseValues
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
get => CollectionsMarshal.AsSpan(_c);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direct access to the underlying Volume array as a Span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> VolumeValues
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
get => CollectionsMarshal.AsSpan(_v);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Add(TBar bar, bool isNew = true)
|
||||
{
|
||||
@@ -150,13 +273,10 @@ public class TBarSeries : IReadOnlyList<TBar>
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerator<TBar> GetEnumerator()
|
||||
{
|
||||
for (int i = 0; i < _c.Count; i++)
|
||||
{
|
||||
yield return new TBar(_t[i], _o[i], _h[i], _l[i], _c[i], _v[i]);
|
||||
}
|
||||
}
|
||||
// IEnumerable implementation with struct enumerator for zero-allocation iteration
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TBarSeriesEnumerator GetEnumerator() => new(_t, _o, _h, _l, _c, _v);
|
||||
|
||||
IEnumerator<TBar> IEnumerable<TBar>.GetEnumerator() => GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
||||
@@ -285,11 +285,14 @@ public class TSeriesTests
|
||||
series.Add(200, 2.0);
|
||||
|
||||
var list = new List<object>();
|
||||
foreach (var item in (IEnumerable)series)
|
||||
IEnumerable enumerable = series;
|
||||
#pragma warning disable S4158
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
list.Add(item);
|
||||
}
|
||||
|
||||
Assert.Equal(2, series.Count);
|
||||
Assert.Equal(2, list.Count);
|
||||
}
|
||||
|
||||
@@ -332,4 +335,204 @@ public class TSeriesTests
|
||||
series.Add(200, 2.0);
|
||||
Assert.Equal(2, series.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithMismatchedLists_WrapsData()
|
||||
{
|
||||
// TSeries wraps the lists directly if they're List<T>, no length validation
|
||||
var times = new List<long> { 100, 200, 300 };
|
||||
var values = new List<double> { 1.0, 2.0 }; // Different length
|
||||
|
||||
var series = new TSeries(times, values);
|
||||
|
||||
// Count is based on values list
|
||||
Assert.Equal(2, series.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_OutOfBounds_ThrowsException()
|
||||
{
|
||||
var series = new TSeries();
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _ = series[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_NegativeIndex_ThrowsException()
|
||||
{
|
||||
var series = new TSeries();
|
||||
series.Add(100, 1.0);
|
||||
|
||||
#pragma warning disable DS003 // Invalid index - intentional for testing exception
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _ = series[-1]);
|
||||
#pragma warning restore DS003
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_BeyondCount_ThrowsException()
|
||||
{
|
||||
var series = new TSeries();
|
||||
series.Add(100, 1.0);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _ = series[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Values_EmptySeries_ReturnsEmptySpan()
|
||||
{
|
||||
var series = new TSeries();
|
||||
|
||||
ReadOnlySpan<double> values = series.Values;
|
||||
|
||||
Assert.Equal(0, values.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Times_EmptySeries_ReturnsEmptySpan()
|
||||
{
|
||||
var series = new TSeries();
|
||||
|
||||
ReadOnlySpan<long> times = series.Times;
|
||||
|
||||
Assert.Equal(0, times.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_WithNaN_PreservesNaN()
|
||||
{
|
||||
var series = new TSeries();
|
||||
|
||||
series.Add(100, double.NaN);
|
||||
|
||||
Assert.True(double.IsNaN(series.Last.Value));
|
||||
Assert.True(double.IsNaN(series.LastValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_WithInfinity_PreservesInfinity()
|
||||
{
|
||||
var series = new TSeries();
|
||||
|
||||
series.Add(100, double.PositiveInfinity);
|
||||
|
||||
Assert.True(double.IsPositiveInfinity(series.Last.Value));
|
||||
Assert.True(double.IsPositiveInfinity(series.LastValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_WithNegativeInfinity_PreservesNegativeInfinity()
|
||||
{
|
||||
var series = new TSeries();
|
||||
|
||||
series.Add(100, double.NegativeInfinity);
|
||||
|
||||
Assert.True(double.IsNegativeInfinity(series.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_EnumerableDoubles_GeneratesIncreasingTimes()
|
||||
{
|
||||
var series = new TSeries();
|
||||
var values = new[] { 1.0, 2.0, 3.0 };
|
||||
|
||||
series.Add(values);
|
||||
|
||||
Assert.Equal(3, series.Count);
|
||||
// Times should be increasing by TicksPerMinute
|
||||
Assert.True(series[1].Time > series[0].Time);
|
||||
Assert.True(series[2].Time > series[1].Time);
|
||||
Assert.Equal(TimeSpan.TicksPerMinute, series[1].Time - series[0].Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_EnumerableDoubles_EmptyArray_AddsNothing()
|
||||
{
|
||||
var series = new TSeries();
|
||||
|
||||
series.Add(Array.Empty<double>());
|
||||
|
||||
Assert.Empty(series);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pub_EventArgs_ContainsIsNewFlag()
|
||||
{
|
||||
var series = new TSeries();
|
||||
bool? receivedIsNew = null;
|
||||
series.Pub += (object? sender, in TValueEventArgs args) => receivedIsNew = args.IsNew;
|
||||
|
||||
series.Add(new TValue(100, 42.0), isNew: true);
|
||||
|
||||
Assert.True(receivedIsNew);
|
||||
|
||||
series.Add(new TValue(100, 43.0), isNew: false);
|
||||
|
||||
Assert.False(receivedIsNew);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithCapacity_DoesNotAffectCount()
|
||||
{
|
||||
var series = new TSeries(1000);
|
||||
|
||||
Assert.Empty(series);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_WithDateTimeLocal_ConvertsToUtc()
|
||||
{
|
||||
var series = new TSeries();
|
||||
var localTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Local);
|
||||
|
||||
series.Add(localTime, 100.0);
|
||||
|
||||
// The stored time should be UTC
|
||||
var storedTime = new DateTime(series.Last.Time, DateTimeKind.Utc);
|
||||
Assert.Equal(DateTimeKind.Utc, storedTime.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_WithDateTimeUnspecified_TreatsAsLocal()
|
||||
{
|
||||
var series = new TSeries();
|
||||
var unspecifiedTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Unspecified);
|
||||
|
||||
series.Add(unspecifiedTime, 100.0);
|
||||
|
||||
Assert.Single(series);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Values_ModifyingUnderlyingList_ReflectsInSpan()
|
||||
{
|
||||
var series = new TSeries();
|
||||
series.Add(100, 1.0);
|
||||
series.Add(200, 2.0);
|
||||
|
||||
// Get the span
|
||||
ReadOnlySpan<double> values1 = series.Values;
|
||||
Assert.Equal(2, values1.Length);
|
||||
|
||||
// Add more data
|
||||
series.Add(300, 3.0);
|
||||
|
||||
// Get new span - should reflect the change
|
||||
ReadOnlySpan<double> values2 = series.Values;
|
||||
Assert.Equal(3, values2.Length);
|
||||
Assert.Equal(3.0, values2[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithReadOnlyLists_CopiesData()
|
||||
{
|
||||
// Using arrays which implement IReadOnlyList but aren't List<T>
|
||||
IReadOnlyList<long> times = new long[] { 100, 200, 300 };
|
||||
IReadOnlyList<double> values = [1.0, 2.0, 3.0];
|
||||
|
||||
var series = new TSeries(times, values);
|
||||
|
||||
Assert.Equal(3, series.Count);
|
||||
Assert.Equal(1.0, series[0].Value);
|
||||
Assert.Equal(3.0, series[2].Value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,66 @@ using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// High-performance enumerator for TSeries.
|
||||
/// </summary>
|
||||
public struct TSeriesEnumerator : IEnumerator<TValue>, IEquatable<TSeriesEnumerator>
|
||||
{
|
||||
private readonly List<long> _t;
|
||||
private readonly List<double> _v;
|
||||
private readonly int _count;
|
||||
private int _index;
|
||||
private TValue _current;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
internal TSeriesEnumerator(List<long> t, List<double> v)
|
||||
{
|
||||
_t = t;
|
||||
_v = v;
|
||||
_count = v.Count;
|
||||
_index = -1;
|
||||
_current = default;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_index + 1 >= _count)
|
||||
return false;
|
||||
|
||||
_index++;
|
||||
_current = new TValue(_t[_index], _v[_index]);
|
||||
return true;
|
||||
}
|
||||
|
||||
public readonly TValue Current => _current;
|
||||
readonly object IEnumerator.Current => Current;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Reset()
|
||||
{
|
||||
_index = -1;
|
||||
_current = default;
|
||||
}
|
||||
|
||||
public readonly void Dispose() { }
|
||||
|
||||
public readonly bool Equals(TSeriesEnumerator other) =>
|
||||
ReferenceEquals(_t, other._t) &&
|
||||
ReferenceEquals(_v, other._v) &&
|
||||
_count == other._count &&
|
||||
_index == other._index;
|
||||
|
||||
public override readonly bool Equals(object? obj) =>
|
||||
obj is TSeriesEnumerator other && Equals(other);
|
||||
|
||||
public override readonly int GetHashCode() =>
|
||||
HashCode.Combine(RuntimeHelpers.GetHashCode(_t), RuntimeHelpers.GetHashCode(_v), _count, _index);
|
||||
|
||||
public static bool operator ==(TSeriesEnumerator left, TSeriesEnumerator right) => left.Equals(right);
|
||||
public static bool operator !=(TSeriesEnumerator left, TSeriesEnumerator right) => !left.Equals(right);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A high-performance time series implementation using Structure of Arrays (SoA) layout.
|
||||
/// Stores Time (long) and Value (double) in separate contiguous arrays for SIMD efficiency.
|
||||
@@ -132,14 +192,10 @@ public class TSeries : IReadOnlyList<TValue>, ITValuePublisher
|
||||
}
|
||||
}
|
||||
|
||||
// IEnumerable implementation
|
||||
public IEnumerator<TValue> GetEnumerator()
|
||||
{
|
||||
for (int i = 0; i < _v.Count; i++)
|
||||
{
|
||||
yield return new TValue(_t[i], _v[i]);
|
||||
}
|
||||
}
|
||||
// IEnumerable implementation with struct enumerator for zero-allocation iteration
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TSeriesEnumerator GetEnumerator() => new(_t, _v);
|
||||
|
||||
IEnumerator<TValue> IEnumerable<TValue>.GetEnumerator() => GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
||||
+327
-137
@@ -1,182 +1,372 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
namespace QuanTAlib.Tests
|
||||
public class TValueTests
|
||||
{
|
||||
public class TValueTests
|
||||
[Fact]
|
||||
public void Constructor_WithLongTime_SetsPropertiesCorrectly()
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithLongTime_SetsPropertiesCorrectly()
|
||||
{
|
||||
long time = DateTime.UtcNow.Ticks;
|
||||
double value = 123.45;
|
||||
long time = DateTime.UtcNow.Ticks;
|
||||
double value = 123.45;
|
||||
|
||||
var tValue = new TValue(time, value);
|
||||
var tValue = new TValue(time, value);
|
||||
|
||||
Assert.Equal(time, tValue.Time);
|
||||
Assert.Equal(value, tValue.Value);
|
||||
}
|
||||
Assert.Equal(time, tValue.Time);
|
||||
Assert.Equal(value, tValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithDateTime_SetsPropertiesCorrectly()
|
||||
{
|
||||
var dateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc);
|
||||
double value = 123.45;
|
||||
[Fact]
|
||||
public void Constructor_WithDateTime_SetsPropertiesCorrectly()
|
||||
{
|
||||
var dateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc);
|
||||
double value = 123.45;
|
||||
|
||||
var tValue = new TValue(dateTime, value);
|
||||
var tValue = new TValue(dateTime, value);
|
||||
|
||||
Assert.Equal(dateTime.Ticks, tValue.Time);
|
||||
Assert.Equal(value, tValue.Value);
|
||||
}
|
||||
Assert.Equal(dateTime.Ticks, tValue.Time);
|
||||
Assert.Equal(value, tValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsDateTime_ReturnsCorrectDateTime()
|
||||
{
|
||||
var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var tValue = new TValue(dt.Ticks, 100.0);
|
||||
[Fact]
|
||||
public void AsDateTime_ReturnsCorrectDateTime()
|
||||
{
|
||||
var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var tValue = new TValue(dt.Ticks, 100.0);
|
||||
|
||||
Assert.Equal(dt, tValue.AsDateTime);
|
||||
Assert.Equal(DateTimeKind.Utc, tValue.AsDateTime.Kind);
|
||||
}
|
||||
Assert.Equal(dt, tValue.AsDateTime);
|
||||
Assert.Equal(DateTimeKind.Utc, tValue.AsDateTime.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_FormatsCorrectly()
|
||||
{
|
||||
var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var tValue = new TValue(dt.Ticks, 123.456);
|
||||
[Fact]
|
||||
public void ToString_FormatsCorrectly()
|
||||
{
|
||||
var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var tValue = new TValue(dt.Ticks, 123.456);
|
||||
|
||||
string result = tValue.ToString();
|
||||
string result = tValue.ToString();
|
||||
|
||||
Assert.Contains("2023-01-01", result, StringComparison.Ordinal);
|
||||
Assert.Contains("12:00:00", result, StringComparison.Ordinal);
|
||||
Assert.Contains("123.46", result, StringComparison.Ordinal);
|
||||
}
|
||||
Assert.Contains("2023-01-01", result, StringComparison.Ordinal);
|
||||
Assert.Contains("12:00:00", result, StringComparison.Ordinal);
|
||||
Assert.Contains("123.46", result, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImplicitConversion_ToDouble_ReturnsValue()
|
||||
{
|
||||
var tValue = new TValue(DateTime.UtcNow.Ticks, 42.0);
|
||||
[Fact]
|
||||
public void ImplicitConversion_ToDouble_ReturnsValue()
|
||||
{
|
||||
var tValue = new TValue(DateTime.UtcNow.Ticks, 42.0);
|
||||
|
||||
double val = tValue;
|
||||
double val = tValue;
|
||||
|
||||
Assert.Equal(42.0, val);
|
||||
}
|
||||
Assert.Equal(42.0, val);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImplicitConversion_ToDateTime_ReturnsCorrectDateTime()
|
||||
{
|
||||
var dateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc);
|
||||
var tValue = new TValue(dateTime.Ticks, 100.0);
|
||||
[Fact]
|
||||
public void ImplicitConversion_ToDateTime_ReturnsCorrectDateTime()
|
||||
{
|
||||
var dateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc);
|
||||
var tValue = new TValue(dateTime.Ticks, 100.0);
|
||||
|
||||
DateTime result = tValue;
|
||||
DateTime result = tValue;
|
||||
|
||||
Assert.Equal(dateTime, result);
|
||||
Assert.Equal(DateTimeKind.Utc, result.Kind);
|
||||
}
|
||||
Assert.Equal(dateTime, result);
|
||||
Assert.Equal(DateTimeKind.Utc, result.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_TValue_SameValues_ReturnsTrue()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12345, 100.0);
|
||||
[Fact]
|
||||
public void Equals_TValue_SameValues_ReturnsTrue()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12345, 100.0);
|
||||
|
||||
Assert.True(tv1.Equals(tv2));
|
||||
}
|
||||
Assert.True(tv1.Equals(tv2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_TValue_DifferentTime_ReturnsFalse()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12346, 100.0);
|
||||
[Fact]
|
||||
public void Equals_TValue_DifferentTime_ReturnsFalse()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12346, 100.0);
|
||||
|
||||
Assert.False(tv1.Equals(tv2));
|
||||
}
|
||||
Assert.False(tv1.Equals(tv2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_TValue_DifferentValue_ReturnsFalse()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12345, 101.0);
|
||||
[Fact]
|
||||
public void Equals_TValue_DifferentValue_ReturnsFalse()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12345, 101.0);
|
||||
|
||||
Assert.False(tv1.Equals(tv2));
|
||||
}
|
||||
Assert.False(tv1.Equals(tv2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_Object_SameTValue_ReturnsTrue()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
object tv2 = new TValue(12345, 100.0);
|
||||
[Fact]
|
||||
public void Equals_Object_SameTValue_ReturnsTrue()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
object tv2 = new TValue(12345, 100.0);
|
||||
|
||||
Assert.True(tv1.Equals(tv2));
|
||||
}
|
||||
Assert.True(tv1.Equals(tv2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_Object_DifferentType_ReturnsFalse()
|
||||
{
|
||||
var tv = new TValue(12345, 100.0);
|
||||
object other = "not a TValue";
|
||||
[Fact]
|
||||
public void Equals_Object_DifferentType_ReturnsFalse()
|
||||
{
|
||||
var tv = new TValue(12345, 100.0);
|
||||
object other = "not a TValue";
|
||||
|
||||
Assert.False(tv.Equals(other));
|
||||
}
|
||||
Assert.False(tv.Equals(other));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_Object_Null_ReturnsFalse()
|
||||
{
|
||||
var tv = new TValue(12345, 100.0);
|
||||
[Fact]
|
||||
public void Equals_Object_Null_ReturnsFalse()
|
||||
{
|
||||
var tv = new TValue(12345, 100.0);
|
||||
|
||||
Assert.False(tv.Equals(null));
|
||||
}
|
||||
Assert.False(tv.Equals(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHashCode_SameValues_ReturnsSameHashCode()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12345, 100.0);
|
||||
[Fact]
|
||||
public void GetHashCode_SameValues_ReturnsSameHashCode()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12345, 100.0);
|
||||
|
||||
Assert.Equal(tv1.GetHashCode(), tv2.GetHashCode());
|
||||
}
|
||||
Assert.Equal(tv1.GetHashCode(), tv2.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHashCode_DifferentValues_ReturnsDifferentHashCode()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12346, 100.0);
|
||||
[Fact]
|
||||
public void GetHashCode_DifferentValues_ReturnsDifferentHashCode()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12346, 100.0);
|
||||
|
||||
Assert.NotEqual(tv1.GetHashCode(), tv2.GetHashCode());
|
||||
}
|
||||
Assert.NotEqual(tv1.GetHashCode(), tv2.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualityOperator_SameValues_ReturnsTrue()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12345, 100.0);
|
||||
[Fact]
|
||||
public void EqualityOperator_SameValues_ReturnsTrue()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12345, 100.0);
|
||||
|
||||
Assert.True(tv1 == tv2);
|
||||
}
|
||||
Assert.True(tv1 == tv2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualityOperator_DifferentValues_ReturnsFalse()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12346, 100.0);
|
||||
[Fact]
|
||||
public void EqualityOperator_DifferentValues_ReturnsFalse()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12346, 100.0);
|
||||
|
||||
Assert.False(tv1 == tv2);
|
||||
}
|
||||
Assert.False(tv1 == tv2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InequalityOperator_SameValues_ReturnsFalse()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12345, 100.0);
|
||||
[Fact]
|
||||
public void InequalityOperator_SameValues_ReturnsFalse()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12345, 100.0);
|
||||
|
||||
Assert.False(tv1 != tv2);
|
||||
}
|
||||
Assert.False(tv1 != tv2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InequalityOperator_DifferentValues_ReturnsTrue()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12346, 100.0);
|
||||
[Fact]
|
||||
public void InequalityOperator_DifferentValues_ReturnsTrue()
|
||||
{
|
||||
var tv1 = new TValue(12345, 100.0);
|
||||
var tv2 = new TValue(12346, 100.0);
|
||||
|
||||
Assert.True(tv1 != tv2);
|
||||
}
|
||||
Assert.True(tv1 != tv2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithDateTimeLocal_ConvertsToUtc()
|
||||
{
|
||||
var localTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Local);
|
||||
double value = 123.45;
|
||||
|
||||
var tValue = new TValue(localTime, value);
|
||||
|
||||
// Time should be stored as UTC ticks
|
||||
var expectedUtc = localTime.ToUniversalTime();
|
||||
Assert.Equal(expectedUtc.Ticks, tValue.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithDateTimeUnspecified_ConvertsToUtc()
|
||||
{
|
||||
var unspecifiedTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Unspecified);
|
||||
double value = 123.45;
|
||||
|
||||
var tValue = new TValue(unspecifiedTime, value);
|
||||
|
||||
// Unspecified is treated as local and converted to UTC
|
||||
var expectedUtc = unspecifiedTime.ToUniversalTime();
|
||||
Assert.Equal(expectedUtc.Ticks, tValue.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithDateTimeUtc_PreservesTicks()
|
||||
{
|
||||
var utcTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc);
|
||||
double value = 123.45;
|
||||
|
||||
var tValue = new TValue(utcTime, value);
|
||||
|
||||
Assert.Equal(utcTime.Ticks, tValue.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_TValue_HasZeroTimeAndValue()
|
||||
{
|
||||
var defaultTValue = default(TValue);
|
||||
|
||||
Assert.Equal(0, defaultTValue.Time);
|
||||
Assert.Equal(0.0, defaultTValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNaN_PreservesNaN()
|
||||
{
|
||||
var tValue = new TValue(DateTime.UtcNow.Ticks, double.NaN);
|
||||
|
||||
Assert.True(double.IsNaN(tValue.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithPositiveInfinity_PreservesInfinity()
|
||||
{
|
||||
var tValue = new TValue(DateTime.UtcNow.Ticks, double.PositiveInfinity);
|
||||
|
||||
Assert.True(double.IsPositiveInfinity(tValue.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNegativeInfinity_PreservesInfinity()
|
||||
{
|
||||
var tValue = new TValue(DateTime.UtcNow.Ticks, double.NegativeInfinity);
|
||||
|
||||
Assert.True(double.IsNegativeInfinity(tValue.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithMaxValue_PreservesMaxValue()
|
||||
{
|
||||
var tValue = new TValue(DateTime.UtcNow.Ticks, double.MaxValue);
|
||||
|
||||
Assert.Equal(double.MaxValue, tValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithMinValue_PreservesMinValue()
|
||||
{
|
||||
var tValue = new TValue(DateTime.UtcNow.Ticks, double.MinValue);
|
||||
|
||||
Assert.Equal(double.MinValue, tValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithEpsilon_PreservesEpsilon()
|
||||
{
|
||||
var tValue = new TValue(DateTime.UtcNow.Ticks, double.Epsilon);
|
||||
|
||||
Assert.Equal(double.Epsilon, tValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImplicitConversion_ToDouble_WithNaN_ReturnsNaN()
|
||||
{
|
||||
var tValue = new TValue(DateTime.UtcNow.Ticks, double.NaN);
|
||||
|
||||
double val = tValue;
|
||||
|
||||
Assert.True(double.IsNaN(val));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_WithNaN_FormatsCorrectly()
|
||||
{
|
||||
var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var tValue = new TValue(dt.Ticks, double.NaN);
|
||||
|
||||
string result = tValue.ToString();
|
||||
|
||||
Assert.Contains("NaN", result, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_WithInfinity_FormatsCorrectly()
|
||||
{
|
||||
var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var tValue = new TValue(dt.Ticks, double.PositiveInfinity);
|
||||
|
||||
string result = tValue.ToString();
|
||||
|
||||
Assert.Contains("∞", result, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_WithNegativeValue_FormatsCorrectly()
|
||||
{
|
||||
var dt = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var tValue = new TValue(dt.Ticks, -123.456);
|
||||
|
||||
string result = tValue.ToString();
|
||||
|
||||
Assert.Contains("-123.46", result, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsDateTime_ReturnsUtcKind()
|
||||
{
|
||||
var tValue = new TValue(DateTime.UtcNow.Ticks, 100.0);
|
||||
|
||||
Assert.Equal(DateTimeKind.Utc, tValue.AsDateTime.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_WithNaN_BothNaN_ReturnsFalse()
|
||||
{
|
||||
// NaN != NaN in IEEE 754
|
||||
var tv1 = new TValue(12345, double.NaN);
|
||||
var tv2 = new TValue(12345, double.NaN);
|
||||
|
||||
// Record struct equality compares fields directly
|
||||
// double.NaN.Equals(double.NaN) returns true in .NET
|
||||
Assert.True(tv1.Equals(tv2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHashCode_WithNaN_DoesNotThrow()
|
||||
{
|
||||
var tv = new TValue(12345, double.NaN);
|
||||
|
||||
var hash = tv.GetHashCode();
|
||||
|
||||
Assert.True(hash != 0 || hash == 0); // Just verify it doesn't throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithZeroTime_Allowed()
|
||||
{
|
||||
var tValue = new TValue(0, 100.0);
|
||||
|
||||
Assert.Equal(0, tValue.Time);
|
||||
Assert.Equal(100.0, tValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNegativeTime_Allowed()
|
||||
{
|
||||
var tValue = new TValue(-12345, 100.0);
|
||||
|
||||
Assert.Equal(-12345, tValue.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithMaxLongTime_Allowed()
|
||||
{
|
||||
var tValue = new TValue(long.MaxValue, 100.0);
|
||||
|
||||
Assert.Equal(long.MaxValue, tValue.Time);
|
||||
}
|
||||
}
|
||||
|
||||
+673
-40
@@ -1,15 +1,42 @@
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class CsvFeedTests
|
||||
public sealed class CsvFeedTests : IDisposable
|
||||
{
|
||||
private const string TestCsvPath = "daily_IBM.csv";
|
||||
private readonly List<string> _tempFiles = new();
|
||||
private bool _disposed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
foreach (var file in _tempFiles)
|
||||
{
|
||||
if (File.Exists(file))
|
||||
{
|
||||
try { File.Delete(file); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string CreateTempCsv(string[] lines)
|
||||
{
|
||||
string tempPath = Path.GetTempFileName() + ".csv";
|
||||
File.WriteAllLines(tempPath, lines);
|
||||
_tempFiles.Add(tempPath);
|
||||
return tempPath;
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidFile_LoadsData()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
Assert.NotNull(feed);
|
||||
Assert.True(feed.Count > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -21,15 +48,185 @@ public class CsvFeedTests
|
||||
[Fact]
|
||||
public void Constructor_NullPath_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new CsvFeed(null!));
|
||||
var ex = Assert.Throws<ArgumentException>(() => new CsvFeed(null!));
|
||||
Assert.Equal("filePath", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_EmptyPath_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new CsvFeed(""));
|
||||
var ex = Assert.Throws<ArgumentException>(() => new CsvFeed(""));
|
||||
Assert.Equal("filePath", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhitespacePath_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new CsvFeed(" "));
|
||||
Assert.Equal("filePath", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_EmptyCsv_ThrowsInvalidDataException()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(Array.Empty<string>());
|
||||
Assert.Throws<InvalidDataException>(() => new CsvFeed(tempCsv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_HeaderOnlyCsv_ThrowsInvalidDataException()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[] { "timestamp,open,high,low,close,volume" });
|
||||
Assert.Throws<InvalidDataException>(() => new CsvFeed(tempCsv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_MalformedDate_ThrowsFormatException()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"not-a-date,100,101,99,100,1000"
|
||||
});
|
||||
Assert.Throws<FormatException>(() => new CsvFeed(tempCsv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_MalformedPrice_ThrowsFormatException()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,not-a-number,101,99,100,1000"
|
||||
});
|
||||
Assert.Throws<FormatException>(() => new CsvFeed(tempCsv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_MissingColumns_ThrowsFormatException()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,100,101,99,100" // Missing volume
|
||||
});
|
||||
Assert.Throws<FormatException>(() => new CsvFeed(tempCsv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ExtraColumns_ThrowsFormatException()
|
||||
{
|
||||
// Extra columns should throw format exception (strict 6-column format)
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume,extra",
|
||||
"2023-01-01,100,101,99,100,1000,extra_data"
|
||||
});
|
||||
Assert.Throws<FormatException>(() => new CsvFeed(tempCsv));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Tests
|
||||
|
||||
[Fact]
|
||||
public void Count_ReturnsCorrectNumber()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
Assert.True(feed.Count > 0);
|
||||
// IBM CSV has 100 rows of data
|
||||
Assert.Equal(100, feed.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilePath_ReturnsLoadedPath()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
Assert.Equal(TestCsvPath, feed.FilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasMore_TrueAtStart()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
Assert.True(feed.HasMore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasMore_FalseWhenExhausted()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,100,101,99,100,1000"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
|
||||
Assert.True(feed.HasMore);
|
||||
feed.Next(isNew: true);
|
||||
Assert.False(feed.HasMore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentIndex_StartsAtZero()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
Assert.Equal(0, feed.CurrentIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentIndex_IncrementsOnNext()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
Assert.Equal(0, feed.CurrentIndex);
|
||||
feed.Next(isNew: true);
|
||||
Assert.Equal(1, feed.CurrentIndex);
|
||||
feed.Next(isNew: true);
|
||||
Assert.Equal(2, feed.CurrentIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentIndex_DoesNotIncrementOnUpdate()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
feed.Next(isNew: true);
|
||||
int indexAfterFirst = feed.CurrentIndex;
|
||||
|
||||
feed.Next(isNew: false);
|
||||
Assert.Equal(indexAfterFirst, feed.CurrentIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasCurrentBar_FalseAtStart()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
Assert.False(feed.HasCurrentBar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasCurrentBar_TrueAfterNext()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
feed.Next(isNew: true);
|
||||
Assert.True(feed.HasCurrentBar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Data_ReturnsUnderlyingSeries()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
var data = feed.Data;
|
||||
|
||||
Assert.NotNull(data);
|
||||
Assert.Equal(feed.Count, data.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Next Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Next_StreamsDataChronologically()
|
||||
{
|
||||
@@ -109,6 +306,43 @@ public class CsvFeedTests
|
||||
Assert.Equal(lastBar.Time, finalBar.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_EmptyData_ReturnsDefaultAndSignalsNoMore()
|
||||
{
|
||||
// Create a mock scenario - but since constructor throws on empty,
|
||||
// we test the behavior when all data is consumed
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,100,101,99,100,1000"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
|
||||
// Consume all data
|
||||
bool isNew = true;
|
||||
feed.Next(ref isNew);
|
||||
|
||||
// Now at end
|
||||
isNew = true;
|
||||
var bar = feed.Next(ref isNew);
|
||||
Assert.False(isNew);
|
||||
Assert.Equal(100.0, bar.Close); // Returns last bar
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_DefaultParameter_IsNewTrue()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
var bar1 = feed.Next(); // Default isNew = true
|
||||
var bar2 = feed.Next(); // Default isNew = true
|
||||
Assert.True(bar2.Time > bar1.Time);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fetch Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Fetch_ReturnsCorrectNumberOfBars()
|
||||
{
|
||||
@@ -124,15 +358,25 @@ public class CsvFeedTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_InvalidCount_ThrowsArgumentException()
|
||||
public void Fetch_ZeroCount_ThrowsArgumentException()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
var startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromDays(1);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => feed.Fetch(0, startTime, interval));
|
||||
Assert.Throws<ArgumentException>(() => feed.Fetch(-1, startTime, interval));
|
||||
var ex = Assert.Throws<ArgumentException>(() => feed.Fetch(0, startTime, interval));
|
||||
Assert.Equal("count", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_NegativeCount_ThrowsArgumentException()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
var startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromDays(1);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => feed.Fetch(-1, startTime, interval));
|
||||
Assert.Equal("count", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -154,6 +398,174 @@ public class CsvFeedTests
|
||||
Assert.True(bar.Time >= startTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_ResetsHasCurrentBar()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
feed.Next(isNew: true);
|
||||
Assert.True(feed.HasCurrentBar);
|
||||
|
||||
var startTime = new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
||||
feed.Fetch(5, startTime, TimeSpan.FromDays(1));
|
||||
|
||||
Assert.False(feed.HasCurrentBar);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Reset_ReturnsToStart()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
// Advance several bars
|
||||
var firstBar = feed.Next(isNew: true);
|
||||
feed.Next(isNew: true);
|
||||
feed.Next(isNew: true);
|
||||
Assert.Equal(3, feed.CurrentIndex);
|
||||
|
||||
// Reset
|
||||
feed.Reset();
|
||||
|
||||
Assert.Equal(0, feed.CurrentIndex);
|
||||
Assert.True(feed.HasMore);
|
||||
Assert.False(feed.HasCurrentBar);
|
||||
|
||||
// Next bar should be first bar again
|
||||
var afterReset = feed.Next(isNew: true);
|
||||
Assert.Equal(firstBar.Time, afterReset.Time);
|
||||
Assert.Equal(firstBar.Close, afterReset.Close);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_WithIndex_SetsCorrectPosition()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
// Reset to middle
|
||||
int targetIndex = 50;
|
||||
feed.Reset(targetIndex);
|
||||
|
||||
Assert.Equal(targetIndex, feed.CurrentIndex);
|
||||
Assert.False(feed.HasCurrentBar);
|
||||
|
||||
// Next bar should be at that index
|
||||
var bar = feed.Next(isNew: true);
|
||||
var expectedBar = feed.GetBar(targetIndex);
|
||||
Assert.Equal(expectedBar.Time, bar.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_WithNegativeIndex_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => feed.Reset(-1));
|
||||
Assert.Equal("index", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_WithIndexBeyondCount_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => feed.Reset(feed.Count + 1));
|
||||
Assert.Equal("index", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_WithIndexAtCount_IsValid()
|
||||
{
|
||||
// Resetting to exactly Count means "at end" - valid but no more data
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
feed.Reset(feed.Count);
|
||||
|
||||
Assert.Equal(feed.Count, feed.CurrentIndex);
|
||||
Assert.False(feed.HasMore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetBar Method Tests
|
||||
|
||||
[Fact]
|
||||
public void GetBar_ReturnsCorrectBar()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
// Get bar without affecting streaming
|
||||
var bar0 = feed.GetBar(0);
|
||||
var bar1 = feed.GetBar(1);
|
||||
|
||||
// Streaming position unchanged
|
||||
Assert.Equal(0, feed.CurrentIndex);
|
||||
|
||||
// Bars should be in chronological order
|
||||
Assert.True(bar1.Time > bar0.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBar_NegativeIndex_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => feed.GetBar(-1));
|
||||
Assert.Equal("index", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBar_IndexAtCount_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => feed.GetBar(feed.Count));
|
||||
Assert.Equal("index", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBar_DoesNotAffectStreaming()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
// Stream first bar
|
||||
var streamed = feed.Next(isNew: true);
|
||||
int indexAfter = feed.CurrentIndex;
|
||||
|
||||
// Random access
|
||||
var bar50 = feed.GetBar(50);
|
||||
Assert.True(bar50.Time > 0);
|
||||
|
||||
// Streaming position unchanged
|
||||
Assert.Equal(indexAfter, feed.CurrentIndex);
|
||||
|
||||
// Continue streaming
|
||||
var next = feed.Next(isNew: true);
|
||||
Assert.True(next.Time > streamed.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBar_ConsistentWithNext()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
// Get bars via random access
|
||||
var bar0 = feed.GetBar(0);
|
||||
var bar1 = feed.GetBar(1);
|
||||
var bar2 = feed.GetBar(2);
|
||||
|
||||
// Get same bars via streaming
|
||||
var streamed0 = feed.Next(isNew: true);
|
||||
var streamed1 = feed.Next(isNew: true);
|
||||
var streamed2 = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(bar0.Time, streamed0.Time);
|
||||
Assert.Equal(bar1.Time, streamed1.Time);
|
||||
Assert.Equal(bar2.Time, streamed2.Time);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region OHLCV Validation Tests
|
||||
|
||||
[Fact]
|
||||
public void LoadFromCsv_ParsesValuesCorrectly()
|
||||
{
|
||||
@@ -190,10 +602,91 @@ public class CsvFeedTests
|
||||
for (int i = 1; i < bars.Count; i++)
|
||||
{
|
||||
Assert.True(bars[i].Time > bars[i - 1].Time,
|
||||
$"Bar {i} time ({bars[i].AsDateTime}) should be after bar {i-1} time ({bars[i-1].AsDateTime})");
|
||||
$"Bar {i} time ({bars[i].AsDateTime}) should be after bar {i - 1} time ({bars[i - 1].AsDateTime})");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromCsv_AllBarsHaveValidOHLCV()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
for (int i = 0; i < feed.Count; i++)
|
||||
{
|
||||
var bar = feed.GetBar(i);
|
||||
|
||||
Assert.True(double.IsFinite(bar.Open), $"Bar {i} has non-finite Open");
|
||||
Assert.True(double.IsFinite(bar.High), $"Bar {i} has non-finite High");
|
||||
Assert.True(double.IsFinite(bar.Low), $"Bar {i} has non-finite Low");
|
||||
Assert.True(double.IsFinite(bar.Close), $"Bar {i} has non-finite Close");
|
||||
Assert.True(double.IsFinite(bar.Volume), $"Bar {i} has non-finite Volume");
|
||||
|
||||
Assert.True(bar.High >= bar.Low, $"Bar {i}: High ({bar.High}) < Low ({bar.Low})");
|
||||
Assert.True(bar.High >= bar.Open, $"Bar {i}: High ({bar.High}) < Open ({bar.Open})");
|
||||
Assert.True(bar.High >= bar.Close, $"Bar {i}: High ({bar.High}) < Close ({bar.Close})");
|
||||
Assert.True(bar.Low <= bar.Open, $"Bar {i}: Low ({bar.Low}) > Open ({bar.Open})");
|
||||
Assert.True(bar.Low <= bar.Close, $"Bar {i}: Low ({bar.Low}) > Close ({bar.Close})");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromCsv_ParsesDecimalsCorrectly()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,100.1234,101.5678,99.9999,100.0001,1234567.89"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var bar = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(100.1234, bar.Open, precision: 4);
|
||||
Assert.Equal(101.5678, bar.High, precision: 4);
|
||||
Assert.Equal(99.9999, bar.Low, precision: 4);
|
||||
Assert.Equal(100.0001, bar.Close, precision: 4);
|
||||
Assert.Equal(1234567.89, bar.Volume, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromCsv_ParsesNegativeValues()
|
||||
{
|
||||
// While negative prices are unusual, the parser should handle them
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,-100,50,-150,-50,1000"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var bar = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(-100, bar.Open);
|
||||
Assert.Equal(50, bar.High);
|
||||
Assert.Equal(-150, bar.Low);
|
||||
Assert.Equal(-50, bar.Close);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromCsv_ParsesScientificNotation()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,1.5e2,2e2,1e2,1.75e2,1e6"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var bar = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(150, bar.Open);
|
||||
Assert.Equal(200, bar.High);
|
||||
Assert.Equal(100, bar.Low);
|
||||
Assert.Equal(175, bar.Close);
|
||||
Assert.Equal(1000000, bar.Volume);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IFeed Interface Tests
|
||||
|
||||
[Fact]
|
||||
public void CsvFeed_WorksWithIFeedInterface()
|
||||
{
|
||||
@@ -206,6 +699,35 @@ public class CsvFeedTests
|
||||
Assert.True(bar2.Time > bar1.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CsvFeed_IFeedRefOverload()
|
||||
{
|
||||
IFeed feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
bool isNew = true;
|
||||
var bar1 = feed.Next(ref isNew);
|
||||
Assert.True(bar1.Time > 0);
|
||||
|
||||
isNew = false;
|
||||
var bar1Update = feed.Next(ref isNew);
|
||||
Assert.Equal(bar1.Time, bar1Update.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CsvFeed_IFeedFetch()
|
||||
{
|
||||
IFeed feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
var startTime = new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
||||
var series = feed.Fetch(5, startTime, TimeSpan.FromDays(1));
|
||||
|
||||
Assert.True(series.Count > 0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Case Tests
|
||||
|
||||
[Fact]
|
||||
public void Next_MixedNewAndUpdate_WorksCorrectly()
|
||||
{
|
||||
@@ -248,49 +770,160 @@ public class CsvFeedTests
|
||||
var series = feed.Fetch(5, startTime, TimeSpan.FromDays(1));
|
||||
|
||||
// Should return empty or minimal data
|
||||
Assert.True(series.Count == 0);
|
||||
Assert.Empty(series);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_HandlesGapsCorrectly()
|
||||
{
|
||||
string tempCsv = Path.GetTempFileName() + ".csv";
|
||||
try
|
||||
// Create CSV with gaps using helper
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
// Create CSV with gaps
|
||||
// Date, Open, High, Low, Close, Volume
|
||||
// 2023-01-01 (Sunday)
|
||||
// 2023-01-02 (Monday)
|
||||
// 2023-01-04 (Wednesday) - Gap of Tuesday
|
||||
// 2023-01-05 (Thursday)
|
||||
var lines = new[]
|
||||
{
|
||||
"Date,Open,High,Low,Close,Volume",
|
||||
"2023-01-05,103,104,102,103,1000",
|
||||
"2023-01-04,102,103,101,102,1000",
|
||||
"2023-01-02,101,102,100,101,1000",
|
||||
"2023-01-01,100,101,99,100,1000"
|
||||
};
|
||||
File.WriteAllLines(tempCsv, lines);
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-05,103,104,102,103,1000",
|
||||
"2023-01-04,102,103,101,102,1000",
|
||||
"2023-01-02,101,102,100,101,1000",
|
||||
"2023-01-01,100,101,99,100,1000"
|
||||
});
|
||||
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var startTime = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
||||
var interval = TimeSpan.FromDays(1);
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var startTime = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
||||
var interval = TimeSpan.FromDays(1);
|
||||
|
||||
// Fetch 5 bars. Should get 4 bars (Jan 1, 2, 4, 5).
|
||||
var series = feed.Fetch(10, startTime, interval);
|
||||
// Fetch bars. Should get 4 bars (Jan 1, 2, 4, 5).
|
||||
var series = feed.Fetch(10, startTime, interval);
|
||||
|
||||
Assert.Equal(4, series.Count);
|
||||
Assert.Equal(startTime, series[0].Time); // Jan 1
|
||||
Assert.Equal(startTime + interval.Ticks, series[1].Time); // Jan 2
|
||||
// Gap here
|
||||
Assert.Equal(startTime + 3 * interval.Ticks, series[2].Time); // Jan 4
|
||||
Assert.Equal(startTime + 4 * interval.Ticks, series[3].Time); // Jan 5
|
||||
Assert.Equal(4, series.Count);
|
||||
Assert.Equal(startTime, series[0].Time); // Jan 1
|
||||
Assert.Equal(startTime + interval.Ticks, series[1].Time); // Jan 2
|
||||
// Gap here (Jan 3 missing)
|
||||
Assert.Equal(startTime + 3 * interval.Ticks, series[2].Time); // Jan 4
|
||||
Assert.Equal(startTime + 4 * interval.Ticks, series[3].Time); // Jan 5
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleBar_StreamsAndEnds()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,100,101,99,100,1000"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
|
||||
Assert.Equal(1, feed.Count);
|
||||
Assert.True(feed.HasMore);
|
||||
|
||||
bool isNew = true;
|
||||
var bar = feed.Next(ref isNew);
|
||||
Assert.True(isNew);
|
||||
Assert.Equal(100.0, bar.Close);
|
||||
Assert.False(feed.HasMore);
|
||||
|
||||
// Try to get next
|
||||
isNew = true;
|
||||
var noMore = feed.Next(ref isNew);
|
||||
Assert.False(isNew); // Signals end
|
||||
Assert.Equal(bar.Time, noMore.Time); // Returns last bar
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhitespaceInValues_Trimmed()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
" 2023-01-01 , 100 , 101 , 99 , 100 , 1000 "
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var bar = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(100.0, bar.Open);
|
||||
Assert.Equal(101.0, bar.High);
|
||||
Assert.Equal(99.0, bar.Low);
|
||||
Assert.Equal(100.0, bar.Close);
|
||||
Assert.Equal(1000.0, bar.Volume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroValues_Accepted()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,0,0,0,0,0"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var bar = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(0.0, bar.Open);
|
||||
Assert.Equal(0.0, bar.High);
|
||||
Assert.Equal(0.0, bar.Low);
|
||||
Assert.Equal(0.0, bar.Close);
|
||||
Assert.Equal(0.0, bar.Volume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VeryLargeValues_Parsed()
|
||||
{
|
||||
string tempCsv = CreateTempCsv(new[]
|
||||
{
|
||||
"timestamp,open,high,low,close,volume",
|
||||
"2023-01-01,999999999.99,1000000000.01,999999999.00,999999999.50,9999999999999"
|
||||
});
|
||||
var feed = new CsvFeed(tempCsv);
|
||||
var bar = feed.Next(isNew: true);
|
||||
|
||||
Assert.Equal(999999999.99, bar.Open, precision: 2);
|
||||
Assert.Equal(1000000000.01, bar.High, precision: 2);
|
||||
Assert.Equal(999999999.00, bar.Low, precision: 2);
|
||||
Assert.Equal(999999999.50, bar.Close, precision: 2);
|
||||
Assert.Equal(9999999999999.0, bar.Volume, precision: 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsecutiveResets_WorkCorrectly()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
feed.Next(isNew: true);
|
||||
feed.Next(isNew: true);
|
||||
feed.Reset();
|
||||
feed.Reset();
|
||||
feed.Reset();
|
||||
|
||||
Assert.Equal(0, feed.CurrentIndex);
|
||||
Assert.False(feed.HasCurrentBar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamThenResetThenStream_Consistent()
|
||||
{
|
||||
var feed = new CsvFeed(TestCsvPath);
|
||||
|
||||
// First pass
|
||||
var firstPass = new List<double>();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
firstPass.Add(feed.Next(isNew: true).Close);
|
||||
}
|
||||
finally
|
||||
|
||||
// Reset
|
||||
feed.Reset();
|
||||
|
||||
// Second pass
|
||||
var secondPass = new List<double>();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
if (File.Exists(tempCsv))
|
||||
File.Delete(tempCsv);
|
||||
secondPass.Add(feed.Next(isNew: true).Close);
|
||||
}
|
||||
|
||||
// Should be identical
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
Assert.Equal(firstPass[i], secondPass[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
+258
-38
@@ -1,28 +1,80 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed OHLCV data from a CSV line.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
internal readonly record struct ParsedOhlcv(long Time, double Open, double High, double Low, double Close, double Volume);
|
||||
|
||||
/// <summary>
|
||||
/// Mutable state for parsing OHLCV columns. Used as ref parameter to reduce method signature size.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
internal ref struct OhlcvParseState
|
||||
{
|
||||
public long Time;
|
||||
public double Open;
|
||||
public double High;
|
||||
public double Low;
|
||||
public double Close;
|
||||
public double Volume;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CSV file feed for loading historical OHLCV data.
|
||||
/// Loads data in constructor and streams through it with Next() or returns batches with Fetch().
|
||||
/// CSV format: timestamp,open,high,low,close,volume (header required)
|
||||
/// Timestamp format: YYYY-MM-DD (UTC midnight assumed)
|
||||
/// </summary>
|
||||
public class CsvFeed : IFeed
|
||||
[SkipLocalsInit]
|
||||
public sealed class CsvFeed : IFeed
|
||||
{
|
||||
private readonly TBarSeries _data;
|
||||
private readonly string _filePath;
|
||||
|
||||
// Streaming state
|
||||
private int _currentIndex;
|
||||
private TBar _currentBar;
|
||||
private bool _hasCurrentBar;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of bars available in the CSV file.
|
||||
/// </summary>
|
||||
public int Count => _data.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file path of the loaded CSV.
|
||||
/// </summary>
|
||||
public string FilePath => _filePath;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are more bars to stream.
|
||||
/// </summary>
|
||||
public bool HasMore => _currentIndex < _data.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current streaming position (0-based index).
|
||||
/// </summary>
|
||||
public int CurrentIndex => _currentIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the feed has a current bar in progress.
|
||||
/// </summary>
|
||||
public bool HasCurrentBar => _hasCurrentBar;
|
||||
|
||||
/// <summary>
|
||||
/// Loads CSV file and prepares data for streaming.
|
||||
/// Data is reversed to chronological order (oldest first).
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to CSV file</param>
|
||||
/// <exception cref="ArgumentException">Thrown when filePath is null or empty</exception>
|
||||
/// <exception cref="FileNotFoundException">Thrown when the specified file does not exist</exception>
|
||||
/// <exception cref="InvalidDataException">Thrown when CSV file is empty or contains only header</exception>
|
||||
/// <exception cref="FormatException">Thrown when CSV format is invalid</exception>
|
||||
public CsvFeed(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
@@ -31,6 +83,7 @@ public class CsvFeed : IFeed
|
||||
if (!File.Exists(filePath))
|
||||
throw new FileNotFoundException($"CSV file not found: {filePath}", filePath);
|
||||
|
||||
_filePath = filePath;
|
||||
_data = LoadFromCsv(filePath);
|
||||
_currentIndex = 0;
|
||||
}
|
||||
@@ -65,37 +118,131 @@ public class CsvFeed : IFeed
|
||||
|
||||
var series = new TBarSeries(dataLines.Count);
|
||||
|
||||
// Pre-allocate arrays for bulk loading (SoA layout)
|
||||
long[] t = new long[dataLines.Count];
|
||||
double[] o = new double[dataLines.Count];
|
||||
double[] h = new double[dataLines.Count];
|
||||
double[] l = new double[dataLines.Count];
|
||||
double[] c = new double[dataLines.Count];
|
||||
double[] v = new double[dataLines.Count];
|
||||
|
||||
for (int i = 0; i < dataLines.Count; i++)
|
||||
{
|
||||
var line = dataLines[i];
|
||||
|
||||
var parts = line.Split(',');
|
||||
int originalLineNumber = dataLines.Count - i + 1;
|
||||
if (parts.Length != 6)
|
||||
throw new FormatException($"Invalid CSV format at line {originalLineNumber}. Expected 6 columns, found {parts.Length}");
|
||||
|
||||
// Parse timestamp (YYYY-MM-DD format, assume UTC midnight)
|
||||
if (!DateTime.TryParseExact(parts[0].Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var timestamp))
|
||||
{
|
||||
throw new FormatException($"Failed to parse timestamp at line {originalLineNumber}: {line}");
|
||||
}
|
||||
|
||||
// Parse OHLCV values
|
||||
if (!double.TryParse(parts[1].Trim(), CultureInfo.InvariantCulture, out double open) ||
|
||||
!double.TryParse(parts[2].Trim(), CultureInfo.InvariantCulture, out double high) ||
|
||||
!double.TryParse(parts[3].Trim(), CultureInfo.InvariantCulture, out double low) ||
|
||||
!double.TryParse(parts[4].Trim(), CultureInfo.InvariantCulture, out double close) ||
|
||||
!double.TryParse(parts[5].Trim(), CultureInfo.InvariantCulture, out double volume))
|
||||
{
|
||||
throw new FormatException($"Failed to parse CSV line {originalLineNumber}: {line}");
|
||||
}
|
||||
|
||||
series.Add(timestamp, open, high, low, close, volume, isNew: true);
|
||||
var parsed = ParseCsvLine(line, originalLineNumber);
|
||||
t[i] = parsed.Time;
|
||||
o[i] = parsed.Open;
|
||||
h[i] = parsed.High;
|
||||
l[i] = parsed.Low;
|
||||
c[i] = parsed.Close;
|
||||
v[i] = parsed.Volume;
|
||||
}
|
||||
|
||||
// Bulk add to series
|
||||
series.Add(t, o, h, l, c, v);
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single CSV line into OHLCV components.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static ParsedOhlcv ParseCsvLine(string line, int lineNumber)
|
||||
{
|
||||
// Use Span-based splitting for reduced allocations
|
||||
ReadOnlySpan<char> lineSpan = line.AsSpan();
|
||||
|
||||
int col = 0;
|
||||
int start = 0;
|
||||
OhlcvParseState state = default;
|
||||
|
||||
for (int i = 0; i < lineSpan.Length; i++)
|
||||
{
|
||||
if (lineSpan[i] == ',')
|
||||
{
|
||||
var segment = lineSpan[start..i].Trim();
|
||||
ParseColumn(segment, col, lineNumber, line, ref state);
|
||||
col++;
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Process the last segment after the final comma
|
||||
if (start <= lineSpan.Length)
|
||||
{
|
||||
var segment = lineSpan[start..].Trim();
|
||||
ParseColumn(segment, col, lineNumber, line, ref state);
|
||||
col++;
|
||||
}
|
||||
|
||||
if (col != 6)
|
||||
{
|
||||
throw new FormatException($"Invalid CSV format at line {lineNumber}. Expected 6 columns, found {col}");
|
||||
}
|
||||
|
||||
return new ParsedOhlcv(state.Time, state.Open, state.High, state.Low, state.Close, state.Volume);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single column value into the appropriate OHLCV field.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void ParseColumn(
|
||||
ReadOnlySpan<char> segment,
|
||||
int col,
|
||||
int lineNumber,
|
||||
string line,
|
||||
ref OhlcvParseState state)
|
||||
{
|
||||
switch (col)
|
||||
{
|
||||
case 0: // Timestamp
|
||||
if (!DateTime.TryParseExact(segment, "yyyy-MM-dd", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var timestamp))
|
||||
{
|
||||
throw new FormatException($"Failed to parse timestamp at line {lineNumber}: {line}");
|
||||
}
|
||||
state.Time = timestamp.Ticks;
|
||||
break;
|
||||
case 1: // Open
|
||||
if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.Open))
|
||||
{
|
||||
throw new FormatException($"Failed to parse open price at line {lineNumber}: {line}");
|
||||
}
|
||||
break;
|
||||
case 2: // High
|
||||
if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.High))
|
||||
{
|
||||
throw new FormatException($"Failed to parse high price at line {lineNumber}: {line}");
|
||||
}
|
||||
break;
|
||||
case 3: // Low
|
||||
if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.Low))
|
||||
{
|
||||
throw new FormatException($"Failed to parse low price at line {lineNumber}: {line}");
|
||||
}
|
||||
break;
|
||||
case 4: // Close
|
||||
if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.Close))
|
||||
{
|
||||
throw new FormatException($"Failed to parse close price at line {lineNumber}: {line}");
|
||||
}
|
||||
break;
|
||||
case 5: // Volume
|
||||
if (!double.TryParse(segment, NumberStyles.Float, CultureInfo.InvariantCulture, out state.Volume))
|
||||
{
|
||||
throw new FormatException($"Failed to parse volume at line {lineNumber}: {line}");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Extra columns are ignored - this handles the default case requirement
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next bar with full bidirectional control.
|
||||
/// When end of data reached, returns last bar and sets isNew=false.
|
||||
@@ -123,11 +270,8 @@ public class CsvFeed : IFeed
|
||||
_currentIndex++;
|
||||
_hasCurrentBar = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update current bar - CSV has no intra-bar updates, return same bar
|
||||
// No change to _currentBar or _currentIndex
|
||||
}
|
||||
// else: Update current bar - CSV has no intra-bar updates, return same bar
|
||||
// No change to _currentBar or _currentIndex
|
||||
|
||||
return _currentBar;
|
||||
}
|
||||
@@ -140,10 +284,16 @@ public class CsvFeed : IFeed
|
||||
{
|
||||
return Next(ref isNew);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a filtered subset of data matching the criteria.
|
||||
/// Resets streaming position to start of returned data.
|
||||
/// </summary>
|
||||
/// <param name="count">Number of bars to retrieve (must be positive)</param>
|
||||
/// <param name="startTime">Starting timestamp in ticks</param>
|
||||
/// <param name="interval">Time interval between bars</param>
|
||||
/// <returns>A TBarSeries containing the matched bars</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when count is not positive</exception>
|
||||
public TBarSeries Fetch(int count, long startTime, TimeSpan interval)
|
||||
{
|
||||
if (count <= 0)
|
||||
@@ -151,16 +301,8 @@ public class CsvFeed : IFeed
|
||||
|
||||
var result = new TBarSeries(count);
|
||||
|
||||
// Find starting index
|
||||
int startIndex = -1;
|
||||
for (int i = 0; i < _data.Count; i++)
|
||||
{
|
||||
if (_data[i].Time >= startTime)
|
||||
{
|
||||
startIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Find starting index using binary search for better performance
|
||||
int startIndex = FindStartIndex(startTime);
|
||||
|
||||
if (startIndex == -1)
|
||||
return result;
|
||||
@@ -168,6 +310,7 @@ public class CsvFeed : IFeed
|
||||
// Collect bars matching interval
|
||||
long expectedTime = startTime;
|
||||
int collected = 0;
|
||||
long tolerance = interval.Ticks / 2; // Allow 50% tolerance
|
||||
|
||||
for (int i = startIndex; i < _data.Count && collected < count; i++)
|
||||
{
|
||||
@@ -175,7 +318,6 @@ public class CsvFeed : IFeed
|
||||
|
||||
// Check if bar time matches expected time (within tolerance)
|
||||
long timeDiff = Math.Abs(bar.Time - expectedTime);
|
||||
long tolerance = interval.Ticks / 2; // Allow 50% tolerance
|
||||
|
||||
if (timeDiff <= tolerance)
|
||||
{
|
||||
@@ -204,4 +346,82 @@ public class CsvFeed : IFeed
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the starting index for the given start time using binary search.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private int FindStartIndex(long startTime)
|
||||
{
|
||||
if (_data.Count == 0)
|
||||
return -1;
|
||||
|
||||
// If startTime is before first bar, return 0
|
||||
if (_data[0].Time >= startTime)
|
||||
return 0;
|
||||
|
||||
// If startTime is after last bar, return -1
|
||||
if (_data[_data.Count - 1].Time < startTime)
|
||||
return -1;
|
||||
|
||||
// Binary search for the first bar >= startTime
|
||||
int left = 0;
|
||||
int right = _data.Count - 1;
|
||||
|
||||
while (left < right)
|
||||
{
|
||||
int mid = left + (right - left) / 2;
|
||||
|
||||
if (_data[mid].Time < startTime)
|
||||
left = mid + 1;
|
||||
else
|
||||
right = mid;
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the streaming position to the beginning.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_currentIndex = 0;
|
||||
_hasCurrentBar = false;
|
||||
_currentBar = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the streaming position to a specific index.
|
||||
/// </summary>
|
||||
/// <param name="index">The index to reset to (must be valid)</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when index is out of range</exception>
|
||||
public void Reset(int index)
|
||||
{
|
||||
if (index < 0 || index > _data.Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(index), index, $"Index must be between 0 and {_data.Count}");
|
||||
|
||||
_currentIndex = index;
|
||||
_hasCurrentBar = false;
|
||||
_currentBar = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bar at the specified index without affecting streaming position.
|
||||
/// </summary>
|
||||
/// <param name="index">The index of the bar to retrieve</param>
|
||||
/// <returns>The bar at the specified index</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when index is out of range</exception>
|
||||
public TBar GetBar(int index)
|
||||
{
|
||||
if (index < 0 || index >= _data.Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(index), index, $"Index must be between 0 and {_data.Count - 1}");
|
||||
|
||||
return _data[index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying data series (read-only access).
|
||||
/// </summary>
|
||||
public TBarSeries Data => _data;
|
||||
}
|
||||
|
||||
+476
-56
@@ -1,12 +1,121 @@
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class GBMTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultParameters_CreatesValidInstance()
|
||||
{
|
||||
var gbm = new GBM();
|
||||
|
||||
Assert.Equal(100.0, gbm.StartPrice);
|
||||
Assert.Equal(0.05, gbm.Mu);
|
||||
Assert.Equal(0.2, gbm.Sigma);
|
||||
Assert.Equal(100.0, gbm.CurrentPrice);
|
||||
Assert.False(gbm.HasCurrentBar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CustomParameters_SetsCorrectly()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 50.0, mu: 0.1, sigma: 0.3, seed: 42);
|
||||
|
||||
Assert.Equal(50.0, gbm.StartPrice);
|
||||
Assert.Equal(0.1, gbm.Mu);
|
||||
Assert.Equal(0.3, gbm.Sigma);
|
||||
Assert.Equal(50.0, gbm.CurrentPrice);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(-100)]
|
||||
public void Constructor_InvalidStartPrice_ThrowsArgumentOutOfRangeException(double startPrice)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(startPrice: startPrice));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NaNStartPrice_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(startPrice: double.NaN));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InfinityStartPrice_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(startPrice: double.PositiveInfinity));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(startPrice: double.NegativeInfinity));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-0.01)]
|
||||
[InlineData(-1)]
|
||||
public void Constructor_NegativeSigma_ThrowsArgumentOutOfRangeException(double sigma)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(sigma: sigma));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NaNSigma_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(sigma: double.NaN));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InfinitySigma_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(sigma: double.PositiveInfinity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NaNMu_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(mu: double.NaN));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InfinityMu_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(mu: double.PositiveInfinity));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(mu: double.NegativeInfinity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroTimeframe_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(defaultTimeframe: TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeTimeframe_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GBM(defaultTimeframe: TimeSpan.FromMinutes(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroSigma_IsValid()
|
||||
{
|
||||
var gbm = new GBM(sigma: 0);
|
||||
Assert.Equal(0, gbm.Sigma);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeMu_IsValid()
|
||||
{
|
||||
var gbm = new GBM(mu: -0.1);
|
||||
Assert.Equal(-0.1, gbm.Mu);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Next Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Next_DefaultParameter_GeneratesNewBar()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var bar1 = gbm.Next();
|
||||
var bar2 = gbm.Next();
|
||||
@@ -18,7 +127,7 @@ public class GBMTests
|
||||
[Fact]
|
||||
public void Next_IsNewTrue_AdvancesToNewBar()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var bar1 = gbm.Next(isNew: true);
|
||||
var bar2 = gbm.Next(isNew: true);
|
||||
@@ -30,7 +139,7 @@ public class GBMTests
|
||||
[Fact]
|
||||
public void Next_IsNewFalse_UpdatesCurrentBar()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var bar1 = gbm.Next(isNew: true);
|
||||
long initialTime = bar1.Time;
|
||||
@@ -38,16 +147,15 @@ public class GBMTests
|
||||
var bar2 = gbm.Next(isNew: false);
|
||||
|
||||
Assert.Equal(initialTime, bar2.Time);
|
||||
// Price likely changed (GBM random walk)
|
||||
Assert.NotEqual(bar1.Close, bar2.Close);
|
||||
Assert.Equal(bar1.Open, bar2.Open);
|
||||
// High/Low/Close/Volume may change
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_RefBool_HonorsRequest()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
// GBM always honors isNew - parameter should remain unchanged
|
||||
bool isNew1 = true;
|
||||
var bar1 = gbm.Next(ref isNew1);
|
||||
Assert.True(isNew1, "GBM should honor isNew=true request");
|
||||
@@ -64,10 +172,58 @@ public class GBMTests
|
||||
Assert.NotEqual(time1, bar3.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_FirstCallWithIsNewFalse_GeneratesBar()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
// First call with isNew=false should still generate a bar
|
||||
var bar = gbm.Next(isNew: false);
|
||||
|
||||
Assert.True(bar.Time > 0);
|
||||
Assert.True(bar.Open > 0);
|
||||
Assert.True(gbm.HasCurrentBar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_MultipleUpdates_AccumulatesVolume()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var bar1 = gbm.Next(isNew: true);
|
||||
double initialVolume = bar1.Volume;
|
||||
|
||||
var bar2 = gbm.Next(isNew: false);
|
||||
|
||||
Assert.True(bar2.Volume > initialVolume, "Volume should accumulate on intra-bar updates");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_IntraBarUpdates_ExpandsHighLow()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, sigma: 0.5, seed: 42);
|
||||
|
||||
var bar1 = gbm.Next(isNew: true);
|
||||
double initialHigh = bar1.High;
|
||||
double initialLow = bar1.Low;
|
||||
|
||||
// Multiple updates should potentially expand the range
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: false);
|
||||
Assert.True(bar.High >= initialHigh || bar.Low <= initialLow || i > 50,
|
||||
"High-Low range should expand or stay same with updates");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fetch Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Fetch_GeneratesCorrectCount()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
int count = 10;
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
@@ -80,13 +236,12 @@ public class GBMTests
|
||||
[Fact]
|
||||
public void Fetch_GeneratesSequentialBars()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
var series = gbm.Fetch(5, startTime, interval);
|
||||
|
||||
// Verify time sequence
|
||||
for (int i = 1; i < series.Count; i++)
|
||||
{
|
||||
Assert.True(series[i].Time > series[i - 1].Time);
|
||||
@@ -96,13 +251,12 @@ public class GBMTests
|
||||
[Fact]
|
||||
public void Fetch_RespectsInterval()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
var interval = TimeSpan.FromHours(1);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
|
||||
var series = gbm.Fetch(5, startTime, interval);
|
||||
|
||||
// Verify interval spacing
|
||||
for (int i = 1; i < series.Count; i++)
|
||||
{
|
||||
long expectedDiff = interval.Ticks;
|
||||
@@ -114,7 +268,7 @@ public class GBMTests
|
||||
[Fact]
|
||||
public void Fetch_StartsAtSpecifiedTime()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
var startTime = new DateTime(2024, 1, 1, 9, 30, 0, DateTimeKind.Utc).Ticks;
|
||||
var interval = TimeSpan.FromMinutes(5);
|
||||
|
||||
@@ -125,13 +279,43 @@ public class GBMTests
|
||||
Assert.Equal(startTime + 2 * interval.Ticks, series[2].Time);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(-100)]
|
||||
public void Fetch_InvalidCount_ThrowsArgumentException(int count)
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => gbm.Fetch(count, startTime, interval));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_WithDifferentIntervals_WorksCorrectly()
|
||||
public void Fetch_ZeroInterval_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
|
||||
// Test different intervals
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => gbm.Fetch(10, startTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_NegativeInterval_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => gbm.Fetch(10, startTime, TimeSpan.FromMinutes(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_WithDifferentIntervals_WorksCorrectly()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
|
||||
var intervals = new[] {
|
||||
TimeSpan.FromMinutes(1),
|
||||
TimeSpan.FromMinutes(5),
|
||||
@@ -142,7 +326,6 @@ public class GBMTests
|
||||
{
|
||||
var series = gbm.Fetch(3, startTime, interval);
|
||||
|
||||
// Verify spacing
|
||||
for (int i = 1; i < series.Count; i++)
|
||||
{
|
||||
long expectedDiff = interval.Ticks;
|
||||
@@ -153,44 +336,276 @@ public class GBMTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeneratesRealisticOHLCV()
|
||||
public void Fetch_LargeCount_WorksCorrectly()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
var series = gbm.Fetch(10, startTime, interval);
|
||||
|
||||
var series = gbm.Fetch(10000, startTime, interval);
|
||||
|
||||
Assert.Equal(10000, series.Count);
|
||||
Assert.All(Enumerable.Range(0, series.Count), i =>
|
||||
{
|
||||
Assert.True(series[i].Open > 0);
|
||||
Assert.True(series[i].High > 0);
|
||||
Assert.True(series[i].Low > 0);
|
||||
Assert.True(series[i].Close > 0);
|
||||
Assert.True(series[i].Volume > 0);
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region OHLCV Validity Tests
|
||||
|
||||
[Fact]
|
||||
public void GeneratesRealisticOHLCV()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
var series = gbm.Fetch(100, startTime, interval);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
var bar = series[i];
|
||||
|
||||
// High should be >= max(Open, Close)
|
||||
Assert.True(bar.High >= Math.Max(bar.Open, bar.Close));
|
||||
Assert.True(bar.High >= Math.Max(bar.Open, bar.Close),
|
||||
$"Bar {i}: High ({bar.High}) should be >= max(Open, Close) ({Math.Max(bar.Open, bar.Close)})");
|
||||
|
||||
// Low should be <= min(Open, Close)
|
||||
Assert.True(bar.Low <= Math.Min(bar.Open, bar.Close));
|
||||
Assert.True(bar.Low <= Math.Min(bar.Open, bar.Close),
|
||||
$"Bar {i}: Low ({bar.Low}) should be <= min(Open, Close) ({Math.Min(bar.Open, bar.Close)})");
|
||||
|
||||
// High should be >= Low
|
||||
Assert.True(bar.High >= bar.Low,
|
||||
$"Bar {i}: High ({bar.High}) should be >= Low ({bar.Low})");
|
||||
|
||||
// Volume should be positive
|
||||
Assert.True(bar.Volume > 0);
|
||||
Assert.True(bar.Volume > 0, $"Bar {i}: Volume should be positive");
|
||||
|
||||
// All prices should be positive
|
||||
Assert.True(bar.Open > 0);
|
||||
Assert.True(bar.High > 0);
|
||||
Assert.True(bar.Low > 0);
|
||||
Assert.True(bar.Close > 0);
|
||||
// All prices should be positive and finite
|
||||
Assert.True(double.IsFinite(bar.Open) && bar.Open > 0, $"Bar {i}: Open should be positive and finite");
|
||||
Assert.True(double.IsFinite(bar.High) && bar.High > 0, $"Bar {i}: High should be positive and finite");
|
||||
Assert.True(double.IsFinite(bar.Low) && bar.Low > 0, $"Bar {i}: Low should be positive and finite");
|
||||
Assert.True(double.IsFinite(bar.Close) && bar.Close > 0, $"Bar {i}: Close should be positive and finite");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsecutiveCalls_MaintainContinuity()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var previousBar = gbm.Next();
|
||||
var currentBar = gbm.Next();
|
||||
|
||||
// currentBar.Open should equal previousBar.Close (continuity)
|
||||
Assert.Equal(previousBar.Close, currentBar.Open);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fetch_MaintainsContinuity()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
var series = gbm.Fetch(10, startTime, interval);
|
||||
|
||||
for (int i = 1; i < series.Count; i++)
|
||||
{
|
||||
Assert.True(Math.Abs(series[i - 1].Close - series[i].Open) < 1e-10,
|
||||
$"Bar {i}: Open should equal previous bar's Close for continuity");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset Tests
|
||||
|
||||
[Fact]
|
||||
public void Reset_RestoresInitialState()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
// Generate some bars
|
||||
gbm.Next();
|
||||
gbm.Next();
|
||||
gbm.Next();
|
||||
|
||||
Assert.NotEqual(100.0, gbm.CurrentPrice);
|
||||
Assert.True(gbm.HasCurrentBar);
|
||||
|
||||
// Reset
|
||||
gbm.Reset();
|
||||
|
||||
Assert.Equal(100.0, gbm.CurrentPrice);
|
||||
Assert.False(gbm.HasCurrentBar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_WithStartTime_SetsSpecificTime()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
long specificTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
||||
|
||||
gbm.Next();
|
||||
gbm.Reset(specificTime);
|
||||
|
||||
var bar = gbm.Next();
|
||||
|
||||
// The bar time should be based on the reset time
|
||||
Assert.True(bar.Time > specificTime);
|
||||
Assert.Equal(100.0, bar.Open); // Should start from initial price
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Seeded Reproducibility Tests
|
||||
|
||||
[Fact]
|
||||
public void SeededGenerator_ProducesReproducibleResults()
|
||||
{
|
||||
var gbm1 = new GBM(startPrice: 100.0, seed: 42);
|
||||
var gbm2 = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
var series1 = gbm1.Fetch(10, startTime, interval);
|
||||
var series2 = gbm2.Fetch(10, startTime, interval);
|
||||
|
||||
for (int i = 0; i < series1.Count; i++)
|
||||
{
|
||||
Assert.Equal(series1[i].Open, series2[i].Open);
|
||||
Assert.Equal(series1[i].High, series2[i].High);
|
||||
Assert.Equal(series1[i].Low, series2[i].Low);
|
||||
Assert.Equal(series1[i].Close, series2[i].Close);
|
||||
Assert.Equal(series1[i].Volume, series2[i].Volume);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentSeeds_ProduceDifferentResults()
|
||||
{
|
||||
var gbm1 = new GBM(startPrice: 100.0, seed: 42);
|
||||
var gbm2 = new GBM(startPrice: 100.0, seed: 123);
|
||||
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
var series1 = gbm1.Fetch(10, startTime, interval);
|
||||
var series2 = gbm2.Fetch(10, startTime, interval);
|
||||
|
||||
bool anyDifferent = false;
|
||||
for (int i = 0; i < series1.Count; i++)
|
||||
{
|
||||
if (series1[i].Close != series2[i].Close)
|
||||
{
|
||||
anyDifferent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(anyDifferent, "Different seeds should produce different results");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnseededGenerator_ProducesVariableResults()
|
||||
{
|
||||
var gbm1 = new GBM(startPrice: 100.0);
|
||||
var gbm2 = new GBM(startPrice: 100.0);
|
||||
|
||||
// Note: This test may occasionally fail due to randomness, but is extremely unlikely
|
||||
var bar1 = gbm1.Next();
|
||||
var bar2 = gbm2.Next();
|
||||
|
||||
// At least one value should be different
|
||||
bool anyDifferent = bar1.Close != bar2.Close ||
|
||||
bar1.High != bar2.High ||
|
||||
bar1.Low != bar2.Low ||
|
||||
bar1.Volume != bar2.Volume;
|
||||
|
||||
Assert.True(anyDifferent, "Unseeded generators should produce different results");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Drift and Volatility Tests
|
||||
|
||||
[Fact]
|
||||
public void DriftAndVolatility_AffectPriceMovement()
|
||||
{
|
||||
var gbmLowVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.01, seed: 42);
|
||||
var gbmHighVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.5, seed: 42);
|
||||
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
var seriesLow = gbmLowVol.Fetch(100, startTime, interval);
|
||||
var seriesHigh = gbmHighVol.Fetch(100, startTime, interval);
|
||||
|
||||
// Calculate standard deviation of returns
|
||||
double[] returnsLow = new double[99];
|
||||
double[] returnsHigh = new double[99];
|
||||
|
||||
for (int i = 1; i < 100; i++)
|
||||
{
|
||||
returnsLow[i - 1] = Math.Log(seriesLow[i].Close / seriesLow[i - 1].Close);
|
||||
returnsHigh[i - 1] = Math.Log(seriesHigh[i].Close / seriesHigh[i - 1].Close);
|
||||
}
|
||||
|
||||
double stdLow = CalculateStdDev(returnsLow);
|
||||
double stdHigh = CalculateStdDev(returnsHigh);
|
||||
|
||||
Assert.True(stdHigh > stdLow, "High volatility should produce larger return dispersion");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroVolatility_ProducesConstantPrices()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.0, seed: 42);
|
||||
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
var series = gbm.Fetch(10, startTime, interval);
|
||||
|
||||
// With zero volatility and zero drift, price should stay constant
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
Assert.Equal(100.0, series[i].Close, 10);
|
||||
}
|
||||
}
|
||||
|
||||
private static double CalculateStdDev(double[] values)
|
||||
{
|
||||
double mean = 0;
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
mean += values[i];
|
||||
mean /= values.Length;
|
||||
|
||||
double sumSquares = 0;
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
sumSquares += (values[i] - mean) * (values[i] - mean);
|
||||
|
||||
return Math.Sqrt(sumSquares / values.Length);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region State Management Tests
|
||||
|
||||
[Fact]
|
||||
public void IntraBarUpdates_ModifyCurrentBar()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var bar1 = gbm.Next(isNew: true);
|
||||
long initialTime = bar1.Time;
|
||||
double initialClose = bar1.Close;
|
||||
|
||||
// Loop until price changes (random walk might stay same but unlikely)
|
||||
bool changed = false;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
@@ -209,13 +624,11 @@ public class GBMTests
|
||||
[Fact]
|
||||
public void MixedStreamingAndBatch_WorksCorrectly()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
// Start with streaming
|
||||
_ = gbm.Next();
|
||||
var bar2 = gbm.Next();
|
||||
|
||||
// Batch generation with explicit time
|
||||
long startTime = bar2.Time + TimeSpan.FromMinutes(1).Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
var series = gbm.Fetch(3, startTime, interval);
|
||||
@@ -223,59 +636,66 @@ public class GBMTests
|
||||
Assert.True(series[0].Time > bar2.Time);
|
||||
Assert.Equal(3, series.Count);
|
||||
|
||||
// Continue streaming after batch (uses internal state)
|
||||
var bar3 = gbm.Next();
|
||||
Assert.True(bar3.Time > series[2].Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DriftAndVolatility_AffectPriceMovement()
|
||||
public void Fetch_ResetsStreamingState()
|
||||
{
|
||||
// High volatility should produce more price variation
|
||||
var gbmLowVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.01);
|
||||
var gbmHighVol = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.5);
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
// Create a bar with intra-bar updates
|
||||
gbm.Next(isNew: true);
|
||||
gbm.Next(isNew: false);
|
||||
Assert.True(gbm.HasCurrentBar);
|
||||
|
||||
// Fetch should reset streaming state
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var interval = TimeSpan.FromMinutes(1);
|
||||
var seriesLow = gbmLowVol.Fetch(100, startTime, interval);
|
||||
var seriesHigh = gbmHighVol.Fetch(100, startTime, interval);
|
||||
gbm.Fetch(5, startTime, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Calculate price ranges
|
||||
double rangeLow = seriesLow[99].Close - seriesLow[0].Open;
|
||||
double rangeHigh = seriesHigh[99].Close - seriesHigh[0].Open;
|
||||
|
||||
// High volatility should generally produce larger absolute movements
|
||||
Assert.True(Math.Abs(rangeHigh) > Math.Abs(rangeLow) * 0.5);
|
||||
Assert.False(gbm.HasCurrentBar);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IFeed Interface Tests
|
||||
|
||||
[Fact]
|
||||
public void ConsecutiveCalls_MaintainContinuity()
|
||||
public void ImplementsIFeed()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
IFeed feed = new GBM(startPrice: 100.0, seed: 42);
|
||||
|
||||
var previousBar = gbm.Next();
|
||||
var currentBar = gbm.Next();
|
||||
var bar1 = feed.Next(isNew: true);
|
||||
Assert.True(bar1.Time > 0);
|
||||
|
||||
// currentBar.Open should equal previousBar.Close (continuity)
|
||||
Assert.Equal(previousBar.Close, currentBar.Open);
|
||||
var bar2 = feed.Next(isNew: true);
|
||||
Assert.True(bar2.Time > bar1.Time);
|
||||
|
||||
long startTime = DateTime.UtcNow.Ticks;
|
||||
var series = feed.Fetch(5, startTime, TimeSpan.FromMinutes(1));
|
||||
Assert.Equal(5, series.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Statelessness Tests
|
||||
|
||||
[Fact]
|
||||
public void Stateless_NoHistoryStorage()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0);
|
||||
|
||||
// Generate multiple bars
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
_ = gbm.Next();
|
||||
}
|
||||
|
||||
// GBM should not expose any history storage
|
||||
// Use typeof() instead of GetType() to satisfy trimming analyzer
|
||||
var type = typeof(GBM);
|
||||
var barsProperty = type.GetProperty("Bars");
|
||||
|
||||
Assert.Null(barsProperty);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,19 +1,75 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Provides validation utilities for comparing indicator results against external libraries.
|
||||
/// Contains tolerance constants and verification methods for cross-library validation.
|
||||
/// </summary>
|
||||
public static class ValidationHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Default tolerance for floating-point comparisons (1e-7).
|
||||
/// Suitable for most indicator comparisons.
|
||||
/// </summary>
|
||||
public const double DefaultTolerance = 1e-7;
|
||||
|
||||
/// <summary>
|
||||
/// Tolerance for Ooples Finance library comparisons (1e-7).
|
||||
/// May need adjustment for specific indicators with different internal precision.
|
||||
/// </summary>
|
||||
public const double OoplesTolerance = 1e-7;
|
||||
|
||||
/// <summary>
|
||||
/// Tolerance for Skender.Stock.Indicators library comparisons (1e-7).
|
||||
/// Skender uses decimal internally, so some precision loss is expected.
|
||||
/// </summary>
|
||||
public const double SkenderTolerance = 1e-7;
|
||||
|
||||
/// <summary>
|
||||
/// Tolerance for TA-Lib (TALib.NETCore) library comparisons (1e-7).
|
||||
/// TA-Lib uses double precision throughout.
|
||||
/// </summary>
|
||||
public const double TalibTolerance = 1e-7;
|
||||
|
||||
/// <summary>
|
||||
/// Tolerance for Tulip library comparisons (1e-7).
|
||||
/// Note: Tulip may have 1-bar shifts due to different initialization strategies.
|
||||
/// </summary>
|
||||
public const double TulipTolerance = 1e-7;
|
||||
|
||||
/// <summary>
|
||||
/// Relative tolerance for percentage-based comparisons (0.5%).
|
||||
/// Use when absolute tolerance is not appropriate.
|
||||
/// </summary>
|
||||
public const double RelativeTolerance = 0.005;
|
||||
|
||||
public static void VerifyData<TResult>(TSeries qSeries, IReadOnlyList<TResult> sSeries, Func<TResult, double?> selector, int skip = 100, double tolerance = DefaultTolerance)
|
||||
/// <summary>
|
||||
/// Default number of bars to verify from the end of the series.
|
||||
/// Using 100 bars ensures we're comparing converged values.
|
||||
/// </summary>
|
||||
public const int DefaultVerificationCount = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies TSeries results against an external library's results.
|
||||
/// Compares the last 'skip' values by default.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type of results from the external library</typeparam>
|
||||
/// <param name="qSeries">QuanTAlib TSeries results</param>
|
||||
/// <param name="sSeries">External library results</param>
|
||||
/// <param name="selector">Function to extract the comparable value from external results</param>
|
||||
/// <param name="skip">Number of values to verify from the end (default: 100)</param>
|
||||
/// <param name="tolerance">Tolerance for floating-point comparison</param>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void VerifyData<TResult>(
|
||||
TSeries qSeries,
|
||||
IReadOnlyList<TResult> sSeries,
|
||||
Func<TResult, double?> selector,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
Assert.Equal(qSeries.Count, sSeries.Count);
|
||||
|
||||
@@ -27,11 +83,22 @@ public static class ValidationHelper
|
||||
|
||||
if (!sValue.HasValue) continue;
|
||||
|
||||
Assert.Equal(sValue.Value, qValue, tolerance);
|
||||
Assert.True(
|
||||
Math.Abs(qValue - sValue.Value) <= tolerance,
|
||||
$"Mismatch at index {i}: QuanTAlib={qValue:G17}, External={sValue.Value:G17}, Diff={Math.Abs(qValue - sValue.Value):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void VerifyData<TResult>(IReadOnlyList<double> qResults, IReadOnlyList<TResult> sSeries, Func<TResult, double?> selector, int skip = 100, double tolerance = DefaultTolerance)
|
||||
/// <summary>
|
||||
/// Verifies IReadOnlyList results against an external library's results.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void VerifyData<TResult>(
|
||||
IReadOnlyList<double> qResults,
|
||||
IReadOnlyList<TResult> sSeries,
|
||||
Func<TResult, double?> selector,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
Assert.Equal(qResults.Count, sSeries.Count);
|
||||
|
||||
@@ -45,11 +112,22 @@ public static class ValidationHelper
|
||||
|
||||
if (!sValue.HasValue) continue;
|
||||
|
||||
Assert.Equal(sValue.Value, qValue, tolerance);
|
||||
Assert.True(
|
||||
Math.Abs(qValue - sValue.Value) <= tolerance,
|
||||
$"Mismatch at index {i}: QuanTAlib={qValue:G17}, External={sValue.Value:G17}, Diff={Math.Abs(qValue - sValue.Value):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void VerifyData<TResult>(double[] qOutput, IReadOnlyList<TResult> sSeries, Func<TResult, double?> selector, int skip = 100, double tolerance = DefaultTolerance)
|
||||
/// <summary>
|
||||
/// Verifies double array results against an external library's results.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void VerifyData<TResult>(
|
||||
double[] qOutput,
|
||||
IReadOnlyList<TResult> sSeries,
|
||||
Func<TResult, double?> selector,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
Assert.Equal(qOutput.Length, sSeries.Count);
|
||||
|
||||
@@ -63,11 +141,27 @@ public static class ValidationHelper
|
||||
|
||||
if (!sValue.HasValue) continue;
|
||||
|
||||
Assert.Equal(sValue.Value, qValue, tolerance);
|
||||
Assert.True(
|
||||
Math.Abs(qValue - sValue.Value) <= tolerance,
|
||||
$"Mismatch at index {i}: QuanTAlib={qValue:G17}, External={sValue.Value:G17}, Diff={Math.Abs(qValue - sValue.Value):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void VerifyData(TSeries qSeries, double[] tOutput, int lookback, int skip = 100, double tolerance = DefaultTolerance)
|
||||
/// <summary>
|
||||
/// Verifies TSeries results against TA-Lib style output with lookback offset.
|
||||
/// </summary>
|
||||
/// <param name="qSeries">QuanTAlib TSeries results</param>
|
||||
/// <param name="tOutput">TA-Lib output array</param>
|
||||
/// <param name="lookback">TA-Lib lookback period (output is shifted by this amount)</param>
|
||||
/// <param name="skip">Number of values to verify from the end</param>
|
||||
/// <param name="tolerance">Tolerance for floating-point comparison</param>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void VerifyData(
|
||||
TSeries qSeries,
|
||||
double[] tOutput,
|
||||
int lookback,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
int count = qSeries.Count;
|
||||
int start = Math.Max(0, count - skip);
|
||||
@@ -83,11 +177,22 @@ public static class ValidationHelper
|
||||
|
||||
double tValue = tOutput[tIndex];
|
||||
|
||||
Assert.Equal(tValue, qValue, tolerance);
|
||||
Assert.True(
|
||||
Math.Abs(qValue - tValue) <= tolerance,
|
||||
$"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void VerifyData(IReadOnlyList<double> qResults, double[] tOutput, int lookback, int skip = 100, double tolerance = DefaultTolerance)
|
||||
/// <summary>
|
||||
/// Verifies IReadOnlyList results against TA-Lib style output with lookback offset.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void VerifyData(
|
||||
IReadOnlyList<double> qResults,
|
||||
double[] tOutput,
|
||||
int lookback,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
int count = qResults.Count;
|
||||
int start = Math.Max(0, count - skip);
|
||||
@@ -103,11 +208,22 @@ public static class ValidationHelper
|
||||
|
||||
double tValue = tOutput[tIndex];
|
||||
|
||||
Assert.Equal(tValue, qValue, tolerance);
|
||||
Assert.True(
|
||||
Math.Abs(qValue - tValue) <= tolerance,
|
||||
$"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void VerifyData(double[] qOutput, double[] tOutput, int lookback, int skip = 100, double tolerance = DefaultTolerance)
|
||||
/// <summary>
|
||||
/// Verifies double array results against TA-Lib style output with lookback offset.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void VerifyData(
|
||||
double[] qOutput,
|
||||
double[] tOutput,
|
||||
int lookback,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
int count = qOutput.Length;
|
||||
int start = Math.Max(0, count - skip);
|
||||
@@ -123,11 +239,23 @@ public static class ValidationHelper
|
||||
|
||||
double tValue = tOutput[tIndex];
|
||||
|
||||
Assert.Equal(tValue, qValue, tolerance);
|
||||
Assert.True(
|
||||
Math.Abs(qValue - tValue) <= tolerance,
|
||||
$"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void VerifyData(TSeries qSeries, double[] tOutput, Range outRange, int lookback, int skip = 100, double tolerance = DefaultTolerance)
|
||||
/// <summary>
|
||||
/// Verifies TSeries results against TA-Lib style output with range and lookback.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void VerifyData(
|
||||
TSeries qSeries,
|
||||
double[] tOutput,
|
||||
Range outRange,
|
||||
int lookback,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
int count = qSeries.Count;
|
||||
int start = Math.Max(0, count - skip);
|
||||
@@ -144,11 +272,23 @@ public static class ValidationHelper
|
||||
|
||||
double tValue = tOutput[tIndex];
|
||||
|
||||
Assert.Equal(tValue, qValue, tolerance);
|
||||
Assert.True(
|
||||
Math.Abs(qValue - tValue) <= tolerance,
|
||||
$"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void VerifyData(IReadOnlyList<double> qResults, double[] tOutput, Range outRange, int lookback, int skip = 100, double tolerance = DefaultTolerance)
|
||||
/// <summary>
|
||||
/// Verifies IReadOnlyList results against TA-Lib style output with range and lookback.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void VerifyData(
|
||||
IReadOnlyList<double> qResults,
|
||||
double[] tOutput,
|
||||
Range outRange,
|
||||
int lookback,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
int count = qResults.Count;
|
||||
int start = Math.Max(0, count - skip);
|
||||
@@ -165,11 +305,23 @@ public static class ValidationHelper
|
||||
|
||||
double tValue = tOutput[tIndex];
|
||||
|
||||
Assert.Equal(tValue, qValue, tolerance);
|
||||
Assert.True(
|
||||
Math.Abs(qValue - tValue) <= tolerance,
|
||||
$"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void VerifyData(double[] qOutput, double[] tOutput, Range outRange, int lookback, int skip = 100, double tolerance = DefaultTolerance)
|
||||
/// <summary>
|
||||
/// Verifies double array results against TA-Lib style output with range and lookback.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void VerifyData(
|
||||
double[] qOutput,
|
||||
double[] tOutput,
|
||||
Range outRange,
|
||||
int lookback,
|
||||
int skip = DefaultVerificationCount,
|
||||
double tolerance = DefaultTolerance)
|
||||
{
|
||||
int count = qOutput.Length;
|
||||
int start = Math.Max(0, count - skip);
|
||||
@@ -186,7 +338,111 @@ public static class ValidationHelper
|
||||
|
||||
double tValue = tOutput[tIndex];
|
||||
|
||||
Assert.Equal(tValue, qValue, tolerance);
|
||||
Assert.True(
|
||||
Math.Abs(qValue - tValue) <= tolerance,
|
||||
$"Mismatch at index {i} (TA-Lib index {tIndex}): QuanTAlib={qValue:G17}, TA-Lib={tValue:G17}, Diff={Math.Abs(qValue - tValue):G17}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that all values in the series are finite (not NaN or Infinity).
|
||||
/// </summary>
|
||||
/// <param name="series">The series to verify</param>
|
||||
/// <param name="startIndex">Starting index for verification (default: 0)</param>
|
||||
public static void VerifyAllFinite(TSeries series, int startIndex = 0)
|
||||
{
|
||||
for (int i = startIndex; i < series.Count; i++)
|
||||
{
|
||||
Assert.True(
|
||||
double.IsFinite(series[i].Value),
|
||||
$"Non-finite value at index {i}: {series[i].Value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that all values in the array are finite (not NaN or Infinity).
|
||||
/// </summary>
|
||||
/// <param name="values">The array to verify</param>
|
||||
/// <param name="startIndex">Starting index for verification (default: 0)</param>
|
||||
public static void VerifyAllFinite(double[] values, int startIndex = 0)
|
||||
{
|
||||
for (int i = startIndex; i < values.Length; i++)
|
||||
{
|
||||
Assert.True(
|
||||
double.IsFinite(values[i]),
|
||||
$"Non-finite value at index {i}: {values[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that two series produce the same results (for consistency testing).
|
||||
/// </summary>
|
||||
/// <param name="series1">First series</param>
|
||||
/// <param name="series2">Second series</param>
|
||||
/// <param name="tolerance">Tolerance for floating-point comparison</param>
|
||||
public static void VerifySeriesEqual(TSeries series1, TSeries series2, double tolerance = DefaultTolerance)
|
||||
{
|
||||
Assert.Equal(series1.Count, series2.Count);
|
||||
|
||||
for (int i = 0; i < series1.Count; i++)
|
||||
{
|
||||
Assert.True(
|
||||
Math.Abs(series1[i].Value - series2[i].Value) <= tolerance,
|
||||
$"Mismatch at index {i}: Series1={series1[i].Value:G17}, Series2={series2[i].Value:G17}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the maximum absolute difference between two series.
|
||||
/// Useful for debugging tolerance issues.
|
||||
/// </summary>
|
||||
public static double MaxAbsoluteDifference<TResult>(
|
||||
TSeries qSeries,
|
||||
IReadOnlyList<TResult> sSeries,
|
||||
Func<TResult, double?> selector)
|
||||
{
|
||||
if (qSeries.Count != sSeries.Count)
|
||||
throw new ArgumentException("Series must have the same count", nameof(sSeries));
|
||||
|
||||
double maxDiff = 0;
|
||||
|
||||
for (int i = 0; i < qSeries.Count; i++)
|
||||
{
|
||||
double? sValue = selector(sSeries[i]);
|
||||
if (!sValue.HasValue) continue;
|
||||
|
||||
double diff = Math.Abs(qSeries[i].Value - sValue.Value);
|
||||
if (diff > maxDiff)
|
||||
maxDiff = diff;
|
||||
}
|
||||
|
||||
return maxDiff;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the maximum relative difference between two series.
|
||||
/// Useful for percentage-based tolerance testing.
|
||||
/// </summary>
|
||||
public static double MaxRelativeDifference<TResult>(
|
||||
TSeries qSeries,
|
||||
IReadOnlyList<TResult> sSeries,
|
||||
Func<TResult, double?> selector)
|
||||
{
|
||||
if (qSeries.Count != sSeries.Count)
|
||||
throw new ArgumentException("Series must have the same count", nameof(sSeries));
|
||||
|
||||
double maxDiff = 0;
|
||||
|
||||
for (int i = 0; i < qSeries.Count; i++)
|
||||
{
|
||||
double? sValue = selector(sSeries[i]);
|
||||
if (!sValue.HasValue || sValue.Value == 0) continue;
|
||||
|
||||
double relDiff = Math.Abs((qSeries[i].Value - sValue.Value) / sValue.Value);
|
||||
if (relDiff > maxDiff)
|
||||
maxDiff = relDiff;
|
||||
}
|
||||
|
||||
return maxDiff;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,219 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Skender.Stock.Indicators;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Provides standardized test data for validation tests.
|
||||
/// Uses GBM (Geometric Brownian Motion) to generate realistic price data
|
||||
/// and converts it to formats required by external validation libraries.
|
||||
/// </summary>
|
||||
public sealed class ValidationTestData : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Default number of bars for validation tests.
|
||||
/// 5000 bars ensures sufficient convergence for most indicators.
|
||||
/// </summary>
|
||||
public const int DefaultCount = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// Default starting price for generated data.
|
||||
/// </summary>
|
||||
public const double DefaultStartPrice = 1000.0;
|
||||
|
||||
/// <summary>
|
||||
/// Default annual drift for GBM (5%).
|
||||
/// </summary>
|
||||
public const double DefaultMu = 0.05;
|
||||
|
||||
/// <summary>
|
||||
/// Default annual volatility for GBM (200%).
|
||||
/// High volatility ensures diverse price scenarios.
|
||||
/// </summary>
|
||||
public const double DefaultSigma = 2.0;
|
||||
|
||||
/// <summary>
|
||||
/// Default random seed for reproducibility.
|
||||
/// </summary>
|
||||
public const int DefaultSeed = 123;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generated bar series.
|
||||
/// </summary>
|
||||
public TBarSeries Bars { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the close price series.
|
||||
/// </summary>
|
||||
public TSeries Data { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the quotes in Skender.Stock.Indicators format.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Quote> SkenderQuotes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw close price data as a ReadOnlyMemory for span-based APIs.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<double> RawData { get; }
|
||||
|
||||
public ValidationTestData(int count = 5000, double startPrice = 1000.0, double mu = 0.05, double sigma = 2.0, int seed = 123)
|
||||
/// <summary>
|
||||
/// Gets the raw open prices as read-only memory.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<double> OpenPrices { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw high prices as read-only memory.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<double> HighPrices { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw low prices as read-only memory.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<double> LowPrices { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw close prices as read-only memory.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<double> ClosePrices { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw volume data as read-only memory.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<double> VolumeData { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamps as read-only memory.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<long> Timestamps { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of bars in the dataset.
|
||||
/// </summary>
|
||||
public int Count => Bars.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Creates validation test data with default parameters.
|
||||
/// </summary>
|
||||
public ValidationTestData()
|
||||
: this(DefaultCount, DefaultStartPrice, DefaultMu, DefaultSigma, DefaultSeed)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates validation test data with specified parameters.
|
||||
/// </summary>
|
||||
/// <param name="count">Number of bars to generate</param>
|
||||
/// <param name="startPrice">Starting price</param>
|
||||
/// <param name="mu">Annual drift rate</param>
|
||||
/// <param name="sigma">Annual volatility</param>
|
||||
/// <param name="seed">Random seed for reproducibility</param>
|
||||
public ValidationTestData(
|
||||
int count,
|
||||
double startPrice = DefaultStartPrice,
|
||||
double mu = DefaultMu,
|
||||
double sigma = DefaultSigma,
|
||||
int seed = DefaultSeed)
|
||||
{
|
||||
var gbm = new GBM(startPrice, mu, sigma, seed: seed);
|
||||
Bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
Data = Bars.Close;
|
||||
RawData = Data.Select(x => x.Value).ToArray();
|
||||
|
||||
var quotes = new List<Quote>();
|
||||
for (int i = 0; i < Bars.Count; i++)
|
||||
// Extract raw arrays efficiently (avoid LINQ in hot path)
|
||||
int barCount = Bars.Count;
|
||||
var openPrices = new double[barCount];
|
||||
var highPrices = new double[barCount];
|
||||
var lowPrices = new double[barCount];
|
||||
var closePrices = new double[barCount];
|
||||
var volumeData = new double[barCount];
|
||||
var timestamps = new long[barCount];
|
||||
|
||||
// Use span-based access for efficiency
|
||||
var openSpan = Bars.OpenValues;
|
||||
var highSpan = Bars.HighValues;
|
||||
var lowSpan = Bars.LowValues;
|
||||
var closeSpan = Bars.CloseValues;
|
||||
var volumeSpan = Bars.VolumeValues;
|
||||
var timeSpan = Bars.Times;
|
||||
|
||||
openSpan.CopyTo(openPrices);
|
||||
highSpan.CopyTo(highPrices);
|
||||
lowSpan.CopyTo(lowPrices);
|
||||
closeSpan.CopyTo(closePrices);
|
||||
volumeSpan.CopyTo(volumeData);
|
||||
timeSpan.CopyTo(timestamps);
|
||||
|
||||
// Expose as ReadOnlyMemory to prevent external modification
|
||||
OpenPrices = openPrices;
|
||||
HighPrices = highPrices;
|
||||
LowPrices = lowPrices;
|
||||
ClosePrices = closePrices;
|
||||
VolumeData = volumeData;
|
||||
Timestamps = timestamps;
|
||||
RawData = closePrices;
|
||||
|
||||
// Build Skender quotes without LINQ
|
||||
var quotes = new Quote[barCount];
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
quotes.Add(new Quote
|
||||
quotes[i] = new Quote
|
||||
{
|
||||
Date = new DateTime(Bars.Open.Times[i], DateTimeKind.Utc),
|
||||
Open = (decimal)Bars.Open[i].Value,
|
||||
High = (decimal)Bars.High[i].Value,
|
||||
Low = (decimal)Bars.Low[i].Value,
|
||||
Close = (decimal)Bars.Close[i].Value,
|
||||
Volume = (decimal)Bars.Volume[i].Value
|
||||
});
|
||||
Date = new DateTime(timestamps[i], DateTimeKind.Utc),
|
||||
Open = (decimal)openPrices[i],
|
||||
High = (decimal)highPrices[i],
|
||||
Low = (decimal)lowPrices[i],
|
||||
Close = (decimal)closePrices[i],
|
||||
Volume = (decimal)volumeData[i],
|
||||
};
|
||||
}
|
||||
SkenderQuotes = quotes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a subset of the data for smaller tests.
|
||||
/// </summary>
|
||||
/// <param name="count">Number of bars to include</param>
|
||||
/// <returns>A new ValidationTestData instance with the subset</returns>
|
||||
public ValidationTestData CreateSubset(int count)
|
||||
{
|
||||
if (count <= 0 || count > Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(count), count, $"Count must be between 1 and {Count}");
|
||||
|
||||
return new ValidationTestData(count, DefaultStartPrice, DefaultMu, DefaultSigma, DefaultSeed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the close price span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> GetCloseSpan() => ClosePrices.Span;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the high price span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> GetHighSpan() => HighPrices.Span;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the low price span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> GetLowSpan() => LowPrices.Span;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the open price span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> GetOpenSpan() => OpenPrices.Span;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the volume span for SIMD operations.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<double> GetVolumeSpan() => VolumeData.Span;
|
||||
|
||||
/// <summary>
|
||||
/// Disposes of resources (no-op, but implements pattern for test fixtures).
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
// No resources to dispose
|
||||
// No unmanaged resources to dispose
|
||||
// Implemented for IDisposable pattern compatibility with test fixtures
|
||||
}
|
||||
}
|
||||
|
||||
+121
-18
@@ -11,10 +11,11 @@ namespace QuanTAlib;
|
||||
[SkipLocalsInit]
|
||||
#pragma warning disable S101 // Rename class 'GBM' to match pascal case naming rules
|
||||
#pragma warning disable S2245 // Random is acceptable for simulation/testing purposes
|
||||
public class GBM : IFeed
|
||||
public sealed class GBM : IFeed
|
||||
#pragma warning restore S101
|
||||
{
|
||||
private readonly Random? _rnd;
|
||||
private readonly double _startPrice;
|
||||
|
||||
private double _lastPrice;
|
||||
private long _lastTime;
|
||||
@@ -35,14 +36,43 @@ public class GBM : IFeed
|
||||
private double _cachedZ;
|
||||
private bool _hasCachedZ;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the annual drift/return rate.
|
||||
/// </summary>
|
||||
public double Mu => _mu;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the annual volatility.
|
||||
/// </summary>
|
||||
public double Sigma => _sigma;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the starting price.
|
||||
/// </summary>
|
||||
public double StartPrice => _startPrice;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current price state.
|
||||
/// </summary>
|
||||
public double CurrentPrice => _lastPrice;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the generator has a current bar in progress.
|
||||
/// </summary>
|
||||
public bool HasCurrentBar => _hasCurrentBar;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new GBM generator.
|
||||
/// </summary>
|
||||
/// <param name="startPrice">Initial price (default: 100.0, must be positive)</param>
|
||||
/// <param name="mu">Annual drift/return rate (default: 0.05 = 5%)</param>
|
||||
/// <param name="sigma">Annual volatility (default: 0.2 = 20%, must be non-negative)</param>
|
||||
/// <param name="defaultTimeframe">Default timeframe for bars (default: 1 minute)</param>
|
||||
/// <param name="startPrice">Initial price (default: 100.0, must be positive and finite)</param>
|
||||
/// <param name="mu">Annual drift/return rate (default: 0.05 = 5%, must be finite)</param>
|
||||
/// <param name="sigma">Annual volatility (default: 0.2 = 20%, must be non-negative and finite)</param>
|
||||
/// <param name="defaultTimeframe">Default timeframe for bars (default: 1 minute, must be positive)</param>
|
||||
/// <param name="seed">Optional random seed for reproducibility (default: null for non-deterministic)</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// Thrown when startPrice is not positive/finite, sigma is negative/non-finite,
|
||||
/// mu is non-finite, or defaultTimeframe is non-positive.
|
||||
/// </exception>
|
||||
public GBM(
|
||||
double startPrice = 100.0,
|
||||
double mu = 0.05,
|
||||
@@ -50,18 +80,33 @@ public class GBM : IFeed
|
||||
TimeSpan? defaultTimeframe = null,
|
||||
int? seed = null)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(startPrice);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(sigma);
|
||||
// Validate startPrice
|
||||
if (startPrice <= 0 || !double.IsFinite(startPrice))
|
||||
throw new ArgumentOutOfRangeException(nameof(startPrice), startPrice, "Start price must be positive and finite");
|
||||
|
||||
// Validate mu
|
||||
if (!double.IsFinite(mu))
|
||||
throw new ArgumentOutOfRangeException(nameof(mu), mu, "Drift (mu) must be finite");
|
||||
|
||||
// Validate sigma
|
||||
if (sigma < 0 || !double.IsFinite(sigma))
|
||||
throw new ArgumentOutOfRangeException(nameof(sigma), sigma, "Volatility (sigma) must be non-negative and finite");
|
||||
|
||||
// Use provided timeframe or default to 1 minute
|
||||
var timeframe = defaultTimeframe ?? TimeSpan.FromMinutes(1);
|
||||
|
||||
// Validate timeframe
|
||||
if (timeframe <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(defaultTimeframe), defaultTimeframe, "Timeframe must be positive");
|
||||
|
||||
_rnd = seed.HasValue ? new Random(seed.Value) : null;
|
||||
_startPrice = startPrice;
|
||||
_lastPrice = startPrice;
|
||||
_lastTime = DateTime.UtcNow.Ticks;
|
||||
|
||||
_mu = mu;
|
||||
_sigma = sigma;
|
||||
|
||||
// Use provided timeframe or default to 1 minute
|
||||
var timeframe = defaultTimeframe ?? TimeSpan.FromMinutes(1);
|
||||
_defaultTimeStep = timeframe.Ticks;
|
||||
|
||||
// Calculate dt based on timeframe (assuming 252 trading days/year, 6.5 hours/day)
|
||||
@@ -72,6 +117,33 @@ public class GBM : IFeed
|
||||
_vol = sigma * Math.Sqrt(dt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the generator to its initial state.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_lastPrice = _startPrice;
|
||||
_lastTime = DateTime.UtcNow.Ticks;
|
||||
_currentBar = default;
|
||||
_hasCurrentBar = false;
|
||||
_cachedZ = 0;
|
||||
_hasCachedZ = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the generator to its initial state with a specific start time.
|
||||
/// </summary>
|
||||
/// <param name="startTime">The start time in ticks.</param>
|
||||
public void Reset(long startTime)
|
||||
{
|
||||
_lastPrice = _startPrice;
|
||||
_lastTime = startTime;
|
||||
_currentBar = default;
|
||||
_hasCurrentBar = false;
|
||||
_cachedZ = 0;
|
||||
_hasCachedZ = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a random double in [0, 1) using either the seeded Random or RandomNumberGenerator.
|
||||
/// </summary>
|
||||
@@ -103,6 +175,11 @@ public class GBM : IFeed
|
||||
|
||||
double u1 = 1.0 - NextDouble();
|
||||
double u2 = 1.0 - NextDouble();
|
||||
|
||||
// Guard against log(0) which produces -Infinity
|
||||
if (u1 <= double.Epsilon)
|
||||
u1 = double.Epsilon;
|
||||
|
||||
double mag = Math.Sqrt(-2.0 * Math.Log(u1));
|
||||
double angle = 2.0 * Math.PI * u2;
|
||||
|
||||
@@ -128,17 +205,26 @@ public class GBM : IFeed
|
||||
|
||||
double z = NextNormal();
|
||||
double price = _lastPrice * Math.Exp(_drift + _vol * z);
|
||||
|
||||
// Ensure price stays positive and finite
|
||||
if (!double.IsFinite(price) || price <= 0)
|
||||
price = _lastPrice;
|
||||
|
||||
double volume = 1000 + NextDouble() * 1000;
|
||||
|
||||
double open = _lastPrice;
|
||||
double close = price;
|
||||
double high = Math.Max(open, close) * (1.0 + Math.Abs(NextDouble()) * 0.01);
|
||||
double low = Math.Min(open, close) * (1.0 - Math.Abs(NextDouble()) * 0.01);
|
||||
|
||||
// Ensure valid OHLC
|
||||
double rnd1 = NextDouble();
|
||||
double rnd2 = NextDouble();
|
||||
|
||||
double high = Math.Max(open, close) * (1.0 + rnd1 * 0.01);
|
||||
double low = Math.Min(open, close) * (1.0 - rnd2 * 0.01);
|
||||
|
||||
// Ensure valid OHLC constraints
|
||||
high = Math.Max(high, Math.Max(open, close));
|
||||
low = Math.Min(low, Math.Min(open, close));
|
||||
low = Math.Max(0.0, low);
|
||||
low = Math.Max(double.Epsilon, low); // Ensure positive
|
||||
|
||||
_currentBar = new TBar(currentTime, open, high, low, close, volume);
|
||||
_hasCurrentBar = true;
|
||||
@@ -151,12 +237,18 @@ public class GBM : IFeed
|
||||
// Update current bar (intra-bar tick)
|
||||
double z = NextNormal();
|
||||
double price = _lastPrice * Math.Exp(_drift + _vol * z);
|
||||
|
||||
// Ensure price stays positive and finite
|
||||
if (!double.IsFinite(price) || price <= 0)
|
||||
price = _lastPrice;
|
||||
|
||||
double additionalVolume = 1000 + NextDouble() * 1000;
|
||||
|
||||
var bar = _currentBar;
|
||||
double newClose = price;
|
||||
double newHigh = Math.Max(bar.High, newClose);
|
||||
double newLow = Math.Min(bar.Low, newClose);
|
||||
newLow = Math.Max(double.Epsilon, newLow); // Ensure positive
|
||||
double newVolume = bar.Volume + additionalVolume;
|
||||
|
||||
_currentBar = new TBar(bar.Time, bar.Open, newHigh, newLow, newClose, newVolume);
|
||||
@@ -179,13 +271,19 @@ public class GBM : IFeed
|
||||
/// <summary>
|
||||
/// Generates a batch of bars using optimized batch processing with explicit time parameters.
|
||||
/// </summary>
|
||||
/// <param name="count">Number of bars to generate (must be positive)</param>
|
||||
/// <param name="startTime">Starting timestamp in ticks</param>
|
||||
/// <param name="interval">Time interval between bars (must be positive)</param>
|
||||
/// <returns>A TBarSeries containing the generated bars</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when count is not positive</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when interval is not positive</exception>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TBarSeries Fetch(int count, long startTime, TimeSpan interval)
|
||||
{
|
||||
if (count <= 0)
|
||||
throw new ArgumentException("Count must be positive", nameof(count));
|
||||
if (interval <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(interval), "Interval must be positive");
|
||||
throw new ArgumentOutOfRangeException(nameof(interval), interval, "Interval must be positive");
|
||||
|
||||
var series = new TBarSeries(count);
|
||||
|
||||
@@ -212,6 +310,10 @@ public class GBM : IFeed
|
||||
double z = NextNormal();
|
||||
double price = currentPrice * Math.Exp(drift + vol * z);
|
||||
|
||||
// Ensure price stays positive and finite
|
||||
if (!double.IsFinite(price) || price <= 0)
|
||||
price = currentPrice;
|
||||
|
||||
double open = currentPrice;
|
||||
double close = price;
|
||||
|
||||
@@ -223,13 +325,13 @@ public class GBM : IFeed
|
||||
o[i] = open;
|
||||
c[i] = close;
|
||||
|
||||
double high = Math.Max(open, close) * (1.0 + Math.Abs(rnd1) * 0.01);
|
||||
double low = Math.Min(open, close) * (1.0 - Math.Abs(rnd2) * 0.01);
|
||||
double high = Math.Max(open, close) * (1.0 + rnd1 * 0.01);
|
||||
double low = Math.Min(open, close) * (1.0 - rnd2 * 0.01);
|
||||
|
||||
// Ensure valid OHLC
|
||||
// Ensure valid OHLC constraints
|
||||
high = Math.Max(high, Math.Max(open, close));
|
||||
low = Math.Min(low, Math.Min(open, close));
|
||||
low = Math.Max(0.0, low);
|
||||
low = Math.Max(double.Epsilon, low); // Ensure positive
|
||||
|
||||
h[i] = high;
|
||||
l[i] = low;
|
||||
@@ -252,3 +354,4 @@ public class GBM : IFeed
|
||||
return series;
|
||||
}
|
||||
}
|
||||
#pragma warning restore S2245
|
||||
|
||||
@@ -54,6 +54,28 @@ public class AdxTests
|
||||
Assert.Equal(adx2.DiMinus.Value, adx.DiMinus.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var adx = new Adx(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
adx.Update(bars[i]);
|
||||
|
||||
var originalValue = adx.Last;
|
||||
|
||||
for (int m = 0; m < 5; m++)
|
||||
{
|
||||
var modified = new TBar(bars[49].Time, bars[49].Open, bars[49].High + m, bars[49].Low - m, bars[49].Close, bars[49].Volume);
|
||||
adx.Update(modified, isNew: false);
|
||||
}
|
||||
|
||||
var restored = adx.Update(bars[49], isNew: false);
|
||||
Assert.Equal(originalValue.Value, restored.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_Works()
|
||||
{
|
||||
@@ -79,6 +101,75 @@ public class AdxTests
|
||||
Assert.True(double.IsFinite(adx.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var adx = new Adx(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.False(adx.IsHot);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
adx.Update(bars[i]);
|
||||
if (adx.IsHot) break;
|
||||
}
|
||||
|
||||
Assert.True(adx.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var adx = new Adx(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 40; i++)
|
||||
adx.Update(bars[i]);
|
||||
|
||||
var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 100);
|
||||
var result = adx.Update(nanBar);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var adx = new Adx(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 40; i++)
|
||||
adx.Update(bars[i]);
|
||||
|
||||
var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, 0, 100, 100);
|
||||
var result = adx.Update(infBar);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
var gbm = new GBM(seed: 123);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode
|
||||
var batchResult = Adx.Batch(bars, 14);
|
||||
double expected = batchResult.Last.Value;
|
||||
|
||||
// 2. Streaming Mode
|
||||
var streamAdx = new Adx(14);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
streamAdx.Update(bars[i]);
|
||||
double streamResult = streamAdx.Last.Value;
|
||||
|
||||
Assert.Equal(expected, streamResult, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TBarSeries_Update_Matches_Streaming()
|
||||
{
|
||||
|
||||
@@ -145,4 +145,130 @@ public class AdxrTests
|
||||
var result2 = adxr.Update(bars[0]);
|
||||
Assert.IsType<TValue>(result2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var adxr = new Adxr(5);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
|
||||
// Feed 20 new values
|
||||
TBar twentiethInput = default;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
twentiethInput = bar;
|
||||
adxr.Update(bar, isNew: true);
|
||||
}
|
||||
|
||||
// Remember state after 20 values
|
||||
double stateAfterTwenty = adxr.Last.Value;
|
||||
|
||||
// Generate 9 corrections with isNew=false (different values)
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: false);
|
||||
adxr.Update(bar, isNew: false);
|
||||
}
|
||||
|
||||
// Feed the remembered 20th input again with isNew=false
|
||||
TValue finalResult = adxr.Update(twentiethInput, isNew: false);
|
||||
|
||||
// State should match the original state after 20 values
|
||||
Assert.Equal(stateAfterTwenty, finalResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var adxr = new Adxr(5);
|
||||
var gbm = new GBM();
|
||||
|
||||
Assert.False(adxr.IsHot);
|
||||
|
||||
// ADXR needs more warmup than just period (ADX warmup + period)
|
||||
// Feed bars until IsHot becomes true
|
||||
int count = 0;
|
||||
while (!adxr.IsHot && count < 100)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
adxr.Update(bar, isNew: true);
|
||||
count++;
|
||||
}
|
||||
|
||||
Assert.True(adxr.IsHot);
|
||||
Assert.True(count > 5); // Should take more than period bars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var adxr = new Adxr(5);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(30, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed some valid bars first
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
adxr.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Create a bar with NaN values
|
||||
var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
|
||||
var result = adxr.Update(nanBar);
|
||||
|
||||
// Should not crash and should return a finite value
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var adxr = new Adxr(5);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(30, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed some valid bars first
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
adxr.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Create a bar with Infinity values
|
||||
var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, double.PositiveInfinity);
|
||||
var result = adxr.Update(infBar);
|
||||
|
||||
// Should not crash and should return a finite value
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
// Arrange
|
||||
int period = 5;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode (static method)
|
||||
var batchSeries = Adxr.Batch(bars, period);
|
||||
double expected = batchSeries.Last.Value;
|
||||
|
||||
// 2. Streaming Mode (instance, one bar at a time)
|
||||
var streamingInd = new Adxr(period);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
streamingInd.Update(bars[i]);
|
||||
}
|
||||
double streamingResult = streamingInd.Last.Value;
|
||||
|
||||
// 3. Instance Update with TBarSeries
|
||||
var instanceInd = new Adxr(period);
|
||||
var instanceResult = instanceInd.Update(bars);
|
||||
double instanceValue = instanceResult.Last.Value;
|
||||
|
||||
// Assert all modes produce identical results
|
||||
Assert.Equal(expected, streamingResult, precision: 9);
|
||||
Assert.Equal(expected, instanceValue, precision: 9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,4 +146,130 @@ public class AoTests
|
||||
Assert.Throws<ArgumentException>(() => new Ao(5, 0));
|
||||
Assert.Throws<ArgumentException>(() => new Ao(34, 5)); // Fast >= Slow
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var ao = new Ao(5, 34);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
|
||||
// Feed 50 new values (more than slow period)
|
||||
TBar fiftiethInput = default;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
fiftiethInput = bar;
|
||||
ao.Update(bar, isNew: true);
|
||||
}
|
||||
|
||||
// Remember state after 50 values
|
||||
double stateAfterFifty = ao.Last.Value;
|
||||
|
||||
// Generate 9 corrections with isNew=false (different values)
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: false);
|
||||
ao.Update(bar, isNew: false);
|
||||
}
|
||||
|
||||
// Feed the remembered 50th input again with isNew=false
|
||||
TValue finalResult = ao.Update(fiftiethInput, isNew: false);
|
||||
|
||||
// State should match the original state after 50 values
|
||||
Assert.Equal(stateAfterFifty, finalResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var ao = new Ao(5, 34);
|
||||
var gbm = new GBM();
|
||||
|
||||
Assert.False(ao.IsHot);
|
||||
|
||||
// Feed bars until IsHot becomes true
|
||||
int count = 0;
|
||||
while (!ao.IsHot && count < 100)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
ao.Update(bar, isNew: true);
|
||||
count++;
|
||||
}
|
||||
|
||||
Assert.True(ao.IsHot);
|
||||
Assert.True(count >= 34); // Should take at least slow period bars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var ao = new Ao(5, 34);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed some valid bars first
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
ao.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Create a bar with NaN values
|
||||
var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
|
||||
var result = ao.Update(nanBar);
|
||||
|
||||
// Should not crash and should return a finite value
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var ao = new Ao(5, 34);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed some valid bars first
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
ao.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Create a bar with Infinity values
|
||||
var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, double.PositiveInfinity);
|
||||
var result = ao.Update(infBar);
|
||||
|
||||
// Should not crash and should return a finite value
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
// Arrange
|
||||
int fastPeriod = 5;
|
||||
int slowPeriod = 34;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode (static method)
|
||||
var batchSeries = Ao.Batch(bars, fastPeriod, slowPeriod);
|
||||
double expected = batchSeries.Last.Value;
|
||||
|
||||
// 2. Streaming Mode (instance, one bar at a time)
|
||||
var streamingInd = new Ao(fastPeriod, slowPeriod);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
streamingInd.Update(bars[i]);
|
||||
}
|
||||
double streamingResult = streamingInd.Last.Value;
|
||||
|
||||
// 3. Instance Update with TBarSeries
|
||||
var instanceInd = new Ao(fastPeriod, slowPeriod);
|
||||
var instanceResult = instanceInd.Update(bars);
|
||||
double instanceValue = instanceResult.Last.Value;
|
||||
|
||||
// Assert all modes produce identical results
|
||||
Assert.Equal(expected, streamingResult, precision: 9);
|
||||
Assert.Equal(expected, instanceValue, precision: 9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,4 +146,131 @@ public class ApoTests
|
||||
Assert.Throws<ArgumentException>(() => new Apo(12, 0));
|
||||
Assert.Throws<ArgumentException>(() => new Apo(26, 12)); // Fast >= Slow
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var apo = new Apo(12, 26);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
|
||||
// Feed 50 new values (more than slow period)
|
||||
TBar fiftiethInput = default;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
fiftiethInput = bar;
|
||||
apo.Update(bar, isNew: true);
|
||||
}
|
||||
|
||||
// Remember state after 50 values
|
||||
double stateAfterFifty = apo.Last.Value;
|
||||
|
||||
// Generate 9 corrections with isNew=false (different values)
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: false);
|
||||
apo.Update(bar, isNew: false);
|
||||
}
|
||||
|
||||
// Feed the remembered 50th input again with isNew=false
|
||||
TValue finalResult = apo.Update(fiftiethInput, isNew: false);
|
||||
|
||||
// State should match the original state after 50 values
|
||||
Assert.Equal(stateAfterFifty, finalResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var apo = new Apo(12, 26);
|
||||
var gbm = new GBM();
|
||||
|
||||
Assert.False(apo.IsHot);
|
||||
|
||||
// Feed bars until IsHot becomes true
|
||||
int count = 0;
|
||||
while (!apo.IsHot && count < 100)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
apo.Update(bar, isNew: true);
|
||||
count++;
|
||||
}
|
||||
|
||||
Assert.True(apo.IsHot);
|
||||
Assert.True(count >= 26); // Should take at least slow period bars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var apo = new Apo(12, 26);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed some valid bars first
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
apo.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Create a bar with NaN close value
|
||||
var nanBar = new TBar(DateTime.UtcNow, 100, 105, 95, double.NaN, 1000);
|
||||
var result = apo.Update(nanBar);
|
||||
|
||||
// Should not crash and should return a finite value
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var apo = new Apo(12, 26);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed some valid bars first
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
apo.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Create a bar with Infinity close value
|
||||
var infBar = new TBar(DateTime.UtcNow, 100, 105, 95, double.PositiveInfinity, 1000);
|
||||
var result = apo.Update(infBar);
|
||||
|
||||
// Should not crash and should return a finite value
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
// Arrange
|
||||
int fastPeriod = 12;
|
||||
int slowPeriod = 26;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var closeSeries = bars.Close;
|
||||
|
||||
// 1. Batch Mode (static method)
|
||||
var batchSeries = Apo.Batch(closeSeries, fastPeriod, slowPeriod);
|
||||
double expected = batchSeries.Last.Value;
|
||||
|
||||
// 2. Streaming Mode (instance, one bar at a time)
|
||||
var streamingInd = new Apo(fastPeriod, slowPeriod);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
streamingInd.Update(bars[i]);
|
||||
}
|
||||
double streamingResult = streamingInd.Last.Value;
|
||||
|
||||
// 3. Instance Update with TSeries
|
||||
var instanceInd = new Apo(fastPeriod, slowPeriod);
|
||||
var instanceResult = instanceInd.Update(closeSeries);
|
||||
double instanceValue = instanceResult.Last.Value;
|
||||
|
||||
// Assert all modes produce identical results
|
||||
Assert.Equal(expected, streamingResult, precision: 9);
|
||||
Assert.Equal(expected, instanceValue, precision: 9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,4 +162,137 @@ public class AroonTests
|
||||
Assert.Equal(100.0, aroon.Down.Value, 1e-9);
|
||||
Assert.Equal(-50.0, result.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var aroon = new Aroon(5);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
|
||||
// Feed 20 new values
|
||||
TBar twentiethInput = default;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
twentiethInput = bar;
|
||||
aroon.Update(bar, isNew: true);
|
||||
}
|
||||
|
||||
// Remember state after 20 values
|
||||
double stateAfterTwenty = aroon.Last.Value;
|
||||
double upAfterTwenty = aroon.Up.Value;
|
||||
double downAfterTwenty = aroon.Down.Value;
|
||||
|
||||
// Generate 9 corrections with isNew=false (different values)
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: false);
|
||||
aroon.Update(bar, isNew: false);
|
||||
}
|
||||
|
||||
// Feed the remembered 20th input again with isNew=false
|
||||
TValue finalResult = aroon.Update(twentiethInput, isNew: false);
|
||||
|
||||
// State should match the original state after 20 values
|
||||
Assert.Equal(stateAfterTwenty, finalResult.Value, 1e-10);
|
||||
Assert.Equal(upAfterTwenty, aroon.Up.Value, 1e-10);
|
||||
Assert.Equal(downAfterTwenty, aroon.Down.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var aroon = new Aroon(5);
|
||||
var gbm = new GBM();
|
||||
|
||||
Assert.False(aroon.IsHot);
|
||||
|
||||
// Feed bars until IsHot becomes true
|
||||
int count = 0;
|
||||
while (!aroon.IsHot && count < 50)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
aroon.Update(bar, isNew: true);
|
||||
count++;
|
||||
}
|
||||
|
||||
Assert.True(aroon.IsHot);
|
||||
Assert.True(count >= 5); // Should take at least period bars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var aroon = new Aroon(5);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed some valid bars first
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
aroon.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Create a bar with NaN values
|
||||
var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
|
||||
var result = aroon.Update(nanBar);
|
||||
|
||||
// Should not crash and should return a finite value
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(double.IsFinite(aroon.Up.Value));
|
||||
Assert.True(double.IsFinite(aroon.Down.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var aroon = new Aroon(5);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed some valid bars first
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
aroon.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Create a bar with Infinity values
|
||||
var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, double.PositiveInfinity);
|
||||
var result = aroon.Update(infBar);
|
||||
|
||||
// Should not crash and should return a finite value
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(double.IsFinite(aroon.Up.Value));
|
||||
Assert.True(double.IsFinite(aroon.Down.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
// Arrange
|
||||
int period = 14;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode (static method)
|
||||
var batchSeries = Aroon.Batch(bars, period);
|
||||
double expected = batchSeries.Last.Value;
|
||||
|
||||
// 2. Streaming Mode (instance, one bar at a time)
|
||||
var streamingInd = new Aroon(period);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
streamingInd.Update(bars[i]);
|
||||
}
|
||||
double streamingResult = streamingInd.Last.Value;
|
||||
|
||||
// 3. Instance Update with TBarSeries
|
||||
var instanceInd = new Aroon(period);
|
||||
var instanceResult = instanceInd.Update(bars);
|
||||
double instanceValue = instanceResult.Last.Value;
|
||||
|
||||
// Assert all modes produce identical results
|
||||
Assert.Equal(expected, streamingResult, precision: 9);
|
||||
Assert.Equal(expected, instanceValue, precision: 9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,4 +156,129 @@ public class AroonOscTests
|
||||
|
||||
Assert.Equal(-50.0, result.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var aroon = new AroonOsc(5);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
|
||||
// Feed 20 new values
|
||||
TBar twentiethInput = default;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
twentiethInput = bar;
|
||||
aroon.Update(bar, isNew: true);
|
||||
}
|
||||
|
||||
// Remember state after 20 values
|
||||
double stateAfterTwenty = aroon.Last.Value;
|
||||
|
||||
// Generate 9 corrections with isNew=false (different values)
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: false);
|
||||
aroon.Update(bar, isNew: false);
|
||||
}
|
||||
|
||||
// Feed the remembered 20th input again with isNew=false
|
||||
TValue finalResult = aroon.Update(twentiethInput, isNew: false);
|
||||
|
||||
// State should match the original state after 20 values
|
||||
Assert.Equal(stateAfterTwenty, finalResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var aroon = new AroonOsc(5);
|
||||
var gbm = new GBM();
|
||||
|
||||
Assert.False(aroon.IsHot);
|
||||
|
||||
// Feed bars until IsHot becomes true
|
||||
int count = 0;
|
||||
while (!aroon.IsHot && count < 50)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
aroon.Update(bar, isNew: true);
|
||||
count++;
|
||||
}
|
||||
|
||||
Assert.True(aroon.IsHot);
|
||||
Assert.True(count >= 5); // Should take at least period bars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var aroon = new AroonOsc(5);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed some valid bars first
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
aroon.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Create a bar with NaN values
|
||||
var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
|
||||
var result = aroon.Update(nanBar);
|
||||
|
||||
// Should not crash and should return a finite value
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var aroon = new AroonOsc(5);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed some valid bars first
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
aroon.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Create a bar with Infinity values
|
||||
var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, double.PositiveInfinity);
|
||||
var result = aroon.Update(infBar);
|
||||
|
||||
// Should not crash and should return a finite value
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
// Arrange
|
||||
int period = 14;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode (static method)
|
||||
var batchSeries = AroonOsc.Batch(bars, period);
|
||||
double expected = batchSeries.Last.Value;
|
||||
|
||||
// 2. Streaming Mode (instance, one bar at a time)
|
||||
var streamingInd = new AroonOsc(period);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
streamingInd.Update(bars[i]);
|
||||
}
|
||||
double streamingResult = streamingInd.Last.Value;
|
||||
|
||||
// 3. Instance Update with TBarSeries
|
||||
var instanceInd = new AroonOsc(period);
|
||||
var instanceResult = instanceInd.Update(bars);
|
||||
double instanceValue = instanceResult.Last.Value;
|
||||
|
||||
// Assert all modes produce identical results
|
||||
Assert.Equal(expected, streamingResult, precision: 9);
|
||||
Assert.Equal(expected, instanceValue, precision: 9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,116 @@ public class BopTests
|
||||
Assert.Equal(-1, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_AcceptsParameter()
|
||||
{
|
||||
// BOP is stateless - each bar is calculated independently
|
||||
// isNew parameter is accepted but doesn't affect stateless calculation
|
||||
var bop = new Bop();
|
||||
// bar1: BOP = (15-10)/(20-5) = 5/15 = 0.333
|
||||
var bar1 = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100);
|
||||
// bar2: BOP = (25-15)/(30-10) = 10/20 = 0.5
|
||||
var bar2 = new TBar(DateTime.UtcNow, 15, 30, 10, 25, 100);
|
||||
|
||||
bop.Update(bar1, isNew: true);
|
||||
var val1 = bop.Last.Value;
|
||||
|
||||
bop.Update(bar2, isNew: true);
|
||||
var val2 = bop.Last.Value;
|
||||
|
||||
// Different bars produce different BOP values
|
||||
Assert.NotEqual(val1, val2);
|
||||
Assert.Equal(1.0 / 3.0, val1, 6); // bar1 BOP
|
||||
Assert.Equal(0.5, val2, 6); // bar2 BOP
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_False_UpdatesValue()
|
||||
{
|
||||
// BOP is stateless - each bar is calculated independently
|
||||
// isNew=false still calculates the new value
|
||||
var bop = new Bop();
|
||||
var bar1 = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100);
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 10, 25, 5, 20, 100);
|
||||
|
||||
var val1 = bop.Update(bar1, isNew: true);
|
||||
var val2 = bop.Update(bar2, isNew: false);
|
||||
|
||||
// Different bars produce different values (BOP has no state to preserve)
|
||||
Assert.NotEqual(val1.Value, val2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var bop = new Bop();
|
||||
var bar = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100);
|
||||
|
||||
var originalValue = bop.Update(bar, isNew: true);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var modified = new TBar(bar.Time, bar.Open, bar.High + i, bar.Low, bar.Close, bar.Volume);
|
||||
bop.Update(modified, isNew: false);
|
||||
}
|
||||
|
||||
var restored = bop.Update(bar, isNew: false);
|
||||
Assert.Equal(originalValue.Value, restored.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var bop = new Bop();
|
||||
var bar = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100);
|
||||
|
||||
bop.Update(bar);
|
||||
bop.Reset();
|
||||
|
||||
Assert.Equal(0, bop.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_AlwaysTrueForBop()
|
||||
{
|
||||
// BOP has no warmup - IsHot is always true (static property)
|
||||
Assert.True(Bop.IsHot);
|
||||
|
||||
var bop = new Bop();
|
||||
var bar = new TBar(DateTime.UtcNow, 10, 20, 5, 15, 100);
|
||||
bop.Update(bar);
|
||||
|
||||
Assert.True(Bop.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_ProducesNaN()
|
||||
{
|
||||
// BOP is stateless and doesn't track last valid value
|
||||
// NaN input propagates through the calculation
|
||||
var bop = new Bop();
|
||||
var barNaN = new TBar(DateTime.UtcNow, double.NaN, 20, 5, 15, 100);
|
||||
|
||||
var result = bop.Update(barNaN);
|
||||
|
||||
// BOP = (Close - Open) / (High - Low) = (15 - NaN) / (20 - 5) = NaN
|
||||
Assert.True(double.IsNaN(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_ProducesInfinity()
|
||||
{
|
||||
// BOP is stateless and doesn't track last valid value
|
||||
// Infinity input propagates through the calculation
|
||||
var bop = new Bop();
|
||||
var barInf = new TBar(DateTime.UtcNow, double.PositiveInfinity, 20, 5, 15, 100);
|
||||
|
||||
var result = bop.Update(barInf);
|
||||
|
||||
// BOP = (Close - Open) / (High - Low) = (15 - Infinity) / (20 - 5) = -Infinity
|
||||
Assert.True(double.IsInfinity(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchMatchesStreaming()
|
||||
{
|
||||
@@ -92,4 +202,48 @@ public class BopTests
|
||||
Assert.Equal(batchResult[0].Value, output[0]);
|
||||
Assert.Equal(batchResult[1].Value, output[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
var gbm = new GBM(seed: 123);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode
|
||||
var batchResult = Bop.Batch(bars);
|
||||
double expected = batchResult.Last.Value;
|
||||
|
||||
// 2. Span Mode
|
||||
var spanOutput = new double[bars.Count];
|
||||
Bop.Calculate(bars.Open.Values, bars.High.Values, bars.Low.Values, bars.Close.Values, spanOutput);
|
||||
double spanResult = spanOutput[^1];
|
||||
|
||||
// 3. Streaming Mode
|
||||
var streamBop = new Bop();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
streamBop.Update(bars[i]);
|
||||
double streamResult = streamBop.Last.Value;
|
||||
|
||||
Assert.Equal(expected, spanResult, 9);
|
||||
Assert.Equal(expected, streamResult, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_ProcessesMinimumLength()
|
||||
{
|
||||
// BOP.Calculate processes the minimum length of all arrays
|
||||
// It doesn't throw when output is smaller - it just processes fewer elements
|
||||
double[] open = [1, 2, 3];
|
||||
double[] high = [2, 3, 4];
|
||||
double[] low = [0, 1, 2];
|
||||
double[] close = [1.5, 2.5, 3.5];
|
||||
double[] smallOutput = new double[2];
|
||||
|
||||
// This should process 2 elements (minimum of all array lengths)
|
||||
Bop.Calculate(open, high, low, close, smallOutput);
|
||||
|
||||
// Verify values are calculated for the first 2 elements
|
||||
Assert.Equal(0.25, smallOutput[0], 6); // (1.5 - 1) / (2 - 0) = 0.5/2 = 0.25
|
||||
Assert.Equal(0.25, smallOutput[1], 6); // (2.5 - 2) / (3 - 1) = 0.5/2 = 0.25
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,24 @@ namespace QuanTAlib;
|
||||
|
||||
public class CfbTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_EmptyLengths_UsesDefaults()
|
||||
{
|
||||
// Cfb uses default lengths (2, 4, ..., 192) when given empty or null lengths
|
||||
var cfb = new Cfb(Array.Empty<int>());
|
||||
Assert.NotNull(cfb);
|
||||
Assert.Equal("Jurik Composite Fractal Behavior", cfb.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CustomLengths_Works()
|
||||
{
|
||||
// Cfb accepts custom lengths
|
||||
var cfb = new Cfb(new[] { 5, 10, 20 });
|
||||
Assert.NotNull(cfb);
|
||||
Assert.Equal("Jurik Composite Fractal Behavior", cfb.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicCalculation_DoesNotCrash()
|
||||
{
|
||||
@@ -110,6 +128,81 @@ public class CfbTests
|
||||
Assert.Equal(val3.Value, val2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var cfb = new Cfb();
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i]));
|
||||
|
||||
var originalValue = cfb.Last;
|
||||
|
||||
for (int m = 0; m < 5; m++)
|
||||
{
|
||||
var modified = new TValue(bars.Close.Times[49], bars.Close.Values[49] + m);
|
||||
cfb.Update(modified, isNew: false);
|
||||
}
|
||||
|
||||
var restored = cfb.Update(new TValue(bars.Close.Times[49], bars.Close.Values[49]), isNew: false);
|
||||
Assert.Equal(originalValue.Value, restored.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var cfb = new Cfb();
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i]));
|
||||
|
||||
var result = cfb.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var cfb = new Cfb();
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i]));
|
||||
|
||||
var result = cfb.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
var gbm = new GBM(seed: 123);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode
|
||||
var batchResult = Cfb.Batch(bars.Close);
|
||||
double expected = batchResult.Last.Value;
|
||||
|
||||
// 2. Span Mode
|
||||
var spanOutput = new double[bars.Count];
|
||||
Cfb.Batch(bars.Close.Values.ToArray(), spanOutput);
|
||||
double spanResult = spanOutput[^1];
|
||||
|
||||
// 3. Streaming Mode
|
||||
var streamCfb = new Cfb();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
streamCfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i]));
|
||||
double streamResult = streamCfb.Last.Value;
|
||||
|
||||
Assert.Equal(expected, spanResult, 9);
|
||||
Assert.Equal(expected, streamResult, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticBatch_Matches_Streaming()
|
||||
{
|
||||
|
||||
@@ -6,6 +6,17 @@ namespace QuanTAlib;
|
||||
|
||||
public class DmxTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_InvalidParameters_ThrowsException()
|
||||
{
|
||||
// Dmx delegates to Jma which throws ArgumentOutOfRangeException (subclass of ArgumentException)
|
||||
var ex1 = Assert.ThrowsAny<ArgumentException>(() => new Dmx(0));
|
||||
Assert.Contains("period", ex1.Message, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var ex2 = Assert.ThrowsAny<ArgumentException>(() => new Dmx(-1));
|
||||
Assert.Contains("period", ex2.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicCalculation_DoesNotCrash()
|
||||
{
|
||||
@@ -52,6 +63,28 @@ public class DmxTests
|
||||
Assert.Equal(val3.Value, val2.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var dmx = new Dmx(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
dmx.Update(bars[i]);
|
||||
|
||||
var originalValue = dmx.Last;
|
||||
|
||||
for (int m = 0; m < 5; m++)
|
||||
{
|
||||
var modified = new TBar(bars[49].Time, bars[49].Open, bars[49].High + m, bars[49].Low - m, bars[49].Close, bars[49].Volume);
|
||||
dmx.Update(modified, isNew: false);
|
||||
}
|
||||
|
||||
var restored = dmx.Update(bars[49], isNew: false);
|
||||
Assert.Equal(originalValue.Value, restored.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_Works()
|
||||
{
|
||||
@@ -76,6 +109,57 @@ public class DmxTests
|
||||
Assert.True(double.IsFinite(dmx.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var dmx = new Dmx(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
dmx.Update(bars[i]);
|
||||
|
||||
var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 100);
|
||||
var result = dmx.Update(nanBar);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var dmx = new Dmx(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
dmx.Update(bars[i]);
|
||||
|
||||
var infBar = new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, 0, 100, 100);
|
||||
var result = dmx.Update(infBar);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
var gbm = new GBM(seed: 123);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode
|
||||
var batchResult = Dmx.Batch(bars, 14);
|
||||
double expected = batchResult.Last.Value;
|
||||
|
||||
// 2. Streaming Mode
|
||||
var streamDmx = new Dmx(14);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
streamDmx.Update(bars[i]);
|
||||
double streamResult = streamDmx.Last.Value;
|
||||
|
||||
Assert.Equal(expected, streamResult, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TBarSeries_Update_Matches_Streaming()
|
||||
{
|
||||
|
||||
@@ -26,7 +26,7 @@ public class MacdIndicatorTests
|
||||
{
|
||||
FastPeriod = 12,
|
||||
SlowPeriod = 26,
|
||||
SignalPeriod = 9
|
||||
SignalPeriod = 9,
|
||||
};
|
||||
|
||||
// 26 + 9 = 35
|
||||
@@ -72,7 +72,7 @@ public class MacdIndicatorTests
|
||||
{
|
||||
FastPeriod = 2,
|
||||
SlowPeriod = 5,
|
||||
SignalPeriod = 2
|
||||
SignalPeriod = 2,
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
|
||||
@@ -12,12 +12,148 @@ public class MacdTests
|
||||
Assert.False(macd.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidParameters_Works()
|
||||
{
|
||||
// Macd delegates to Ema which handles validation
|
||||
// Testing that valid parameters work correctly
|
||||
var macd = new Macd(12, 26, 9);
|
||||
Assert.NotNull(macd);
|
||||
Assert.Equal("Macd(12,26,9)", macd.Name);
|
||||
Assert.Equal(35, macd.WarmupPeriod); // max(12,26) + 9 = 35
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CustomParameters_Works()
|
||||
{
|
||||
var macd = new Macd(5, 10, 3);
|
||||
Assert.NotNull(macd);
|
||||
Assert.Equal("Macd(5,10,3)", macd.Name);
|
||||
Assert.Equal(13, macd.WarmupPeriod); // max(5,10) + 3 = 13
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_AcceptsParameter()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 49; i++)
|
||||
macd.Update(series.Close[i], isNew: true);
|
||||
|
||||
var val1 = macd.Update(series.Close[49], isNew: true);
|
||||
var val2 = macd.Update(new TValue(DateTime.UtcNow, series.Close[49].Value + 1), isNew: true);
|
||||
|
||||
Assert.NotEqual(val1.Value, val2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_False_UpdatesValue()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 49; i++)
|
||||
macd.Update(series.Close[i]);
|
||||
|
||||
var val1 = macd.Update(series.Close[49], isNew: true);
|
||||
var val2 = macd.Update(new TValue(series.Close[49].Time, series.Close[49].Value + 5), isNew: false);
|
||||
|
||||
Assert.Equal(val1.Time, val2.Time);
|
||||
Assert.NotEqual(val1.Value, val2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
macd.Update(series.Close[i]);
|
||||
|
||||
var originalValue = macd.Last;
|
||||
|
||||
for (int m = 0; m < 5; m++)
|
||||
{
|
||||
var modified = new TValue(series.Close[49].Time, series.Close[49].Value + m);
|
||||
macd.Update(modified, isNew: false);
|
||||
}
|
||||
|
||||
var restored = macd.Update(series.Close[49], isNew: false);
|
||||
Assert.Equal(originalValue.Value, restored.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
macd.Update(series.Close[i]);
|
||||
|
||||
macd.Reset();
|
||||
|
||||
Assert.Equal(0, macd.Last.Value);
|
||||
Assert.False(macd.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.False(macd.IsHot);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
macd.Update(series.Close[i]);
|
||||
if (i >= 40) break;
|
||||
}
|
||||
|
||||
Assert.True(macd.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 40; i++)
|
||||
macd.Update(series.Close[i]);
|
||||
|
||||
var result = macd.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var gbm = new GBM();
|
||||
var series = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
for (int i = 0; i < 40; i++)
|
||||
macd.Update(series.Close[i]);
|
||||
|
||||
var result = macd.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchMatchesStreaming()
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var series = new TSeries();
|
||||
// Generate some data
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
series.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + Math.Sin(i * 0.1) * 10));
|
||||
@@ -44,7 +180,6 @@ public class MacdTests
|
||||
{
|
||||
var macd = new Macd(12, 26, 9);
|
||||
var series = new TSeries();
|
||||
// Generate some data
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
series.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + Math.Sin(i * 0.1) * 10));
|
||||
@@ -60,4 +195,50 @@ public class MacdTests
|
||||
Assert.Equal(batchResult[i].Value, output[i], 8);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
var gbm = new GBM(seed: 123);
|
||||
var series = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Batch Mode
|
||||
var batchMacd = new Macd(12, 26, 9);
|
||||
var batchResult = batchMacd.Update(series.Close);
|
||||
double expected = batchResult.Last.Value;
|
||||
|
||||
// 2. Span Mode
|
||||
var spanOutput = new double[series.Count];
|
||||
Macd.Calculate(series.Close.Values, spanOutput, 12, 26);
|
||||
double spanResult = spanOutput[^1];
|
||||
|
||||
// 3. Streaming Mode
|
||||
var streamMacd = new Macd(12, 26, 9);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
streamMacd.Update(series.Close[i]);
|
||||
double streamResult = streamMacd.Last.Value;
|
||||
|
||||
// 4. Eventing Mode
|
||||
var pubSource = new TSeries();
|
||||
var eventMacd = new Macd(pubSource, 12, 26, 9);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
pubSource.Add(series.Close[i]);
|
||||
double eventResult = eventMacd.Last.Value;
|
||||
|
||||
Assert.Equal(expected, spanResult, 9);
|
||||
Assert.Equal(expected, streamResult, 9);
|
||||
Assert.Equal(expected, eventResult, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_ValidatesInput()
|
||||
{
|
||||
double[] source = [1, 2, 3, 4, 5];
|
||||
double[] output = new double[5];
|
||||
double[] wrongSize = new double[3];
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Macd.Calculate(source, wrongSize, 12, 26));
|
||||
Assert.Throws<ArgumentException>(() => Macd.Calculate(source, output, 0, 26));
|
||||
Assert.Throws<ArgumentException>(() => Macd.Calculate(source, output, 12, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public class RsiIndicatorTests
|
||||
{
|
||||
var indicator = new RsiIndicator
|
||||
{
|
||||
Period = 20
|
||||
Period = 20,
|
||||
};
|
||||
|
||||
Assert.Equal(0, RsiIndicator.MinHistoryDepths);
|
||||
@@ -35,7 +35,7 @@ public class RsiIndicatorTests
|
||||
{
|
||||
var indicator = new RsiIndicator
|
||||
{
|
||||
Period = 20
|
||||
Period = 20,
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
@@ -68,7 +68,7 @@ public class RsiIndicatorTests
|
||||
{
|
||||
var indicator = new RsiIndicator
|
||||
{
|
||||
Period = 2 // Short period for testing
|
||||
Period = 2, // Short period for testing
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
|
||||
@@ -127,4 +127,109 @@ public class RsxTests
|
||||
var result = rsx2.Update(new TValue(DateTime.UtcNow, 100));
|
||||
Assert.False(double.IsNaN(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var rsx = new Rsx(5);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
|
||||
// Feed 20 new values
|
||||
TValue twentiethInput = default;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
twentiethInput = new TValue(bar.Time, bar.Close);
|
||||
rsx.Update(twentiethInput, isNew: true);
|
||||
}
|
||||
|
||||
// Remember state after 20 values
|
||||
double stateAfterTwenty = rsx.Last.Value;
|
||||
|
||||
// Generate 9 corrections with isNew=false (different values)
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: false);
|
||||
rsx.Update(new TValue(bar.Time, bar.Close), isNew: false);
|
||||
}
|
||||
|
||||
// Feed the remembered 20th input again with isNew=false
|
||||
TValue finalResult = rsx.Update(twentiethInput, isNew: false);
|
||||
|
||||
// State should match the original state after 20 values
|
||||
Assert.Equal(stateAfterTwenty, finalResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueAfterFirstValue()
|
||||
{
|
||||
var rsx = new Rsx(5);
|
||||
|
||||
Assert.False(rsx.IsHot);
|
||||
|
||||
// RSX uses IsInitialized for IsHot, which becomes true after first value
|
||||
rsx.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
|
||||
|
||||
Assert.True(rsx.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var rsx = new Rsx(14);
|
||||
rsx.Update(new TValue(DateTime.UtcNow, 100));
|
||||
rsx.Update(new TValue(DateTime.UtcNow, 110));
|
||||
|
||||
var resultAfterPosInf = rsx.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.False(double.IsNaN(resultAfterPosInf.Value));
|
||||
Assert.True(double.IsFinite(resultAfterPosInf.Value));
|
||||
Assert.InRange(resultAfterPosInf.Value, 0, 100);
|
||||
|
||||
var resultAfterNegInf = rsx.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
|
||||
Assert.False(double.IsNaN(resultAfterNegInf.Value));
|
||||
Assert.True(double.IsFinite(resultAfterNegInf.Value));
|
||||
Assert.InRange(resultAfterNegInf.Value, 0, 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
// Arrange
|
||||
int period = 14;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = bars.Close;
|
||||
|
||||
// 1. Batch Mode (static method)
|
||||
var batchSeries = Rsx.Batch(series, period);
|
||||
double expected = batchSeries.Last.Value;
|
||||
|
||||
// 2. Span Mode (static method with spans)
|
||||
var spanInput = series.Values.ToArray();
|
||||
var spanOutput = new double[spanInput.Length];
|
||||
Rsx.Batch(spanInput, spanOutput, period);
|
||||
double spanResult = spanOutput[^1];
|
||||
|
||||
// 3. Streaming Mode (instance, one value at a time)
|
||||
var streamingInd = new Rsx(period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
streamingInd.Update(series[i]);
|
||||
}
|
||||
double streamingResult = streamingInd.Last.Value;
|
||||
|
||||
// 4. Eventing Mode (chained via ITValuePublisher)
|
||||
var pubSource = new TSeries();
|
||||
var eventingInd = new Rsx(pubSource, period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
pubSource.Add(series[i]);
|
||||
}
|
||||
double eventingResult = eventingInd.Last.Value;
|
||||
|
||||
// Assert all modes produce identical results
|
||||
Assert.Equal(expected, spanResult, precision: 9);
|
||||
Assert.Equal(expected, streamingResult, precision: 9);
|
||||
Assert.Equal(expected, eventingResult, precision: 9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,4 +161,119 @@ public class VelTests
|
||||
vel.Update(new TValue(DateTime.UtcNow, 100));
|
||||
Assert.False(double.IsNaN(vel2.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var vel = new Vel(5);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
|
||||
// Feed 20 new values
|
||||
TValue twentiethInput = default;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
twentiethInput = new TValue(bar.Time, bar.Close);
|
||||
vel.Update(twentiethInput, isNew: true);
|
||||
}
|
||||
|
||||
// Remember state after 20 values
|
||||
double stateAfterTwenty = vel.Last.Value;
|
||||
|
||||
// Generate 9 corrections with isNew=false (different values)
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: false);
|
||||
vel.Update(new TValue(bar.Time, bar.Close), isNew: false);
|
||||
}
|
||||
|
||||
// Feed the remembered 20th input again with isNew=false
|
||||
TValue finalResult = vel.Update(twentiethInput, isNew: false);
|
||||
|
||||
// State should match the original state after 20 values
|
||||
Assert.Equal(stateAfterTwenty, finalResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var vel = new Vel(5);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed some valid values first
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
vel.Update(new TValue(bars[i].Time, bars[i].Close));
|
||||
}
|
||||
|
||||
// Feed NaN
|
||||
var result = vel.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
// Should not crash and should return a finite value
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var vel = new Vel(5);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed some valid values first
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
vel.Update(new TValue(bars[i].Time, bars[i].Close));
|
||||
}
|
||||
|
||||
// Feed Infinity
|
||||
var resultPos = vel.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(resultPos.Value));
|
||||
|
||||
var resultNeg = vel.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
|
||||
Assert.True(double.IsFinite(resultNeg.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
// Arrange
|
||||
int period = 10;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = bars.Close;
|
||||
|
||||
// 1. Batch Mode (static method)
|
||||
var batchSeries = Vel.Batch(series, period);
|
||||
double expected = batchSeries.Last.Value;
|
||||
|
||||
// 2. Span Mode (static method with spans)
|
||||
var spanInput = series.Values.ToArray();
|
||||
var spanOutput = new double[spanInput.Length];
|
||||
Vel.Batch(spanInput.AsSpan(), spanOutput.AsSpan(), period);
|
||||
double spanResult = spanOutput[^1];
|
||||
|
||||
// 3. Streaming Mode (instance, one value at a time)
|
||||
var streamingInd = new Vel(period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
streamingInd.Update(series[i]);
|
||||
}
|
||||
double streamingResult = streamingInd.Last.Value;
|
||||
|
||||
// 4. Eventing Mode (chained via ITValuePublisher)
|
||||
var pubSource = new TSeries();
|
||||
var eventingInd = new Vel(pubSource, period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
pubSource.Add(series[i]);
|
||||
}
|
||||
double eventingResult = eventingInd.Last.Value;
|
||||
|
||||
// Assert all modes produce identical results
|
||||
Assert.Equal(expected, spanResult, precision: 9);
|
||||
Assert.Equal(expected, streamingResult, precision: 9);
|
||||
Assert.Equal(expected, eventingResult, precision: 9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ public class BesselIndicatorTests
|
||||
SourceType.Low,
|
||||
SourceType.Close,
|
||||
SourceType.HL2,
|
||||
SourceType.HLC3
|
||||
SourceType.HLC3,
|
||||
};
|
||||
|
||||
foreach (var source in sources)
|
||||
|
||||
@@ -32,7 +32,7 @@ public sealed class Bessel : AbstractBase, IDisposable
|
||||
F2 = 0,
|
||||
LastValidValue = 0,
|
||||
Count = 0,
|
||||
IsHot = false
|
||||
IsHot = false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -303,7 +303,6 @@ public sealed class Ema : AbstractBase
|
||||
else
|
||||
val = lastValidValue;
|
||||
|
||||
|
||||
state.Ema = Math.FusedMultiplyAdd(state.Ema, decay, alpha * val);
|
||||
state.E *= decay;
|
||||
|
||||
|
||||
@@ -65,10 +65,12 @@ public sealed class Pwma : AbstractBase
|
||||
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
#pragma warning disable RCS1032 // Remove redundant parentheses
|
||||
private double GetValidValue(double input, double lastValid)
|
||||
{
|
||||
return double.IsFinite(input) ? input : lastValid;
|
||||
}
|
||||
#pragma warning restore RCS1032
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void UpdateLastValidValue(double val)
|
||||
|
||||
@@ -24,7 +24,7 @@ public class AdoscIndicatorTests
|
||||
{
|
||||
var indicator = new AdoscIndicator
|
||||
{
|
||||
SlowPeriod = 20
|
||||
SlowPeriod = 20,
|
||||
};
|
||||
|
||||
Assert.Equal(0, AdoscIndicator.MinHistoryDepths);
|
||||
@@ -37,7 +37,7 @@ public class AdoscIndicatorTests
|
||||
{
|
||||
var indicator = new AdoscIndicator
|
||||
{
|
||||
SlowPeriod = 40
|
||||
SlowPeriod = 40,
|
||||
};
|
||||
|
||||
Assert.Equal(40, indicator.SlowPeriod);
|
||||
|
||||
@@ -71,7 +71,7 @@ public static class IndicatorExtensions
|
||||
SourceType.HLC3 => item => (item[PriceType.High] + item[PriceType.Low] + item[PriceType.Close]) * 0.333333333333333333,
|
||||
SourceType.OHLC4 => item => (item[PriceType.Open] + item[PriceType.High] + item[PriceType.Low] + item[PriceType.Close]) * 0.25,
|
||||
SourceType.HLCC4 => item => (item[PriceType.High] + item[PriceType.Low] + item[PriceType.Close] + item[PriceType.Close]) * 0.25,
|
||||
_ => item => item[PriceType.Close]
|
||||
_ => item => item[PriceType.Close],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Mock types for TradingPlatform.BusinessLayer.Chart to enable testing
|
||||
// These are minimal implementations for unit testing purposes only
|
||||
|
||||
namespace TradingPlatform.BusinessLayer.Chart;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates converter interface
|
||||
/// </summary>
|
||||
public interface IChartWindowCoordinatesConverter
|
||||
{
|
||||
DateTime GetTime(int x);
|
||||
double GetChartX(DateTime time);
|
||||
double GetChartY(double value);
|
||||
}
|
||||
@@ -4,493 +4,479 @@
|
||||
using System.Drawing;
|
||||
using TradingPlatform.BusinessLayer.Chart;
|
||||
|
||||
namespace TradingPlatform.BusinessLayer
|
||||
namespace TradingPlatform.BusinessLayer;
|
||||
|
||||
#region Enums
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the style of indicator line.
|
||||
/// </summary>
|
||||
public enum LineStyle
|
||||
{
|
||||
namespace Chart
|
||||
{
|
||||
/// <summary>
|
||||
/// Coordinates converter interface
|
||||
/// </summary>
|
||||
public interface IChartWindowCoordinatesConverter
|
||||
{
|
||||
DateTime GetTime(int x);
|
||||
double GetChartX(DateTime time);
|
||||
double GetChartY(double value);
|
||||
}
|
||||
}
|
||||
|
||||
#region Enums
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the style of indicator line.
|
||||
/// </summary>
|
||||
public enum LineStyle
|
||||
{
|
||||
Solid,
|
||||
Dash,
|
||||
Dot,
|
||||
DashDot,
|
||||
Histogramm,
|
||||
Points,
|
||||
Columns,
|
||||
StepLine
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Price data types
|
||||
/// </summary>
|
||||
public enum PriceType
|
||||
{
|
||||
Open,
|
||||
High,
|
||||
Low,
|
||||
Close,
|
||||
Median,
|
||||
Typical,
|
||||
Weighted,
|
||||
Bid,
|
||||
BidSize,
|
||||
Ask,
|
||||
AskSize,
|
||||
Last,
|
||||
Volume,
|
||||
Ticks,
|
||||
AggressorFlag,
|
||||
TickDirection,
|
||||
BidTickDirection,
|
||||
AskTickDirection,
|
||||
OpenInterest,
|
||||
Mark,
|
||||
FundingRate,
|
||||
QuoteAssetVolume
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seek origin for historical data
|
||||
/// </summary>
|
||||
public enum SeekOriginHistory
|
||||
{
|
||||
Begin,
|
||||
End
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update reason for indicator
|
||||
/// </summary>
|
||||
public enum UpdateReason
|
||||
{
|
||||
Unknown,
|
||||
HistoricalBar,
|
||||
NewTick,
|
||||
NewBar
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicator line marker icon type
|
||||
/// </summary>
|
||||
public enum IndicatorLineMarkerIconType
|
||||
{
|
||||
None,
|
||||
Point,
|
||||
Circle,
|
||||
Square,
|
||||
Diamond,
|
||||
Triangle,
|
||||
TriangleDown,
|
||||
Cross,
|
||||
Plus,
|
||||
Star,
|
||||
Flag,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Attributes
|
||||
|
||||
/// <summary>
|
||||
/// Attribute for input parameters
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public class InputParameterAttribute(
|
||||
string name = "",
|
||||
int sortIndex = 0,
|
||||
double minimum = int.MinValue,
|
||||
double maximum = int.MaxValue,
|
||||
double increment = 0.01,
|
||||
int decimalPlaces = 2,
|
||||
object[]? variants = null) : Attribute
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public int SortIndex { get; } = sortIndex;
|
||||
public double Minimum { get; } = minimum;
|
||||
public double Maximum { get; } = maximum;
|
||||
public double Increment { get; } = increment;
|
||||
public int DecimalPlaces { get; } = decimalPlaces;
|
||||
public IComparable[]? Variants { get; } = variants?.Cast<IComparable>().ToArray();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region History Item
|
||||
|
||||
/// <summary>
|
||||
/// History item interface
|
||||
/// </summary>
|
||||
public interface IHistoryItem
|
||||
{
|
||||
DateTime TimeLeft { get; }
|
||||
long TicksLeft { get; set; }
|
||||
long TicksRight { get; set; }
|
||||
double this[PriceType priceType] { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock history item for testing
|
||||
/// </summary>
|
||||
public class MockHistoryItem : IHistoryItem
|
||||
{
|
||||
public DateTime TimeLeft { get; set; }
|
||||
public long TicksLeft { get; set; }
|
||||
public long TicksRight { get; set; }
|
||||
public double Open { get; set; }
|
||||
public double High { get; set; }
|
||||
public double Low { get; set; }
|
||||
public double Close { get; set; }
|
||||
public double Volume { get; set; }
|
||||
|
||||
public double this[PriceType priceType] => priceType switch
|
||||
{
|
||||
PriceType.Open => Open,
|
||||
PriceType.High => High,
|
||||
PriceType.Low => Low,
|
||||
PriceType.Close => Close,
|
||||
PriceType.Volume => Volume,
|
||||
PriceType.Median => (High + Low) / 2,
|
||||
PriceType.Typical => (High + Low + Close) / 3,
|
||||
PriceType.Weighted => (High + Low + Close + Close) / 4,
|
||||
_ => Close
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Historical Data
|
||||
|
||||
/// <summary>
|
||||
/// Mock historical data for testing
|
||||
/// </summary>
|
||||
public class HistoricalData
|
||||
{
|
||||
private readonly List<IHistoryItem> _items = [];
|
||||
|
||||
public int Count => _items.Count;
|
||||
|
||||
public IHistoryItem this[int offset, SeekOriginHistory origin = SeekOriginHistory.End]
|
||||
{
|
||||
get
|
||||
{
|
||||
int index = origin == SeekOriginHistory.End
|
||||
? Count - 1 - offset
|
||||
: offset;
|
||||
return _items[index];
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime Time(int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End)
|
||||
{
|
||||
return this[offset, origin].TimeLeft;
|
||||
}
|
||||
|
||||
public long GetIndexByTime(long ticks)
|
||||
{
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
{
|
||||
if (_items[i].TicksLeft == ticks)
|
||||
return Count - 1 - i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public void Add(IHistoryItem item)
|
||||
{
|
||||
_items.Add(item);
|
||||
}
|
||||
|
||||
public void AddBar(DateTime time, double open, double high, double low, double close, double volume = 0)
|
||||
{
|
||||
_items.Add(new MockHistoryItem
|
||||
{
|
||||
TimeLeft = time,
|
||||
TicksLeft = time.Ticks,
|
||||
TicksRight = time.Ticks,
|
||||
Open = open,
|
||||
High = high,
|
||||
Low = low,
|
||||
Close = close,
|
||||
Volume = volume
|
||||
});
|
||||
}
|
||||
|
||||
public void Clear() => _items.Clear();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Update Args
|
||||
|
||||
/// <summary>
|
||||
/// Update arguments for indicator
|
||||
/// </summary>
|
||||
public class UpdateArgs(UpdateReason reason)
|
||||
{
|
||||
public UpdateReason Reason { get; } = reason;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Line Series
|
||||
|
||||
/// <summary>
|
||||
/// Base class for lines
|
||||
/// </summary>
|
||||
public class IndicatorLineMarker(Color color, IndicatorLineMarkerIconType icon = IndicatorLineMarkerIconType.None)
|
||||
{
|
||||
public Color Color { get; set; } = color;
|
||||
public IndicatorLineMarkerIconType Icon { get; set; } = icon;
|
||||
}
|
||||
|
||||
public class Line(string name, Color color, int width, LineStyle style)
|
||||
{
|
||||
public string Name { get; set; } = name;
|
||||
public Color Color { get; set; } = color;
|
||||
public int Width { get; set; } = width;
|
||||
public LineStyle Style { get; set; } = style;
|
||||
public bool Visible { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Line series for indicator output
|
||||
/// </summary>
|
||||
public class LineSeries(string name, Color color, int width, LineStyle style)
|
||||
: Line(name, color, width, style)
|
||||
{
|
||||
private readonly List<double> _values = [];
|
||||
private readonly List<Color> _markers = [];
|
||||
|
||||
public int TimeShift { get; set; }
|
||||
public int DrawBegin { get; set; }
|
||||
public bool ShowLineMarker { get; set; } = true;
|
||||
|
||||
public double this[int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End]
|
||||
{
|
||||
get => GetValue(offset, origin);
|
||||
set => SetValue(value, offset, origin);
|
||||
}
|
||||
|
||||
public double GetValue(int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End)
|
||||
{
|
||||
if (_values.Count == 0)
|
||||
return double.NaN;
|
||||
|
||||
int index = origin == SeekOriginHistory.End
|
||||
? _values.Count - 1 - offset
|
||||
: offset;
|
||||
|
||||
if (index < 0 || index >= _values.Count)
|
||||
return double.NaN;
|
||||
|
||||
return _values[index];
|
||||
}
|
||||
|
||||
public void SetValue(double value, int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End)
|
||||
{
|
||||
EnsureCapacity(offset + 1);
|
||||
int index = origin == SeekOriginHistory.End
|
||||
? _values.Count - 1 - offset
|
||||
: offset;
|
||||
_values[index] = value;
|
||||
}
|
||||
|
||||
public void SetMarker(int offset, Color color)
|
||||
{
|
||||
EnsureMarkerCapacity(offset + 1);
|
||||
int index = _markers.Count - 1 - offset;
|
||||
if (index >= 0 && index < _markers.Count)
|
||||
_markers[index] = color;
|
||||
}
|
||||
|
||||
public void SetMarker(int offset, IndicatorLineMarker marker)
|
||||
{
|
||||
SetMarker(offset, marker.Color);
|
||||
}
|
||||
|
||||
internal void AddValue()
|
||||
{
|
||||
_values.Add(double.NaN);
|
||||
_markers.Add(Color.Transparent);
|
||||
}
|
||||
|
||||
private void EnsureCapacity(int count)
|
||||
{
|
||||
while (_values.Count < count)
|
||||
_values.Add(double.NaN);
|
||||
}
|
||||
|
||||
private void EnsureMarkerCapacity(int count)
|
||||
{
|
||||
while (_markers.Count < count)
|
||||
_markers.Add(Color.Transparent);
|
||||
}
|
||||
|
||||
public int Count => _values.Count;
|
||||
public IReadOnlyList<double> Values => _values;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Paint Chart Event Args
|
||||
|
||||
/// <summary>
|
||||
/// Paint chart event arguments
|
||||
/// </summary>
|
||||
public class PaintChartEventArgs(Graphics graphics, Rectangle clipRectangle, int windowIndex = 0) : EventArgs
|
||||
{
|
||||
public Graphics Graphics { get; } = graphics;
|
||||
public Rectangle ClipRectangle { get; } = clipRectangle;
|
||||
public int WindowIndex { get; } = windowIndex;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Chart
|
||||
|
||||
/// <summary>
|
||||
/// Chart interface
|
||||
/// </summary>
|
||||
public interface IChart
|
||||
{
|
||||
ChartWindow MainWindow { get; }
|
||||
IList<ChartWindow> Windows { get; }
|
||||
int BarsWidth { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chart window
|
||||
/// </summary>
|
||||
public class ChartWindow
|
||||
{
|
||||
public Rectangle ClientRectangle { get; set; }
|
||||
public IChartWindowCoordinatesConverter CoordinatesConverter { get; set; } = new MockCoordinatesConverter();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock coordinates converter
|
||||
/// </summary>
|
||||
public class MockCoordinatesConverter : IChartWindowCoordinatesConverter
|
||||
{
|
||||
public DateTime GetTime(int x) => DateTime.UtcNow;
|
||||
public double GetChartX(DateTime time) => 0;
|
||||
public double GetChartY(double value) => 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock chart for testing
|
||||
/// </summary>
|
||||
public class MockChart : IChart
|
||||
{
|
||||
public ChartWindow MainWindow { get; } = new();
|
||||
public IList<ChartWindow> Windows { get; } = [new ChartWindow()];
|
||||
public int BarsWidth { get; set; } = 10;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Indicator Base
|
||||
|
||||
/// <summary>
|
||||
/// Watchlist indicator interface
|
||||
/// </summary>
|
||||
public interface IWatchlistIndicator
|
||||
{
|
||||
int MinHistoryDepths { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base class for indicators
|
||||
/// </summary>
|
||||
public abstract class Indicator
|
||||
{
|
||||
private readonly List<LineSeries> _lineSeries = [];
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public virtual string ShortName => Name;
|
||||
public virtual string SourceCodeLink => string.Empty;
|
||||
|
||||
public bool SeparateWindow { get; set; }
|
||||
public bool OnBackGround { get; set; }
|
||||
|
||||
public HistoricalData HistoricalData { get; set; } = new();
|
||||
public IChart? CurrentChart { get; set; }
|
||||
|
||||
public int Count => HistoricalData.Count;
|
||||
|
||||
public IList<LineSeries> LinesSeries => _lineSeries.ToArray();
|
||||
|
||||
protected void AddLineSeries(LineSeries series)
|
||||
{
|
||||
_lineSeries.Add(series);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when indicator is initialized
|
||||
/// </summary>
|
||||
protected virtual void OnInit()
|
||||
{
|
||||
// Intentionally empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called on each update
|
||||
/// </summary>
|
||||
protected virtual void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
// Intentionally empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called for chart painting
|
||||
/// </summary>
|
||||
public virtual void OnPaintChart(PaintChartEventArgs args)
|
||||
{
|
||||
// Intentionally empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the indicator (for testing)
|
||||
/// </summary>
|
||||
public void Initialize()
|
||||
{
|
||||
OnInit();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process an update (for testing)
|
||||
/// </summary>
|
||||
public void ProcessUpdate(UpdateArgs args)
|
||||
{
|
||||
// Ensure line series have capacity for new data
|
||||
foreach (var series in _lineSeries)
|
||||
{
|
||||
series.AddValue();
|
||||
}
|
||||
OnUpdate(args);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
Solid,
|
||||
Dash,
|
||||
Dot,
|
||||
DashDot,
|
||||
Histogramm,
|
||||
Points,
|
||||
Columns,
|
||||
StepLine,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Price data types
|
||||
/// </summary>
|
||||
public enum PriceType
|
||||
{
|
||||
Open,
|
||||
High,
|
||||
Low,
|
||||
Close,
|
||||
Median,
|
||||
Typical,
|
||||
Weighted,
|
||||
Bid,
|
||||
BidSize,
|
||||
Ask,
|
||||
AskSize,
|
||||
Last,
|
||||
Volume,
|
||||
Ticks,
|
||||
AggressorFlag,
|
||||
TickDirection,
|
||||
BidTickDirection,
|
||||
AskTickDirection,
|
||||
OpenInterest,
|
||||
Mark,
|
||||
FundingRate,
|
||||
QuoteAssetVolume,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seek origin for historical data
|
||||
/// </summary>
|
||||
public enum SeekOriginHistory
|
||||
{
|
||||
Begin,
|
||||
End,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update reason for indicator
|
||||
/// </summary>
|
||||
public enum UpdateReason
|
||||
{
|
||||
Unknown,
|
||||
HistoricalBar,
|
||||
NewTick,
|
||||
NewBar,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicator line marker icon type
|
||||
/// </summary>
|
||||
public enum IndicatorLineMarkerIconType
|
||||
{
|
||||
None,
|
||||
Point,
|
||||
Circle,
|
||||
Square,
|
||||
Diamond,
|
||||
Triangle,
|
||||
TriangleDown,
|
||||
Cross,
|
||||
Plus,
|
||||
Star,
|
||||
Flag,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Attributes
|
||||
|
||||
/// <summary>
|
||||
/// Attribute for input parameters
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public class InputParameterAttribute(
|
||||
string name = "",
|
||||
int sortIndex = 0,
|
||||
double minimum = int.MinValue,
|
||||
double maximum = int.MaxValue,
|
||||
double increment = 0.01,
|
||||
int decimalPlaces = 2,
|
||||
object[]? variants = null) : Attribute
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public int SortIndex { get; } = sortIndex;
|
||||
public double Minimum { get; } = minimum;
|
||||
public double Maximum { get; } = maximum;
|
||||
public double Increment { get; } = increment;
|
||||
public int DecimalPlaces { get; } = decimalPlaces;
|
||||
public IComparable[]? Variants { get; } = variants?.Cast<IComparable>().ToArray();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region History Item
|
||||
|
||||
/// <summary>
|
||||
/// History item interface
|
||||
/// </summary>
|
||||
public interface IHistoryItem
|
||||
{
|
||||
DateTime TimeLeft { get; }
|
||||
long TicksLeft { get; set; }
|
||||
long TicksRight { get; set; }
|
||||
double this[PriceType priceType] { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock history item for testing
|
||||
/// </summary>
|
||||
public class MockHistoryItem : IHistoryItem
|
||||
{
|
||||
public DateTime TimeLeft { get; set; }
|
||||
public long TicksLeft { get; set; }
|
||||
public long TicksRight { get; set; }
|
||||
public double Open { get; set; }
|
||||
public double High { get; set; }
|
||||
public double Low { get; set; }
|
||||
public double Close { get; set; }
|
||||
public double Volume { get; set; }
|
||||
|
||||
public double this[PriceType priceType] => priceType switch
|
||||
{
|
||||
PriceType.Open => Open,
|
||||
PriceType.High => High,
|
||||
PriceType.Low => Low,
|
||||
PriceType.Close => Close,
|
||||
PriceType.Volume => Volume,
|
||||
PriceType.Median => (High + Low) / 2,
|
||||
PriceType.Typical => (High + Low + Close) / 3,
|
||||
PriceType.Weighted => (High + Low + Close + Close) / 4,
|
||||
_ => Close,
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Historical Data
|
||||
|
||||
/// <summary>
|
||||
/// Mock historical data for testing
|
||||
/// </summary>
|
||||
public class HistoricalData
|
||||
{
|
||||
private readonly List<IHistoryItem> _items = [];
|
||||
|
||||
public int Count => _items.Count;
|
||||
|
||||
public IHistoryItem this[int offset, SeekOriginHistory origin = SeekOriginHistory.End]
|
||||
{
|
||||
get
|
||||
{
|
||||
int index = origin == SeekOriginHistory.End
|
||||
? Count - 1 - offset
|
||||
: offset;
|
||||
return _items[index];
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime Time(int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End)
|
||||
{
|
||||
return this[offset, origin].TimeLeft;
|
||||
}
|
||||
|
||||
public long GetIndexByTime(long ticks)
|
||||
{
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
{
|
||||
if (_items[i].TicksLeft == ticks)
|
||||
return Count - 1 - i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public void Add(IHistoryItem item)
|
||||
{
|
||||
_items.Add(item);
|
||||
}
|
||||
|
||||
public void AddBar(DateTime time, double open, double high, double low, double close, double volume = 0)
|
||||
{
|
||||
_items.Add(new MockHistoryItem
|
||||
{
|
||||
TimeLeft = time,
|
||||
TicksLeft = time.Ticks,
|
||||
TicksRight = time.Ticks,
|
||||
Open = open,
|
||||
High = high,
|
||||
Low = low,
|
||||
Close = close,
|
||||
Volume = volume
|
||||
});
|
||||
}
|
||||
|
||||
public void Clear() => _items.Clear();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Update Args
|
||||
|
||||
/// <summary>
|
||||
/// Update arguments for indicator
|
||||
/// </summary>
|
||||
public class UpdateArgs(UpdateReason reason)
|
||||
{
|
||||
public UpdateReason Reason { get; } = reason;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Line Series
|
||||
|
||||
/// <summary>
|
||||
/// Base class for lines
|
||||
/// </summary>
|
||||
public class IndicatorLineMarker(Color color, IndicatorLineMarkerIconType icon = IndicatorLineMarkerIconType.None)
|
||||
{
|
||||
public Color Color { get; set; } = color;
|
||||
public IndicatorLineMarkerIconType Icon { get; set; } = icon;
|
||||
}
|
||||
|
||||
public class Line(string name, Color color, int width, LineStyle style)
|
||||
{
|
||||
public string Name { get; set; } = name;
|
||||
public Color Color { get; set; } = color;
|
||||
public int Width { get; set; } = width;
|
||||
public LineStyle Style { get; set; } = style;
|
||||
public bool Visible { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Line series for indicator output
|
||||
/// </summary>
|
||||
public class LineSeries(string name, Color color, int width, LineStyle style)
|
||||
: Line(name, color, width, style)
|
||||
{
|
||||
private readonly List<double> _values = [];
|
||||
private readonly List<Color> _markers = [];
|
||||
|
||||
public int TimeShift { get; set; }
|
||||
public int DrawBegin { get; set; }
|
||||
public bool ShowLineMarker { get; set; } = true;
|
||||
|
||||
public double this[int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End]
|
||||
{
|
||||
get => GetValue(offset, origin);
|
||||
set => SetValue(value, offset, origin);
|
||||
}
|
||||
|
||||
public double GetValue(int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End)
|
||||
{
|
||||
if (_values.Count == 0)
|
||||
return double.NaN;
|
||||
|
||||
int index = origin == SeekOriginHistory.End
|
||||
? _values.Count - 1 - offset
|
||||
: offset;
|
||||
|
||||
if (index < 0 || index >= _values.Count)
|
||||
return double.NaN;
|
||||
|
||||
return _values[index];
|
||||
}
|
||||
|
||||
public void SetValue(double value, int offset = 0, SeekOriginHistory origin = SeekOriginHistory.End)
|
||||
{
|
||||
EnsureCapacity(offset + 1);
|
||||
int index = origin == SeekOriginHistory.End
|
||||
? _values.Count - 1 - offset
|
||||
: offset;
|
||||
_values[index] = value;
|
||||
}
|
||||
|
||||
public void SetMarker(int offset, Color color)
|
||||
{
|
||||
EnsureMarkerCapacity(offset + 1);
|
||||
int index = _markers.Count - 1 - offset;
|
||||
if (index >= 0 && index < _markers.Count)
|
||||
_markers[index] = color;
|
||||
}
|
||||
|
||||
public void SetMarker(int offset, IndicatorLineMarker marker)
|
||||
{
|
||||
SetMarker(offset, marker.Color);
|
||||
}
|
||||
|
||||
internal void AddValue()
|
||||
{
|
||||
_values.Add(double.NaN);
|
||||
_markers.Add(Color.Transparent);
|
||||
}
|
||||
|
||||
private void EnsureCapacity(int count)
|
||||
{
|
||||
while (_values.Count < count)
|
||||
_values.Add(double.NaN);
|
||||
}
|
||||
|
||||
private void EnsureMarkerCapacity(int count)
|
||||
{
|
||||
while (_markers.Count < count)
|
||||
_markers.Add(Color.Transparent);
|
||||
}
|
||||
|
||||
public int Count => _values.Count;
|
||||
public IReadOnlyList<double> Values => _values;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Paint Chart Event Args
|
||||
|
||||
/// <summary>
|
||||
/// Paint chart event arguments
|
||||
/// </summary>
|
||||
public class PaintChartEventArgs(Graphics graphics, Rectangle clipRectangle, int windowIndex = 0) : EventArgs
|
||||
{
|
||||
public Graphics Graphics { get; } = graphics;
|
||||
public Rectangle ClipRectangle { get; } = clipRectangle;
|
||||
public int WindowIndex { get; } = windowIndex;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Chart
|
||||
|
||||
/// <summary>
|
||||
/// Chart interface
|
||||
/// </summary>
|
||||
public interface IChart
|
||||
{
|
||||
ChartWindow MainWindow { get; }
|
||||
IList<ChartWindow> Windows { get; }
|
||||
int BarsWidth { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chart window
|
||||
/// </summary>
|
||||
public class ChartWindow
|
||||
{
|
||||
public Rectangle ClientRectangle { get; set; }
|
||||
public IChartWindowCoordinatesConverter CoordinatesConverter { get; set; } = new MockCoordinatesConverter();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock coordinates converter
|
||||
/// </summary>
|
||||
public class MockCoordinatesConverter : IChartWindowCoordinatesConverter
|
||||
{
|
||||
public DateTime GetTime(int x) => DateTime.UtcNow;
|
||||
public double GetChartX(DateTime time) => 0;
|
||||
public double GetChartY(double value) => 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock chart for testing
|
||||
/// </summary>
|
||||
public class MockChart : IChart
|
||||
{
|
||||
public ChartWindow MainWindow { get; } = new();
|
||||
public IList<ChartWindow> Windows { get; } = [new ChartWindow()];
|
||||
public int BarsWidth { get; set; } = 10;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Indicator Base
|
||||
|
||||
/// <summary>
|
||||
/// Watchlist indicator interface
|
||||
/// </summary>
|
||||
public interface IWatchlistIndicator
|
||||
{
|
||||
int MinHistoryDepths { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base class for indicators
|
||||
/// </summary>
|
||||
public abstract class Indicator
|
||||
{
|
||||
private readonly List<LineSeries> _lineSeries = [];
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public virtual string ShortName => Name;
|
||||
public virtual string SourceCodeLink => string.Empty;
|
||||
|
||||
public bool SeparateWindow { get; set; }
|
||||
public bool OnBackGround { get; set; }
|
||||
|
||||
public HistoricalData HistoricalData { get; set; } = new();
|
||||
public IChart? CurrentChart { get; set; }
|
||||
|
||||
public int Count => HistoricalData.Count;
|
||||
|
||||
public IList<LineSeries> LinesSeries => _lineSeries.ToArray();
|
||||
|
||||
protected void AddLineSeries(LineSeries series)
|
||||
{
|
||||
_lineSeries.Add(series);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when indicator is initialized
|
||||
/// </summary>
|
||||
protected virtual void OnInit()
|
||||
{
|
||||
// Intentionally empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called on each update
|
||||
/// </summary>
|
||||
protected virtual void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
// Intentionally empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called for chart painting
|
||||
/// </summary>
|
||||
public virtual void OnPaintChart(PaintChartEventArgs args)
|
||||
{
|
||||
// Intentionally empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the indicator (for testing)
|
||||
/// </summary>
|
||||
public void Initialize()
|
||||
{
|
||||
OnInit();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process an update (for testing)
|
||||
/// </summary>
|
||||
public void ProcessUpdate(UpdateArgs args)
|
||||
{
|
||||
// Ensure line series have capacity for new data
|
||||
foreach (var series in _lineSeries)
|
||||
{
|
||||
series.AddValue();
|
||||
}
|
||||
OnUpdate(args);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
Reference in New Issue
Block a user