|
| 1 | +/* |
| 2 | + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. |
| 3 | + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. |
| 4 | + * |
| 5 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 6 | + * you may not use this file except in compliance with the License. |
| 7 | + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | + * |
| 9 | + * Unless required by applicable law or agreed to in writing, software |
| 10 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | + * See the License for the specific language governing permissions and |
| 13 | + * limitations under the License. |
| 14 | + * |
| 15 | +*/ |
| 16 | + |
| 17 | +using Newtonsoft.Json; |
| 18 | +using QuantConnect.Logging; |
| 19 | +using QuantConnect.Optimizer.Parameters; |
| 20 | +using System.Collections.Generic; |
| 21 | +using System.Globalization; |
| 22 | +using System.Linq; |
| 23 | + |
| 24 | +namespace QuantConnect.Optimizer.Analysis |
| 25 | +{ |
| 26 | + // Types like OptimizationAnalysis / SharpeSummary / Cluster / Mode / etc. live in |
| 27 | + // QuantConnect.Optimizer (the parent namespace); they're referenced unqualified |
| 28 | + // because the file is inside QuantConnect.Optimizer.Analysis and the C# compiler |
| 29 | + // walks outward through parent namespaces when resolving simple names. |
| 30 | + /// <summary> |
| 31 | + /// Builds an aggregate diagnostic (<see cref="OptimizationAnalysis"/>) from a completed |
| 32 | + /// optimization's compute-job results. Computes the Sharpe distribution, the best trial, |
| 33 | + /// per-parameter sensitivity slices, k-means clusters in parameter space, local maxima, |
| 34 | + /// and a zero-order failure breakdown. The optimization-side analogue of |
| 35 | + /// <see cref="ResultsAnalyzer"/>; invoked from <c>LeanOptimizer.TriggerOnEndEvent</c>. |
| 36 | + /// </summary> |
| 37 | + public class OptimizationAnalyzer |
| 38 | + { |
| 39 | + private readonly OptimizationAnalysisRunParameters _parameters; |
| 40 | + |
| 41 | + /// <summary> |
| 42 | + /// Initializes a new instance of the <see cref="OptimizationAnalyzer"/> class. |
| 43 | + /// </summary> |
| 44 | + /// <param name="parameters">The inputs to analyze.</param> |
| 45 | + public OptimizationAnalyzer(OptimizationAnalysisRunParameters parameters) |
| 46 | + { |
| 47 | + _parameters = parameters; |
| 48 | + } |
| 49 | + |
| 50 | + /// <summary> |
| 51 | + /// Runs the full analysis pipeline and returns the aggregate diagnostic. |
| 52 | + /// </summary> |
| 53 | + /// <returns>The populated <see cref="OptimizationAnalysis"/>, or <c>null</c> if no usable trials remain.</returns> |
| 54 | + public OptimizationAnalysis Run() |
| 55 | + { |
| 56 | + var allTrials = ExtractTrials(_parameters.CompletedTrials); |
| 57 | + var trials = allTrials.Where(t => t.HasSharpe).ToList(); |
| 58 | + if (trials.Count == 0) |
| 59 | + { |
| 60 | + Log.Trace("OptimizationAnalyzer.Run(): no completed backtests with parsable Sharpe ratios; skipping analysis"); |
| 61 | + return null; |
| 62 | + } |
| 63 | + |
| 64 | + var sharpes = trials.Select(t => t.Sharpe).ToList(); |
| 65 | + var overall = new SharpeSummary |
| 66 | + { |
| 67 | + Mean = sharpes.Average(), |
| 68 | + StdDev = StdDev(sharpes), |
| 69 | + Min = sharpes.Min(), |
| 70 | + Max = sharpes.Max(), |
| 71 | + Median = Median(sharpes) |
| 72 | + }; |
| 73 | + |
| 74 | + // Always maximize Sharpe. The optimization's chosen Criterion may be something else |
| 75 | + // but the analyzer uses Sharpe as the universal yardstick for the analysis surface. |
| 76 | + var best = trials.OrderByDescending(t => t.Sharpe).First(); |
| 77 | + var bestSummary = new BestTrialSummary |
| 78 | + { |
| 79 | + BacktestId = best.BacktestId, |
| 80 | + Parameters = new Dictionary<string, double>(best.Parameters), |
| 81 | + SharpeRatio = best.Sharpe |
| 82 | + }; |
| 83 | + |
| 84 | + var paramReports = _parameters.OptimizationParameters |
| 85 | + .Select(p => OptimizationSlicing.AnalyzeParameter(p, trials, best)) |
| 86 | + .ToList(); |
| 87 | + |
| 88 | + var clusters = OptimizationClustering.Build(trials, _parameters.OptimizationParameters); |
| 89 | + var modes = OptimizationModes.Find(trials, _parameters.OptimizationParameters); |
| 90 | + var failed = OptimizationFailedBacktests.Build(allTrials); |
| 91 | + |
| 92 | + return new OptimizationAnalysis |
| 93 | + { |
| 94 | + TrialCountTotal = allTrials.Count, |
| 95 | + TrialCountUsed = trials.Count, |
| 96 | + OverallSharpe = overall, |
| 97 | + Best = bestSummary, |
| 98 | + Parameters = paramReports, |
| 99 | + Clusters = clusters, |
| 100 | + Modes = modes, |
| 101 | + FailedBacktests = failed |
| 102 | + }; |
| 103 | + } |
| 104 | + |
| 105 | + // ── Trial extraction ───────────────────────────────────────────────────── |
| 106 | + |
| 107 | + /// <summary> |
| 108 | + /// Parses each completed trial's JSON backtest payload into a typed |
| 109 | + /// <see cref="TrialRecord"/>: parameter values + Sharpe + total orders + the backtest's |
| 110 | + /// own diagnostic Analysis tags. |
| 111 | + /// </summary> |
| 112 | + private static List<TrialRecord> ExtractTrials(IReadOnlyList<OptimizationTrial> trialInputs) |
| 113 | + { |
| 114 | + var trials = new List<TrialRecord>(); |
| 115 | + if (trialInputs == null) return trials; |
| 116 | + |
| 117 | + foreach (var t in trialInputs) |
| 118 | + { |
| 119 | + if (t == null || string.IsNullOrEmpty(t.JsonBacktestResult) || t.ParameterSet == null) |
| 120 | + { |
| 121 | + continue; |
| 122 | + } |
| 123 | + |
| 124 | + Dictionary<string, double> paramValues; |
| 125 | + try |
| 126 | + { |
| 127 | + paramValues = ParseParameterSet(t.ParameterSet); |
| 128 | + } |
| 129 | + catch |
| 130 | + { |
| 131 | + continue; |
| 132 | + } |
| 133 | + if (paramValues.Count == 0) |
| 134 | + { |
| 135 | + continue; |
| 136 | + } |
| 137 | + |
| 138 | + ParsedBacktest parsed; |
| 139 | + try |
| 140 | + { |
| 141 | + parsed = JsonConvert.DeserializeObject<ParsedBacktest>(t.JsonBacktestResult); |
| 142 | + } |
| 143 | + catch |
| 144 | + { |
| 145 | + continue; |
| 146 | + } |
| 147 | + if (parsed == null) |
| 148 | + { |
| 149 | + continue; |
| 150 | + } |
| 151 | + |
| 152 | + var hasSharpe = TryReadDouble(parsed.Statistics, "Sharpe Ratio", out var sharpe); |
| 153 | + TryReadInt(parsed.Statistics, "Total Orders", out var totalOrders); |
| 154 | + |
| 155 | + var analysisNames = parsed.Analysis == null |
| 156 | + ? new List<string>() |
| 157 | + : parsed.Analysis |
| 158 | + .Where(a => !string.IsNullOrEmpty(a?.Name)) |
| 159 | + .Select(a => a.Name) |
| 160 | + .ToList(); |
| 161 | + |
| 162 | + trials.Add(new TrialRecord( |
| 163 | + backtestId: t.BacktestId, |
| 164 | + parameters: paramValues, |
| 165 | + sharpe: sharpe, |
| 166 | + hasSharpe: hasSharpe, |
| 167 | + totalOrders: totalOrders, |
| 168 | + analysisNames: analysisNames)); |
| 169 | + } |
| 170 | + return trials; |
| 171 | + } |
| 172 | + |
| 173 | + private static Dictionary<string, double> ParseParameterSet(ParameterSet parameterSet) |
| 174 | + { |
| 175 | + var result = new Dictionary<string, double>(); |
| 176 | + if (parameterSet?.Value == null) return result; |
| 177 | + foreach (var kv in parameterSet.Value) |
| 178 | + { |
| 179 | + if (double.TryParse(kv.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) |
| 180 | + { |
| 181 | + result[kv.Key] = d; |
| 182 | + } |
| 183 | + } |
| 184 | + return result; |
| 185 | + } |
| 186 | + |
| 187 | + private static bool TryReadDouble(IDictionary<string, string> statistics, string key, out double value) |
| 188 | + { |
| 189 | + value = 0; |
| 190 | + if (statistics == null) return false; |
| 191 | + if (!statistics.TryGetValue(key, out var raw) || string.IsNullOrEmpty(raw)) return false; |
| 192 | + // QC statistics often carry trailing units like "%" — strip them before parsing. |
| 193 | + var trimmed = raw.TrimEnd('%').Trim(); |
| 194 | + return double.TryParse(trimmed, NumberStyles.Any, CultureInfo.InvariantCulture, out value); |
| 195 | + } |
| 196 | + |
| 197 | + private static bool TryReadInt(IDictionary<string, string> statistics, string key, out int value) |
| 198 | + { |
| 199 | + value = 0; |
| 200 | + if (!TryReadDouble(statistics, key, out var d)) return false; |
| 201 | + value = (int)d; |
| 202 | + return true; |
| 203 | + } |
| 204 | + |
| 205 | + // ── Aggregate helpers ──────────────────────────────────────────────────── |
| 206 | + |
| 207 | + private static double StdDev(IReadOnlyCollection<double> values) |
| 208 | + { |
| 209 | + if (values.Count < 2) return 0; |
| 210 | + var mean = values.Average(); |
| 211 | + var s = values.Sum(v => (v - mean) * (v - mean)); |
| 212 | + return System.Math.Sqrt(s / (values.Count - 1)); |
| 213 | + } |
| 214 | + |
| 215 | + private static double Median(IEnumerable<double> values) |
| 216 | + { |
| 217 | + var sorted = values.OrderBy(v => v).ToList(); |
| 218 | + if (sorted.Count == 0) return 0; |
| 219 | + return sorted.Count % 2 == 1 |
| 220 | + ? sorted[sorted.Count / 2] |
| 221 | + : 0.5 * (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]); |
| 222 | + } |
| 223 | + |
| 224 | + // ── Minimal-shape DTO for JSON deserialization ─────────────────────────── |
| 225 | + // |
| 226 | + // The JsonBacktestResult string carried by OptimizationResult is the Newtonsoft.Json |
| 227 | + // serialization of the in-process backtest Result. We only need a few fields; rather |
| 228 | + // than depend on the full Result type (with its many nested dependencies), bind a |
| 229 | + // minimal shape with the two properties we read. |
| 230 | + |
| 231 | + private sealed class ParsedBacktest |
| 232 | + { |
| 233 | + [JsonProperty("Statistics")] |
| 234 | + public IDictionary<string, string> Statistics { get; set; } |
| 235 | + |
| 236 | + [JsonProperty("Analysis")] |
| 237 | + public List<QuantConnect.Analysis> Analysis { get; set; } |
| 238 | + } |
| 239 | + } |
| 240 | + |
| 241 | + /// <summary> |
| 242 | + /// Internal per-trial record used across the optimization-analysis helpers |
| 243 | + /// (<see cref="OptimizationClustering"/>, <see cref="OptimizationModes"/>, |
| 244 | + /// <see cref="OptimizationSlicing"/>, <see cref="OptimizationFailedBacktests"/>). |
| 245 | + /// Public visibility kept low: only the helpers in this namespace need it. |
| 246 | + /// </summary> |
| 247 | + internal sealed class TrialRecord |
| 248 | + { |
| 249 | + public string BacktestId { get; } |
| 250 | + public IReadOnlyDictionary<string, double> Parameters { get; } |
| 251 | + public double Sharpe { get; } |
| 252 | + public bool HasSharpe { get; } |
| 253 | + public int TotalOrders { get; } |
| 254 | + public IReadOnlyList<string> AnalysisNames { get; } |
| 255 | + |
| 256 | + public TrialRecord( |
| 257 | + string backtestId, |
| 258 | + IReadOnlyDictionary<string, double> parameters, |
| 259 | + double sharpe, |
| 260 | + bool hasSharpe, |
| 261 | + int totalOrders, |
| 262 | + IReadOnlyList<string> analysisNames) |
| 263 | + { |
| 264 | + BacktestId = backtestId; |
| 265 | + Parameters = parameters; |
| 266 | + Sharpe = sharpe; |
| 267 | + HasSharpe = hasSharpe; |
| 268 | + TotalOrders = totalOrders; |
| 269 | + AnalysisNames = analysisNames; |
| 270 | + } |
| 271 | + } |
| 272 | +} |
0 commit comments