I challenged my coders to develop a C# parallel optimizer-safe rotation framework. This is what they developed:
I have tested it only minimally and I think it works properly. I am opening it up to the community to assess and, if found correct, use.
Vince
CODE:
using System; using System.Collections.Generic; using WealthLab.Backtest; using WealthLab.Core; namespace WealthScript1 { /* Generic Parallel-Safe Rotation Framework A minimal, parallel-optimizer-safe stock rotation framework The only optimizable Strategy parameters are: 1. Minimum Hold Bars range 1..10 2. Positions To Hold range 5..20 Default ranking model: ---------------------- Symbols are ranked by a simple fixed-period price ROC: ROC % = 100 * (Close[idx] / Close[idx - RocPeriod] - 1) To build a custom rotation system, replace ComputeRankScore() and, if desired, IsEligibleForRotation(). The rotation, minimum-hold, deterministic ranking, position-count, and Backtester.Cache state-management framework can remain unchanged. */ public class GenericParallelSafeRotationFramework : UserStrategyBase { // Edit this constant directly if you want a different demonstration ROC period. private readonly int _rocPeriod = 10; // Instance-level immutable cache key. The cached object itself is created per backtest. private readonly string _rotationStateCacheKey = "GenericParallelSafeRotationFramework.RotationState"; private class RotationState { public readonly HashSet<string> SelectedSymbols = new HashSet<string>(StringComparer.Ordinal); public readonly Dictionary<string, double> RankBySymbol = new Dictionary<string, double>(StringComparer.Ordinal); public readonly Dictionary<string, int> EntryBarBySymbol = new Dictionary<string, int>(StringComparer.Ordinal); } private class RotationCandidate { public BarHistory Bars; public string Symbol; public double Rank; public RotationCandidate(BarHistory bars, double rank) { Bars = bars; Symbol = bars.Symbol; Rank = rank; } } public GenericParallelSafeRotationFramework() { AddParameter("Minimum Hold Bars", ParameterType.Int32, 3, 1, 10, 1); AddParameter("Positions To Hold", ParameterType.Int32, 10, 5, 20, 1); } public override void BacktestBegin() { Backtester.Cache[_rotationStateCacheKey] = new RotationState(); } public override void Initialize(BarHistory bars) { StartIndex = _rocPeriod + 1; TimeSeries rocSeries = BuildRocSeries(bars, _rocPeriod); PlotTimeSeries(rocSeries, "ROC Rank %", "Rotation Rank", WLColor.Blue, PlotStyle.Line); PlotTimeSeries(ConstantSeries(bars, 0.0), "Zero", "Rotation Rank", WLColor.Gray, PlotStyle.Line); } public override void PreExecute(DateTime dt, List<BarHistory> participants) { RotationState state = GetRotationState(); int minimumHoldBars = Parameters[0].AsInt; int positionsToHold = Parameters[1].AsInt; minimumHoldBars = ClampInt(minimumHoldBars, 1, 10); positionsToHold = ClampInt(positionsToHold, 5, 20); state.SelectedSymbols.Clear(); state.RankBySymbol.Clear(); List<RotationCandidate> candidates = new List<RotationCandidate>(); // Work from participants without sorting or mutating the participants list itself. foreach (BarHistory bh in participants) { int idx = GetCurrentIndex(bh); string symbol = bh.Symbol; double rank = double.NegativeInfinity; if (idx >= StartIndex) rank = ComputeRankScore(bh, idx); if (!IsValidNumber(rank)) rank = double.NegativeInfinity; state.RankBySymbol[symbol] = rank; bool hasOpenLong = HasOpenPosition(bh, PositionType.Long); if (hasOpenLong && IsMinimumHoldProtected(state, symbol, idx, minimumHoldBars)) { // Minimum-hold protection takes precedence over the target position count. // This prevents optimizer-dependent churn and premature exits. state.SelectedSymbols.Add(symbol); continue; } if (IsEligibleForRotation(bh, idx, rank)) candidates.Add(new RotationCandidate(bh, rank)); } // Deterministic ordering: best rank first; symbol ascending breaks ties. candidates.Sort(CompareCandidates); foreach (RotationCandidate candidate in candidates) { if (state.SelectedSymbols.Count >= positionsToHold) break; state.SelectedSymbols.Add(candidate.Symbol); } } public override void Execute(BarHistory bars, int idx) { if (idx < StartIndex) return; RotationState state = GetRotationState(); int minimumHoldBars = Parameters[0].AsInt; minimumHoldBars = ClampInt(minimumHoldBars, 1, 5); string symbol = bars.Symbol; bool selected = state.SelectedSymbols.Contains(symbol); bool hasOpenLong = HasOpenPosition(bars, PositionType.Long); if (!hasOpenLong) { if (selected) { Transaction t = PlaceTrade(bars, TransactionType.Buy, OrderType.Market); if (t != null) { double rank = 0.0; state.RankBySymbol.TryGetValue(symbol, out rank); t.Weight = ComputeTransactionWeight(rank); // This records the signal bar. For market orders this is typically // one bar before the actual fill bar, but it is deterministic and // sufficient for a simple minimum-hold rotation framework. state.EntryBarBySymbol[symbol] = idx; } } return; } if (!selected) { if (!IsMinimumHoldProtected(state, symbol, idx, minimumHoldBars)) { PlaceTrade(bars, TransactionType.Sell, OrderType.Market); state.EntryBarBySymbol.Remove(symbol); } } } // -------------------------------------------------------------------- // Replace this method to create a different rotation system. // The framework expects larger values to be better. // -------------------------------------------------------------------- private double ComputeRankScore(BarHistory bars, int idx) { if (idx < _rocPeriod) return double.NegativeInfinity; double oldPrice = SafePrice(bars.Close[idx - _rocPeriod]); double currentPrice = SafePrice(bars.Close[idx]); return 100.0 * (currentPrice / oldPrice - 1.0); } // -------------------------------------------------------------------- // Optional extension point. // Default behavior: every symbol with a valid ROC rank is eligible. // For example, a long-only momentum system might require rank > 0.0. // -------------------------------------------------------------------- private bool IsEligibleForRotation(BarHistory bars, int idx, double rank) { return IsValidNumber(rank) && rank > double.NegativeInfinity; } // -------------------------------------------------------------------- // Weight used by WealthLab when more trade candidates exist than capital. // Since ROC can be negative, the transaction weight is shifted into a // positive range while preserving rank order for ordinary ROC values. // -------------------------------------------------------------------- private double ComputeTransactionWeight(double rank) { if (!IsValidNumber(rank)) return 0.0001; return Math.Max(0.0001, 100.0 + rank); } private bool IsMinimumHoldProtected(RotationState state, string symbol, int idx, int minimumHoldBars) { int entryBar; if (!state.EntryBarBySymbol.TryGetValue(symbol, out entryBar)) return false; return idx - entryBar < minimumHoldBars; } private RotationState GetRotationState() { object obj; if (!Backtester.Cache.TryGetValue(_rotationStateCacheKey, out obj) || obj == null) { obj = new RotationState(); Backtester.Cache[_rotationStateCacheKey] = obj; } return (RotationState)obj; } private int CompareCandidates(RotationCandidate a, RotationCandidate b) { int rankCompare = b.Rank.CompareTo(a.Rank); if (rankCompare != 0) return rankCompare; return string.Compare(a.Symbol, b.Symbol, StringComparison.Ordinal); } private TimeSeries BuildRocSeries(BarHistory bars, int period) { TimeSeries ts = new TimeSeries(bars.DateTimes, 0.0); for (int i = 0; i < bars.Count; i++) { if (i < period) { ts[i] = 0.0; continue; } double oldPrice = SafePrice(bars.Close[i - period]); double currentPrice = SafePrice(bars.Close[i]); ts[i] = 100.0 * (currentPrice / oldPrice - 1.0); } return ts; } private TimeSeries ConstantSeries(BarHistory bars, double value) { TimeSeries ts = new TimeSeries(bars.DateTimes, value); for (int i = 0; i < bars.Count; i++) ts[i] = value; return ts; } private double SafePrice(double price) { if (double.IsNaN(price) || double.IsInfinity(price) || price <= 0.0) return 0.0000001; return price; } private bool IsValidNumber(double x) { return !double.IsNaN(x) && !double.IsInfinity(x); } private int ClampInt(int x, int lo, int hi) { if (x < lo) return lo; if (x > hi) return hi; return x; } } }
I have tested it only minimally and I think it works properly. I am opening it up to the community to assess and, if found correct, use.
Vince
Rename
Currently there are no replies yet. Please check back later.
Your Response
Post
Edit Post
Login is required