|
| 1 | +namespace StockSharp.Algo; |
| 2 | + |
| 3 | +using System.Collections.Generic; |
| 4 | + |
| 5 | +using StockSharp.Messages; |
| 6 | + |
| 7 | +/// <summary> |
| 8 | +/// Symmetric spread widener for <see cref="QuoteChangeMessage"/>. Collapses |
| 9 | +/// every raw level inside the new (wider) spread onto the new visible best; |
| 10 | +/// outer levels pass through. Reads current raw book state from an |
| 11 | +/// <see cref="OrderBookSnapshotHolder"/> the caller maintains and emits |
| 12 | +/// <see cref="QuoteChangeStates.Increment"/> frames after the initial |
| 13 | +/// <see cref="QuoteChangeStates.SnapshotComplete"/>. |
| 14 | +/// </summary> |
| 15 | +public sealed class OrderBookSpreadWidener |
| 16 | +{ |
| 17 | + private readonly decimal _bidFactor; |
| 18 | + private readonly decimal _askFactor; |
| 19 | + |
| 20 | + private readonly Dictionary<SecurityId, EmittedBook> _lastEmitted = []; |
| 21 | + private readonly Lock _stateLock = new(); |
| 22 | + |
| 23 | + private sealed class EmittedBook |
| 24 | + { |
| 25 | + public readonly Dictionary<decimal, QuoteChange> Bids = []; |
| 26 | + public readonly Dictionary<decimal, QuoteChange> Asks = []; |
| 27 | + } |
| 28 | + |
| 29 | + public OrderBookSpreadWidener(decimal percent) |
| 30 | + { |
| 31 | + Percent = percent; |
| 32 | + |
| 33 | + if (percent > 0m) |
| 34 | + { |
| 35 | + var p = percent / 100m; |
| 36 | + _bidFactor = 1m - p; |
| 37 | + _askFactor = 1m + p; |
| 38 | + } |
| 39 | + else |
| 40 | + { |
| 41 | + _bidFactor = 1m; |
| 42 | + _askFactor = 1m; |
| 43 | + } |
| 44 | + } |
| 45 | + |
| 46 | + public decimal Percent { get; } |
| 47 | + |
| 48 | + public bool IsEnabled => Percent > 0m; |
| 49 | + |
| 50 | + public void ResetSnapshot(SecurityId securityId) |
| 51 | + { |
| 52 | + using (_stateLock.EnterScope()) |
| 53 | + { |
| 54 | + if (securityId == default) |
| 55 | + _lastEmitted.Clear(); |
| 56 | + else |
| 57 | + _lastEmitted.Remove(securityId); |
| 58 | + } |
| 59 | + } |
| 60 | + |
| 61 | + /// <summary> |
| 62 | + /// Reads the current raw book from <paramref name="holder"/> (caller must |
| 63 | + /// have applied <paramref name="msg"/> to the holder first) and rewrites |
| 64 | + /// <paramref name="msg"/> as either a full <c>SnapshotComplete</c> (first |
| 65 | + /// call for this security) or an <c>Increment</c> with only the changes |
| 66 | + /// against the previously-emitted collapsed view. |
| 67 | + /// </summary> |
| 68 | + /// <summary> |
| 69 | + /// Returns the current collapsed view of <paramref name="securityId"/> as a |
| 70 | + /// fresh <c>SnapshotComplete</c>. Pure read — does not touch the diff-state |
| 71 | + /// used by <see cref="Apply"/>. Useful for replying to a new subscriber |
| 72 | + /// without re-emitting deltas from before their subscription. |
| 73 | + /// </summary> |
| 74 | + public QuoteChangeMessage Collapse(SecurityId securityId, OrderBookSnapshotHolder holder) |
| 75 | + { |
| 76 | + if (!IsEnabled || holder is null) |
| 77 | + return null; |
| 78 | + |
| 79 | + if (!holder.TryGetSnapshot(securityId, out var raw)) |
| 80 | + return null; |
| 81 | + |
| 82 | + var copy = CopyHeader(raw); |
| 83 | + copy.Bids = Collapse(raw.Bids, _bidFactor, descending: true); |
| 84 | + copy.Asks = Collapse(raw.Asks, _askFactor, descending: false); |
| 85 | + copy.State = QuoteChangeStates.SnapshotComplete; |
| 86 | + return copy; |
| 87 | + } |
| 88 | + |
| 89 | + public QuoteChangeMessage Apply(QuoteChangeMessage msg, OrderBookSnapshotHolder holder) |
| 90 | + { |
| 91 | + if (msg is null) |
| 92 | + return null; |
| 93 | + |
| 94 | + if (!IsEnabled || holder is null) |
| 95 | + return msg; |
| 96 | + |
| 97 | + if (!holder.TryGetSnapshot(msg.SecurityId, out var raw)) |
| 98 | + return msg; |
| 99 | + |
| 100 | + var newBids = Collapse(raw.Bids, _bidFactor, descending: true); |
| 101 | + var newAsks = Collapse(raw.Asks, _askFactor, descending: false); |
| 102 | + |
| 103 | + var copy = CopyHeader(msg); |
| 104 | + |
| 105 | + using (_stateLock.EnterScope()) |
| 106 | + { |
| 107 | + if (!_lastEmitted.TryGetValue(msg.SecurityId, out var prev)) |
| 108 | + { |
| 109 | + prev = new EmittedBook(); |
| 110 | + _lastEmitted[msg.SecurityId] = prev; |
| 111 | + |
| 112 | + copy.Bids = newBids; |
| 113 | + copy.Asks = newAsks; |
| 114 | + copy.State = QuoteChangeStates.SnapshotComplete; |
| 115 | + } |
| 116 | + else |
| 117 | + { |
| 118 | + copy.Bids = BuildDelta(prev.Bids, newBids); |
| 119 | + copy.Asks = BuildDelta(prev.Asks, newAsks); |
| 120 | + copy.State = QuoteChangeStates.Increment; |
| 121 | + } |
| 122 | + |
| 123 | + Replace(prev.Bids, newBids); |
| 124 | + Replace(prev.Asks, newAsks); |
| 125 | + } |
| 126 | + |
| 127 | + return copy; |
| 128 | + } |
| 129 | + |
| 130 | + // Builds a clone with all header fields copied but Bids/Asks left empty — the |
| 131 | + // caller fills them with the collapsed view. Avoids the wasted Bids/Asks |
| 132 | + // array allocation that QuoteChangeMessage.CopyTo would otherwise do. |
| 133 | + private static QuoteChangeMessage CopyHeader(QuoteChangeMessage src) => new() |
| 134 | + { |
| 135 | + SecurityId = src.SecurityId, |
| 136 | + ServerTime = src.ServerTime, |
| 137 | + LocalTime = src.LocalTime, |
| 138 | + Currency = src.Currency, |
| 139 | + BuildFrom = src.BuildFrom, |
| 140 | + IsFiltered = src.IsFiltered, |
| 141 | + HasPositions = src.HasPositions, |
| 142 | + SeqNum = src.SeqNum, |
| 143 | + OriginalTransactionId = src.OriginalTransactionId, |
| 144 | + SubscriptionId = src.SubscriptionId, |
| 145 | + SubscriptionIds = src.SubscriptionIds, |
| 146 | + BackMode = src.BackMode, |
| 147 | + OfflineMode = src.OfflineMode, |
| 148 | + }; |
| 149 | + |
| 150 | + private static void Replace(Dictionary<decimal, QuoteChange> dst, QuoteChange[] src) |
| 151 | + { |
| 152 | + dst.Clear(); |
| 153 | + foreach (var q in src) |
| 154 | + dst[q.Price] = q; |
| 155 | + } |
| 156 | + |
| 157 | + private static QuoteChange[] BuildDelta(Dictionary<decimal, QuoteChange> prev, QuoteChange[] @new) |
| 158 | + { |
| 159 | + var deltas = new List<QuoteChange>(); |
| 160 | + var seen = new HashSet<decimal>(); |
| 161 | + |
| 162 | + foreach (var q in @new) |
| 163 | + { |
| 164 | + seen.Add(q.Price); |
| 165 | + if (!prev.TryGetValue(q.Price, out var was) |
| 166 | + || was.Volume != q.Volume |
| 167 | + || was.OrdersCount != q.OrdersCount) |
| 168 | + { |
| 169 | + deltas.Add(q); |
| 170 | + } |
| 171 | + } |
| 172 | + |
| 173 | + foreach (var p in prev.Keys) |
| 174 | + { |
| 175 | + if (!seen.Contains(p)) |
| 176 | + deltas.Add(new QuoteChange(p, 0m)); |
| 177 | + } |
| 178 | + |
| 179 | + return [.. deltas]; |
| 180 | + } |
| 181 | + |
| 182 | + private static QuoteChange[] Collapse(QuoteChange[] side, decimal factor, bool descending) |
| 183 | + { |
| 184 | + if (side is null || side.Length == 0) |
| 185 | + return side ?? []; |
| 186 | + |
| 187 | + if (side[0].Price <= 0m) |
| 188 | + return side; |
| 189 | + |
| 190 | + var newBestPrice = side[0].Price * factor; |
| 191 | + |
| 192 | + decimal aggVolume = 0m; |
| 193 | + var hasOrdersCount = false; |
| 194 | + var aggOrdersCount = 0; |
| 195 | + var collapseCount = 0; |
| 196 | + |
| 197 | + for (var i = 0; i < side.Length; i++) |
| 198 | + { |
| 199 | + ref var q = ref side[i]; |
| 200 | + var inside = descending ? q.Price >= newBestPrice : q.Price <= newBestPrice; |
| 201 | + if (!inside) |
| 202 | + break; |
| 203 | + |
| 204 | + aggVolume += q.Volume; |
| 205 | + if (q.OrdersCount is int oc) |
| 206 | + { |
| 207 | + hasOrdersCount = true; |
| 208 | + aggOrdersCount += oc; |
| 209 | + } |
| 210 | + collapseCount++; |
| 211 | + } |
| 212 | + |
| 213 | + if (collapseCount == 0) |
| 214 | + return side; |
| 215 | + |
| 216 | + var topCondition = side[0].Condition; |
| 217 | + var result = new QuoteChange[side.Length - collapseCount + 1]; |
| 218 | + result[0] = new QuoteChange(newBestPrice, aggVolume, hasOrdersCount ? aggOrdersCount : null, topCondition); |
| 219 | + |
| 220 | + var tail = side.Length - collapseCount; |
| 221 | + if (tail > 0) |
| 222 | + Array.Copy(side, collapseCount, result, 1, tail); |
| 223 | + |
| 224 | + return result; |
| 225 | + } |
| 226 | +} |
0 commit comments