filters update

This commit is contained in:
Miha Kralj
2026-02-23 17:27:35 -08:00
parent 7253f61299
commit 467a8c1cef
239 changed files with 17880 additions and 6329 deletions
+34
View File
@@ -0,0 +1,34 @@
# Core
Price transforms and fundamental building blocks. These indicators compute derived prices from OHLCV bars and serve as inputs to higher-order indicators.
## Indicators
| Indicator | Full Name | Description |
| :-------- | :-------- | :---------- |
| [AVGPRICE](avgprice/Avgprice.md) | Average Price | (O+H+L+C) * 0.25 via FMA |
| [MEDPRICE](medprice/Medprice.md) | Median Price | (H+L) * 0.5 |
| [MIDPOINT](midpoint/Midpoint.md) | Rolling Midpoint | (Max+Min) * 0.5 over lookback window |
| [MIDPRICE](midprice/Midprice.md) | Mid Price | (Highest High + Lowest Low) * 0.5 |
| [TYPPRICE](typprice/Typprice.md) | Typical Price | (H+L+C) * OneThird via FMA |
| [HA](ha/Ha.md) | Heikin-Ashi | Modified OHLC candles. Smoothed trend visualization. Output is TBar. |
| [WCLPRICE](wclprice/Wclprice.md) | Weighted Close Price | (H+L+2C) * 0.25 via FMA |
## Architecture
All Core indicators share common traits:
- **Zero allocation** in `Update` hot path
- **FMA optimization** where applicable (Avgprice, Typprice, Wclprice)
- **Multiplication over division** (0.25 instead of /4, OneThird instead of /3)
- **NaN/Infinity guard** via last-valid-value substitution
- **Bar correction** via `isNew` rollback pattern
- **Dual API** with stateful `Update` + stateless static `Calculate`
- **SIMD batch** via `ReadOnlySpan<double>` / `Span<double>` overloads
### TBar-Based vs TValue-Based
| Type | Indicators | Input |
| :--- | :--------- | :---- |
| TBar | AVGPRICE, MEDPRICE, MIDPRICE, TYPPRICE, WCLPRICE | OHLCV bars |
| TValue | MIDPOINT | Single value series |
@@ -0,0 +1,131 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class AvgpriceIndicatorTests
{
[Fact]
public void AvgpriceIndicator_Constructor_SetsDefaults()
{
var indicator = new AvgpriceIndicator();
Assert.True(indicator.ShowColdValues);
Assert.Equal("AVGPRICE - Average Price", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void AvgpriceIndicator_ShortName_IsAvgprice()
{
var indicator = new AvgpriceIndicator();
Assert.Equal("AVGPRICE", indicator.ShortName);
}
[Fact]
public void AvgpriceIndicator_MinHistoryDepths_EqualsOne()
{
var indicator = new AvgpriceIndicator();
Assert.Equal(1, AvgpriceIndicator.MinHistoryDepths);
Assert.Equal(1, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void AvgpriceIndicator_Initialize_CreatesInternalIndicator()
{
var indicator = new AvgpriceIndicator();
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void AvgpriceIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new AvgpriceIndicator();
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 AvgpriceIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new AvgpriceIndicator();
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 AvgpriceIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new AvgpriceIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void AvgpriceIndicator_SourceCodeLink_IsValid()
{
var indicator = new AvgpriceIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Avgprice.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void AvgpriceIndicator_ComputesCorrectAverage()
{
var indicator = new AvgpriceIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// O=100, H=110, L=90, C=105 → (100+110+90+105)/4 = 101.25
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double val = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(101.25, val, 10);
}
[Fact]
public void AvgpriceIndicator_IsHotImmediately()
{
var indicator = new AvgpriceIndicator();
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));
}
}
+46
View File
@@ -0,0 +1,46 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class AvgpriceIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Avgprice _avgprice = null!;
private readonly LineSeries _series;
public static int MinHistoryDepths => 1;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => "AVGPRICE";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/core/avgprice/Avgprice.Quantower.cs";
public AvgpriceIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "AVGPRICE - Average Price";
Description = "Average of Open, High, Low, and Close prices: (O+H+L+C)/4.";
_series = new LineSeries(name: "AVGPRICE", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_avgprice = new Avgprice();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _avgprice.Update(bar, isNew: args.IsNewBar());
_series.SetValue(result.Value, _avgprice.IsHot, ShowColdValues);
}
}
+285
View File
@@ -0,0 +1,285 @@
// Avgprice Unit Tests
using Xunit;
namespace QuanTAlib.Tests;
public class AvgpriceTests
{
private readonly GBM _gbm;
private const double Tolerance = 1e-10;
public AvgpriceTests()
{
_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 Avgprice();
Assert.Equal("Avgprice", indicator.Name);
Assert.Equal(1, indicator.WarmupPeriod);
}
[Fact]
public void Constructor_WithSource_SubscribesToEvents()
{
var source = new TSeries();
var indicator = new Avgprice(source);
source.Add(new TValue(DateTime.UtcNow, 100.0));
Assert.NotEqual(default, indicator.Last);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_Bar_ReturnsOHLC4()
{
var indicator = new Avgprice();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.Update(bar);
// (100 + 110 + 90 + 105) / 4 = 101.25
Assert.Equal(101.25, result.Value, Tolerance);
}
[Fact]
public void Update_Bar_MatchesTBarOHLC4()
{
var indicator = new Avgprice();
var bar = new TBar(DateTime.UtcNow, 50, 60, 40, 55, 500);
var result = indicator.Update(bar);
Assert.Equal(bar.OHLC4, result.Value, Tolerance);
}
[Fact]
public void Update_TValue_ReturnsIdentity()
{
var indicator = new Avgprice();
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 Avgprice();
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 Avgprice();
var time = DateTime.UtcNow;
// First bar
indicator.Update(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
_ = indicator.Last.Value;
// Second bar (new)
indicator.Update(new TBar(time.AddMinutes(1), 105, 115, 95, 110, 1000), isNew: true);
// Correction on second bar — should produce same result as a fresh update
var corrected = indicator.Update(new TBar(time.AddMinutes(1), 106, 116, 96, 111, 1000), isNew: false);
double expected = (106 + 116 + 96 + 111) * 0.25;
Assert.Equal(expected, corrected.Value, Tolerance);
}
[Fact]
public void Update_MultipleIsNewFalse_ProducesIdempotentResults()
{
var indicator = new Avgprice();
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 Avgprice();
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 Avgprice();
var time = DateTime.UtcNow;
// Valid bar first
indicator.Update(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
double validResult = indicator.Last.Value;
// NaN bar — should substitute last valid values
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);
}
[Fact]
public void Update_Infinity_UsesLastValidValue()
{
var indicator = new Avgprice();
var time = DateTime.UtcNow;
indicator.Update(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
_ = indicator.Last.Value;
var infBar = new TBar(time.AddMinutes(1), double.PositiveInfinity, double.NegativeInfinity, double.NaN, double.PositiveInfinity, 1000);
var result = indicator.Update(infBar, isNew: true);
Assert.True(double.IsFinite(result.Value));
}
#endregion
#region Consistency Tests (All Modes)
[Fact]
public void AllModes_ProduceConsistentResults()
{
var bars = GenerateBars(100);
// Mode 1: Streaming
var streaming = new Avgprice();
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 = Avgprice.Batch(bars);
// Mode 3: Span batch
double[] spanOutput = new double[bars.Count];
Avgprice.Batch(bars.OpenValues, bars.HighValues, bars.LowValues, 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_MatchTBarOHLC4()
{
var bars = GenerateBars(50);
var indicator = new Avgprice();
for (int i = 0; i < bars.Count; i++)
{
var result = indicator.Update(bars[i], isNew: true);
Assert.Equal(bars[i].OHLC4, result.Value, Tolerance);
}
}
#endregion
#region Batch Validation Tests
[Fact]
public void Batch_MismatchedLengths_ThrowsArgumentException()
{
double[] open = new double[10];
double[] high = new double[10];
double[] low = new double[5]; // mismatched
double[] close = new double[10];
double[] output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Avgprice.Batch(open, high, low, close, output));
Assert.Equal("high", ex.ParamName);
}
[Fact]
public void Batch_OutputTooShort_ThrowsArgumentException()
{
double[] open = new double[10];
double[] high = new double[10];
double[] low = new double[10];
double[] close = new double[10];
double[] output = new double[5]; // too short
var ex = Assert.Throws<ArgumentException>(() => Avgprice.Batch(open, high, low, close, output));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_EmptyInput_NoOutput()
{
var bars = new TBarSeries();
var result = Avgprice.Batch(bars);
Assert.Empty(result);
}
[Fact]
public void Batch_LargeDataset_NoStackOverflow()
{
var bars = GenerateBars(10_000);
double[] output = new double[bars.Count];
Avgprice.Batch(bars.OpenValues, bars.HighValues, bars.LowValues, bars.CloseValues, output);
Assert.True(double.IsFinite(output[^1]));
}
#endregion
#region Event Chaining Tests
[Fact]
public void Pub_EventFires_OnUpdate()
{
var indicator = new Avgprice();
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) = Avgprice.Calculate(bars);
Assert.Equal(bars.Count, results.Count);
Assert.True(ind.IsHot);
}
#endregion
}
+282
View File
@@ -0,0 +1,282 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// AVGPRICE: Average Price
/// Calculates the average of Open, High, Low, and Close prices.
/// Equivalent to TBar.OHLC4 but as a proper streaming indicator with bar correction.
/// </summary>
/// <remarks>
/// <b>Calculation:</b>
/// <list type="number">
/// <item>AvgPrice = (Open + High + Low + Close) / 4</item>
/// </list>
///
/// <b>Key characteristics:</b>
/// <list type="bullet">
/// <item>Stateless bar-by-bar calculation (no lookback period)</item>
/// <item>TA-Lib compatible (AVGPRICE function)</item>
/// <item>Always hot after first bar</item>
/// <item>Useful as a smoothed input for other indicators</item>
/// </list>
/// </remarks>
[SkipLocalsInit]
public sealed class Avgprice : AbstractBase
{
[StructLayout(LayoutKind.Auto)]
private record struct State(
double LastValidOpen,
double LastValidHigh,
double LastValidLow,
double LastValidClose,
double LastResult,
int Count
);
private State _s;
private State _ps;
/// <summary>
/// Initializes a new instance of the Avgprice class.
/// </summary>
public Avgprice()
{
WarmupPeriod = 1;
Name = "Avgprice";
_s = new State(0, 0, 0, 0, 0, 0);
_ps = _s;
}
/// <summary>
/// Initializes a new instance of the Avgprice class with a source.
/// </summary>
/// <param name="source">The data source for chaining.</param>
public Avgprice(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 average price from OHLC values.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double ComputeAvgPrice(double open, double high, double low, double close)
{
return Math.FusedMultiplyAdd(open + high, 0.25, (low + close) * 0.25);
}
/// <summary>
/// Updates the indicator with a TValue input.
/// For TValue input, treats the value as all four OHLC prices (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, 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 Average Price value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar bar, bool isNew = true)
{
return UpdateCore(bar.Time, bar.Open, bar.High, bar.Low, 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 Average Price 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.HighValues, source.LowValues, 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 high, double low, 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(high)) { high = s.LastValidHigh; } else { s.LastValidHigh = high; }
if (!double.IsFinite(low)) { low = s.LastValidLow; } else { s.LastValidLow = low; }
if (!double.IsFinite(close)) { close = s.LastValidClose; } else { s.LastValidClose = close; }
double result = ComputeAvgPrice(open, high, low, 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, 0, 0);
_ps = _s;
Last = default;
}
/// <summary>
/// Calculates Average Price for a bar series (static).
/// </summary>
public static TSeries Batch(TBarSeries source)
{
var indicator = new Avgprice();
return indicator.Update(source);
}
/// <summary>
/// Batch calculation using spans for OHLC data.
/// </summary>
public static void Batch(
ReadOnlySpan<double> open,
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
Span<double> output)
{
int len = open.Length;
if (high.Length != len || low.Length != len || close.Length != len)
{
throw new ArgumentException("All input spans must have the same length", nameof(high));
}
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] = ComputeAvgPrice(open[i], high[i], low[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.HighValues, source.LowValues, source.CloseValues, output);
}
public static (TSeries Results, Avgprice Indicator) Calculate(TBarSeries source)
{
var indicator = new Avgprice();
TSeries results = indicator.Update(source);
return (results, indicator);
}
}
+97
View File
@@ -0,0 +1,97 @@
# AVGPRICE: Average Price
AVGPRICE computes the arithmetic mean of a bar's four canonical prices: Open, High, Low, and Close. The formula $\frac{O + H + L + C}{4}$ produces a single representative price that weights all four price components equally, unlike Typical Price (which excludes Open) or Weighted Close (which double-weights Close). This equal weighting makes AVGPRICE the least biased single-bar summary statistic, useful as a neutral input to downstream indicators when no particular price component deserves emphasis. The calculation is stateless, requires no warmup, and costs a single FMA instruction per bar.
## Historical Context
Average Price is one of the oldest price transforms in technical analysis, predating computer-based charting by decades. Its inclusion in the TA-Lib function set (`TA_AVGPRICE`) standardized it as a canonical operation alongside MEDPRICE, TYPPRICE, and WCLPRICE. The four-price average gained popularity because it distributes weight across the full intra-bar range: Open captures the session's starting sentiment, High and Low bound the extremes where supply and demand exhausted themselves, and Close reflects the final consensus.
In practice, AVGPRICE and OHLC4 are identical. QuanTAlib exposes both: `TBar.OHLC4` as a zero-cost computed property for inline use, and `Avgprice` as a streaming indicator class supporting bar correction, event chaining, and batch processing. The indicator form exists because downstream consumers (Quantower adapters, chained indicator pipelines) require the `ITValuePublisher` interface and `isNew` rollback semantics that a bare struct property cannot provide.
## Architecture & Physics
### 1. Core Formula
$$\text{AvgPrice}_t = \frac{O_t + H_t + L_t + C_t}{4}$$
Implemented as FMA to avoid division on the hot path:
$$\text{AvgPrice}_t = \text{FMA}(O_t + H_t,\; 0.25,\; (L_t + C_t) \times 0.25)$$
### 2. State Management
No rolling window, no lookback buffer. The indicator is stateless per bar. State exists only for:
- **Last-valid substitution**: If any OHLC component is `NaN`/`Infinity`, the last finite value for that component is used.
- **Bar correction**: `isNew=false` rolls back to previous state, enabling same-timestamp rewrites.
### 3. Complexity
$O(1)$ per bar. Two additions, one FMA. No memory allocation. Always hot after the first bar.
## Mathematical Foundation
### Parameters
| Parameter | Description | Default | Constraint |
|-----------|-------------|---------|------------|
| (none) | No user-configurable parameters | | |
### Relationship to TBar Properties
| Transform | Formula | TBar Property | Indicator Class |
|-----------|---------|---------------|-----------------|
| Average Price | $(O+H+L+C) \times 0.25$ | `OHLC4` | `Avgprice` |
| Median Price | $(H+L) \times 0.5$ | `HL2` | `Medprice` |
| Typical Price | $(H+L+C) \times \frac{1}{3}$ | `HLC3` | `Typprice` |
| Weighted Close | $(H+L+2C) \times 0.25$ | `HLCC4` | `Wclprice` |
### Pseudo-code
```
function AVGPRICE(bar):
o, h, l, c ← bar.Open, bar.High, bar.Low, bar.Close
// Substitute last-valid for non-finite inputs
if !finite(o): o ← lastValidOpen
if !finite(h): h ← lastValidHigh
if !finite(l): l ← lastValidLow
if !finite(c): c ← lastValidClose
result ← FMA(o + h, 0.25, (l + c) × 0.25)
return result
```
### Output Interpretation
| Context | Meaning |
|---------|---------|
| AVGPRICE > Close | Intra-bar action skewed higher than settlement |
| AVGPRICE < Close | Close settled above the bar's center of mass |
| AVGPRICE $\approx$ Close | Symmetric bar (doji-like) |
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Cost (cycles) | Subtotal |
|-----------|:-----:|:-------------:|:--------:|
| ADD (O+H) | 1 | 1 | 1 |
| ADD (L+C) | 1 | 1 | 1 |
| MUL ((L+C) × 0.25) | 1 | 3 | 3 |
| FMA ((O+H) × 0.25 + prev) | 1 | 4 | 4 |
| **Total (hot)** | **4** | | **~9 cycles** |
### Batch Mode (SIMD Analysis)
| Aspect | Assessment |
|--------|------------|
| SIMD vectorizable | Yes: element-wise arithmetic, no inter-bar dependency |
| Optimal strategy | `Vector<double>` over OHLC spans; 4-wide on AVX2, 8-wide on AVX-512 |
| Memory | $O(1)$ streaming; $O(n)$ batch output span |
| Throughput | Near memory-bandwidth bound for large series |
## Resources
- **TA-Lib** `TA_AVGPRICE` function reference.
- **Murphy, J.J.** *Technical Analysis of the Financial Markets*. New York Institute of Finance, 1999.
+13
View File
@@ -0,0 +1,13 @@
// AVGPRICE: Average Price
// (Open + High + Low + Close) / 4
// TA-Lib compatible — equivalent to TBar.OHLC4
//@version=6
indicator("AVGPRICE: Average Price", overlay=true)
avgprice(float o, float h, float l, float c) =>
(o + h + l + c) * 0.25
result = avgprice(open, high, low, close)
plot(result, "AvgPrice", color.new(color.blue, 0), 2)
+173
View File
@@ -0,0 +1,173 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class HaIndicatorTests
{
[Fact]
public void HaIndicator_Constructor_SetsDefaults()
{
var indicator = new HaIndicator();
Assert.True(indicator.ShowColdValues);
Assert.Equal("HA - Heikin-Ashi", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void HaIndicator_ShortName_IsHa()
{
var indicator = new HaIndicator();
Assert.Equal("HA", indicator.ShortName);
}
[Fact]
public void HaIndicator_MinHistoryDepths_EqualsOne()
{
var indicator = new HaIndicator();
Assert.Equal(1, HaIndicator.MinHistoryDepths);
Assert.Equal(1, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void HaIndicator_Initialize_CreatesFourLineSeries()
{
var indicator = new HaIndicator();
indicator.Initialize();
Assert.Equal(4, indicator.LinesSeries.Count);
}
[Fact]
public void HaIndicator_ProcessUpdate_HistoricalBar_ComputesValues()
{
var indicator = new HaIndicator();
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));
}
// All 4 series should have finite values
for (int s = 0; s < 4; s++)
{
double val = indicator.LinesSeries[s].GetValue(0);
Assert.True(double.IsFinite(val), $"LineSeries[{s}] should be finite");
}
}
[Fact]
public void HaIndicator_ProcessUpdate_NewBar_ComputesValues()
{
var indicator = new HaIndicator();
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);
Assert.Equal(2, indicator.LinesSeries[1].Count);
Assert.Equal(2, indicator.LinesSeries[2].Count);
Assert.Equal(2, indicator.LinesSeries[3].Count);
}
[Fact]
public void HaIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new HaIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void HaIndicator_SourceCodeLink_IsValid()
{
var indicator = new HaIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Ha.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void HaIndicator_ComputesCorrectValues()
{
var indicator = new HaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// First bar: O=100, H=110, L=90, C=105
// HA_Close = (100+110+90+105)/4 = 101.25
// HA_Open = (100+105)/2 = 102.5 (seed)
// HA_High = max(110, 102.5, 101.25) = 110
// HA_Low = min(90, 102.5, 101.25) = 90
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double haOpen = indicator.LinesSeries[0].GetValue(0);
double haHigh = indicator.LinesSeries[1].GetValue(0);
double haLow = indicator.LinesSeries[2].GetValue(0);
double haClose = indicator.LinesSeries[3].GetValue(0);
Assert.Equal(102.5, haOpen, 10);
Assert.Equal(110.0, haHigh, 10);
Assert.Equal(90.0, haLow, 10);
Assert.Equal(101.25, haClose, 10);
}
[Fact]
public void HaIndicator_IsHotImmediately()
{
var indicator = new HaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// All 4 series should have finite values (IsHot after first bar)
for (int s = 0; s < 4; s++)
{
double val = indicator.LinesSeries[s].GetValue(0);
Assert.True(double.IsFinite(val), $"LineSeries[{s}] should be finite after one bar");
}
}
[Fact]
public void HaIndicator_HighAlwaysAboveOrEqualLow()
{
var indicator = new HaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
double basePrice = 100 + (i * 2);
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 1, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double haHigh = indicator.LinesSeries[1].GetValue(0);
double haLow = indicator.LinesSeries[2].GetValue(0);
Assert.True(haHigh >= haLow, "HA High must be >= HA Low");
}
}
+61
View File
@@ -0,0 +1,61 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class HaIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Ha _ha = null!;
private readonly LineSeries _openSeries;
private readonly LineSeries _highSeries;
private readonly LineSeries _lowSeries;
private readonly LineSeries _closeSeries;
public static int MinHistoryDepths => 1;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => "HA";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/core/ha/Ha.Quantower.cs";
public HaIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "HA - Heikin-Ashi";
Description = "Transforms standard OHLC bars into smoothed Heikin-Ashi candles that filter noise and clarify trend direction.";
_openSeries = new LineSeries(name: "HA Open", color: Color.FromArgb(0, 200, 0), width: 2, style: LineStyle.Solid);
_highSeries = new LineSeries(name: "HA High", color: IndicatorExtensions.Averages, width: 1, style: LineStyle.Solid);
_lowSeries = new LineSeries(name: "HA Low", color: IndicatorExtensions.Averages, width: 1, style: LineStyle.Solid);
_closeSeries = new LineSeries(name: "HA Close", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(_openSeries);
AddLineSeries(_highSeries);
AddLineSeries(_lowSeries);
AddLineSeries(_closeSeries);
}
protected override void OnInit()
{
_ha = new Ha();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
_ = _ha.UpdateBar(bar, isNew: args.IsNewBar());
TBar haBar = _ha.LastBar;
_openSeries.SetValue(haBar.Open, _ha.IsHot, ShowColdValues);
_highSeries.SetValue(haBar.High, _ha.IsHot, ShowColdValues);
_lowSeries.SetValue(haBar.Low, _ha.IsHot, ShowColdValues);
_closeSeries.SetValue(haBar.Close, _ha.IsHot, ShowColdValues);
}
}
+457
View File
@@ -0,0 +1,457 @@
// Ha Unit Tests
using Xunit;
namespace QuanTAlib.Tests;
public class HaTests
{
private readonly GBM _gbm;
private const double Tolerance = 1e-10;
public HaTests()
{
_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 Ha();
Assert.Equal("Ha", indicator.Name);
Assert.Equal(1, indicator.WarmupPeriod);
}
[Fact]
public void Constructor_WithSource_SubscribesToEvents()
{
var source = new TSeries();
var indicator = new Ha(source);
source.Add(new TValue(DateTime.UtcNow, 100.0));
Assert.NotEqual(default, indicator.Last);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_FirstBar_HaCloseIsOHLC4()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.UpdateBar(bar);
// HA Close = (100 + 110 + 90 + 105) / 4 = 101.25
Assert.Equal(101.25, result.Close, Tolerance);
}
[Fact]
public void Update_FirstBar_HaOpenIsMidpointOC()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.UpdateBar(bar);
// HA Open on first bar = (O + C) / 2 = (100 + 105) / 2 = 102.5
Assert.Equal(102.5, result.Open, Tolerance);
}
[Fact]
public void Update_FirstBar_HaHighIsMaxOfHOC()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.UpdateBar(bar);
// HA High = max(110, 102.5, 101.25) = 110
Assert.Equal(110, result.High, Tolerance);
}
[Fact]
public void Update_FirstBar_HaLowIsMinOfLOC()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.UpdateBar(bar);
// HA Low = min(90, 102.5, 101.25) = 90
Assert.Equal(90, result.Low, Tolerance);
}
[Fact]
public void Update_SecondBar_HaOpenIsRecursive()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
// First bar: O=100, H=110, L=90, C=105
// HA_Open1 = (100+105)/2 = 102.5, HA_Close1 = 101.25
indicator.UpdateBar(new TBar(time, 100, 110, 90, 105, 1000));
// Second bar: O=105, H=115, L=95, C=110
// HA_Open2 = (prevHaOpen + prevHaClose) / 2 = (102.5 + 101.25) / 2 = 101.875
var result = indicator.UpdateBar(new TBar(time.AddMinutes(1), 105, 115, 95, 110, 1000));
Assert.Equal(101.875, result.Open, Tolerance);
}
[Fact]
public void Update_SecondBar_HaCloseIsOHLC4()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
indicator.UpdateBar(new TBar(time, 100, 110, 90, 105, 1000));
var result = indicator.UpdateBar(new TBar(time.AddMinutes(1), 105, 115, 95, 110, 1000));
// HA Close = (105 + 115 + 95 + 110) / 4 = 106.25
Assert.Equal(106.25, result.Close, Tolerance);
}
[Fact]
public void Update_VolumePassthrough()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1234.5);
var result = indicator.UpdateBar(bar);
Assert.Equal(1234.5, result.Volume, Tolerance);
}
[Fact]
public void Update_TimePassthrough()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
var bar = new TBar(time, 100, 110, 90, 105, 1000);
var result = indicator.UpdateBar(bar);
Assert.Equal(time.Ticks, result.Time);
}
[Fact]
public void Update_HaHighAlwaysGEHaOpenAndHaClose()
{
var indicator = new Ha();
var bars = GenerateBars(100);
for (int i = 0; i < bars.Count; i++)
{
var ha = indicator.UpdateBar(bars[i], isNew: true);
Assert.True(ha.High >= ha.Open, $"Bar {i}: High {ha.High} < Open {ha.Open}");
Assert.True(ha.High >= ha.Close, $"Bar {i}: High {ha.High} < Close {ha.Close}");
}
}
[Fact]
public void Update_HaLowAlwaysLEHaOpenAndHaClose()
{
var indicator = new Ha();
var bars = GenerateBars(100);
for (int i = 0; i < bars.Count; i++)
{
var ha = indicator.UpdateBar(bars[i], isNew: true);
Assert.True(ha.Low <= ha.Open, $"Bar {i}: Low {ha.Low} > Open {ha.Open}");
Assert.True(ha.Low <= ha.Close, $"Bar {i}: Low {ha.Low} > Close {ha.Close}");
}
}
[Fact]
public void Update_LastProperty_ReturnsHaClose()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
indicator.UpdateBar(bar);
// Last.Value should equal HA Close
Assert.Equal(101.25, indicator.Last.Value, Tolerance);
}
[Fact]
public void Update_LastBarProperty_ReturnsFullHaBar()
{
var indicator = new Ha();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.UpdateBar(bar);
Assert.Equal(result, indicator.LastBar);
}
#endregion
#region State and Bar Correction Tests
[Fact]
public void IsHot_AfterFirstBar_ReturnsTrue()
{
var indicator = new Ha();
Assert.False(indicator.IsHot);
indicator.UpdateBar(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
Assert.True(indicator.IsHot);
}
[Fact]
public void Update_IsNewFalse_RestoresPreviousState()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
// First bar
indicator.UpdateBar(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
// Second bar (new)
indicator.UpdateBar(new TBar(time.AddMinutes(1), 105, 115, 95, 110, 1000), isNew: true);
// Correction on second bar
var corrected = indicator.UpdateBar(new TBar(time.AddMinutes(1), 106, 116, 96, 111, 1000), isNew: false);
// Verify the HA Open is computed from first bar's HA values, not second bar's
// After first bar: prevHaOpen=102.5, prevHaClose=101.25
// Corrected HA_Open = (102.5 + 101.25)/2 = 101.875
Assert.Equal(101.875, corrected.Open, Tolerance);
// Corrected HA_Close = (106+116+96+111)/4 = 107.25
Assert.Equal(107.25, corrected.Close, Tolerance);
}
[Fact]
public void Update_MultipleIsNewFalse_ProducesIdempotentResults()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
indicator.UpdateBar(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.UpdateBar(bar, isNew: false);
var result2 = indicator.UpdateBar(bar, isNew: false);
var result3 = indicator.UpdateBar(bar, isNew: false);
Assert.Equal(result1.Open, result2.Open, Tolerance);
Assert.Equal(result1.Close, result2.Close, Tolerance);
Assert.Equal(result1.High, result2.High, Tolerance);
Assert.Equal(result1.Low, result2.Low, Tolerance);
Assert.Equal(result2, result3);
}
[Fact]
public void Reset_ClearsState()
{
var indicator = new Ha();
indicator.UpdateBar(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
Assert.True(indicator.IsHot);
indicator.Reset();
Assert.False(indicator.IsHot);
Assert.Equal(default, indicator.Last);
Assert.Equal(default, indicator.LastBar);
}
#endregion
#region NaN/Infinity Robustness Tests
[Fact]
public void Update_NaN_UsesLastValidValue()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
// Valid bar first
indicator.UpdateBar(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
_ = indicator.LastBar;
// NaN bar — should substitute last valid values
var nanBar = new TBar(time.AddMinutes(1), double.NaN, double.NaN, double.NaN, double.NaN, 1000);
var result = indicator.UpdateBar(nanBar, isNew: true);
Assert.True(double.IsFinite(result.Open));
Assert.True(double.IsFinite(result.High));
Assert.True(double.IsFinite(result.Low));
Assert.True(double.IsFinite(result.Close));
}
[Fact]
public void Update_Infinity_UsesLastValidValue()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
indicator.UpdateBar(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
var infBar = new TBar(time.AddMinutes(1), double.PositiveInfinity, double.NegativeInfinity, double.NaN, double.PositiveInfinity, 1000);
var result = indicator.UpdateBar(infBar, isNew: true);
Assert.True(double.IsFinite(result.Open));
Assert.True(double.IsFinite(result.High));
Assert.True(double.IsFinite(result.Low));
Assert.True(double.IsFinite(result.Close));
}
#endregion
#region Consistency Tests (All Modes)
[Fact]
public void AllModes_StreamingAndBatch_ProduceConsistentResults()
{
var bars = GenerateBars(100);
// Mode 1: Streaming
var streaming = new Ha();
TBar[] streamingResults = new TBar[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
streamingResults[i] = streaming.UpdateBar(bars[i], isNew: true);
}
// Mode 2: Batch (TBarSeries)
var batchResult = Ha.Batch(bars);
// Mode 3: Span batch
double[] haOpenOut = new double[bars.Count];
double[] haHighOut = new double[bars.Count];
double[] haLowOut = new double[bars.Count];
double[] haCloseOut = new double[bars.Count];
Ha.Batch(bars.OpenValues, bars.HighValues, bars.LowValues, bars.CloseValues,
haOpenOut, haHighOut, haLowOut, haCloseOut);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingResults[i].Open, batchResult[i].Open, Tolerance);
Assert.Equal(streamingResults[i].High, batchResult[i].High, Tolerance);
Assert.Equal(streamingResults[i].Low, batchResult[i].Low, Tolerance);
Assert.Equal(streamingResults[i].Close, batchResult[i].Close, Tolerance);
Assert.Equal(streamingResults[i].Open, haOpenOut[i], Tolerance);
Assert.Equal(streamingResults[i].High, haHighOut[i], Tolerance);
Assert.Equal(streamingResults[i].Low, haLowOut[i], Tolerance);
Assert.Equal(streamingResults[i].Close, haCloseOut[i], Tolerance);
}
}
[Fact]
public void AllBars_HaCloseMatchesOHLC4()
{
var bars = GenerateBars(50);
var indicator = new Ha();
for (int i = 0; i < bars.Count; i++)
{
var result = indicator.UpdateBar(bars[i], isNew: true);
Assert.Equal(bars[i].OHLC4, result.Close, Tolerance);
}
}
#endregion
#region Batch Validation Tests
[Fact]
public void Batch_MismatchedLengths_ThrowsArgumentException()
{
double[] open = new double[10];
double[] high = new double[10];
double[] low = new double[5]; // mismatched
double[] close = new double[10];
double[] ho = new double[10], hh = new double[10], hl = new double[10], hc = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Ha.Batch(open, high, low, close, ho, hh, hl, hc));
Assert.Equal("high", ex.ParamName);
}
[Fact]
public void Batch_OutputTooShort_ThrowsArgumentException()
{
double[] open = new double[10];
double[] high = new double[10];
double[] low = new double[10];
double[] close = new double[10];
double[] ho = new double[5]; // too short
double[] hh = new double[10], hl = new double[10], hc = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Ha.Batch(open, high, low, close, ho, hh, hl, hc));
Assert.Equal("haOpenOut", ex.ParamName);
}
[Fact]
public void Batch_EmptyInput_NoOutput()
{
var bars = new TBarSeries();
var result = Ha.Batch(bars);
Assert.Empty(result);
}
[Fact]
public void Batch_LargeDataset_NoStackOverflow()
{
var bars = GenerateBars(10_000);
var result = Ha.Batch(bars);
Assert.Equal(bars.Count, result.Count);
Assert.True(double.IsFinite(result[^1].Close));
}
#endregion
#region HA-Specific Property Tests
[Fact]
public void ConstantInput_ConvergesToConstant()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
// Feed constant bars: O=100, H=100, L=100, C=100
for (int i = 0; i < 20; i++)
{
_ = indicator.UpdateBar(new TBar(time.AddMinutes(i), 100, 100, 100, 100, 1000), isNew: true);
}
var last = indicator.LastBar;
// After many constant bars, all HA values should converge to 100
Assert.Equal(100.0, last.Open, 1e-6);
Assert.Equal(100.0, last.High, 1e-6);
Assert.Equal(100.0, last.Low, 1e-6);
Assert.Equal(100.0, last.Close, 1e-6);
}
[Fact]
public void HaHighGERealHigh_WhenBodyExceedsHigh()
{
// This tests the clamping: HA High is at least as large as HA Open and HA Close
var indicator = new Ha();
var bars = GenerateBars(100);
for (int i = 0; i < bars.Count; i++)
{
var ha = indicator.UpdateBar(bars[i], isNew: true);
// HA High should be >= real High OR >= haOpen/haClose
Assert.True(ha.High >= ha.Open);
Assert.True(ha.High >= ha.Close);
}
}
#endregion
#region Event Chaining Tests
[Fact]
public void Pub_EventFires_OnUpdate()
{
var indicator = new Ha();
bool fired = false;
indicator.Pub += (object? sender, in TValueEventArgs args) => fired = true;
indicator.UpdateBar(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) = Ha.Calculate(bars);
Assert.Equal(bars.Count, results.Count);
Assert.True(ind.IsHot);
}
#endregion
}
+173
View File
@@ -0,0 +1,173 @@
// Ha Validation Tests
// No external library (TA-Lib, Tulip) has a direct HA function.
// Skender and Ooples have GetHeikinAshi but validation is self-consistency.
using Xunit;
namespace QuanTAlib.Tests;
public class HaValidationTests
{
private readonly GBM _gbm;
private const double Tolerance = 1e-10;
private const int DataSize = 5000;
public HaValidationTests()
{
_gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.5, seed: 42);
}
private TBarSeries GenerateBars(int count)
{
_gbm.Reset(DateTime.UtcNow.Ticks);
return _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
[Fact]
public void BatchAndStreaming_Match()
{
var bars = GenerateBars(DataSize);
// Streaming
var streaming = new Ha();
var streamingBars = new List<TBar>(DataSize);
for (int i = 0; i < bars.Count; i++)
{
streamingBars.Add(streaming.UpdateBar(bars[i], isNew: true));
}
// Batch
var batchResult = Ha.Batch(bars);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingBars[i].Open, batchResult[i].Open, Tolerance);
Assert.Equal(streamingBars[i].High, batchResult[i].High, Tolerance);
Assert.Equal(streamingBars[i].Low, batchResult[i].Low, Tolerance);
Assert.Equal(streamingBars[i].Close, batchResult[i].Close, Tolerance);
}
}
[Fact]
public void SpanAndStreaming_Match()
{
var bars = GenerateBars(DataSize);
// Streaming
var streaming = new Ha();
double[] sOpen = new double[bars.Count];
double[] sHigh = new double[bars.Count];
double[] sLow = new double[bars.Count];
double[] sClose = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
var ha = streaming.UpdateBar(bars[i], isNew: true);
sOpen[i] = ha.Open;
sHigh[i] = ha.High;
sLow[i] = ha.Low;
sClose[i] = ha.Close;
}
// Span batch
double[] haO = new double[bars.Count];
double[] haH = new double[bars.Count];
double[] haL = new double[bars.Count];
double[] haC = new double[bars.Count];
Ha.Batch(bars.OpenValues, bars.HighValues, bars.LowValues, bars.CloseValues,
haO, haH, haL, haC);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(sOpen[i], haO[i], Tolerance);
Assert.Equal(sHigh[i], haH[i], Tolerance);
Assert.Equal(sLow[i], haL[i], Tolerance);
Assert.Equal(sClose[i], haC[i], Tolerance);
}
}
[Fact]
public void ConstantBars_ConvergeToConstant()
{
var indicator = new Ha();
var time = DateTime.UtcNow;
double price = 50.0;
TBar last = default;
for (int i = 0; i < 100; i++)
{
last = indicator.UpdateBar(new TBar(time.AddMinutes(i), price, price, price, price, 1000), isNew: true);
}
Assert.Equal(price, last.Open, 1e-6);
Assert.Equal(price, last.High, 1e-6);
Assert.Equal(price, last.Low, 1e-6);
Assert.Equal(price, last.Close, 1e-6);
}
[Fact]
public void HaClose_AlwaysEqualsOHLC4()
{
var bars = GenerateBars(DataSize);
var indicator = new Ha();
for (int i = 0; i < bars.Count; i++)
{
var ha = indicator.UpdateBar(bars[i], isNew: true);
double expected = bars[i].OHLC4;
Assert.Equal(expected, ha.Close, Tolerance);
}
}
[Fact]
public void HaHighLow_AlwaysContainBody()
{
var bars = GenerateBars(DataSize);
var indicator = new Ha();
for (int i = 0; i < bars.Count; i++)
{
var ha = indicator.UpdateBar(bars[i], isNew: true);
Assert.True(ha.High >= ha.Open, $"Bar {i}: High {ha.High} < Open {ha.Open}");
Assert.True(ha.High >= ha.Close, $"Bar {i}: High {ha.High} < Close {ha.Close}");
Assert.True(ha.Low <= ha.Open, $"Bar {i}: Low {ha.Low} > Open {ha.Open}");
Assert.True(ha.Low <= ha.Close, $"Bar {i}: Low {ha.Low} > Close {ha.Close}");
}
}
[Fact]
public void BarCorrection_Consistency()
{
var bars = GenerateBars(100);
var indicator1 = new Ha();
var indicator2 = new Ha();
// Run indicator1 normally
for (int i = 0; i < bars.Count; i++)
{
indicator1.UpdateBar(bars[i], isNew: true);
}
// Run indicator2 with corrections
for (int i = 0; i < bars.Count; i++)
{
indicator2.UpdateBar(bars[i], isNew: true);
// Simulate correction
if (i > 0 && i % 5 == 0)
{
indicator2.UpdateBar(bars[i], isNew: false);
}
}
Assert.Equal(indicator1.LastBar.Open, indicator2.LastBar.Open, Tolerance);
Assert.Equal(indicator1.LastBar.Close, indicator2.LastBar.Close, Tolerance);
}
[Fact]
public void Calculate_ReturnsHotIndicator()
{
var bars = GenerateBars(50);
var (results, indicator) = Ha.Calculate(bars);
Assert.True(indicator.IsHot);
Assert.Equal(bars.Count, results.Count);
}
}
+297
View File
@@ -0,0 +1,297 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// HA: Heikin-Ashi
/// Transforms standard OHLC bars into smoothed Heikin-Ashi candles.
/// </summary>
/// <remarks>
/// <b>Calculation:</b>
/// <list type="number">
/// <item>HA_Close = (O + H + L + C) / 4</item>
/// <item>HA_Open = (prev_HA_Open + prev_HA_Close) / 2</item>
/// <item>HA_High = max(H, HA_Open, HA_Close)</item>
/// <item>HA_Low = min(L, HA_Open, HA_Close)</item>
/// </list>
///
/// <b>Key characteristics:</b>
/// <list type="bullet">
/// <item>Output is TBar (smoothed OHLC), not TValue</item>
/// <item>HA_Open is a recursive IIR filter (alpha=0.5, half-life=1 bar)</item>
/// <item>HA_Close is stateless OHLC4 (identical to AVGPRICE)</item>
/// <item>Always hot after first bar</item>
/// </list>
/// </remarks>
/// <seealso href="Ha.md">Detailed documentation</seealso>
/// <seealso href="ha.pine">Reference Pine Script implementation</seealso>
[SkipLocalsInit]
public sealed class Ha : AbstractBase
{
[StructLayout(LayoutKind.Auto)]
private record struct State(
double PrevHaOpen,
double PrevHaClose,
double LastValidOpen,
double LastValidHigh,
double LastValidLow,
double LastValidClose,
int Count
);
private State _s;
private State _ps;
/// <summary>
/// The last computed Heikin-Ashi bar (full OHLC output).
/// </summary>
public TBar LastBar { get; private set; }
/// <summary>
/// Initializes a new instance of the Ha class.
/// </summary>
public Ha()
{
WarmupPeriod = 1;
Name = "Ha";
_s = default;
_ps = _s;
}
/// <summary>
/// Initializes a new instance of the Ha class with a source.
/// </summary>
/// <param name="source">The data source for chaining.</param>
public Ha(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 HA_Close = (O+H+L+C)/4 via FMA.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double ComputeHaClose(double open, double high, double low, double close)
{
return Math.FusedMultiplyAdd(open + high, 0.25, (low + close) * 0.25);
}
/// <summary>
/// Updates the indicator with a TValue input.
/// For TValue input, treats value as all four OHLC prices.
/// Prefer Update(TBar) for standard OHLC data.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
_ = UpdateBar(new TBar(input.Time, input.Value, input.Value, input.Value, input.Value, 0), isNew);
return Last;
}
/// <summary>
/// Updates the indicator with a bar series.
/// Returns a TBarSeries containing the Heikin-Ashi bars.
/// </summary>
public TBarSeries Update(TBarSeries source)
{
if (source.Count == 0)
{
return new TBarSeries();
}
int len = source.Count;
var result = new TBarSeries();
for (int i = 0; i < len; i++)
{
TBar haBar = UpdateBar(source[i], isNew: true);
result.Add(haBar);
}
return result;
}
/// <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);
for (int i = 0; i < len; i++)
{
TValue result = Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
tSpan[i] = result.Time;
vSpan[i] = result.Value;
}
return new TSeries(t, v);
}
/// <summary>
/// Updates the indicator with a new bar (preferred method).
/// Returns the smoothed Heikin-Ashi TBar.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TBar UpdateBar(TBar bar, bool isNew = true)
{
return UpdateCore(bar.Time, bar.Open, bar.High, bar.Low, bar.Close, bar.Volume, isNew);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private TBar UpdateCore(long timeTicks, double open, double high, double low, double close, double volume, 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(high)) { high = s.LastValidHigh; } else { s.LastValidHigh = high; }
if (!double.IsFinite(low)) { low = s.LastValidLow; } else { s.LastValidLow = low; }
if (!double.IsFinite(close)) { close = s.LastValidClose; } else { s.LastValidClose = close; }
// HA Close = OHLC4
double haClose = ComputeHaClose(open, high, low, close);
// HA Open = recursive IIR
double haOpen;
if (s.Count == 0)
{
// Seed: midpoint of O and C
haOpen = (open + close) * 0.5;
}
else
{
haOpen = (s.PrevHaOpen + s.PrevHaClose) * 0.5;
}
// HA High = max(H, haOpen, haClose)
double haHigh = Math.Max(high, Math.Max(haOpen, haClose));
// HA Low = min(L, haOpen, haClose)
double haLow = Math.Min(low, Math.Min(haOpen, haClose));
// Store state for next bar
s.PrevHaOpen = haOpen;
s.PrevHaClose = haClose;
if (isNew) { s.Count++; }
_s = s;
LastBar = new TBar(timeTicks, haOpen, haHigh, haLow, haClose, volume);
Last = new TValue(timeTicks, haClose);
PubEvent(Last, isNew);
return LastBar;
}
/// <inheritdoc/>
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(DateTime.UtcNow.Ticks, source[i]), isNew: true);
}
}
/// <inheritdoc/>
public override void Reset()
{
_s = default;
_ps = _s;
Last = default;
LastBar = default;
}
/// <summary>
/// Calculates Heikin-Ashi bars for a bar series (static).
/// </summary>
public static TBarSeries Batch(TBarSeries source)
{
var indicator = new Ha();
return indicator.Update(source);
}
/// <summary>
/// Batch calculation using OHLC spans. Outputs 4 spans for HA O, H, L, C.
/// HA_Open is sequential (IIR), so this cannot be fully vectorized.
/// </summary>
public static void Batch(
ReadOnlySpan<double> open,
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
Span<double> haOpenOut,
Span<double> haHighOut,
Span<double> haLowOut,
Span<double> haCloseOut)
{
int len = open.Length;
if (high.Length != len || low.Length != len || close.Length != len)
{
throw new ArgumentException("All input spans must have the same length", nameof(high));
}
if (haOpenOut.Length < len || haHighOut.Length < len || haLowOut.Length < len || haCloseOut.Length < len)
{
throw new ArgumentException("All output spans must be at least as long as input spans", nameof(haOpenOut));
}
if (len == 0) { return; }
// First bar: seed
double hc = ComputeHaClose(open[0], high[0], low[0], close[0]);
double ho = (open[0] + close[0]) * 0.5;
haCloseOut[0] = hc;
haOpenOut[0] = ho;
haHighOut[0] = Math.Max(high[0], Math.Max(ho, hc));
haLowOut[0] = Math.Min(low[0], Math.Min(ho, hc));
double prevHaOpen = ho;
double prevHaClose = hc;
// Sequential pass (IIR dependency on HA_Open)
for (int i = 1; i < len; i++)
{
hc = ComputeHaClose(open[i], high[i], low[i], close[i]);
ho = (prevHaOpen + prevHaClose) * 0.5;
haCloseOut[i] = hc;
haOpenOut[i] = ho;
haHighOut[i] = Math.Max(high[i], Math.Max(ho, hc));
haLowOut[i] = Math.Min(low[i], Math.Min(ho, hc));
prevHaOpen = ho;
prevHaClose = hc;
}
}
/// <summary>
/// Static Calculate returning both results and indicator state.
/// </summary>
public static (TBarSeries Results, Ha Indicator) Calculate(TBarSeries source)
{
var indicator = new Ha();
TBarSeries results = indicator.Update(source);
return (results, indicator);
}
}
+214
View File
@@ -0,0 +1,214 @@
# HA: Heikin-Ashi
> "The trend is your friend — but only if the noise doesn't make you abandon it at the first bump." — Every trader, eventually
HA transforms standard OHLC bars into smoothed Heikin-Ashi candles by averaging each component with its predecessor. The Close is the bar's four-price mean $(O+H+L+C)/4$, the Open is a recursive midpoint of the prior HA Open and HA Close, and High/Low are clamped extremes that guarantee the HA body always fits inside the HA wick. Unlike most indicators that reduce a bar to a single scalar, HA outputs a complete `TBar` — four smoothed prices per bar — making it a bar-to-bar transform rather than a bar-to-value reduction. The recursive Open gives HA an IIR character: each bar carries a decaying memory of the entire price history, which is what flattens trend noise but also why HA prices do not match any actual traded price.
## Historical Context
Heikin-Ashi (平均足, literally "average bar") is a Japanese charting technique that predates modern computing. The method gained widespread adoption in Western markets after Steve Nison introduced Japanese candlestick charting in the early 1990s, though Heikin-Ashi itself was popularized separately by Dan Valcu in a 2004 *Technical Analysis of Stocks & Commodities* article. The technique did not originate in academic quantitative finance; it emerged from the practitioner tradition of visually simplifying price action to identify trends.
The transformation is sometimes confused with a moving average, but the mechanics differ. A moving average produces a single smoothed value from a rolling window of N bars. Heikin-Ashi produces four smoothed values (O, H, L, C) using no window — the smoothing comes entirely from the recursive Open, which is a first-order IIR filter with $\alpha = 0.5$. This makes HA closer to an EMA(2) applied to the Open channel than to any FIR filter. The Close channel ($\text{OHLC4}$) is identical to `AVGPRICE` — it carries no memory between bars.
A persistent source of confusion across platforms: TradingView's `ticker.heikinashi()` function applies the transform at the data-feed level, meaning all built-in variables (`open`, `high`, `low`, `close`) become HA values. Indicators computed on HA data produce doubly-smoothed results that do not match the same indicator on standard data. QuanTAlib applies HA as an explicit indicator, keeping the standard data pipeline intact and the smoothing auditable.
## Architecture & Physics
### 1. HA Close (Stateless)
$$\text{HA\_Close}_t = \frac{O_t + H_t + L_t + C_t}{4}$$
This is identical to `AVGPRICE` / `OHLC4`. No inter-bar dependency. Implemented as FMA:
$$\text{HA\_Close}_t = \text{FMA}(O_t + H_t,\; 0.25,\; (L_t + C_t) \times 0.25)$$
### 2. HA Open (Recursive IIR)
$$\text{HA\_Open}_t = \frac{\text{HA\_Open}_{t-1} + \text{HA\_Close}_{t-1}}{2}$$
Seed on the first bar:
$$\text{HA\_Open}_0 = \frac{O_0 + C_0}{2}$$
This is a first-order IIR filter with $\alpha = 0.5$ and $\beta = 0.5$, giving it an effective half-life of 1 bar and exponential memory decay. The recursive structure means HA_Open carries the entire price history with geometrically decaying weights — it never fully forgets, but contributions older than ~7 bars contribute less than 1% each.
### 3. HA High (Clamped Maximum)
$$\text{HA\_High}_t = \max(H_t,\; \text{HA\_Open}_t,\; \text{HA\_Close}_t)$$
Guarantees the wick extends above the body. In strong uptrends where the actual High exceeds both HA Open and HA Close, the HA High equals the real High.
### 4. HA Low (Clamped Minimum)
$$\text{HA\_Low}_t = \min(L_t,\; \text{HA\_Open}_t,\; \text{HA\_Close}_t)$$
Guarantees the wick extends below the body. In strong downtrends where the actual Low is below both HA Open and HA Close, the HA Low equals the real Low.
### 5. Output Structure
Unlike standard indicators that output a `TValue` (timestamp + double), HA outputs a `TBar`:
```
TBar(Time, HA_Open, HA_High, HA_Low, HA_Close, Volume)
```
Volume passes through untransformed.
### 6. Complexity
$O(1)$ per bar. One FMA + one multiplication + two comparisons (max/min). State: two doubles (previous HA_Open and HA_Close). No buffers, no lookback window.
## Mathematical Foundation
### Parameters
| Parameter | Description | Default | Constraint |
|-----------|-------------|---------|------------|
| (none) | No user-configurable parameters | | |
### IIR Transfer Function
The HA Open channel is a first-order IIR filter on the midpoint of (HA_Open, HA_Close):
$$H(z) = \frac{0.5}{1 - 0.5z^{-1}}$$
This yields an exponential impulse response with decay factor $\beta = 0.5$ per bar:
$$h[n] = 0.5^{n+1}, \quad n \geq 0$$
Half-life: $t_{1/2} = \frac{-\ln 2}{\ln 0.5} = 1$ bar.
### Warmup Period
$$\text{WarmupPeriod} = 1$$
HA is "hot" from bar 1. The seed bar uses $(O_0 + C_0)/2$ for HA_Open and produces valid output immediately. The recursive filter converges rapidly due to the $\beta = 0.5$ decay — after 7 bars, the contribution of the seed value is less than 0.4%.
### Pseudo-code
```
function HA(bar, prevHaOpen, prevHaClose):
o, h, l, c ← bar.Open, bar.High, bar.Low, bar.Close
// Substitute last-valid for non-finite inputs
if !finite(o): o ← lastValidOpen
if !finite(h): h ← lastValidHigh
if !finite(l): l ← lastValidLow
if !finite(c): c ← lastValidClose
haClose ← FMA(o + h, 0.25, (l + c) × 0.25)
if firstBar:
haOpen ← (o + c) × 0.5
else:
haOpen ← (prevHaOpen + prevHaClose) × 0.5
haHigh ← max(h, haOpen, haClose)
haLow ← min(l, haOpen, haClose)
return TBar(bar.Time, haOpen, haHigh, haLow, haClose, bar.Volume)
```
### Output Interpretation
| Candle Pattern | Meaning |
|----------------|---------|
| Green body, no lower wick | Strong uptrend |
| Red body, no upper wick | Strong downtrend |
| Small body, both wicks | Indecision / potential reversal |
| Increasing body size | Trend acceleration |
| Decreasing body size | Trend deceleration |
## Interpretation and Signals
### Signal Patterns
- **Wickless candles**: An HA candle with no lower wick (uptrend) or no upper wick (downtrend) signals strong directional momentum. Three or more consecutive wickless candles in one direction is a high-confidence trend signal.
- **Doji / spinning top**: Small HA bodies with wicks on both sides indicate weakening momentum and potential reversal. The smaller the body relative to the wicks, the stronger the indecision signal.
- **Color change**: A transition from red to green (or vice versa) after a series of same-colored candles signals trend reversal. Confirmation from volume or a secondary indicator reduces false signals.
- **Body size sequence**: Monotonically increasing HA body sizes indicate trend acceleration; decreasing sizes indicate exhaustion.
### Practical Notes
HA candles should never be used for precise entry/exit pricing because HA Open and HA Close are synthetic — they do not correspond to any traded price. Use HA for trend direction and standard candles for execution levels. Combining HA trend direction with a momentum oscillator (RSI, CCI) on standard data provides trend-filtered signals without the double-smoothing problem.
## Quality Metrics
| Metric | Score | Notes |
|--------|:-----:|-------|
| **Accuracy** | 7/10 | HA Close = OHLC4 (exact); HA Open drifts from real prices due to recursion |
| **Timeliness** | 8/10 | Only 1-bar effective lag from IIR Open; responds quickly to trend changes |
| **Overshoot** | 10/10 | High/Low clamping guarantees HA range ⊆ real range on High/Low channels |
| **Smoothness** | 8/10 | IIR Open provides consistent smoothing; Close is unsmoothed (bar-local) |
## Related Indicators
- **[AVGPRICE](../avgprice/Avgprice.md)**: HA_Close is identical to AVGPRICE. If you only need the average price per bar, AVGPRICE avoids the recursive state overhead.
- **[EMA](../../trends_IIR/ema/Ema.md)**: HA_Open is effectively EMA(2) on the midpoint stream. For single-value smoothing with configurable responsiveness, EMA offers more control.
- **[MEDPRICE](../medprice/Medprice.md)**: Uses (H+L)/2 — HA's seed value on bar 0 uses (O+C)/2 instead, weighting session boundaries over extremes.
## Validation
Validated against external libraries in `Ha.Validation.Tests.cs`. HA is widely implemented; cross-validation is straightforward since the formula has no ambiguity.
| Library | Batch | Streaming | Span | Notes |
|---------|:-----:|:---------:|:----:|-------|
| **TA-Lib** | ? | ? | ? | No direct `TA_HA` function; requires manual OHLC transform |
| **Skender** | ? | ? | ? | `GetHeikinAshi()` returns OHLC results |
| **Tulip** | ? | ? | ? | No Heikin-Ashi function |
| **Ooples** | ? | ? | ? | `GetHeikinAshi()` |
## Performance Profile
### Key Optimizations
- **FMA usage**: HA_Close uses `Math.FusedMultiplyAdd(o + h, 0.25, (l + c) * 0.25)` — single instruction for the four-price average.
- **Multiplication over division**: `× 0.5` and `× 0.25` replace `/2` and `/4`.
- **No buffer**: Only two doubles of state (previous HA_Open, previous HA_Close). No `RingBuffer` or history required.
- **Aggressive inlining**: `Update` method decorated with `[MethodImpl(MethodImplOptions.AggressiveInlining)]`.
### Operation Count (Streaming Mode)
| Operation | Count | Cost (cycles) | Subtotal |
|-----------|:-----:|:-------------:|:--------:|
| ADD (O+H) | 1 | 1 | 1 |
| ADD (L+C) | 1 | 1 | 1 |
| MUL ((L+C) × 0.25) | 1 | 3 | 3 |
| FMA (haClose) | 1 | 4 | 4 |
| ADD (prevHaOpen + prevHaClose) | 1 | 1 | 1 |
| MUL (× 0.5) | 1 | 3 | 3 |
| MAX (3-way) | 2 | 1 | 2 |
| MIN (3-way) | 2 | 1 | 2 |
| **Total (hot)** | **10** | | **~17 cycles** |
### SIMD Analysis (Batch Mode)
| Aspect | Assessment |
|--------|------------|
| HA_Close | Fully vectorizable (element-wise OHLC4) |
| HA_Open | Sequential — IIR dependency blocks vectorization |
| HA_High/Low | Vectorizable after Open/Close are computed |
| Strategy | Vectorize Close in pass 1, scalar Open in pass 2, vectorize High/Low in pass 3 |
## Common Pitfalls
1. **Synthetic prices**: HA Open and HA Close do not correspond to any actual traded price. Using HA values for order placement or stop-loss levels produces fills at non-real prices. Always use standard OHLC for execution.
2. **Double smoothing**: Applying indicators (RSI, MACD, etc.) to HA data instead of standard data produces doubly-smoothed results with increased lag and reduced sensitivity. This is the single most common misuse of Heikin-Ashi.
3. **Backtesting on HA data**: Strategies backtested on HA candles show artificially smooth equity curves because the smoothed prices overstate trend persistence. Results do not replicate on live standard-data execution.
4. **Volume passthrough**: HA transforms only prices. Volume is unchanged. Interpreting HA candle patterns without checking whether volume confirms the signal leads to false trend readings.
5. **Seed sensitivity**: The first bar's HA_Open seed $(O_0 + C_0)/2$ affects all subsequent HA_Open values. Different start dates produce different HA series for the same instrument. The impact decays as $0.5^n$ — after 10 bars the seed contributes less than 0.1%.
6. **Gap handling**: Real gaps (overnight, weekend) produce HA_Open values that split the difference between the gap ends. This is by design (smoothing), but users expecting gap preservation will be surprised. The actual High and Low still reflect the real extremes via the max/min clamping.
7. **No parameters**: Unlike most indicators, HA has no configurable period or smoothing factor. The $\alpha = 0.5$ is fixed. Users wanting adjustable smoothing should consider applying an EMA or other moving average to standard OHLC data instead.
## References
- **Valcu, D.** (2004). "Using The Heikin-Ashi Technique." *Technical Analysis of Stocks & Commodities*, Vol. 22, No. 2.
- **Nison, S.** (1991). *Japanese Candlestick Charting Techniques*. New York Institute of Finance.
- **Vervoort, S.** (2008). "Smoothing Heikin-Ashi." *Technical Analysis of Stocks & Commodities*.
- [Investopedia: Heikin-Ashi](https://www.investopedia.com/terms/h/heikinashi.asp) — accessible introduction to the technique and its trading applications.
+19
View File
@@ -0,0 +1,19 @@
// HA: Heikin-Ashi
// Smoothed candle transformation with recursive open
// HA_Close = (O + H + L + C) / 4
// HA_Open = (prev_HA_Open + prev_HA_Close) / 2
// HA_High = max(H, HA_Open, HA_Close)
// HA_Low = min(L, HA_Open, HA_Close)
//@version=6
indicator("HA: Heikin-Ashi", overlay=true)
var float haOpen = na
var float haClose = na
haClose := (open + high + low + close) * 0.25
haOpen := na(haOpen) ? (open + close) * 0.5 : (haOpen + haClose[1]) * 0.5
haHigh = math.max(high, haOpen, haClose)
haLow = math.min(low, haOpen, haClose)
plotcandle(haOpen, haHigh, haLow, haClose, "HA", color=haClose >= haOpen ? color.green : color.red)
@@ -0,0 +1,131 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class MedpriceIndicatorTests
{
[Fact]
public void MedpriceIndicator_Constructor_SetsDefaults()
{
var indicator = new MedpriceIndicator();
Assert.True(indicator.ShowColdValues);
Assert.Equal("MEDPRICE - Median Price", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void MedpriceIndicator_ShortName_IsMedprice()
{
var indicator = new MedpriceIndicator();
Assert.Equal("MEDPRICE", indicator.ShortName);
}
[Fact]
public void MedpriceIndicator_MinHistoryDepths_EqualsOne()
{
var indicator = new MedpriceIndicator();
Assert.Equal(1, MedpriceIndicator.MinHistoryDepths);
Assert.Equal(1, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void MedpriceIndicator_Initialize_CreatesInternalIndicator()
{
var indicator = new MedpriceIndicator();
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void MedpriceIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new MedpriceIndicator();
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 MedpriceIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new MedpriceIndicator();
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 MedpriceIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new MedpriceIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void MedpriceIndicator_SourceCodeLink_IsValid()
{
var indicator = new MedpriceIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Medprice.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void MedpriceIndicator_ComputesCorrectMedian()
{
var indicator = new MedpriceIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// H=110, L=90 → (110+90)/2 = 100.0
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double val = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(100.0, val, 10);
}
[Fact]
public void MedpriceIndicator_IsHotImmediately()
{
var indicator = new MedpriceIndicator();
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));
}
}
+46
View File
@@ -0,0 +1,46 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class MedpriceIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Medprice _medprice = null!;
private readonly LineSeries _series;
public static int MinHistoryDepths => 1;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => "MEDPRICE";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/core/medprice/Medprice.Quantower.cs";
public MedpriceIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "MEDPRICE - Median Price";
Description = "Midpoint of High and Low prices: (H+L)/2.";
_series = new LineSeries(name: "MEDPRICE", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_medprice = new Medprice();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _medprice.Update(bar, isNew: args.IsNewBar());
_series.SetValue(result.Value, _medprice.IsHot, ShowColdValues);
}
}
+260
View File
@@ -0,0 +1,260 @@
// Medprice Unit Tests
using Xunit;
namespace QuanTAlib.Tests;
public class MedpriceTests
{
private readonly GBM _gbm;
private const double Tolerance = 1e-10;
public MedpriceTests()
{
_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 Medprice();
Assert.Equal("Medprice", indicator.Name);
Assert.Equal(1, indicator.WarmupPeriod);
}
[Fact]
public void Constructor_WithSource_SubscribesToEvents()
{
var source = new TSeries();
var indicator = new Medprice(source);
source.Add(new TValue(DateTime.UtcNow, 100.0));
Assert.NotEqual(default, indicator.Last);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_Bar_ReturnsHL2()
{
var indicator = new Medprice();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.Update(bar);
// (110 + 90) / 2 = 100
Assert.Equal(100.0, result.Value, Tolerance);
}
[Fact]
public void Update_Bar_MatchesTBarHL2()
{
var indicator = new Medprice();
var bar = new TBar(DateTime.UtcNow, 50, 60, 40, 55, 500);
var result = indicator.Update(bar);
Assert.Equal(bar.HL2, result.Value, Tolerance);
}
[Fact]
public void Update_TValue_ReturnsIdentity()
{
var indicator = new Medprice();
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 Medprice();
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 Medprice();
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 = (120 + 80) * 0.5;
Assert.Equal(expected, corrected.Value, Tolerance);
}
[Fact]
public void Update_MultipleIsNewFalse_ProducesIdempotentResults()
{
var indicator = new Medprice();
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 Medprice();
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 Medprice();
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 Medprice();
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 = Medprice.Batch(bars);
// Mode 3: Span batch
double[] spanOutput = new double[bars.Count];
Medprice.Batch(bars.HighValues, bars.LowValues, 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_MatchTBarHL2()
{
var bars = GenerateBars(50);
var indicator = new Medprice();
for (int i = 0; i < bars.Count; i++)
{
var result = indicator.Update(bars[i], isNew: true);
Assert.Equal(bars[i].HL2, result.Value, Tolerance);
}
}
#endregion
#region Batch Validation Tests
[Fact]
public void Batch_MismatchedLengths_ThrowsArgumentException()
{
double[] high = new double[10];
double[] low = new double[5]; // mismatched
double[] output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Medprice.Batch(high, low, output));
Assert.Equal("low", ex.ParamName);
}
[Fact]
public void Batch_OutputTooShort_ThrowsArgumentException()
{
double[] high = new double[10];
double[] low = new double[10];
double[] output = new double[5]; // too short
var ex = Assert.Throws<ArgumentException>(() => Medprice.Batch(high, low, output));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_EmptyInput_NoOutput()
{
var bars = new TBarSeries();
var result = Medprice.Batch(bars);
Assert.Empty(result);
}
[Fact]
public void Batch_LargeDataset_NoStackOverflow()
{
var bars = GenerateBars(10_000);
double[] output = new double[bars.Count];
Medprice.Batch(bars.HighValues, bars.LowValues, output);
Assert.True(double.IsFinite(output[^1]));
}
#endregion
#region Event Chaining Tests
[Fact]
public void Pub_EventFires_OnUpdate()
{
var indicator = new Medprice();
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) = Medprice.Calculate(bars);
Assert.Equal(bars.Count, results.Count);
Assert.True(ind.IsHot);
}
#endregion
}
+276
View File
@@ -0,0 +1,276 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// MEDPRICE: Median Price
/// Calculates the midpoint of High and Low prices.
/// Equivalent to TBar.HL2 but as a proper streaming indicator with bar correction.
/// </summary>
/// <remarks>
/// <b>Calculation:</b>
/// <list type="number">
/// <item>MedPrice = (High + Low) / 2</item>
/// </list>
///
/// <b>Key characteristics:</b>
/// <list type="bullet">
/// <item>Stateless bar-by-bar calculation (no lookback period)</item>
/// <item>TA-Lib compatible (MEDPRICE function)</item>
/// <item>Always hot after first bar</item>
/// <item>Common proxy for "fair value" within a bar</item>
/// </list>
/// </remarks>
[SkipLocalsInit]
public sealed class Medprice : AbstractBase
{
[StructLayout(LayoutKind.Auto)]
private record struct State(
double LastValidHigh,
double LastValidLow,
double LastResult,
int Count
);
private State _s;
private State _ps;
/// <summary>
/// Initializes a new instance of the Medprice class.
/// </summary>
public Medprice()
{
WarmupPeriod = 1;
Name = "Medprice";
_s = new State(0, 0, 0, 0);
_ps = _s;
}
/// <summary>
/// Initializes a new instance of the Medprice class with a source.
/// </summary>
/// <param name="source">The data source for chaining.</param>
public Medprice(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 median price from High and Low values.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double ComputeMedianPrice(double high, double low)
{
return (high + low) * 0.5;
}
/// <summary>
/// Updates the indicator with a TValue input.
/// For TValue input, treats the value as both High and Low (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 Median Price value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar bar, bool isNew = true)
{
return UpdateCore(bar.Time, bar.High, bar.Low, isNew);
}
/// <summary>
/// Updates the indicator with a bar series.
/// </summary>
/// <param name="source">The source bar series.</param>
/// <returns>A TSeries containing the Median Price 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.HighValues, source.LowValues, 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 high, double low, bool isNew)
{
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
var s = _s;
// Handle non-finite values — use last valid values
if (!double.IsFinite(high)) { high = s.LastValidHigh; } else { s.LastValidHigh = high; }
if (!double.IsFinite(low)) { low = s.LastValidLow; } else { s.LastValidLow = low; }
double result = ComputeMedianPrice(high, low);
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 Median Price for a bar series (static).
/// </summary>
public static TSeries Batch(TBarSeries source)
{
var indicator = new Medprice();
return indicator.Update(source);
}
/// <summary>
/// Batch calculation using spans for High/Low data.
/// </summary>
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
Span<double> output)
{
int len = high.Length;
if (low.Length != len)
{
throw new ArgumentException("All input spans must have the same length", nameof(low));
}
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] = ComputeMedianPrice(high[i], low[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.HighValues, source.LowValues, output);
}
public static (TSeries Results, Medprice Indicator) Calculate(TBarSeries source)
{
var indicator = new Medprice();
TSeries results = indicator.Update(source);
return (results, indicator);
}
}
+94
View File
@@ -0,0 +1,94 @@
# MEDPRICE: Median Price
MEDPRICE computes the midpoint of a bar's High and Low: $(H + L) \times 0.5$. This is the simplest possible estimate of a bar's "fair value," splitting the difference between the session's extremes while ignoring both the opening gap and closing settlement. The result represents the geometric center of the bar's vertical range. Because it excludes Open and Close, MEDPRICE responds purely to the supply/demand boundaries that the market tested, making it a useful input for range-based indicators like CCI or as a detrending reference. Stateless, zero-warmup, one addition and one multiply per bar.
## Historical Context
Median Price (also called "Mid Price" or "HL/2") is among the most elemental price transforms, used long before computers entered trading floors. The TA-Lib function `TA_MEDPRICE` standardized the computation, and most charting platforms expose it as a built-in price source. The name "Median Price" is a slight misnomer in the statistical sense: it is the midrange (arithmetic mean of extremes), not the median of a distribution. The name stuck through decades of usage.
The key distinction from Typical Price ($HLC/3$) is the exclusion of Close. This matters when the closing price diverges significantly from the bar's center, as happens with gap-up closes, stop runs, or end-of-session order flow. MEDPRICE treats the bar as a symmetric range and asks: where was the midpoint of price exploration?
In QuanTAlib, `TBar.HL2` provides the same value as a zero-cost computed property. The `Medprice` indicator class wraps this in the streaming `ITValuePublisher` interface with bar correction, NaN safety, and event chaining support.
## Architecture & Physics
### 1. Core Formula
$$\text{MedPrice}_t = (H_t + L_t) \times 0.5$$
No FMA benefit here: the pattern is $(a + b) \times c$, not $a \times b + c$.
### 2. State Management
Stateless per bar. State exists only for:
- **Last-valid substitution**: Non-finite High or Low values are replaced with the last known finite value for that component.
- **Bar correction**: `isNew=false` rolls back to previous state for same-timestamp rewrites.
### 3. Complexity
$O(1)$ per bar. One addition, one multiply. No memory allocation. Always hot after the first bar.
## Mathematical Foundation
### Parameters
| Parameter | Description | Default | Constraint |
|-----------|-------------|---------|------------|
| (none) | No user-configurable parameters | | |
### Price Transform Comparison
| Transform | Components | Weights | Bias |
|-----------|:----------:|---------|------|
| MEDPRICE | H, L | Equal | Range-centered; ignores O/C |
| TYPPRICE | H, L, C | Equal | Close-influenced |
| AVGPRICE | O, H, L, C | Equal | Fully balanced |
| WCLPRICE | H, L, C | C double-weighted | Close-biased |
### Pseudo-code
```
function MEDPRICE(bar):
h, l ← bar.High, bar.Low
// Substitute last-valid for non-finite inputs
if !finite(h): h ← lastValidHigh
if !finite(l): l ← lastValidLow
result ← (h + l) × 0.5
return result
```
### Output Interpretation
| Context | Meaning |
|---------|---------|
| Close > MEDPRICE | Close above the range midpoint (bullish bar body) |
| Close < MEDPRICE | Close below the range midpoint (bearish bar body) |
| Close $\approx$ MEDPRICE | Close near center of range (indecision) |
| MEDPRICE expanding | Increasing bar ranges (volatility expanding) |
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Cost (cycles) | Subtotal |
|-----------|:-----:|:-------------:|:--------:|
| ADD (H+L) | 1 | 1 | 1 |
| MUL (× 0.5) | 1 | 3 | 3 |
| **Total (hot)** | **2** | | **~4 cycles** |
### Batch Mode (SIMD Analysis)
| Aspect | Assessment |
|--------|------------|
| SIMD vectorizable | Yes: element-wise add + multiply, no inter-bar dependency |
| Optimal strategy | `Vector<double>` over High/Low spans |
| Memory | $O(1)$ streaming; $O(n)$ batch output span |
| Throughput | Memory-bandwidth bound; trivial compute |
## Resources
- **TA-Lib** `TA_MEDPRICE` function reference.
- **Murphy, J.J.** *Technical Analysis of the Financial Markets*. New York Institute of Finance, 1999.
+13
View File
@@ -0,0 +1,13 @@
// MEDPRICE: Median Price
// (High + Low) / 2
// TA-Lib compatible — equivalent to TBar.HL2
//@version=6
indicator("MEDPRICE: Median Price", overlay=true)
medprice(float h, float l) =>
(h + l) * 0.5
result = medprice(high, low)
plot(result, "MedPrice", color.new(color.orange, 0), 2)
@@ -0,0 +1,245 @@
using Xunit;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class MidpointIndicatorTests
{
[Fact]
public void MidpointIndicator_Constructor_SetsDefaults()
{
var indicator = new MidpointIndicator();
Assert.Equal(14, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("MIDPOINT - Rolling Range Midpoint", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void MidpointIndicator_MinHistoryDepths_EqualsPeriod()
{
var indicator = new MidpointIndicator { Period = 20 };
Assert.Equal(20, indicator.MinHistoryDepths);
}
[Fact]
public void MidpointIndicator_ShortName_IncludesPeriod()
{
var indicator = new MidpointIndicator { Period = 14 };
Assert.Equal("MIDPOINT(14)", indicator.ShortName);
}
[Fact]
public void MidpointIndicator_Initialize_CreatesLineSeries()
{
var indicator = new MidpointIndicator();
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
Assert.Equal("Midpoint", indicator.LinesSeries[0].Name);
}
[Fact]
public void MidpointIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new MidpointIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
Assert.Equal(1, indicator.LinesSeries[0].Count);
}
[Fact]
public void MidpointIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new MidpointIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 92, 106);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void MidpointIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new MidpointIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void MidpointIndicator_MultipleUpdates_ProducesCorrectSequence()
{
var indicator = new MidpointIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(
now.AddMinutes(i),
100 + i * 2,
105 + i * 2,
95 + i * 2,
102 + i * 2);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
Assert.Equal(20, indicator.LinesSeries[0].Count);
for (int i = 0; i < 20; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(i)));
}
}
[Fact]
public void MidpointIndicator_DifferentSourceTypes_Work()
{
var sources = new[]
{
SourceType.Open,
SourceType.High,
SourceType.Low,
SourceType.Close,
SourceType.HL2,
SourceType.HLC3,
};
foreach (var source in sources)
{
var indicator = new MidpointIndicator { Period = 5, Source = source };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.Equal(1, indicator.LinesSeries[0].Count);
}
}
[Fact]
public void MidpointIndicator_ShowColdValues_False_SetsNaN()
{
var indicator = new MidpointIndicator { Period = 10, ShowColdValues = false };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.True(double.IsNaN(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void MidpointIndicator_ComputesMidpoint_Correctly()
{
var indicator = new MidpointIndicator { Period = 5, Source = SourceType.Close };
indicator.Initialize();
var now = DateTime.UtcNow;
// Close prices: 100, 110, 90, 105, 95
// Highest = 110, Lowest = 90, Midpoint = (110 + 90) / 2 = 100
double[] closes = { 100, 110, 90, 105, 95 };
for (int i = 0; i < closes.Length; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), closes[i], closes[i] + 5, closes[i] - 5, closes[i]);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double lastMidpoint = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(100, lastMidpoint);
}
[Fact]
public void MidpointIndicator_WindowSlides_Correctly()
{
var indicator = new MidpointIndicator { Period = 3, Source = SourceType.Close };
indicator.Initialize();
var now = DateTime.UtcNow;
// Closes: 100, 120, 80, 90, 110
// After 5 bars, window = [80, 90, 110]
// Highest = 110, Lowest = 80, Midpoint = 95
double[] closes = { 100, 120, 80, 90, 110 };
for (int i = 0; i < closes.Length; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), closes[i], closes[i] + 5, closes[i] - 5, closes[i]);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double lastMidpoint = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(95, lastMidpoint);
}
[Fact]
public void MidpointIndicator_SymmetricRange_MidpointEqualsCenter()
{
var indicator = new MidpointIndicator { Period = 3, Source = SourceType.Close };
indicator.Initialize();
var now = DateTime.UtcNow;
// Symmetric: 50, 100, 150 -> midpoint = (150 + 50) / 2 = 100
double[] closes = { 50, 100, 150 };
for (int i = 0; i < closes.Length; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), closes[i], closes[i] + 5, closes[i] - 5, closes[i]);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double midpoint = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(100, midpoint);
}
[Fact]
public void MidpointIndicator_DifferentPeriods_Work()
{
var periods = new[] { 5, 10, 20, 50 };
foreach (int period in periods)
{
var indicator = new MidpointIndicator { Period = period };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < period + 10; i++)
{
indicator.HistoricalData.AddBar(
now.AddMinutes(i),
100 + i,
105 + i,
95 + i,
102 + i);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
Assert.Equal(period + 10, indicator.LinesSeries[0].Count);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
using static QuanTAlib.IndicatorExtensions;
namespace QuanTAlib;
/// <summary>
/// MIDPOINT (Rolling Range Midpoint) Quantower indicator.
/// Calculates (Highest + Lowest) / 2 over a rolling lookback window.
/// </summary>
public class MidpointIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 0, minimum: 1, maximum: 1000)]
public int Period { get; set; } = 14;
[DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show Cold Values", sortIndex: 100)]
public bool ShowColdValues { get; set; } = true;
private Midpoint? _midpoint;
private Func<IHistoryItem, double>? _selector;
public int MinHistoryDepths => Period;
public override string ShortName => $"MIDPOINT({Period})";
public MidpointIndicator()
{
Name = "MIDPOINT - Rolling Range Midpoint";
Description = "Calculates (Highest + Lowest) / 2 over a rolling lookback window";
SeparateWindow = false;
OnBackGround = true;
}
protected override void OnInit()
{
_midpoint = new Midpoint(Period);
_selector = Source.GetPriceSelector();
AddLineSeries(new LineSeries("Midpoint", Color.Blue, 2, LineStyle.Solid));
}
protected override void OnUpdate(UpdateArgs args)
{
if (_midpoint == null || _selector == null)
{
return;
}
var item = HistoricalData[0, SeekOriginHistory.End];
double value = _selector(item);
bool isNew = args.IsNewBar();
TValue input = new(item.TimeLeft, value);
_midpoint.Update(input, isNew);
bool isHot = _midpoint.IsHot;
LinesSeries[0].SetValue(_midpoint.Last.Value, isHot, ShowColdValues);
}
}
+325
View File
@@ -0,0 +1,325 @@
using Xunit;
namespace QuanTAlib.Tests;
public class MidpointTests
{
private const double Tolerance = 1e-10;
[Fact]
public void Constructor_InvalidPeriod_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new Midpoint(0));
Assert.Throws<ArgumentException>(() => new Midpoint(-1));
}
[Fact]
public void Constructor_ValidPeriod_SetsProperties()
{
var indicator = new Midpoint(14);
Assert.Equal("Midpoint(14)", indicator.Name);
Assert.Equal(14, indicator.WarmupPeriod);
Assert.False(indicator.IsHot);
}
[Fact]
public void Update_ReturnsMidpointInWindow()
{
var indicator = new Midpoint(3);
var time = DateTime.UtcNow;
// Single value: midpoint = (5+5)/2 = 5
indicator.Update(new TValue(time, 5.0));
Assert.Equal(5.0, indicator.Last.Value, Tolerance);
// Window [5, 8]: midpoint = (8+5)/2 = 6.5
indicator.Update(new TValue(time.AddMinutes(1), 8.0));
Assert.Equal(6.5, indicator.Last.Value, Tolerance);
// Window [5, 8, 3]: midpoint = (8+3)/2 = 5.5
indicator.Update(new TValue(time.AddMinutes(2), 3.0));
Assert.Equal(5.5, indicator.Last.Value, Tolerance);
// Window [8, 3, 2]: midpoint = (8+2)/2 = 5.0
indicator.Update(new TValue(time.AddMinutes(3), 2.0));
Assert.Equal(5.0, indicator.Last.Value, Tolerance);
// Window [3, 2, 10]: midpoint = (10+2)/2 = 6.0
indicator.Update(new TValue(time.AddMinutes(4), 10.0));
Assert.Equal(6.0, indicator.Last.Value, Tolerance);
}
[Fact]
public void Update_Period1_ReturnsSameValue()
{
var indicator = new Midpoint(1);
var time = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
double value = i * 2.5;
indicator.Update(new TValue(time.AddMinutes(i), value));
// Midpoint of single value = that value
Assert.Equal(value, indicator.Last.Value, Tolerance);
}
}
[Fact]
public void Update_IsNewFalse_CorrectsPreviousValue()
{
var indicator = new Midpoint(5);
var time = DateTime.UtcNow;
indicator.Update(new TValue(time, 10.0));
indicator.Update(new TValue(time.AddMinutes(1), 20.0));
indicator.Update(new TValue(time.AddMinutes(2), 15.0));
// Window [10, 20, 15]: midpoint = (20+10)/2 = 15.0
Assert.Equal(15.0, indicator.Last.Value, Tolerance);
// Correct last value to 5.0
// Window [10, 20, 5]: midpoint = (20+5)/2 = 12.5
indicator.Update(new TValue(time.AddMinutes(2), 5.0), isNew: false);
Assert.Equal(12.5, indicator.Last.Value, Tolerance);
}
[Fact]
public void Update_IterativeCorrection_RestoresState()
{
var indicator = new Midpoint(5);
var time = DateTime.UtcNow;
double[] values = { 5.0, 10.0, 8.0, 12.0, 7.0, 15.0, 11.0 };
// Process all values
foreach (var v in values)
{
indicator.Update(new TValue(time, v));
time = time.AddMinutes(1);
}
double finalResult = indicator.Last.Value;
// Reset and process with corrections
indicator.Reset();
time = DateTime.UtcNow;
foreach (var v in values)
{
// Submit wrong value first
indicator.Update(new TValue(time, 0.0));
// Correct it
indicator.Update(new TValue(time, v), isNew: false);
time = time.AddMinutes(1);
}
Assert.Equal(finalResult, indicator.Last.Value, Tolerance);
}
[Fact]
public void Update_NaN_UsesLastValidValue()
{
var indicator = new Midpoint(5);
var time = DateTime.UtcNow;
indicator.Update(new TValue(time, 10.0));
indicator.Update(new TValue(time.AddMinutes(1), 20.0));
double beforeNaN = indicator.Last.Value;
indicator.Update(new TValue(time.AddMinutes(2), double.NaN));
// Should use last valid value
Assert.Equal(beforeNaN, indicator.Last.Value, Tolerance);
}
[Fact]
public void Update_Infinity_UsesLastValidValue()
{
var indicator = new Midpoint(5);
var time = DateTime.UtcNow;
indicator.Update(new TValue(time, 15.0));
indicator.Update(new TValue(time.AddMinutes(1), double.PositiveInfinity));
Assert.True(double.IsFinite(indicator.Last.Value));
}
[Fact]
public void IsHot_BecomesTrueAfterWarmup()
{
var indicator = new Midpoint(5);
var time = DateTime.UtcNow;
for (int i = 0; i < 4; i++)
{
indicator.Update(new TValue(time.AddMinutes(i), i));
Assert.False(indicator.IsHot);
}
indicator.Update(new TValue(time.AddMinutes(4), 4));
Assert.True(indicator.IsHot);
}
[Fact]
public void Reset_ClearsState()
{
var indicator = new Midpoint(5);
var time = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.Update(new TValue(time.AddMinutes(i), i * 2));
}
Assert.True(indicator.IsHot);
indicator.Reset();
Assert.False(indicator.IsHot);
Assert.Equal(default, indicator.Last);
}
[Fact]
public void Pub_EventFires()
{
var indicator = new Midpoint(5);
int eventCount = 0;
indicator.Pub += (object? sender, in TValueEventArgs args) => eventCount++;
indicator.Update(new TValue(DateTime.UtcNow, 10.0));
Assert.Equal(1, eventCount);
}
[Fact]
public void Chaining_Constructor_Works()
{
var source = new TSeries();
var indicator = new Midpoint(source, 5);
source.Add(new TValue(DateTime.UtcNow, 10.0), true);
Assert.Equal(10.0, indicator.Last.Value, Tolerance);
// Window [10, 20]: midpoint = (20+10)/2 = 15
source.Add(new TValue(DateTime.UtcNow.AddMinutes(1), 20.0), true);
Assert.Equal(15.0, indicator.Last.Value, Tolerance);
}
[Fact]
public void Calculate_TSeries_MatchesStreaming()
{
int period = 5;
int count = 50;
var gbm = new GBM(10000);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
// Streaming
var streaming = new Midpoint(period);
var streamingResults = new List<double>();
for (int i = 0; i < source.Count; i++)
{
streaming.Update(source[i]);
streamingResults.Add(streaming.Last.Value);
}
// Batch
var batch = Midpoint.Batch(source, period);
// Compare last values (after warmup)
for (int i = period; i < source.Count; i++)
{
Assert.Equal(streamingResults[i], batch[i].Value, Tolerance);
}
}
[Fact]
public void Calculate_Span_MatchesTSeries()
{
int period = 5;
int count = 50;
var gbm = new GBM(10001);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
// TSeries batch
var batchResult = Midpoint.Batch(source, period);
// Span calculation
var sourceArray = source.Values.ToArray();
var output = new double[count];
Midpoint.Batch(sourceArray.AsSpan(), output.AsSpan(), period);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(batchResult[i].Value, output[i], Tolerance);
}
}
[Fact]
public void Calculate_Span_ValidatesArguments()
{
Assert.Throws<ArgumentException>(() =>
{
Span<double> output = stackalloc double[10];
Midpoint.Batch(ReadOnlySpan<double>.Empty, output, 5);
});
Assert.Throws<ArgumentException>(() =>
{
ReadOnlySpan<double> source = stackalloc double[10];
Span<double> output = stackalloc double[5];
Midpoint.Batch(source, output, 5);
});
Assert.Throws<ArgumentException>(() =>
{
ReadOnlySpan<double> source = stackalloc double[10];
Span<double> output = stackalloc double[10];
Midpoint.Batch(source, output, 0);
});
}
[Fact]
public void ConstantSequence_ReturnsSameValue()
{
var indicator = new Midpoint(5);
var time = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.Update(new TValue(time.AddMinutes(i), 7.5));
// Midpoint of constant sequence = that constant
Assert.Equal(7.5, indicator.Last.Value, Tolerance);
}
}
[Fact]
public void Midpoint_EqualsAverageOfMaxAndMin()
{
// Verify Midpoint matches manually computed (Max + Min) / 2 from values in window
int period = 5;
var gbm = new GBM(12345);
var bars = gbm.Fetch(30, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
var midpoint = new Midpoint(period);
var values = new List<double>();
for (int i = 0; i < source.Count; i++)
{
values.Add(source[i].Value);
midpoint.Update(source[i]);
// Manually compute max and min over the window
int start = Math.Max(0, values.Count - period);
double max = double.MinValue;
double min = double.MaxValue;
for (int j = start; j < values.Count; j++)
{
if (values[j] > max)
{
max = values[j];
}
if (values[j] < min)
{
min = values[j];
}
}
double expected = (max + min) * 0.5;
Assert.Equal(expected, midpoint.Last.Value, Tolerance);
}
}
}
@@ -0,0 +1,197 @@
using TALib;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public sealed class MidpointValidationTests : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public MidpointValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
public void Dispose()
{
Dispose(true);
}
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
_disposed = true;
if (disposing)
{
_testData?.Dispose();
}
}
[Fact]
public void Validate_Talib_Batch()
{
int[] periods = { 5, 10, 14, 20, 50 };
double[] tData = _testData.RawData.ToArray();
double[] output = new double[tData.Length];
foreach (var period in periods)
{
// Calculate QuanTAlib Midpoint (batch TSeries)
var midpoint = new Midpoint(period);
var qResult = midpoint.Update(_testData.Data);
// Calculate TA-Lib MIDPOINT
var retCode = TALib.Functions.MidPoint<double>(tData, 0..^0, output, out var outRange, period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.MidPointLookback(period);
// Compare last 100 records
ValidationHelper.VerifyData(qResult, output, outRange, lookback);
}
_output.WriteLine("Midpoint Batch(TSeries) validated successfully against TA-Lib MIDPOINT");
}
[Fact]
public void Validate_Talib_Streaming()
{
int[] periods = { 5, 10, 14, 20, 50 };
double[] tData = _testData.RawData.ToArray();
double[] output = new double[tData.Length];
foreach (var period in periods)
{
// Calculate QuanTAlib Midpoint (streaming)
var midpoint = new Midpoint(period);
var qResults = new List<double>();
foreach (var item in _testData.Data)
{
qResults.Add(midpoint.Update(item).Value);
}
// Calculate TA-Lib MIDPOINT
var retCode = TALib.Functions.MidPoint<double>(tData, 0..^0, output, out var outRange, period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.MidPointLookback(period);
// Compare last 100 records
ValidationHelper.VerifyData(qResults, output, outRange, lookback);
}
_output.WriteLine("Midpoint Streaming validated successfully against TA-Lib MIDPOINT");
}
[Fact]
public void Validate_Talib_Span()
{
int[] periods = { 5, 10, 14, 20, 50 };
double[] sourceData = _testData.RawData.ToArray();
double[] talibOutput = new double[sourceData.Length];
foreach (var period in periods)
{
// Calculate QuanTAlib Midpoint (Span API)
double[] qOutput = new double[sourceData.Length];
Midpoint.Batch(sourceData.AsSpan(), qOutput.AsSpan(), period);
// Calculate TA-Lib MIDPOINT
var retCode = TALib.Functions.MidPoint<double>(sourceData, 0..^0, talibOutput, out var outRange, period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.MidPointLookback(period);
// Compare last 100 records
ValidationHelper.VerifyData(qOutput, talibOutput, outRange, lookback);
}
_output.WriteLine("Midpoint Span validated successfully against TA-Lib MIDPOINT");
}
[Fact]
public void Validate_KnownValues()
{
// Test with simple known sequence
double[] data = { 1, 5, 3, 8, 2, 9, 4, 7, 6, 10 };
int period = 3;
// For each window:
// [1] -> (1+1)/2 = 1
// [1,5] -> (5+1)/2 = 3
// [1,5,3] -> (5+1)/2 = 3
// [5,3,8] -> (8+3)/2 = 5.5
// [3,8,2] -> (8+2)/2 = 5
// [8,2,9] -> (9+2)/2 = 5.5
// [2,9,4] -> (9+2)/2 = 5.5
// [9,4,7] -> (9+4)/2 = 6.5
// [4,7,6] -> (7+4)/2 = 5.5
// [7,6,10] -> (10+6)/2 = 8
double[] expected = { 1, 3, 3, 5.5, 5, 5.5, 5.5, 6.5, 5.5, 8 };
var midpoint = new Midpoint(period);
for (int i = 0; i < data.Length; i++)
{
var result = midpoint.Update(new TValue(DateTime.UtcNow, data[i]));
Assert.Equal(expected[i], result.Value, precision: 10);
}
_output.WriteLine("Midpoint validated with known values");
}
[Fact]
public void Validate_ConsistencyBatchStreamingSpan()
{
// Verify that Batch, Streaming, and Span all produce the same results
int period = 14;
var gbm = new GBM(42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
// Streaming
var streaming = new Midpoint(period);
var streamResults = new List<double>();
foreach (var item in source)
{
streamResults.Add(streaming.Update(item).Value);
}
// Batch TSeries
var batchResult = Midpoint.Batch(source, period);
// Span
double[] sourceArray = source.Values.ToArray();
double[] spanOutput = new double[sourceArray.Length];
Midpoint.Batch(sourceArray.AsSpan(), spanOutput.AsSpan(), period);
for (int i = period; i < source.Count; i++)
{
Assert.Equal(streamResults[i], batchResult[i].Value, precision: 10);
Assert.Equal(streamResults[i], spanOutput[i], precision: 10);
}
_output.WriteLine("Midpoint consistency validated: Batch == Streaming == Span");
}
[Fact]
public void Validate_ConstantInput()
{
// For constant input, midpoint should equal that constant
double constant = 42.5;
int period = 10;
var midpoint = new Midpoint(period);
for (int i = 0; i < 50; i++)
{
var result = midpoint.Update(new TValue(DateTime.UtcNow, constant));
Assert.Equal(constant, result.Value, precision: 10);
}
_output.WriteLine("Midpoint validated with constant input");
}
}
+168
View File
@@ -0,0 +1,168 @@
// MIDPOINT: Rolling Midpoint - (Highest + Lowest) / 2 over lookback window
// Uses RingBuffer directly for self-contained core dependency (no Highest/Lowest composition)
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// MIDPOINT: Rolling Midpoint
/// Calculates the midpoint ((highest + lowest) / 2) over a specified lookback period.
/// Uses RingBuffer directly for O(N) max/min scanning per update.
/// </summary>
/// <remarks>
/// Key properties:
/// - Returns the center of the value range within the lookback window
/// - Useful for mean reversion, channel center, trend direction
/// - Can be validated against TA-Lib MIDPOINT function
/// - Self-contained: uses RingBuffer directly (no Highest/Lowest dependency)
/// </remarks>
[SkipLocalsInit]
public sealed class Midpoint : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
[StructLayout(LayoutKind.Auto)]
private record struct State(double LastValid);
private State _s, _ps;
public override bool IsHot => _buffer.Count >= _period;
/// <summary>
/// Initializes a new Midpoint indicator with specified lookback period.
/// </summary>
/// <param name="period">Lookback window size (must be >= 1)</param>
public Midpoint(int period)
{
if (period < 1)
{
throw new ArgumentException("Period must be >= 1", nameof(period));
}
_period = period;
_buffer = new RingBuffer(period);
Name = $"Midpoint({period})";
WarmupPeriod = period;
}
/// <summary>
/// Initializes a new Midpoint indicator with source for event-based chaining.
/// </summary>
/// <param name="source">Source indicator for chaining</param>
/// <param name="period">Lookback window size</param>
public Midpoint(ITValuePublisher source, int period) : this(period)
{
source.Pub += Handle;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
var s = _s;
double value = double.IsFinite(input.Value) ? input.Value : s.LastValid;
s = new State(value);
_buffer.Add(value, isNew);
double result = (_buffer.Max() + _buffer.Min()) * 0.5;
_s = s;
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
var result = new TSeries(source.Count);
ReadOnlySpan<double> values = source.Values;
ReadOnlySpan<long> times = source.Times;
for (int i = 0; i < source.Count; i++)
{
var tv = Update(new TValue(new DateTime(times[i], DateTimeKind.Utc), values[i]), true);
result.Add(tv, true);
}
return result;
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
DateTime time = DateTime.UtcNow - (interval * source.Length);
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(time, source[i]), true);
time += interval;
}
}
public static TSeries Batch(TSeries source, int period)
{
var indicator = new Midpoint(period);
return indicator.Update(source);
}
/// <summary>
/// Calculates rolling midpoint over a span of values.
/// </summary>
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length == 0)
{
throw new ArgumentException("Source cannot be empty", nameof(source));
}
if (output.Length < source.Length)
{
throw new ArgumentException("Output length must be >= source length", nameof(output));
}
if (period < 1)
{
throw new ArgumentException("Period must be >= 1", nameof(period));
}
int len = source.Length;
var buf = new RingBuffer(period);
for (int i = 0; i < len; i++)
{
double fallback = i > 0 ? output[i - 1] : 0;
double v = double.IsFinite(source[i]) ? source[i] : fallback;
buf.Add(v, true);
output[i] = (buf.Max() + buf.Min()) * 0.5;
}
}
public static (TSeries Results, Midpoint Indicator) Calculate(TSeries source, int period)
{
var indicator = new Midpoint(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
public override void Reset()
{
_buffer.Clear();
_s = default;
_ps = default;
Last = default;
}
}
+95
View File
@@ -0,0 +1,95 @@
# MIDPOINT: Rolling Range Midpoint
> "The center holds, but only for the window you're watching." — Statistical folk wisdom
Single-series rolling midpoint: `(Highest(V, N) + Lowest(V, N)) * 0.5`. Returns the center of the value range within a lookback window. TA-Lib compatible (`MIDPOINT` function). Unlike MIDPRICE which operates on separate High/Low bar channels, MIDPOINT operates on a single value series.
## Historical Context
The midpoint of a rolling range is one of the simplest channel-center calculations in technical analysis. It appears in virtually every charting platform as the baseline for range-based indicators. TA-Lib implements it as `MIDPOINT` (single series) vs `MIDPRICE` (dual H/L series). The distinction matters: MIDPOINT feeds any single-valued series through a rolling window, while MIDPRICE decomposes OHLC bars into separate high/low channels.
## Architecture and Physics
### 1. RingBuffer Pattern
Uses a single `RingBuffer(period)` to store the last N values. On each update, the buffer provides `Max()` and `Min()` for the rolling window. This is self-contained with no external indicator dependencies.
### 2. Data Flow
```text
Input(value) --> NaN guard --> RingBuffer.Add(v, isNew)
|
(Max() + Min()) * 0.5
|
Output
```
### 3. State Synchronization
Uses the standard `_s` / `_ps` state local copy pattern for bar correction (`isNew = false`). The `RingBuffer.Add(v, isNew)` call handles rollback internally when `isNew` is false.
## Mathematical Foundation
### Midpoint Definition
$$
\text{MIDPOINT}(N) = \frac{\max(V_0, V_1, \ldots, V_{N-1}) + \min(V_0, V_1, \ldots, V_{N-1})}{2}
$$
### Equivalent Formulation
$$
\text{MIDPOINT}(N) = \min(V, N) + \frac{\text{range}(V, N)}{2}
$$
where $\text{range}(V, N) = \max(V, N) - \min(V, N)$.
### Properties
- **Bounded:** Always between the minimum and maximum of the window
- **Idempotent on constants:** If all values equal $c$, midpoint equals $c$
- **Lag:** Responds only when the max or min of the window changes
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count |
|-----------|-------|
| Comparison (Max scan) | $O(N)$ per update |
| Comparison (Min scan) | $O(N)$ per update |
| Addition | 1 |
| Multiplication | 1 |
| **Total** | $O(N)$ |
### Batch Mode
The span-based `Batch` method uses a single `RingBuffer` with linear scan for max/min. For large datasets, amortized cost is $O(N \cdot P)$ where $P$ is the period.
### Quality Metrics
| Metric | Score |
|--------|-------|
| Simplicity | 9/10 |
| Responsiveness | 5/10 |
| Smoothness | 3/10 |
| SIMD potential | Low (sequential max/min dependency) |
## Validation
| Library | Function | Match | Notes |
|---------|----------|-------|-------|
| TA-Lib | `MIDPOINT` | Exact (1e-10) | Batch + Streaming + Span validated |
## Common Pitfalls
1. **Confusing MIDPOINT with MIDPRICE:** MIDPOINT takes a single value series; MIDPRICE takes separate High/Low channels from bars.
2. **Window lag:** The midpoint only changes when the rolling max or min changes. It can remain flat for extended periods.
3. **NaN propagation:** Implementation substitutes last-valid value for NaN/Infinity inputs to prevent corruption.
4. **Period = 1:** Returns the input value unchanged (max = min = value).
5. **Warmup:** First `period - 1` values use a partial window (fewer than N values).
## References
- TA-Lib `MIDPOINT` function documentation
- Murphy, J. *Technical Analysis of the Financial Markets* (range-based indicators)
+17
View File
@@ -0,0 +1,17 @@
// MIDPOINT: Rolling Midpoint
// (Highest(source, N) + Lowest(source, N)) / 2
// TA-Lib compatible — rolling center of value range
//@version=6
indicator("MIDPOINT: Rolling Midpoint", overlay=true)
int p = input.int(14, "Period", minval=1)
midpoint(series float src, int period) =>
float hi = ta.highest(src, period)
float lo = ta.lowest(src, period)
(hi + lo) * 0.5
result = midpoint(close, p)
plot(result, "Midpoint", color.new(color.teal, 0), 2)
@@ -0,0 +1,136 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class MidpriceIndicatorTests
{
[Fact]
public void MidpriceIndicator_Constructor_SetsDefaults()
{
var indicator = new MidpriceIndicator();
Assert.True(indicator.ShowColdValues);
Assert.Equal("MIDPRICE - Midpoint Price", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(14, indicator.Period);
}
[Fact]
public void MidpriceIndicator_ShortName_IncludesPeriod()
{
var indicator = new MidpriceIndicator();
Assert.Equal("MIDPRICE(14)", indicator.ShortName);
indicator.Period = 20;
Assert.Equal("MIDPRICE(20)", indicator.ShortName);
}
[Fact]
public void MidpriceIndicator_MinHistoryDepths_EqualsPeriod()
{
var indicator = new MidpriceIndicator { Period = 10 };
Assert.Equal(10, indicator.MinHistoryDepths);
Assert.Equal(10, ((IWatchlistIndicator)indicator).MinHistoryDepths);
indicator.Period = 25;
Assert.Equal(25, indicator.MinHistoryDepths);
}
[Fact]
public void MidpriceIndicator_Initialize_CreatesInternalIndicator()
{
var indicator = new MidpriceIndicator();
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void MidpriceIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new MidpriceIndicator { Period = 5 };
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 MidpriceIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new MidpriceIndicator { Period = 5 };
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, 120, 100, 115, 1500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void MidpriceIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new MidpriceIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void MidpriceIndicator_SourceCodeLink_IsValid()
{
var indicator = new MidpriceIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Midprice.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void MidpriceIndicator_Period_CanBeChanged()
{
var indicator = new MidpriceIndicator();
Assert.Equal(14, indicator.Period);
indicator.Period = 30;
Assert.Equal(30, indicator.Period);
}
[Fact]
public void MidpriceIndicator_IsHotAfterWarmup()
{
var indicator = new MidpriceIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 5; 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));
}
}
+49
View File
@@ -0,0 +1,49 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class MidpriceIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 10, minimum: 1, maximum: 500, increment: 1, decimalPlaces: 0)]
public int Period { get; set; } = 14;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Midprice _midprice = null!;
private readonly LineSeries _series;
public int MinHistoryDepths => Period;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"MIDPRICE({Period})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/core/midprice/Midprice.Quantower.cs";
public MidpriceIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "MIDPRICE - Midpoint Price";
Description = "Midpoint of rolling highest high and lowest low over a period: (HH+LL)/2.";
_series = new LineSeries(name: "MIDPRICE", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_midprice = new Midprice(Period);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _midprice.Update(bar, isNew: args.IsNewBar());
_series.SetValue(result.Value, _midprice.IsHot, ShowColdValues);
}
}
+300
View File
@@ -0,0 +1,300 @@
// Midprice Unit Tests
using Xunit;
namespace QuanTAlib.Tests;
public class MidpriceTests
{
private readonly GBM _gbm;
private const double Tolerance = 1e-10;
public MidpriceTests()
{
_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_ValidPeriod_SetsCorrectValues()
{
var indicator = new Midprice(14);
Assert.Equal("Midprice(14)", indicator.Name);
Assert.Equal(14, indicator.WarmupPeriod);
}
[Fact]
public void Constructor_InvalidPeriod_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new Midprice(0));
Assert.Throws<ArgumentException>(() => new Midprice(-1));
}
[Fact]
public void Constructor_Period1_IsValid()
{
var indicator = new Midprice(1);
Assert.Equal("Midprice(1)", indicator.Name);
Assert.Equal(1, indicator.WarmupPeriod);
}
[Fact]
public void Constructor_WithSource_SubscribesToEvents()
{
var source = new TSeries();
var indicator = new Midprice(source, 5);
source.Add(new TValue(DateTime.UtcNow, 100.0));
Assert.NotEqual(default, indicator.Last);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_SingleBar_ReturnsMidpointOfHL()
{
var indicator = new Midprice(1);
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.Update(bar);
// Period=1: highest high = 110, lowest low = 90
// (110 + 90) / 2 = 100
Assert.Equal(100.0, result.Value, Tolerance);
}
[Fact]
public void Update_ThreeBars_UsesRollingWindow()
{
var indicator = new Midprice(3);
var time = DateTime.UtcNow;
indicator.Update(new TBar(time, 100, 105, 95, 102, 1000), isNew: true);
indicator.Update(new TBar(time.AddMinutes(1), 101, 110, 93, 108, 1000), isNew: true);
var result = indicator.Update(new TBar(time.AddMinutes(2), 106, 108, 98, 104, 1000), isNew: true);
// Highest high over 3 bars: max(105, 110, 108) = 110
// Lowest low over 3 bars: min(95, 93, 98) = 93
// Midprice = (110 + 93) / 2 = 101.5
Assert.Equal(101.5, result.Value, Tolerance);
}
[Fact]
public void Update_TValue_UsesSameValueForBothChannels()
{
var indicator = new Midprice(3);
var time = DateTime.UtcNow;
indicator.Update(new TValue(time, 100), isNew: true);
indicator.Update(new TValue(time.AddMinutes(1), 110), isNew: true);
var result = indicator.Update(new TValue(time.AddMinutes(2), 105), isNew: true);
// With TValue, H=L=value, so highest = 110, lowest = 100
// Midprice = (110 + 100) / 2 = 105
Assert.Equal(105.0, result.Value, Tolerance);
}
#endregion
#region Warmup Tests
[Fact]
public void IsHot_BeforeWarmup_ReturnsFalse()
{
var indicator = new Midprice(5);
Assert.False(indicator.IsHot);
for (int i = 0; i < 4; i++)
{
indicator.Update(new TBar(DateTime.UtcNow.AddMinutes(i), 100, 110, 90, 105, 1000));
Assert.False(indicator.IsHot);
}
}
[Fact]
public void IsHot_AtWarmup_ReturnsTrue()
{
var indicator = new Midprice(5);
for (int i = 0; i < 5; i++)
{
indicator.Update(new TBar(DateTime.UtcNow.AddMinutes(i), 100, 110, 90, 105, 1000));
}
Assert.True(indicator.IsHot);
}
#endregion
#region State and Bar Correction Tests
[Fact]
public void Update_IsNewFalse_RestoresPreviousState()
{
var indicator = new Midprice(3);
var time = DateTime.UtcNow;
indicator.Update(new TBar(time, 100, 105, 95, 102, 1000), isNew: true);
indicator.Update(new TBar(time.AddMinutes(1), 101, 110, 93, 108, 1000), isNew: true);
// New bar
indicator.Update(new TBar(time.AddMinutes(2), 106, 108, 98, 104, 1000), isNew: true);
// Correction on third bar
var corrected = indicator.Update(new TBar(time.AddMinutes(2), 106, 120, 80, 104, 1000), isNew: false);
// Highest high: max(105, 110, 120) = 120
// Lowest low: min(95, 93, 80) = 80
// Midprice = (120 + 80) / 2 = 100
Assert.Equal(100.0, corrected.Value, Tolerance);
}
[Fact]
public void Update_MultipleIsNewFalse_ProducesIdempotentResults()
{
var indicator = new Midprice(3);
var time = DateTime.UtcNow;
indicator.Update(new TBar(time, 100, 105, 95, 102, 1000), isNew: true);
indicator.Update(new TBar(time.AddMinutes(1), 101, 110, 93, 108, 1000), isNew: true);
var bar = new TBar(time.AddMinutes(2), 106, 108, 98, 104, 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 Midprice(5);
for (int i = 0; i < 10; i++)
{
indicator.Update(new TBar(DateTime.UtcNow.AddMinutes(i), 100, 110, 90, 105, 1000));
}
Assert.True(indicator.IsHot);
indicator.Reset();
Assert.False(indicator.IsHot);
Assert.Equal(default, indicator.Last);
}
#endregion
#region Consistency Tests (All Modes)
[Fact]
public void AllModes_ProduceConsistentResults()
{
int period = 14;
var bars = GenerateBars(100);
// Mode 1: Streaming
var streaming = new Midprice(period);
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 = Midprice.Batch(bars, period);
// Mode 3: Span batch
double[] spanOutput = new double[bars.Count];
Midprice.Batch(bars.HighValues, bars.LowValues, spanOutput, period);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingResults[i], batchResult.Values[i], Tolerance);
Assert.Equal(streamingResults[i], spanOutput[i], Tolerance);
}
}
#endregion
#region Batch Validation Tests
[Fact]
public void Batch_MismatchedLengths_ThrowsArgumentException()
{
double[] high = new double[10];
double[] low = new double[5]; // mismatched
double[] output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Midprice.Batch(high, low, output, 5));
Assert.Equal("low", ex.ParamName);
}
[Fact]
public void Batch_OutputTooShort_ThrowsArgumentException()
{
double[] high = new double[10];
double[] low = new double[10];
double[] output = new double[5]; // too short
var ex = Assert.Throws<ArgumentException>(() => Midprice.Batch(high, low, output, 5));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_InvalidPeriod_ThrowsArgumentException()
{
double[] high = new double[10];
double[] low = new double[10];
double[] output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Midprice.Batch(high, low, output, 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Batch_EmptyInput_NoOutput()
{
var bars = new TBarSeries();
var result = Midprice.Batch(bars, 5);
Assert.Empty(result);
}
[Fact]
public void Batch_LargeDataset_NoStackOverflow()
{
var bars = GenerateBars(10_000);
double[] output = new double[bars.Count];
Midprice.Batch(bars.HighValues, bars.LowValues, output, 14);
Assert.True(double.IsFinite(output[^1]));
}
#endregion
#region Event Chaining Tests
[Fact]
public void Pub_EventFires_OnUpdate()
{
var indicator = new Midprice(5);
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) = Midprice.Calculate(bars, 14);
Assert.Equal(bars.Count, results.Count);
Assert.True(ind.IsHot);
}
#endregion
}
+279
View File
@@ -0,0 +1,279 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// MIDPRICE: Midpoint Price over Period
/// Calculates the midpoint of the highest High and lowest Low over a rolling window.
/// Unlike Midpoint (which operates on a single series), Midprice uses separate H/L channels.
/// </summary>
/// <remarks>
/// <b>Calculation:</b>
/// <list type="number">
/// <item>MidPrice = (Highest(High, N) + Lowest(Low, N)) / 2</item>
/// </list>
///
/// <b>Key characteristics:</b>
/// <list type="bullet">
/// <item>Rolling bar-level calculation with lookback period</item>
/// <item>TA-Lib compatible (MIDPRICE function)</item>
/// <item>Uses RingBuffer directly for self-contained core dependency</item>
/// <item>Represents the center of the price channel over the lookback window</item>
/// </list>
///
/// <b>Difference from Midpoint:</b>
/// <list type="bullet">
/// <item>Midpoint operates on a single value series: (Highest(V,N) + Lowest(V,N)) / 2</item>
/// <item>Midprice operates on OHLC bars: (Highest(H,N) + Lowest(L,N)) / 2</item>
/// </list>
/// </remarks>
[SkipLocalsInit]
public sealed class Midprice : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _highBuffer;
private readonly RingBuffer _lowBuffer;
[StructLayout(LayoutKind.Auto)]
private record struct State(double LastValidHigh, double LastValidLow);
private State _s, _ps;
/// <summary>
/// True if both internal buffers have enough data for valid results.
/// </summary>
public override bool IsHot => _highBuffer.Count >= _period;
/// <summary>
/// Initializes a new instance of the Midprice class.
/// </summary>
/// <param name="period">Lookback window size (must be >= 1)</param>
public Midprice(int period)
{
if (period < 1)
{
throw new ArgumentException("Period must be >= 1", nameof(period));
}
_period = period;
_highBuffer = new RingBuffer(period);
_lowBuffer = new RingBuffer(period);
Name = $"Midprice({period})";
WarmupPeriod = period;
}
/// <summary>
/// Initializes a new instance of the Midprice class with a source.
/// </summary>
/// <param name="source">The data source for chaining.</param>
/// <param name="period">Lookback window size.</param>
public Midprice(ITValuePublisher source, int period) : this(period)
{
source.Pub += Handle;
}
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
/// <summary>
/// Updates the indicator with a TValue input.
/// For TValue input, treats the value as both High and Low (same as Midpoint behavior).
/// 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 Midprice value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar bar, bool isNew = true)
{
return UpdateCore(bar.Time, bar.High, bar.Low, isNew);
}
/// <summary>
/// Updates the indicator with a bar series.
/// </summary>
/// <param name="source">The source bar series.</param>
/// <returns>A TSeries containing the Midprice 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.HighValues, source.LowValues, vSpan, WarmupPeriod);
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)
{
var result = new TSeries(source.Count);
ReadOnlySpan<double> values = source.Values;
ReadOnlySpan<long> times = source.Times;
for (int i = 0; i < source.Count; i++)
{
var tv = Update(new TValue(new DateTime(times[i], DateTimeKind.Utc), values[i]), true);
result.Add(tv, true);
}
return result;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private TValue UpdateCore(long timeTicks, double high, double low, bool isNew)
{
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
var s = _s;
double h = double.IsFinite(high) ? high : s.LastValidHigh;
double l = double.IsFinite(low) ? low : s.LastValidLow;
s = new State(h, l);
_highBuffer.Add(h, isNew);
_lowBuffer.Add(l, isNew);
double result = (_highBuffer.Max() + _lowBuffer.Min()) * 0.5;
_s = s;
Last = new TValue(timeTicks, result);
PubEvent(Last, isNew);
return Last;
}
/// <inheritdoc/>
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
DateTime time = DateTime.UtcNow - (interval * source.Length);
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(time, source[i]), true);
time += interval;
}
}
/// <inheritdoc/>
public override void Reset()
{
_highBuffer.Clear();
_lowBuffer.Clear();
_s = default;
_ps = default;
Last = default;
}
/// <summary>
/// Calculates Midprice for a bar series (static).
/// </summary>
public static TSeries Batch(TBarSeries source, int period)
{
var indicator = new Midprice(period);
return indicator.Update(source);
}
/// <summary>
/// Batch calculation using spans for High/Low data with rolling window.
/// </summary>
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
Span<double> output,
int period)
{
int len = high.Length;
if (low.Length != len)
{
throw new ArgumentException("High and Low spans must have the same length", nameof(low));
}
if (output.Length < len)
{
throw new ArgumentException("Output span must be at least as long as input spans", nameof(output));
}
if (period < 1)
{
throw new ArgumentException("Period must be >= 1", nameof(period));
}
// Use RingBuffer for rolling max/min — self-contained, no Highest/Lowest dependency
var highBuf = new RingBuffer(period);
var lowBuf = new RingBuffer(period);
for (int i = 0; i < len; i++)
{
double fallback = i > 0 ? output[i - 1] : 0;
double h = double.IsFinite(high[i]) ? high[i] : fallback;
double l = double.IsFinite(low[i]) ? low[i] : fallback;
highBuf.Add(h, true);
lowBuf.Add(l, true);
output[i] = (highBuf.Max() + lowBuf.Min()) * 0.5;
}
}
/// <summary>
/// Batch calculation using a TBarSeries (convenience overload).
/// </summary>
public static void Batch(TBarSeries source, Span<double> output, int period)
{
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.HighValues, source.LowValues, output, period);
}
public static (TSeries Results, Midprice Indicator) Calculate(TBarSeries source, int period)
{
var indicator = new Midprice(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
}
+120
View File
@@ -0,0 +1,120 @@
# MIDPRICE: Midpoint Price over Period
MIDPRICE computes the center of a rolling price channel by averaging the highest High and lowest Low over the past $N$ bars: $(\text{Highest}(H, N) + \text{Lowest}(L, N)) \times 0.5$. Unlike the stateless price transforms (AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE) that operate on a single bar, MIDPRICE maintains a lookback window and produces a rolling estimate of the price range's midpoint. This makes it a simplified channel center line, equivalent to the midpoint of a Donchian Channel. The calculation uses two internal RingBuffers for $O(N)$ max/min computation per bar. TA-Lib compatible via `TA_MIDPRICE`.
## Historical Context
MIDPRICE is the simplest possible channel-based price reference, conceptually dating back to Richard Donchian's channel breakout work in the 1960s. Where Donchian Channels plot the full upper/lower envelope, MIDPRICE extracts only the midline. The TA-Lib function `TA_MIDPRICE` takes separate High and Low arrays and a period parameter, which distinguishes it from `TA_MIDPOINT` (which operates on a single series).
The distinction between MIDPRICE and MIDPOINT matters:
- **MIDPOINT**: $(\text{Highest}(V, N) + \text{Lowest}(V, N)) \times 0.5$ on a single value series
- **MIDPRICE**: $(\text{Highest}(H, N) + \text{Lowest}(L, N)) \times 0.5$ on separate High/Low channels
MIDPRICE always produces a wider (or equal) range because the highest High is at least as large as the highest Close, and the lowest Low is at most as small as the lowest Close. This makes MIDPRICE a more conservative channel center, reflecting the full extent of price exploration rather than just settlement levels.
## Architecture & Physics
### 1. Core Formula
$$\text{MidPrice}_t = \left(\max_{i=0}^{N-1} H_{t-i} + \min_{i=0}^{N-1} L_{t-i}\right) \times 0.5$$
### 2. Rolling Window Implementation
Two independent `RingBuffer` instances maintain the last $N$ High and Low values:
- `_highBuffer`: Stores High values; `Max()` returns the rolling maximum
- `_lowBuffer`: Stores Low values; `Min()` returns the rolling minimum
The `RingBuffer.Max()` and `RingBuffer.Min()` operations scan the buffer linearly, making each `Update` call $O(N)$. This was a deliberate design choice to avoid the cross-project dependency that composing `Highest`/`Lowest` indicator instances from `lib/numerics/` would introduce. The core library must remain self-contained for Quantower builds.
### 3. State Management
- **RingBuffer snapshots**: `isNew=true` captures buffer state via `Snapshot()`; `isNew=false` restores via `Restore()` for bar correction.
- **Last-valid substitution**: Non-finite High or Low values are replaced with the last known finite value.
- **Warmup**: `IsHot` becomes true when the buffer reaches `period` elements.
### 4. Complexity
$O(N)$ per bar where $N$ is the period, due to linear scan for max/min. For typical periods (5-20), this is negligible. Always-hot after $N$ bars.
## Mathematical Foundation
### Parameters
| Parameter | Description | Default | Constraint |
|-----------|-------------|---------|------------|
| `period` | Lookback window for rolling max/min ($N$) | (required) | $\geq 1$ |
### MIDPRICE vs Related Indicators
| Indicator | Formula | Input | State |
|-----------|---------|-------|-------|
| MIDPRICE | $(\max(H,N) + \min(L,N)) \times 0.5$ | TBar (H/L channels) | Rolling window |
| MIDPOINT | $(\max(V,N) + \min(V,N)) \times 0.5$ | Single series | Rolling window |
| MEDPRICE | $(H + L) \times 0.5$ | TBar (single bar) | Stateless |
| Donchian Mid | Same as MIDPRICE | TBar (H/L channels) | Rolling window |
### Pseudo-code
```
function MIDPRICE(bar, period):
validate: period ≥ 1
h, l ← bar.High, bar.Low
// Substitute last-valid for non-finite inputs
if !finite(h): h ← lastValidHigh
if !finite(l): l ← lastValidLow
highBuffer.Add(h)
lowBuffer.Add(l)
result ← (highBuffer.Max() + lowBuffer.Min()) × 0.5
return result
```
### Output Interpretation
| Context | Meaning |
|---------|---------|
| Price > MIDPRICE | Trading in the upper half of the $N$-bar channel |
| Price < MIDPRICE | Trading in the lower half of the $N$-bar channel |
| MIDPRICE rising | Channel shifting upward (uptrend) |
| MIDPRICE flat | Range-bound market; channel stable |
| MIDPRICE converging with price | Trend exhaustion; approaching channel center |
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Cost (cycles) | Subtotal |
|-----------|:-----:|:-------------:|:--------:|
| RingBuffer.Add (high) | 1 | ~3 | 3 |
| RingBuffer.Add (low) | 1 | ~3 | 3 |
| RingBuffer.Max() scan | $N$ | ~$N$ | $N$ |
| RingBuffer.Min() scan | $N$ | ~$N$ | $N$ |
| ADD (max+min) | 1 | 1 | 1 |
| MUL (× 0.5) | 1 | 3 | 3 |
| **Total (hot)** | **$2N+4$** | | **~$2N + 10$ cycles** |
For period=14: approximately 38 cycles per bar.
### Batch Mode (SIMD Analysis)
| Aspect | Assessment |
|--------|------------|
| SIMD vectorizable | Partial: max/min scans are sequential per window; final midpoint is vectorizable |
| Optimal strategy | Monotonic deque for $O(1)$ amortized max/min (not yet implemented) |
| Memory | $O(N)$: two RingBuffers of size $N$ |
| Throughput | Dominated by max/min scans; ~5x slower than stateless transforms at period=14 |
### Potential Optimization
A monotonic deque (sliding window max/min) would reduce per-bar cost from $O(N)$ to $O(1)$ amortized. This is a known optimization path stored for future implementation when profiling shows MIDPRICE as a bottleneck in production pipelines.
## Resources
- **TA-Lib** `TA_MIDPRICE` function reference.
- **Donchian, R.** "High Finance in Copper." *Financial Analysts Journal*, 1960. (Origin of channel-based price analysis)
- **Achelis, S.B.** *Technical Analysis from A to Z*. McGraw-Hill, 2000.
+17
View File
@@ -0,0 +1,17 @@
// MIDPRICE: Midpoint Price over Period
// (Highest(High, N) + Lowest(Low, N)) / 2
// TA-Lib compatible — center of the H/L price channel
//@version=6
indicator("MIDPRICE: Midpoint Price over Period", overlay=true)
int p = input.int(14, "Period", minval=1)
midprice(int period) =>
float hi = ta.highest(high, period)
float lo = ta.lowest(low, period)
(hi + lo) * 0.5
result = midprice(p)
plot(result, "MidPrice", color.new(color.purple, 0), 2)
@@ -0,0 +1,131 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class TyppriceIndicatorTests
{
[Fact]
public void TyppriceIndicator_Constructor_SetsDefaults()
{
var indicator = new TyppriceIndicator();
Assert.True(indicator.ShowColdValues);
Assert.Equal("TYPPRICE - Typical Price", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void TyppriceIndicator_ShortName_IsTypprice()
{
var indicator = new TyppriceIndicator();
Assert.Equal("TYPPRICE", indicator.ShortName);
}
[Fact]
public void TyppriceIndicator_MinHistoryDepths_EqualsOne()
{
var indicator = new TyppriceIndicator();
Assert.Equal(1, TyppriceIndicator.MinHistoryDepths);
Assert.Equal(1, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void TyppriceIndicator_Initialize_CreatesInternalIndicator()
{
var indicator = new TyppriceIndicator();
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void TyppriceIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new TyppriceIndicator();
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 TyppriceIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new TyppriceIndicator();
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 TyppriceIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new TyppriceIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void TyppriceIndicator_SourceCodeLink_IsValid()
{
var indicator = new TyppriceIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Typprice.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void TyppriceIndicator_ComputesCorrectTypicalPrice()
{
var indicator = new TyppriceIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// H=110, L=90, C=105 → (110+90+105)/3 = 101.666...
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double val = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(305.0 * (1.0 / 3.0), val, 10);
}
[Fact]
public void TyppriceIndicator_IsHotImmediately()
{
var indicator = new TyppriceIndicator();
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));
}
}
+46
View File
@@ -0,0 +1,46 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class TyppriceIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Typprice _typprice = null!;
private readonly LineSeries _series;
public static int MinHistoryDepths => 1;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => "TYPPRICE";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/core/typprice/Typprice.Quantower.cs";
public TyppriceIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "TYPPRICE - Typical Price";
Description = "Average of High, Low, and Close prices: (H+L+C)/3.";
_series = new LineSeries(name: "TYPPRICE", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_typprice = new Typprice();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _typprice.Update(bar, isNew: args.IsNewBar());
_series.SetValue(result.Value, _typprice.IsHot, ShowColdValues);
}
}
+263
View File
@@ -0,0 +1,263 @@
// Typprice Unit Tests
using Xunit;
namespace QuanTAlib.Tests;
public class TyppriceTests
{
private readonly GBM _gbm;
private const double Tolerance = 1e-10;
public TyppriceTests()
{
_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 Typprice();
Assert.Equal("Typprice", indicator.Name);
Assert.Equal(1, indicator.WarmupPeriod);
}
[Fact]
public void Constructor_WithSource_SubscribesToEvents()
{
var source = new TSeries();
var indicator = new Typprice(source);
source.Add(new TValue(DateTime.UtcNow, 100.0));
Assert.NotEqual(default, indicator.Last);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_Bar_ReturnsHLC3()
{
var indicator = new Typprice();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.Update(bar);
// (110 + 90 + 105) * (1/3) = 101.666...
double expected = (110.0 + 90.0 + 105.0) * (1.0 / 3.0);
Assert.Equal(expected, result.Value, Tolerance);
}
[Fact]
public void Update_Bar_MatchesTBarHLC3()
{
var indicator = new Typprice();
var bar = new TBar(DateTime.UtcNow, 50, 60, 40, 55, 500);
var result = indicator.Update(bar);
Assert.Equal(bar.HLC3, result.Value, Tolerance);
}
[Fact]
public void Update_TValue_ReturnsIdentity()
{
var indicator = new Typprice();
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 Typprice();
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 Typprice();
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 = (120.0 + 80.0 + 111.0) * (1.0 / 3.0);
Assert.Equal(expected, corrected.Value, Tolerance);
}
[Fact]
public void Update_MultipleIsNewFalse_ProducesIdempotentResults()
{
var indicator = new Typprice();
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 Typprice();
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 Typprice();
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 Typprice();
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 = Typprice.Batch(bars);
// Mode 3: Span batch
double[] spanOutput = new double[bars.Count];
Typprice.Batch(bars.HighValues, bars.LowValues, 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_MatchTBarHLC3()
{
var bars = GenerateBars(50);
var indicator = new Typprice();
for (int i = 0; i < bars.Count; i++)
{
var result = indicator.Update(bars[i], isNew: true);
Assert.Equal(bars[i].HLC3, result.Value, Tolerance);
}
}
#endregion
#region Batch Validation Tests
[Fact]
public void Batch_MismatchedLengths_ThrowsArgumentException()
{
double[] high = new double[10];
double[] low = new double[5]; // mismatched
double[] close = new double[10];
double[] output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Typprice.Batch(high, low, close, output));
Assert.Equal("low", ex.ParamName);
}
[Fact]
public void Batch_OutputTooShort_ThrowsArgumentException()
{
double[] high = new double[10];
double[] low = new double[10];
double[] close = new double[10];
double[] output = new double[5]; // too short
var ex = Assert.Throws<ArgumentException>(() => Typprice.Batch(high, low, close, output));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_EmptyInput_NoOutput()
{
var bars = new TBarSeries();
var result = Typprice.Batch(bars);
Assert.Empty(result);
}
[Fact]
public void Batch_LargeDataset_NoStackOverflow()
{
var bars = GenerateBars(10_000);
double[] output = new double[bars.Count];
Typprice.Batch(bars.HighValues, bars.LowValues, bars.CloseValues, output);
Assert.True(double.IsFinite(output[^1]));
}
#endregion
#region Event Chaining Tests
[Fact]
public void Pub_EventFires_OnUpdate()
{
var indicator = new Typprice();
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) = Typprice.Calculate(bars);
Assert.Equal(bars.Count, results.Count);
Assert.True(ind.IsHot);
}
#endregion
}
+281
View File
@@ -0,0 +1,281 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// TYPPRICE: Typical Price
/// Calculates the average of High, Low, and Close prices.
/// Equivalent to TBar.HLC3 but as a proper streaming indicator with bar correction.
/// </summary>
/// <remarks>
/// <b>Calculation:</b>
/// <list type="number">
/// <item>TypPrice = (High + Low + Close) / 3</item>
/// </list>
///
/// <b>Key characteristics:</b>
/// <list type="bullet">
/// <item>Stateless bar-by-bar calculation (no lookback period)</item>
/// <item>TA-Lib compatible (TYPPRICE function)</item>
/// <item>Always hot after first bar</item>
/// <item>Widely used as the default price input for many indicators (e.g., CCI)</item>
/// </list>
/// </remarks>
[SkipLocalsInit]
public sealed class Typprice : AbstractBase
{
private const double OneThird = 1.0 / 3.0;
[StructLayout(LayoutKind.Auto)]
private record struct State(
double LastValidHigh,
double LastValidLow,
double LastValidClose,
double LastResult,
int Count
);
private State _s;
private State _ps;
/// <summary>
/// Initializes a new instance of the Typprice class.
/// </summary>
public Typprice()
{
WarmupPeriod = 1;
Name = "Typprice";
_s = new State(0, 0, 0, 0, 0);
_ps = _s;
}
/// <summary>
/// Initializes a new instance of the Typprice class with a source.
/// </summary>
/// <param name="source">The data source for chaining.</param>
public Typprice(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 typical price from HLC values.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double ComputeTypicalPrice(double high, double low, double close)
{
return Math.FusedMultiplyAdd(high, OneThird, (low + close) * OneThird);
}
/// <summary>
/// Updates the indicator with a TValue input.
/// For TValue input, treats the value as H, L, and C (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, 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 Typical Price value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar bar, bool isNew = true)
{
return UpdateCore(bar.Time, bar.High, bar.Low, 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 Typical Price 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.HighValues, source.LowValues, 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 high, double low, 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(high)) { high = s.LastValidHigh; } else { s.LastValidHigh = high; }
if (!double.IsFinite(low)) { low = s.LastValidLow; } else { s.LastValidLow = low; }
if (!double.IsFinite(close)) { close = s.LastValidClose; } else { s.LastValidClose = close; }
double result = ComputeTypicalPrice(high, low, 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, 0);
_ps = _s;
Last = default;
}
/// <summary>
/// Calculates Typical Price for a bar series (static).
/// </summary>
public static TSeries Batch(TBarSeries source)
{
var indicator = new Typprice();
return indicator.Update(source);
}
/// <summary>
/// Batch calculation using spans for HLC data.
/// </summary>
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
Span<double> output)
{
int len = high.Length;
if (low.Length != len || close.Length != len)
{
throw new ArgumentException("All input spans must have the same length", nameof(low));
}
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] = ComputeTypicalPrice(high[i], low[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.HighValues, source.LowValues, source.CloseValues, output);
}
public static (TSeries Results, Typprice Indicator) Calculate(TBarSeries source)
{
var indicator = new Typprice();
TSeries results = indicator.Update(source);
return (results, indicator);
}
}
+98
View File
@@ -0,0 +1,98 @@
# TYPPRICE: Typical Price
TYPPRICE computes the equal-weighted average of High, Low, and Close: $(H + L + C) \times \frac{1}{3}$. This three-component mean is the most widely used "representative price" in technical analysis, serving as the default input for CCI, MFI, and many other indicators. By including Close but excluding Open, Typical Price captures both the range extremes and the settlement point, giving slightly more weight to closing action than AVGPRICE does. The calculation is stateless and costs a single FMA instruction per bar.
## Historical Context
Typical Price became the standard price transform through its adoption by Donald Lambert in his 1980 Commodity Channel Index (CCI), which explicitly requires $(H+L+C)/3$ as its input. Gene Quong and Avrum Soudack used it in the Money Flow Index (MFI) in 1989. The TA-Lib function `TA_TYPPRICE` codified it as a standalone operation. TradingView exposes it as the `hlc3` built-in source selector.
The choice of three components rather than four is not arbitrary. Excluding Open removes the overnight gap component, which reflects news-driven repositioning rather than intra-session supply and demand. For intraday analysis, this makes Typical Price a purer measure of within-session fair value than AVGPRICE. For daily bars on instruments with significant gaps (equities, futures at session boundaries), the distinction matters; for 24-hour markets (forex, crypto), it is negligible.
In QuanTAlib, `TBar.HLC3` provides the same value as a zero-cost computed property. The `Typprice` indicator class wraps this in the streaming `ITValuePublisher` interface with bar correction, NaN safety, and event chaining.
## Architecture & Physics
### 1. Core Formula
$$\text{TypPrice}_t = (H_t + L_t + C_t) \times \tfrac{1}{3}$$
Implemented as FMA with a precomputed reciprocal constant:
$$\text{TypPrice}_t = \text{FMA}\!\left(H_t,\; \tfrac{1}{3},\; (L_t + C_t) \times \tfrac{1}{3}\right)$$
The constant $\frac{1}{3}$ is stored as `private const double OneThird = 1.0 / 3.0`, evaluated at compile time. No runtime division occurs.
### 2. State Management
Stateless per bar. State exists only for:
- **Last-valid substitution**: Non-finite H, L, or C values are replaced with the last known finite value for that component.
- **Bar correction**: `isNew=false` rolls back to previous state for same-timestamp rewrites.
### 3. Complexity
$O(1)$ per bar. One addition, one FMA. No memory allocation. Always hot after the first bar.
## Mathematical Foundation
### Parameters
| Parameter | Description | Default | Constraint |
|-----------|-------------|---------|------------|
| (none) | No user-configurable parameters | | |
### Why Not Divide by 3?
Division by a non-power-of-two constant is 4-5x more expensive than multiplication on modern x86 CPUs (~15 cycles vs ~3 cycles). Precomputing $\frac{1}{3}$ as a `const double` and multiplying eliminates the division entirely. The compiler constant-folds `1.0 / 3.0` to the IEEE 754 double `0x3FD5555555555555` at compile time, so the hot path sees only multiply/FMA operations.
### Pseudo-code
```
function TYPPRICE(bar):
const OneThird ← 1.0 / 3.0 // compile-time constant
h, l, c ← bar.High, bar.Low, bar.Close
// Substitute last-valid for non-finite inputs
if !finite(h): h ← lastValidHigh
if !finite(l): l ← lastValidLow
if !finite(c): c ← lastValidClose
result ← FMA(h, OneThird, (l + c) × OneThird)
return result
```
### Output Interpretation
| Context | Meaning |
|---------|---------|
| Close > TYPPRICE | Close above session's HLC center (bullish settlement) |
| Close < TYPPRICE | Close below session's HLC center (bearish settlement) |
| TYPPRICE trending up | Both range and settlement are rising |
| TYPPRICE as CCI input | Standard; CCI = (Price - SMA(Price)) / (0.015 × MeanDeviation) |
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Cost (cycles) | Subtotal |
|-----------|:-----:|:-------------:|:--------:|
| ADD (L+C) | 1 | 1 | 1 |
| MUL ((L+C) × OneThird) | 1 | 3 | 3 |
| FMA (H × OneThird + prev) | 1 | 4 | 4 |
| **Total (hot)** | **3** | | **~8 cycles** |
### Batch Mode (SIMD Analysis)
| Aspect | Assessment |
|--------|------------|
| SIMD vectorizable | Yes: element-wise arithmetic, no inter-bar dependency |
| Optimal strategy | `Vector<double>` over H/L/C spans with broadcast OneThird |
| Memory | $O(1)$ streaming; $O(n)$ batch output span |
| Throughput | Near memory-bandwidth bound for large series |
## Resources
- **Lambert, D.R.** "Commodity Channel Index: Tools for Trading Cyclical Trends." *Technical Analysis of Stocks & Commodities*, 1980.
- **Quong, G. & Soudack, A.** "Volume-Weighted RSI: Money Flow." *Technical Analysis of Stocks & Commodities*, 1989.
- **TA-Lib** `TA_TYPPRICE` function reference.
+13
View File
@@ -0,0 +1,13 @@
// TYPPRICE: Typical Price
// (High + Low + Close) / 3
// TA-Lib compatible — equivalent to TBar.HLC3
//@version=6
indicator("TYPPRICE: Typical Price", overlay=true)
typprice(float h, float l, float c) =>
(h + l + c) / 3.0
result = typprice(high, low, close)
plot(result, "TypPrice", color.new(color.green, 0), 2)
@@ -0,0 +1,131 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class WclpriceIndicatorTests
{
[Fact]
public void WclpriceIndicator_Constructor_SetsDefaults()
{
var indicator = new WclpriceIndicator();
Assert.True(indicator.ShowColdValues);
Assert.Equal("WCLPRICE - Weighted Close Price", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void WclpriceIndicator_ShortName_IsWclprice()
{
var indicator = new WclpriceIndicator();
Assert.Equal("WCLPRICE", indicator.ShortName);
}
[Fact]
public void WclpriceIndicator_MinHistoryDepths_EqualsOne()
{
var indicator = new WclpriceIndicator();
Assert.Equal(1, WclpriceIndicator.MinHistoryDepths);
Assert.Equal(1, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void WclpriceIndicator_Initialize_CreatesInternalIndicator()
{
var indicator = new WclpriceIndicator();
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void WclpriceIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new WclpriceIndicator();
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 WclpriceIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new WclpriceIndicator();
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 WclpriceIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new WclpriceIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void WclpriceIndicator_SourceCodeLink_IsValid()
{
var indicator = new WclpriceIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Wclprice.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void WclpriceIndicator_ComputesCorrectWeightedClose()
{
var indicator = new WclpriceIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// H=110, L=90, C=105 → (110+90+2*105)/4 = (110+90+210)/4 = 410/4 = 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 WclpriceIndicator_IsHotImmediately()
{
var indicator = new WclpriceIndicator();
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));
}
}
+46
View File
@@ -0,0 +1,46 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class WclpriceIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Wclprice _wclprice = null!;
private readonly LineSeries _series;
public static int MinHistoryDepths => 1;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => "WCLPRICE";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/core/wclprice/Wclprice.Quantower.cs";
public WclpriceIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "WCLPRICE - Weighted Close Price";
Description = "Weighted average emphasizing Close: (H+L+2*C)/4.";
_series = new LineSeries(name: "WCLPRICE", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_wclprice = new Wclprice();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _wclprice.Update(bar, isNew: args.IsNewBar());
_series.SetValue(result.Value, _wclprice.IsHot, ShowColdValues);
}
}
+275
View File
@@ -0,0 +1,275 @@
// Wclprice Unit Tests
using Xunit;
namespace QuanTAlib.Tests;
public class WclpriceTests
{
private readonly GBM _gbm;
private const double Tolerance = 1e-10;
public WclpriceTests()
{
_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 Wclprice();
Assert.Equal("Wclprice", indicator.Name);
Assert.Equal(1, indicator.WarmupPeriod);
}
[Fact]
public void Constructor_WithSource_SubscribesToEvents()
{
var source = new TSeries();
var indicator = new Wclprice(source);
source.Add(new TValue(DateTime.UtcNow, 100.0));
Assert.NotEqual(default, indicator.Last);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_Bar_ReturnsHLCC4()
{
var indicator = new Wclprice();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = indicator.Update(bar);
// (110 + 90 + 2*105) / 4 = 410/4 = 102.5
Assert.Equal(102.5, result.Value, Tolerance);
}
[Fact]
public void Update_Bar_MatchesTBarHLCC4()
{
var indicator = new Wclprice();
var bar = new TBar(DateTime.UtcNow, 50, 60, 40, 55, 500);
var result = indicator.Update(bar);
Assert.Equal(bar.HLCC4, result.Value, Tolerance);
}
[Fact]
public void Update_TValue_ReturnsIdentity()
{
var indicator = new Wclprice();
var result = indicator.Update(new TValue(DateTime.UtcNow, 42.0));
Assert.Equal(42.0, result.Value, Tolerance);
}
[Fact]
public void Update_Bar_UsesFMA()
{
// Verify FMA computation: close*0.5 + (high+low)*0.25
var indicator = new Wclprice();
var bar = new TBar(DateTime.UtcNow, 100, 200, 50, 150, 1000);
var result = indicator.Update(bar);
// FMA: 150*0.5 + (200+50)*0.25 = 75 + 62.5 = 137.5
// Standard: (200+50+2*150)/4 = 550/4 = 137.5
Assert.Equal(137.5, result.Value, Tolerance);
}
#endregion
#region State and Bar Correction Tests
[Fact]
public void IsHot_AfterFirstBar_ReturnsTrue()
{
var indicator = new Wclprice();
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 Wclprice();
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);
// FMA: 111*0.5 + (120+80)*0.25 = 55.5 + 50 = 105.5
double expected = Math.FusedMultiplyAdd(111.0, 0.5, (120.0 + 80.0) * 0.25);
Assert.Equal(expected, corrected.Value, Tolerance);
}
[Fact]
public void Update_MultipleIsNewFalse_ProducesIdempotentResults()
{
var indicator = new Wclprice();
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 Wclprice();
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 Wclprice();
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 Wclprice();
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 = Wclprice.Batch(bars);
// Mode 3: Span batch
double[] spanOutput = new double[bars.Count];
Wclprice.Batch(bars.HighValues, bars.LowValues, 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_MatchTBarHLCC4()
{
var bars = GenerateBars(50);
var indicator = new Wclprice();
for (int i = 0; i < bars.Count; i++)
{
var result = indicator.Update(bars[i], isNew: true);
Assert.Equal(bars[i].HLCC4, result.Value, Tolerance);
}
}
#endregion
#region Batch Validation Tests
[Fact]
public void Batch_MismatchedLengths_ThrowsArgumentException()
{
double[] high = new double[10];
double[] low = new double[5]; // mismatched
double[] close = new double[10];
double[] output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Wclprice.Batch(high, low, close, output));
Assert.Equal("low", ex.ParamName);
}
[Fact]
public void Batch_OutputTooShort_ThrowsArgumentException()
{
double[] high = new double[10];
double[] low = new double[10];
double[] close = new double[10];
double[] output = new double[5]; // too short
var ex = Assert.Throws<ArgumentException>(() => Wclprice.Batch(high, low, close, output));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_EmptyInput_NoOutput()
{
var bars = new TBarSeries();
var result = Wclprice.Batch(bars);
Assert.Empty(result);
}
[Fact]
public void Batch_LargeDataset_NoStackOverflow()
{
var bars = GenerateBars(10_000);
double[] output = new double[bars.Count];
Wclprice.Batch(bars.HighValues, bars.LowValues, bars.CloseValues, output);
Assert.True(double.IsFinite(output[^1]));
}
#endregion
#region Event Chaining Tests
[Fact]
public void Pub_EventFires_OnUpdate()
{
var indicator = new Wclprice();
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) = Wclprice.Calculate(bars);
Assert.Equal(bars.Count, results.Count);
Assert.True(ind.IsHot);
}
#endregion
}
+279
View File
@@ -0,0 +1,279 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// WCLPRICE: Weighted Close Price
/// Calculates the weighted average of High, Low, and Close, giving Close double weight.
/// Equivalent to TBar.HLCC4 but as a proper streaming indicator with bar correction.
/// </summary>
/// <remarks>
/// <b>Calculation:</b>
/// <list type="number">
/// <item>WclPrice = (High + Low + 2 × Close) / 4</item>
/// </list>
///
/// <b>Key characteristics:</b>
/// <list type="bullet">
/// <item>Stateless bar-by-bar calculation (no lookback period)</item>
/// <item>TA-Lib compatible (WCLPRICE function)</item>
/// <item>Always hot after first bar</item>
/// <item>Close-weighted — emphasizes settlement price over intra-bar extremes</item>
/// </list>
/// </remarks>
[SkipLocalsInit]
public sealed class Wclprice : AbstractBase
{
[StructLayout(LayoutKind.Auto)]
private record struct State(
double LastValidHigh,
double LastValidLow,
double LastValidClose,
double LastResult,
int Count
);
private State _s;
private State _ps;
/// <summary>
/// Initializes a new instance of the Wclprice class.
/// </summary>
public Wclprice()
{
WarmupPeriod = 1;
Name = "Wclprice";
_s = new State(0, 0, 0, 0, 0);
_ps = _s;
}
/// <summary>
/// Initializes a new instance of the Wclprice class with a source.
/// </summary>
/// <param name="source">The data source for chaining.</param>
public Wclprice(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 weighted close price from HLC values.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double ComputeWeightedClose(double high, double low, double close)
{
return Math.FusedMultiplyAdd(close, 0.5, (high + low) * 0.25);
}
/// <summary>
/// Updates the indicator with a TValue input.
/// For TValue input, treats the value as H, L, and C (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, 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 Weighted Close Price value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar bar, bool isNew = true)
{
return UpdateCore(bar.Time, bar.High, bar.Low, 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 Weighted Close Price 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.HighValues, source.LowValues, 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 high, double low, 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(high)) { high = s.LastValidHigh; } else { s.LastValidHigh = high; }
if (!double.IsFinite(low)) { low = s.LastValidLow; } else { s.LastValidLow = low; }
if (!double.IsFinite(close)) { close = s.LastValidClose; } else { s.LastValidClose = close; }
double result = ComputeWeightedClose(high, low, 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, 0);
_ps = _s;
Last = default;
}
/// <summary>
/// Calculates Weighted Close Price for a bar series (static).
/// </summary>
public static TSeries Batch(TBarSeries source)
{
var indicator = new Wclprice();
return indicator.Update(source);
}
/// <summary>
/// Batch calculation using spans for HLC data.
/// </summary>
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
Span<double> output)
{
int len = high.Length;
if (low.Length != len || close.Length != len)
{
throw new ArgumentException("All input spans must have the same length", nameof(low));
}
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] = ComputeWeightedClose(high[i], low[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.HighValues, source.LowValues, source.CloseValues, output);
}
public static (TSeries Results, Wclprice Indicator) Calculate(TBarSeries source)
{
var indicator = new Wclprice();
TSeries results = indicator.Update(source);
return (results, indicator);
}
}
+100
View File
@@ -0,0 +1,100 @@
# WCLPRICE: Weighted Close Price
WCLPRICE computes a Close-biased average of High, Low, and Close by double-weighting the closing price: $(H + L + 2C) \times 0.25$. This gives Close 50% of the total weight versus 25% each for High and Low, reflecting the widely held belief that the closing price is the most important price of the bar because it represents the final consensus of buyers and sellers. The calculation is stateless, costs a single FMA instruction per bar, and is TA-Lib compatible (`TA_WCLPRICE`).
## Historical Context
Weighted Close Price appears in technical analysis literature from the 1970s onward, typically credited to the general tradition of market technicians rather than a single inventor. The rationale is straightforward: while High and Low show where price was rejected, Close shows where participants were willing to hold positions overnight (or into the next period). Double-weighting Close amplifies this "settlement consensus" signal.
The formula $(H + L + 2C) / 4$ is algebraically equivalent to $(H + L) / 4 + C / 2$, which reveals its structure: half the weight on Close, and the other half split equally between the range extremes. This makes WCLPRICE a compromise between raw Close and the range-neutral MEDPRICE. When Close is at the midpoint of the range, WCLPRICE equals MEDPRICE; when Close diverges from the midpoint, WCLPRICE follows Close more aggressively than either TYPPRICE or AVGPRICE.
In QuanTAlib, `TBar.HLCC4` provides the same value as a zero-cost computed property. The `Wclprice` indicator class wraps this in the streaming `ITValuePublisher` interface with bar correction, NaN safety, and event chaining.
## Architecture & Physics
### 1. Core Formula
$$\text{WclPrice}_t = (H_t + L_t + 2C_t) \times 0.25$$
Implemented as FMA to avoid division:
$$\text{WclPrice}_t = \text{FMA}(C_t,\; 0.5,\; (H_t + L_t) \times 0.25)$$
This form is optimal: the FMA computes $C \times 0.5 + (H+L) \times 0.25$ in a single fused operation, avoiding the intermediate rounding that separate multiply-add would produce.
### 2. State Management
Stateless per bar. State exists only for:
- **Last-valid substitution**: Non-finite H, L, or C values are replaced with the last known finite value for that component.
- **Bar correction**: `isNew=false` rolls back to previous state for same-timestamp rewrites.
### 3. Complexity
$O(1)$ per bar. One addition, one FMA. No memory allocation. Always hot after the first bar.
## Mathematical Foundation
### Parameters
| Parameter | Description | Default | Constraint |
|-----------|-------------|---------|------------|
| (none) | No user-configurable parameters | | |
### Weight Distribution
| Transform | O weight | H weight | L weight | C weight |
|-----------|:--------:|:--------:|:--------:|:--------:|
| AVGPRICE | 25% | 25% | 25% | 25% |
| MEDPRICE | 0% | 50% | 50% | 0% |
| TYPPRICE | 0% | 33.3% | 33.3% | 33.3% |
| **WCLPRICE** | **0%** | **25%** | **25%** | **50%** |
### Pseudo-code
```
function WCLPRICE(bar):
h, l, c ← bar.High, bar.Low, bar.Close
// Substitute last-valid for non-finite inputs
if !finite(h): h ← lastValidHigh
if !finite(l): l ← lastValidLow
if !finite(c): c ← lastValidClose
result ← FMA(c, 0.5, (h + l) × 0.25)
return result
```
### Output Interpretation
| Context | Meaning |
|---------|---------|
| WCLPRICE > TYPPRICE | Close above the HLC midpoint (strong close) |
| WCLPRICE < TYPPRICE | Close below the HLC midpoint (weak close) |
| WCLPRICE $\approx$ MEDPRICE | Close at range midpoint; balanced bar |
| WCLPRICE diverging from AVGPRICE | Open and Close on opposite sides of the range |
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Cost (cycles) | Subtotal |
|-----------|:-----:|:-------------:|:--------:|
| ADD (H+L) | 1 | 1 | 1 |
| MUL ((H+L) × 0.25) | 1 | 3 | 3 |
| FMA (C × 0.5 + prev) | 1 | 4 | 4 |
| **Total (hot)** | **3** | | **~8 cycles** |
### Batch Mode (SIMD Analysis)
| Aspect | Assessment |
|--------|------------|
| SIMD vectorizable | Yes: element-wise FMA, no inter-bar dependency |
| Optimal strategy | `Fma.MultiplyAdd` over H/L/C vectors on AVX2+ |
| Memory | $O(1)$ streaming; $O(n)$ batch output span |
| Throughput | Near memory-bandwidth bound for large series |
## Resources
- **TA-Lib** `TA_WCLPRICE` function reference.
- **Achelis, S.B.** *Technical Analysis from A to Z*. McGraw-Hill, 2000. (Weighted Close definition)
+13
View File
@@ -0,0 +1,13 @@
// WCLPRICE: Weighted Close Price
// (High + Low + 2 * Close) / 4
// TA-Lib compatible — equivalent to TBar.HLCC4
//@version=6
indicator("WCLPRICE: Weighted Close Price", overlay=true)
wclprice(float h, float l, float c) =>
(h + l + 2.0 * c) * 0.25
result = wclprice(high, low, close)
plot(result, "WclPrice", color.new(color.red, 0), 2)