- ago
I challenged my coders to develop a C# parallel optimizer-safe rotation framework. This is what they developed:
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
0
193
10 Replies

Reply

Bookmark

Sort
- ago
#1
Please describe what the original problem is with the multithreaded parameter optimizer. It's helpful to know what the underlying question is before studying the answer.
0
- ago
#2
superticker,

According to Glitch, a parallel optimizer-safe C# rotation strategy was not possible. I got my coders to develop a parallel optimizer-safe C# rotation strategy framework I am giving to the community.

Vince
0
- ago
#3
QUOTE:
a parallel optimizer-safe C# rotation strategy was not possible.

To simply say it doesn't work doesn't answer the question. So why doesn't it work?

I'm guessing here, but could it be because of the "classic" reader/writer problem (Remember that one from your Computer Science systems course?) where there's more than one writer (i.e. optimizer thread in this case) trying to write into shared memory of another simultaneously?

The classical solution is to employ scheduler semaphores (thread locks in .NET terms) to restrict only one writer in shared memory at any given time. Multiple readers are allowed. Microsoft published a special class for managing .NET threads in this scenario. (No, I didn't save that link; otherwise, I would cite it.)
0
- ago
#4
superticker

You will have to ask Glitch why he says it doesn't work.

Vince
1
- ago
#5
The ones we developed so far wouldn't work because they use static members to track a list of positions for sorting in the PreExecute method. Carova's coders' code avoid using static members, so it looks fine to use for parallel optimizers. Give those coders a bonus!
0
- ago
#6
Also, I'm looking into WL's native Rotation strategy, it might be possible to let that run without issues in parallel optimizers. Stay tuned.
1
- ago
#7
Carova,
I took the code you supplied and had AI analyze it, especially with respect to thread-safety and separation of concerns. So, below is what it came up with. I ran a few simple backtests and optimizations, and it seems to run fine. But, if you decide to use it, please test thoroughly. Also, note that in the WL8 optimizer optimization settings you'll still see the warning about how the strategy might not function properly due to it implementing PreExecute and having static fields. But, in this special case, I believe PreExecute and any statics are being used safely.
CODE:
using System; using System.Collections.Concurrent; using System.Collections.Generic; using WealthLab.Backtest; using WealthLab.Core; namespace WealthLabStrategies.Test.Refactored { /* Generic Parallel-Safe Rotation Framework for WealthLab 8 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. */ /// <summary> /// Refactored rotation strategy that coordinates settings, selection, policy, and hold tracking. /// </summary> public sealed class GenericParallelSafeRotationFrameworkRefactored : UserStrategyBase { private const int RocPeriod = 10; private readonly IRotationPolicy _rotationPolicy = new RocRotationPolicy(RocPeriod); // WealthLab 8 creates one strategy instance per symbol, so this field exists once per symbol instance. // That can look suspicious in a rotation strategy because selection state must be shared across symbols. // The key point is that this object is only an accessor/wrapper, not the shared state payload itself. // The actual shared RotationState is stored in Backtester.Cache under this fixed key, so all symbol // instances participating in the same backtest run resolve to the same cached RotationState object. // This gives us cross-symbol coordination with clean separation of concerns: strategy orchestration stays // here, while cache access and synchronization stay in RotationStateStore. private readonly RotationStateStore _stateStore = new("GenericParallelSafeRotationFrameworkRefactored.RotationState"); private RotationState _rotationState; public GenericParallelSafeRotationFrameworkRefactored() { AddParameter("Minimum Hold Bars", ParameterType.Int32, 3, 1, 10); AddParameter("Positions To Hold", ParameterType.Int32, 10, 5, 20); } public override void BacktestBegin() { // In WealthLab 8 optimization runs, many backtests are executed repeatedly across parameter combinations, // and each run must start from a fully clean, deterministic state. This strategy shares rotation state // across symbol instances through Backtester.Cache so PreExecute (portfolio-level selection) and Execute // (per-symbol trading) can coordinate on the same data during a run. If we reused prior cached state, // entries, selected symbols, or hold-tracking data from a previous optimization iteration could leak into // the next run and produce non-reproducible or optimizer-dependent results. By explicitly replacing the // cached state at BacktestBegin, we establish a hard run boundary: all symbols in the current run share // one fresh RotationState, and no state survives from earlier runs. _stateStore.Set(Backtester, new RotationState()); _rotationState = null; } public override void Initialize(BarHistory bars) { StartIndex = RocPeriod + 1; var rocSeries = SeriesBuilder.BuildRocSeries(bars, RocPeriod); PlotTimeSeries(rocSeries, "ROC Rank %", "Rotation Rank", WLColor.Blue); PlotTimeSeries(SeriesBuilder.BuildConstantSeries(bars, 0.0), "Zero", "Rotation Rank", WLColor.Gray); } public override void PreExecute(DateTime dt, List<BarHistory> participants) { var state = GetRunState(); var settings = RotationSettings.FromValues(Parameters[0].AsInt, Parameters[1].AsInt); var snapshot = RotationSelector.BuildSelectionSnapshot( participants, StartIndex, settings, state.PositionHoldTracker, GetCurrentIndex, HasOpenPosition, _rotationPolicy); state.SelectionSnapshot = snapshot; } public override void Execute(BarHistory bars, int idx) { if (idx < StartIndex) { return; } var state = GetRunState(); var snapshot = state.SelectionSnapshot ?? RotationSelectionSnapshot.Empty; var settings = RotationSettings.FromValues(Parameters[0].AsInt, Parameters[1].AsInt); var symbol = bars.Symbol; var selected = snapshot.SelectedSymbols.Contains(symbol); var hasOpenLong = HasOpenPosition(bars, PositionType.Long); if (!hasOpenLong) { if (!selected) { return; } var t = PlaceTrade(bars, TransactionType.Buy, OrderType.Market); if (t == null) { return; } double rank; if (!snapshot.RankBySymbol.TryGetValue(symbol, out rank)) { rank = 0.0; } t.Weight = RotationMath.ComputeTransactionWeight(rank); state.PositionHoldTracker.RecordEntrySignalBar(symbol, idx); return; } if (selected) { return; } if (state.PositionHoldTracker.IsMinimumHoldProtected(symbol, idx, settings.MinimumHoldBars)) { return; } PlaceTrade(bars, TransactionType.Sell, OrderType.Market); state.PositionHoldTracker.RemoveEntry(symbol); } private RotationState GetRunState() { if (_rotationState != null) { return _rotationState; } _rotationState = _stateStore.GetOrCreate(Backtester); return _rotationState; } } /// <summary> /// Per-backtest shared state containing the hold tracker and latest pre-execute snapshot. /// </summary> internal sealed class RotationState { public PositionHoldTracker PositionHoldTracker { get; } = new(); public RotationSelectionSnapshot SelectionSnapshot { get; set; } = RotationSelectionSnapshot.Empty; } /// <summary> /// Encapsulates synchronized persistence of the rotation state in Backtester.Cache. /// </summary> internal sealed class RotationStateStore { private static readonly object CacheSync = new(); private readonly string _cacheKey; public RotationStateStore(string cacheKey) { _cacheKey = cacheKey; } public RotationState GetOrCreate(Backtester backtester) { lock (CacheSync) { object obj; if (backtester.Cache.TryGetValue(_cacheKey, out obj) && obj != null) { return (RotationState) obj; } var state = new RotationState(); backtester.Cache[_cacheKey] = state; return state; } } public void Set(Backtester backtester, RotationState state) { lock (CacheSync) { backtester.Cache[_cacheKey] = state; } } } /// <summary> /// Immutable strategy settings normalized from runtime parameter values. /// </summary> internal sealed class RotationSettings { private RotationSettings(int minimumHoldBars, int positionsToHold) { MinimumHoldBars = minimumHoldBars; PositionsToHold = positionsToHold; } public int MinimumHoldBars { get; } public int PositionsToHold { get; } public static RotationSettings FromValues(int minimumHoldBarsValue, int positionsToHoldValue) { var minimumHoldBars = RotationMath.ClampInt(minimumHoldBarsValue, 1, 10); var positionsToHold = RotationMath.ClampInt(positionsToHoldValue, 5, 20); return new RotationSettings(minimumHoldBars, positionsToHold); } } /// <summary> /// Thread-safe tracker for entry signal bars used to enforce minimum-hold protection. /// </summary> internal sealed class PositionHoldTracker { private readonly ConcurrentDictionary<string, int> _entryBarBySymbol = new(StringComparer.Ordinal); public bool IsMinimumHoldProtected(string symbol, int idx, int minimumHoldBars) { if (!_entryBarBySymbol.TryGetValue(symbol, out var entryBar)) { return false; } return idx - entryBar < minimumHoldBars; } public void RecordEntrySignalBar(string symbol, int idx) { _entryBarBySymbol[symbol] = idx; } public void RemoveEntry(string symbol) { _entryBarBySymbol.TryRemove(symbol, out _); } } /// <summary> /// Candidate symbol and rank value used during rotation selection ordering. /// </summary> internal sealed class RotationCandidate { public RotationCandidate(BarHistory bars, double rank) { Symbol = bars.Symbol; Rank = rank; } public string Symbol { get; } public double Rank { get; } } /// <summary> /// Immutable selection output from PreExecute containing selected symbols and per-symbol ranks. /// </summary> internal sealed class RotationSelectionSnapshot { public RotationSelectionSnapshot(HashSet<string> selectedSymbols, Dictionary<string, double> rankBySymbol) { SelectedSymbols = selectedSymbols; RankBySymbol = rankBySymbol; } public static RotationSelectionSnapshot Empty { get; } = new( new HashSet<string>(StringComparer.Ordinal), new Dictionary<string, double>(StringComparer.Ordinal)); public HashSet<string> SelectedSymbols { get; } public Dictionary<string, double> RankBySymbol { get; } } /// <summary> /// Builds a deterministic selection snapshot from participants for a single PreExecute cycle. /// </summary> internal static class RotationSelector { public static RotationSelectionSnapshot BuildSelectionSnapshot( List<BarHistory> participants, int startIndex, RotationSettings settings, PositionHoldTracker holdTracker, Func<BarHistory, int> getCurrentIndex, Func<BarHistory, PositionType, bool> hasOpenPosition, IRotationPolicy rotationPolicy) { var selectedSymbols = new HashSet<string>(StringComparer.Ordinal); var rankBySymbol = new Dictionary<string, double>(StringComparer.Ordinal); var candidates = new List<RotationCandidate>(); foreach (var bars in participants) { var idx = getCurrentIndex(bars); var symbol = bars.Symbol; var context = new RotationContext(bars, idx, startIndex); var rank = double.NegativeInfinity; if (idx >= startIndex) { rank = rotationPolicy.ComputeRank(context); } if (!RotationMath.IsValidNumber(rank)) { rank = double.NegativeInfinity; } rankBySymbol[symbol] = rank; var hasOpenLong = hasOpenPosition(bars, PositionType.Long); if (hasOpenLong && holdTracker.IsMinimumHoldProtected(symbol, idx, settings.MinimumHoldBars)) { selectedSymbols.Add(symbol); continue; } if (rotationPolicy.IsEligible(context, rank)) { candidates.Add(new RotationCandidate(bars, rank)); } } candidates.Sort(CompareCandidates); foreach (var candidate in candidates) { if (selectedSymbols.Count >= settings.PositionsToHold) { break; } selectedSymbols.Add(candidate.Symbol); } return new RotationSelectionSnapshot(selectedSymbols, rankBySymbol); } private static int CompareCandidates(RotationCandidate a, RotationCandidate b) { var rankCompare = b.Rank.CompareTo(a.Rank); return rankCompare != 0 ? rankCompare : string.Compare(a.Symbol, b.Symbol, StringComparison.Ordinal); } } /// <summary> /// Rank-evaluation context for a symbol at a specific backtest index. /// </summary> internal sealed class RotationContext { public RotationContext(BarHistory bars, int idx, int startIndex) { Bars = bars; Index = idx; StartIndex = startIndex; } public BarHistory Bars { get; } public int Index { get; } public int StartIndex { get; } } /// <summary> /// Pluggable ranking and eligibility policy contract for rotation selection. /// </summary> internal interface IRotationPolicy { double ComputeRank(RotationContext context); bool IsEligible(RotationContext context, double rank); } /// <summary> /// Default policy that ranks by fixed-period ROC and accepts all finite ranks. /// </summary> internal sealed class RocRotationPolicy : IRotationPolicy { private readonly int _rocPeriod; public RocRotationPolicy(int rocPeriod) { _rocPeriod = rocPeriod; } public double ComputeRank(RotationContext context) { if (context.Index < _rocPeriod) { return double.NegativeInfinity; } var oldPrice = RotationMath.SafePrice(context.Bars.Close[context.Index - _rocPeriod]); var currentPrice = RotationMath.SafePrice(context.Bars.Close[context.Index]); return 100.0 * (currentPrice / oldPrice - 1.0); } public bool IsEligible(RotationContext context, double rank) => RotationMath.IsValidNumber(rank) && rank > double.NegativeInfinity; } /// <summary> /// Helpers for building plotted time series used by the strategy. /// </summary> internal static class SeriesBuilder { public static TimeSeries BuildRocSeries(BarHistory bars, int period) { var ts = new TimeSeries(bars.DateTimes, 0.0); for (var i = 0; i < bars.Count; i++) { if (i < period) { ts[i] = 0.0; continue; } var oldPrice = RotationMath.SafePrice(bars.Close[i - period]); var currentPrice = RotationMath.SafePrice(bars.Close[i]); ts[i] = 100.0 * (currentPrice / oldPrice - 1.0); } return ts; } public static TimeSeries BuildConstantSeries(BarHistory bars, double value) { var ts = new TimeSeries(bars.DateTimes, value); for (var i = 0; i < bars.Count; i++) { ts[i] = value; } return ts; } } /// <summary> /// Numeric utility functions for validation, clamping, and transaction weighting. /// </summary> internal static class RotationMath { public static double ComputeTransactionWeight(double rank) => !IsValidNumber(rank) ? 0.0001 : Math.Max(0.0001, 100.0 + rank); public static double SafePrice(double price) { if (double.IsNaN(price) || double.IsInfinity(price) || price <= 0.0) { return 0.0000001; } return price; } public static bool IsValidNumber(double x) => !double.IsNaN(x) && !double.IsInfinity(x); public static int ClampInt(int x, int lo, int hi) => x < lo ? lo : x > hi ? hi : x; } }
0
- ago
#8
Glitch,

As I mentioned in the Discord thread, I provided a lucrative financial incentive to the coders and rather than compete they collaborated. They are happy and I am happy.

Vince
1
- ago
#9
paul1986,

Thanks for checking it out also. As I mentioned, I only did minimal testing o see if it appeared to work. I am glad that it is getting the "thumps up". I hope some folks will find it useful.

Vince
0
- ago
#10
QUOTE:
The ones we developed so far wouldn't work because they use static members to track a list of positions for sorting in the PreExecute method.

"Static members" is C# speak for "shared memory" in a multi-threaded context. Yes, you can have scheduling conflicts with multiple writer threads and shared memory. It's the classical reader/writer problem in a threading context.
0

Reply

Bookmark

Sort