python wrapper

This commit is contained in:
Miha Kralj
2026-02-28 14:14:35 -08:00
parent 82e0248eb0
commit 83e9511261
521 changed files with 62395 additions and 15669 deletions
+134
View File
@@ -0,0 +1,134 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class Oc2IndicatorTests
{
[Fact]
public void Oc2Indicator_Constructor_SetsDefaults()
{
var indicator = new MidbodyIndicator();
Assert.True(indicator.ShowColdValues);
Assert.Equal("MIDBODY - Open-Close Average", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void Oc2Indicator_ShortName_IsOc2()
{
var indicator = new MidbodyIndicator();
Assert.Equal("MIDBODY", indicator.ShortName);
}
[Fact]
public void Oc2Indicator_MinHistoryDepths_EqualsOne()
{
var indicator = new MidbodyIndicator();
Assert.Equal(1, MidbodyIndicator.MinHistoryDepths);
Assert.Equal(1, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void Oc2Indicator_Initialize_CreatesInternalIndicator()
{
var indicator = new MidbodyIndicator();
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void Oc2Indicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new MidbodyIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
double basePrice = 100 + i;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 1, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
}
[Fact]
public void Oc2Indicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new MidbodyIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 1000);
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.HistoricalData.AddBar(now.AddMinutes(10), 110, 115, 105, 112, 1500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void Oc2Indicator_ShowColdValues_CanBeToggled()
{
var indicator = new MidbodyIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void Oc2Indicator_SourceCodeLink_IsValid()
{
var indicator = new MidbodyIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Midbody.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void Oc2Indicator_ComputesCorrectOc2()
{
var indicator = new MidbodyIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// O=100, C=105 → (100+105)/2 = 102.5
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double val = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(102.5, val, 10);
}
[Fact]
public void Oc2Indicator_IsHotImmediately()
{
var indicator = new MidbodyIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
}
}
+56
View File
@@ -0,0 +1,56 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class MidbodyIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Midbody _midbody = null!;
private readonly LineSeries _series;
public static int MinHistoryDepths => 1;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => "MIDBODY";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/core/midbody/Midbody.Quantower.cs";
public MidbodyIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "MIDBODY - Open-Close Average";
Description = "Midpoint of Open and Close prices: (O+C)/2.";
_series = new LineSeries(name: "MIDBODY", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_midbody = new Midbody();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _midbody.Update(bar, isNew: args.IsNewBar());
_series.SetValue(result.Value, _midbody.IsHot, ShowColdValues);
}
}
+260
View File
@@ -0,0 +1,260 @@
// Midbody Unit Tests
using Xunit;
namespace QuanTAlib.Tests;
public class Oc2Tests
{
private readonly GBM _gbm;
private const double Tolerance = 1e-10;
public Oc2Tests()
{
_gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
}
private TBarSeries GenerateBars(int count)
{
_gbm.Reset(DateTime.UtcNow.Ticks);
return _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
#region Constructor Tests
[Fact]
public void Constructor_DefaultParameters_SetsCorrectValues()
{
var indicator = new Midbody();
Assert.Equal("Midbody", indicator.Name);
Assert.Equal(1, indicator.WarmupPeriod);
}
[Fact]
public void Constructor_WithSource_SubscribesToEvents()
{
var source = new TSeries();
var indicator = new Midbody(source);
source.Add(new TValue(DateTime.UtcNow, 100.0));
Assert.NotEqual(default, indicator.Last);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_Bar_ReturnsOC2()
{
var indicator = new Midbody();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.Update(bar);
// (100 + 105) / 2 = 102.5
Assert.Equal(102.5, result.Value, Tolerance);
}
[Fact]
public void Update_Bar_MatchesTBarOC2()
{
var indicator = new Midbody();
var bar = new TBar(DateTime.UtcNow, 50, 60, 40, 55, 500);
var result = indicator.Update(bar);
Assert.Equal(bar.OC2, result.Value, Tolerance);
}
[Fact]
public void Update_TValue_ReturnsIdentity()
{
var indicator = new Midbody();
var result = indicator.Update(new TValue(DateTime.UtcNow, 42.0));
Assert.Equal(42.0, result.Value, Tolerance);
}
#endregion
#region State and Bar Correction Tests
[Fact]
public void IsHot_AfterFirstBar_ReturnsTrue()
{
var indicator = new Midbody();
Assert.False(indicator.IsHot);
indicator.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
Assert.True(indicator.IsHot);
}
[Fact]
public void Update_IsNewFalse_RestoresPreviousState()
{
var indicator = new Midbody();
var time = DateTime.UtcNow;
indicator.Update(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
indicator.Update(new TBar(time.AddMinutes(1), 105, 115, 95, 110, 1000), isNew: true);
var corrected = indicator.Update(new TBar(time.AddMinutes(1), 106, 120, 80, 111, 1000), isNew: false);
double expected = (106 + 111) * 0.5;
Assert.Equal(expected, corrected.Value, Tolerance);
}
[Fact]
public void Update_MultipleIsNewFalse_ProducesIdempotentResults()
{
var indicator = new Midbody();
var time = DateTime.UtcNow;
indicator.Update(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
var bar = new TBar(time.AddMinutes(1), 105, 115, 95, 110, 1000);
var result1 = indicator.Update(bar, isNew: false);
var result2 = indicator.Update(bar, isNew: false);
var result3 = indicator.Update(bar, isNew: false);
Assert.Equal(result1.Value, result2.Value, Tolerance);
Assert.Equal(result2.Value, result3.Value, Tolerance);
}
[Fact]
public void Reset_ClearsState()
{
var indicator = new Midbody();
indicator.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
Assert.True(indicator.IsHot);
indicator.Reset();
Assert.False(indicator.IsHot);
Assert.Equal(default, indicator.Last);
}
#endregion
#region NaN/Infinity Robustness Tests
[Fact]
public void Update_NaN_UsesLastValidValue()
{
var indicator = new Midbody();
var time = DateTime.UtcNow;
indicator.Update(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
double validResult = indicator.Last.Value;
var nanBar = new TBar(time.AddMinutes(1), double.NaN, double.NaN, double.NaN, double.NaN, 1000);
var result = indicator.Update(nanBar, isNew: true);
Assert.True(double.IsFinite(result.Value));
Assert.Equal(validResult, result.Value, Tolerance);
}
#endregion
#region Consistency Tests (All Modes)
[Fact]
public void AllModes_ProduceConsistentResults()
{
var bars = GenerateBars(100);
// Mode 1: Streaming
var streaming = new Midbody();
double[] streamingResults = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
streamingResults[i] = streaming.Update(bars[i], isNew: true).Value;
}
// Mode 2: Batch (TBarSeries)
var batchResult = Midbody.Batch(bars);
// Mode 3: Span batch
double[] spanOutput = new double[bars.Count];
Midbody.Batch(bars.OpenValues, bars.CloseValues, spanOutput);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingResults[i], batchResult.Values[i], Tolerance);
Assert.Equal(streamingResults[i], spanOutput[i], Tolerance);
}
}
[Fact]
public void AllBars_MatchTBarOC2()
{
var bars = GenerateBars(50);
var indicator = new Midbody();
for (int i = 0; i < bars.Count; i++)
{
var result = indicator.Update(bars[i], isNew: true);
Assert.Equal(bars[i].OC2, result.Value, Tolerance);
}
}
#endregion
#region Batch Validation Tests
[Fact]
public void Batch_MismatchedLengths_ThrowsArgumentException()
{
double[] open = new double[10];
double[] close = new double[5]; // mismatched
double[] output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Midbody.Batch(open, close, output));
Assert.Equal("close", ex.ParamName);
}
[Fact]
public void Batch_OutputTooShort_ThrowsArgumentException()
{
double[] open = new double[10];
double[] close = new double[10];
double[] output = new double[5]; // too short
var ex = Assert.Throws<ArgumentException>(() => Midbody.Batch(open, close, output));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_EmptyInput_NoOutput()
{
var bars = new TBarSeries();
var result = Midbody.Batch(bars);
Assert.Empty(result);
}
[Fact]
public void Batch_LargeDataset_NoStackOverflow()
{
var bars = GenerateBars(10_000);
double[] output = new double[bars.Count];
Midbody.Batch(bars.OpenValues, bars.CloseValues, output);
Assert.True(double.IsFinite(output[^1]));
}
#endregion
#region Event Chaining Tests
[Fact]
public void Pub_EventFires_OnUpdate()
{
var indicator = new Midbody();
bool fired = false;
indicator.Pub += (object? sender, in TValueEventArgs args) => fired = true;
indicator.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
Assert.True(fired);
}
[Fact]
public void Calculate_Static_ReturnsResultsAndIndicator()
{
var bars = GenerateBars(50);
var (results, ind) = Midbody.Calculate(bars);
Assert.Equal(bars.Count, results.Count);
Assert.True(ind.IsHot);
}
#endregion
}
@@ -0,0 +1,218 @@
using System.Runtime.CompilerServices;
using Skender.Stock.Indicators;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation for Midbody (Open-Close Average) = (O+C)/2.
/// Cross-validated against Skender CandlePart.OC2.
/// Note: TA-Lib does not have an OC2 function.
/// </summary>
public sealed class Oc2ValidationTests : IDisposable
{
private readonly ValidationTestData _data = new();
private readonly ITestOutputHelper _output;
private bool _disposed;
public Oc2ValidationTests(ITestOutputHelper output)
{
_output = output;
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
private void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
_data.Dispose();
_disposed = true;
}
}
// ── A) Formula verification: (O+C)/2 ──────────────────────────────────────
[Fact]
public void Validate_Formula_Manual()
{
var bar = new TBar(DateTime.UtcNow, open: 10.0, high: 20.0, low: 5.0, close: 15.0, volume: 1000);
var ind = new Midbody();
var result = ind.Update(bar, isNew: true);
double expected = (10.0 + 15.0) / 2.0; // = 12.5
Assert.Equal(expected, result.Value, 1e-12);
_output.WriteLine($"Midbody formula: expected={expected}, actual={result.Value}: PASSED");
}
// ── B) Streaming == Batch span ────────────────────────────────────────────
[Fact]
[SkipLocalsInit]
public void Validate_Streaming_Equals_Batch()
{
const int N = 200;
var gbm = new GBM(100.0, 0.05, 0.2, seed: 1001);
var bars = new TBar[N];
for (int i = 0; i < N; i++) { bars[i] = gbm.Next(isNew: true); }
// Streaming
var ind = new Midbody();
for (int i = 0; i < N; i++) { ind.Update(bars[i], isNew: true); }
double streamVal = ind.Last.Value;
// Batch span
double[] o = new double[N], c = new double[N];
for (int i = 0; i < N; i++) { o[i] = bars[i].Open; c[i] = bars[i].Close; }
var qlOut = new double[N];
Midbody.Batch(o.AsSpan(), c.AsSpan(), qlOut.AsSpan());
_output.WriteLine($"Streaming={streamVal:F10}, Batch={qlOut[N - 1]:F10}");
Assert.Equal(streamVal, qlOut[N - 1], 1e-12);
}
// ── C) Always hot after first bar ─────────────────────────────────────────
[Fact]
public void Validate_AlwaysHotAfterFirstBar()
{
var ind = new Midbody();
Assert.False(ind.IsHot);
ind.Update(new TBar(DateTime.UtcNow, 10, 12, 8, 11, 1000), isNew: true);
Assert.True(ind.IsHot);
_output.WriteLine("Midbody always hot after first bar: PASSED");
}
// ── D) Batch(TBarSeries) == Calculate ─────────────────────────────────────
[Fact]
public void Validate_BatchBarSeries_Equals_Calculate()
{
var (results, _) = Midbody.Calculate(_data.Bars);
var batchResult = Midbody.Batch(_data.Bars);
for (int i = 0; i < _data.Bars.Count; i++)
{
Assert.Equal(batchResult.Values[i], results.Values[i], 1e-12);
}
_output.WriteLine("Midbody Batch(TBarSeries) == Calculate: PASSED");
}
// ── E) Determinism ────────────────────────────────────────────────────────
[Fact]
public void Validate_Deterministic()
{
var r1 = Midbody.Batch(_data.Bars);
var r2 = Midbody.Batch(_data.Bars);
for (int i = 0; i < r1.Count; i++) { Assert.Equal(r1.Values[i], r2.Values[i], 15); }
_output.WriteLine("Midbody determinism: PASSED");
}
// ═══════════════════════════════════════════════════════════════════════════
// Skender.Stock.Indicators Validation — CandlePart.OC2
// ═══════════════════════════════════════════════════════════════════════════
// ── F) Skender OC2 batch validation (Midbody mapping) ───────────────────────────────────────
[Fact]
public void Validate_Against_Skender_OC2_Batch()
{
var skenderResults = _data.SkenderQuotes
.GetBaseQuote(CandlePart.OC2)
.ToList();
var qlResult = Midbody.Batch(_data.Bars);
Assert.Equal(qlResult.Count, skenderResults.Count);
int count = qlResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
double qlVal = qlResult.Values[i];
double skVal = skenderResults[i].Value;
Assert.True(
Math.Abs(qlVal - skVal) <= ValidationHelper.SkenderTolerance,
$"Mismatch at index {i}: QuanTAlib={qlVal:G17}, Skender={skVal:G17}, Diff={Math.Abs(qlVal - skVal):G17}");
}
_output.WriteLine($"Midbody vs Skender OC2 batch: {count} bars, last {count - start} verified within {ValidationHelper.SkenderTolerance}: PASSED");
}
// ── G) Skender OC2 streaming validation (Midbody mapping) ───────────────────────────────────
[Fact]
public void Validate_Against_Skender_OC2_Streaming()
{
var skenderResults = _data.SkenderQuotes
.GetBaseQuote(CandlePart.OC2)
.ToList();
var ind = new Midbody();
int count = _data.Bars.Count;
double[] streamValues = new double[count];
for (int i = 0; i < count; i++)
{
var result = ind.Update(_data.Bars[i], isNew: true);
streamValues[i] = result.Value;
}
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
double qlVal = streamValues[i];
double skVal = skenderResults[i].Value;
Assert.True(
Math.Abs(qlVal - skVal) <= ValidationHelper.SkenderTolerance,
$"Mismatch at index {i}: QuanTAlib={qlVal:G17}, Skender={skVal:G17}");
}
_output.WriteLine($"Midbody streaming vs Skender OC2: {count} bars, last {count - start} verified: PASSED");
}
// ── H) Skender OC2 span validation (Midbody mapping) ────────────────────────────────────────
[Fact]
[SkipLocalsInit]
public void Validate_Against_Skender_OC2_Span()
{
var skenderResults = _data.SkenderQuotes
.GetBaseQuote(CandlePart.OC2)
.ToList();
int count = _data.Bars.Count;
double[] o = new double[count], c = new double[count];
for (int i = 0; i < count; i++)
{
o[i] = _data.Bars[i].Open;
c[i] = _data.Bars[i].Close;
}
var qlOut = new double[count];
Midbody.Batch(o.AsSpan(), c.AsSpan(), qlOut.AsSpan());
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
double qlVal = qlOut[i];
double skVal = skenderResults[i].Value;
Assert.True(
Math.Abs(qlVal - skVal) <= ValidationHelper.SkenderTolerance,
$"Span mismatch at index {i}: QuanTAlib={qlVal:G17}, Skender={skVal:G17}");
}
_output.WriteLine($"Midbody span vs Skender OC2: {count} bars, last {count - start} verified: PASSED");
}
}
+285
View File
@@ -0,0 +1,285 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// MIDBODY: Open-Close Average
/// Calculates the midpoint of Open and Close prices.
/// Equivalent to TBar.OC2 but as a proper streaming indicator with bar correction.
/// </summary>
/// <remarks>
/// <b>Calculation:</b>
/// <list type="number">
/// <item>Midbody = (Open + Close) / 2</item>
/// </list>
///
/// <b>Key characteristics:</b>
/// <list type="bullet">
/// <item>Stateless bar-by-bar calculation (no lookback period)</item>
/// <item>Skender compatible (CandlePart.OC2)</item>
/// <item>Always hot after first bar</item>
/// <item>Captures the midpoint between session open and close</item>
/// </list>
/// </remarks>
[SkipLocalsInit]
public sealed class Midbody : AbstractBase
{
[StructLayout(LayoutKind.Auto)]
private record struct State(
double LastValidOpen,
double LastValidClose,
double LastResult,
int Count
);
private State _s;
private State _ps;
/// <summary>
/// Initializes a new instance of the Midbody class.
/// </summary>
public Midbody()
{
WarmupPeriod = 1;
Name = "Midbody";
_s = new State(0, 0, 0, 0);
_ps = _s;
}
/// <summary>
/// Initializes a new instance of the Midbody class with a source.
/// </summary>
/// <param name="source">The data source for chaining.</param>
public Midbody(ITValuePublisher source) : this()
{
source.Pub += Handle;
}
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
/// <summary>
/// True if the indicator has enough data for valid results.
/// </summary>
public override bool IsHot => _s.Count >= WarmupPeriod;
/// <summary>
/// Computes the Midbody price from Open and Close values.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double ComputeMidbody(double open, double close)
{
return (open + close) * 0.5;
}
/// <summary>
/// Updates the indicator with a TValue input.
/// For TValue input, treats the value as both Open and Close (result = value).
/// Prefer Update(TBar) for standard OHLC data.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
return UpdateCore(input.Time, input.Value, input.Value, isNew);
}
/// <summary>
/// Updates the indicator with a new bar (preferred method).
/// </summary>
/// <param name="bar">The input bar.</param>
/// <param name="isNew">Whether this is a new bar or an update.</param>
/// <returns>The calculated Midbody value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar bar, bool isNew = true)
{
return UpdateCore(bar.Time, bar.Open, bar.Close, isNew);
}
/// <summary>
/// Updates the indicator with a bar series.
/// </summary>
/// <param name="source">The source bar series.</param>
/// <returns>A TSeries containing the Midbody values.</returns>
public TSeries Update(TBarSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
Batch(source.OpenValues, source.CloseValues, vSpan);
for (int i = 0; i < len; i++)
{
tSpan[i] = source[i].Time;
}
// Update internal state
for (int i = 0; i < len; i++)
{
Update(source[i], isNew: true);
}
return new TSeries(t, v);
}
/// <inheritdoc/>
public override TSeries Update(TSeries source)
{
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
var values = source.Values;
// TValue-only: result = value (identity)
for (int i = 0; i < len; i++)
{
tSpan[i] = source.Times[i];
vSpan[i] = values[i];
}
// Update internal state
for (int i = 0; i < len; i++)
{
Update(new TValue(source.Times[i], values[i]), isNew: true);
}
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private TValue UpdateCore(long timeTicks, double open, double close, bool isNew)
{
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
var s = _s;
// Handle non-finite values — use last valid values
if (!double.IsFinite(open)) { open = s.LastValidOpen; } else { s.LastValidOpen = open; }
if (!double.IsFinite(close)) { close = s.LastValidClose; } else { s.LastValidClose = close; }
double result = ComputeMidbody(open, close);
if (!double.IsFinite(result))
{
result = s.LastResult;
}
else
{
s.LastResult = result;
}
if (isNew) { s.Count++; }
_s = s;
Last = new TValue(timeTicks, result);
PubEvent(Last, isNew);
return Last;
}
/// <inheritdoc/>
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(DateTime.UtcNow, source[i]), isNew: true);
}
}
/// <inheritdoc/>
public override void Reset()
{
_s = new State(0, 0, 0, 0);
_ps = _s;
Last = default;
}
/// <summary>
/// Calculates Midbody for a bar series (static).
/// </summary>
public static TSeries Batch(TBarSeries source)
{
var indicator = new Midbody();
return indicator.Update(source);
}
/// <summary>
/// Batch calculation using spans for Open/Close data.
/// </summary>
public static void Batch(
ReadOnlySpan<double> open,
ReadOnlySpan<double> close,
Span<double> output)
{
int len = open.Length;
if (close.Length != len)
{
throw new ArgumentException("All input spans must have the same length", nameof(close));
}
if (output.Length < len)
{
throw new ArgumentException("Output span must be at least as long as input spans", nameof(output));
}
for (int i = 0; i < len; i++)
{
output[i] = ComputeMidbody(open[i], close[i]);
}
}
/// <summary>
/// Batch calculation using a TBarSeries (convenience overload).
/// </summary>
public static void Batch(TBarSeries source, Span<double> output)
{
int len = source.Count;
if (output.Length < len)
{
throw new ArgumentException("Output span must be at least as long as source", nameof(output));
}
if (len == 0)
{
return;
}
Batch(source.OpenValues, source.CloseValues, output);
}
public static (TSeries Results, Midbody Indicator) Calculate(TBarSeries source)
{
var indicator = new Midbody();
TSeries results = indicator.Update(source);
return (results, indicator);
}
}
+129
View File
@@ -0,0 +1,129 @@
# MIDBODY: Open-Close Average
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Core |
| **Inputs** | OHLCV bar (TBar) |
| **Parameters** | None |
| **Outputs** | Single series (Midbody) |
| **Output range** | Varies (see docs) |
| **Warmup** | `1` bars |
### TL;DR
Midbody computes the arithmetic mean of Open and Close prices: $(O + C) \times 0.5$. It captures where price started and ended within a bar, ignoring intra-bar extremes. No lookback period, no state, always hot after the first bar. Equivalent to `TBar.OC2`.
## Historical Context
The Open-Close average has no formal attribution in technical analysis literature. Unlike `HL2` (Median Price) or `HLC3` (Typical Price) which appear in TA-Lib and classic references, OC2 exists primarily as a computed property in modern libraries like Skender.Stock.Indicators (`CandlePart.OC2`).
The rationale for OC2 is straightforward: Open and Close represent the consensus prices at session boundaries. High and Low represent transient extremes that may reflect noise or stops being triggered. By averaging only the session endpoints, OC2 filters out intra-bar volatility entirely.
OC2 is useful as an input to trend-following indicators when you want the trend signal to reflect directional bias (where did the bar open and close?) rather than range (how far did it swing?). It also serves as the natural center for Heikin-Ashi calculations (HA Close = OHLC4, but HA state tracking uses the prior bar's OC2).
## Architecture & Physics
### 1. Core Formula
$$\text{Midbody} = (O + C) \times 0.5$$
The multiplication form avoids a division operation. The JIT compiles `* 0.5` to a single `vmulsd` instruction.
### 2. State Management
OC2 is stateless. Each bar's output depends only on that bar's Open and Close values. The `State` record struct tracks only:
- `LastValidOpen` / `LastValidClose` for NaN substitution
- `LastResult` for fallback when both inputs are non-finite
- `Count` for `IsHot` tracking
### 3. Complexity
| Metric | Value |
|--------|-------|
| Time (streaming) | $O(1)$ |
| Time (batch) | $O(n)$ |
| Space | $O(1)$ — no buffers |
| Warmup | 1 bar |
## Mathematical Foundation
### Parameters
None. OC2 is parameterless.
### Weight Distribution
| Component | Weight |
|-----------|--------|
| Open | 0.5 |
| High | 0 |
| Low | 0 |
| Close | 0.5 |
### Comparison with Other Price Transforms
| Transform | Formula | Components Used | Bias |
|-----------|---------|:---------------:|------|
| Midbody | $(O+C) \times 0.5$ | O, C | Session endpoints only |
| MEDPRICE | $(H+L) \times 0.5$ | H, L | Range-centered; ignores O/C |
| TYPPRICE | $(O+H+L) / 3$ | O, H, L | Opening-biased range |
| HLC3 | $(H+L+C) / 3$ | H, L, C | Close-influenced range |
| AVGPRICE | $(O+H+L+C) \times 0.25$ | O, H, L, C | Fully balanced |
| WCLPRICE | $(H+L+2C) \times 0.25$ | H, L, C | Close double-weighted |
### Pseudo-code
```text
function Midbody(bar):
return (bar.Open + bar.Close) * 0.5
```
### Output Interpretation
- OC2 > Close: bar closed below its midpoint (bearish lean)
- OC2 < Close: bar closed above its midpoint (bullish lean)
- OC2 = Close: Open = Close (doji-like bar)
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count |
|-----------|-------|
| Addition | 1 |
| Multiplication | 1 |
| Comparison | 0 |
| Memory access | 2 (O, C) |
| **Total** | **4 ops** |
### Batch Mode (SIMD Analysis)
The batch loop is a trivial element-wise `(a[i] + b[i]) * 0.5`. Auto-vectorization by the JIT is expected for aligned spans. Manual SIMD is not implemented because the operation is already memory-bandwidth-bound at this simplicity level.
## Validation
| Library | Method | Tolerance | Status |
|---------|--------|-----------|--------|
| Skender | `CandlePart.OC2` | `1e-7` | ✅ Batch + Streaming + Span |
| TA-Lib | N/A | — | Not available |
| TBar.OC2 | Property | `1e-10` | ✅ All bars match |
## Common Pitfalls
1. **Confusing OC2 with MEDPRICE.** MEDPRICE is `(H+L)/2`; OC2 is `(O+C)/2`. They answer different questions: range center vs. session endpoint average.
2. **Confusing OC2 with Midpoint.** Midpoint is `(Highest(V,N) + Lowest(V,N))/2` — a rolling indicator with a period parameter. OC2 has no period.
3. **Using OC2 for volatility estimation.** OC2 deliberately ignores H and L. For volatility-aware price proxies, use HLC3 or OHLC4 instead.
4. **Expecting TA-Lib compatibility.** TA-Lib does not implement OC2. Validation is against Skender only.
5. **Gap analysis with OC2.** When Open and Close are nearly equal (doji bars), OC2 converges to Close. This is correct behavior, not a bug.
## Resources
- **Skender.Stock.Indicators** `CandlePart.OC2` enum documentation.
- **Murphy, J.J.** *Technical Analysis of the Financial Markets*. New York Institute of Finance, 1999.