Series arithmetic (lib/numerics): - Add, Sub, Mul, Div, Lag, reusing BiInputIndicatorBase at period=1 - Div publishes 0.0 (not NaN) on a zero denominator, since a non-finite ComputeError result permanently poisons the base's running sum - BiInputIndicatorBase gains Subscribe(a, b) implementing the strategy-primitives spec's time-join rule for event-chained two-input nodes Phase 0 contracts (lib/core): - TTradeContext, BracketFlags, ExitReason, TrendState, VolState - SignalConstants (DefaultEpsilon, TruthThreshold), OutOfRangePolicy - ConfirmOnClose: bar-confirmation wrapper (Section 8.6) Signal predicates (lib/signals/predicates), Phase 1: - Above, Below, Equal (ComparePredicateBase<TOp> generic comparison core) - InsideChannel, OutsideChannel, ChannelPosition (new MultiInputBase, 3-input time-join) - IsRising, IsFalling (exclude current bar, confirm-then-push window) - AtHighest, AtLowest (include current bar, rebuild-on-correction, mirrors channels/Dc) - Hysteresis, PercentDistance All new nodes ship with .cs, tests, and .md docs; indices and the StrategyPrimitives.Spec.md are updated with placement decisions and design notes. Full suite: 16730 tests, 0 failures, 0 warnings.
5.0 KiB
CHANNELPOSITION: Normalized Channel Position
Not just "is the price inside the band" but "where inside the band" — the %B indicator's underlying idea, generalized to any channel.
| Property | Value |
|---|---|
| Category | Signals (Predicates) |
| Inputs | Value, Lower, Upper (triple series, or Value and fixed scalar bounds) |
| Parameters | policy (OutOfRangePolicy.Cold default, or Saturate) |
| Outputs | Single graded series (ChannelPosition) |
| Output range | [0.0, 1.0] while hot; NaN while cold or invalid |
| Warmup | 0 bars (hot after the first valid tick) |
| PineScript | (value - lower) / (upper - lower) (Bollinger %B, generalized) |
- CHANNELPOSITION returns the value's normalized position within a pair of channel bounds: 0.0 at the lower bound, 1.0 at the upper bound.
- Similar: InsideChannel, OutsideChannel | Trading note: The general form of Bollinger %B, usable with any channel indicator (Keltner, Donchian, STARC, ...).
- Part of the strategy-primitives signal algebra (
lib/signals/StrategyPrimitives.Spec.md, Section 9.1).
Mathematical Foundation
\text{ChannelPosition}_t = \frac{\text{value}_t - \text{lower}_t}{\text{upper}_t - \text{lower}_t}
Two Distinct Failure Modes
This predicate has two ways to fail to produce a valid [0, 1] result, handled differently on purpose:
- Zero-width channel (
|upper - lower| <= DefaultEpsilon): a division-by-zero guard, not a policy choice. Always publishesNaNand reports not hot for that bar, regardless of the constructor'spolicyargument. - Value outside the channel: the raw ratio falls outside
[0, 1]. This is governed by theOutOfRangePolicyconstructor parameter:Cold(default): publishesNaNand reports not hot for that bar.Saturate: clamps the ratio into[0, 1]— an explicit, documented choice the caller opts into.
IsHot Is Not a One-Way Latch Here
Unlike almost every other node in this library, ChannelPosition.IsHot can become false again on a specific bar without calling Reset(). It combines "has a full set of three inputs ever arrived" (inherited from MultiInputBase) with "was the most recent computation valid" (this class's own _lastValid flag, recomputed fresh on every call). A bar that hits either failure mode above reports IsHot = false; the very next valid bar reports IsHot = true again.
Implementation Details
Built on MultiInputBase with 3 inputs (value, lower, upper), the same base as InsideChannel and OutsideChannel. The _lastValid flag needs no explicit rollback bookkeeping (no _p_lastValid snapshot): it is a pure function of the current call's sanitized inputs, recomputed unconditionally inside Compute on every Update call, whether isNew is true or false.
Performance Profile
| Operation | Count | Notes |
|---|---|---|
| Subtraction | 2 | value - lower, upper - lower |
| Division | 1 | Guarded by the zero-width check |
| Comparison | up to 2 | Range check against [0, 1] |
| Sanitize (NaN check) | 3 | One per input |
| Total | ~8 | O(1) constant time |
Validation
| Test | Status |
|---|---|
| At lower bound → 0.0, at upper bound → 1.0, midpoint → 0.5 | ✅ |
| Zero-width channel → NaN, regardless of policy | ✅ |
| Out-of-range value, Cold policy → NaN | ✅ |
| Out-of-range value, Saturate policy → clamped | ✅ |
IsHot recovers on the next valid bar after a Cold-policy failure |
✅ |
| Fixed scalar bounds | ✅ |
Common Pitfalls
- Zero-width is always cold, never saturated.
Saturateonly affects out-of-range values; it does not give a division-by-zero a defined result, since there is no sensible position to report when the channel itself has collapsed. IsHotis per-bar here, not a permanent latch. Code that assumes "once hot, always hot untilReset()" (true for almost every other indicator in this library) will be surprised byChannelPositionunder the defaultColdpolicy.- Default policy silently drops out-of-range bars. If a strategy needs every bar to report some number even when the price is outside its bands, construct with
OutOfRangePolicy.Saturateexplicitly rather than relying on the default.
Usage Examples
// Bollinger %B, generalized
var bb = new Bbands(price, 20, 2.0);
var percentB = new ChannelPosition(price, bb.Lower, bb.Upper);
// Saturating variant: always produces a [0, 1] number
var clamped = new ChannelPosition(price, bb.Lower, bb.Upper, OutOfRangePolicy.Saturate);
// Fixed scalar bounds
var rsiPosition = new ChannelPosition(rsi, 0.0, 100.0);
References
- Bollinger, J. (2001). Bollinger on Bollinger Bands. McGraw-Hill.