Skip to content

Commit 10d0e5d

Browse files
committed
Algo: add Level1SpreadWidener + OrderBookSpreadWidener helpers
Symmetric spread widening primitives for delivery-edge gateways. Level1 helper is stateless — shifts BestBidPrice / BestAskPrice on a returned clone. OrderBook helper is stateful per SecurityId and reads the current raw book from a caller-owned OrderBookSnapshotHolder (no duplicate mirror). Apply emits the first SnapshotComplete then Increment frames diffed against the last-emitted collapsed view; Collapse returns a fresh SnapshotComplete without touching diff state (new-subscriber replay path). Collapse rule: levels inside the new (wider) spread merge onto the new visible best with volume and orders count summed; outer levels pass through unchanged. Covered by Level1SpreadWidenerTests and OrderBookSpreadWidenerTests.
1 parent b3103bb commit 10d0e5d

4 files changed

Lines changed: 585 additions & 0 deletions

File tree

Algo/Level1SpreadWidener.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
namespace StockSharp.Algo;
2+
3+
using StockSharp.Messages;
4+
5+
/// <summary>
6+
/// Symmetric spread widener for <see cref="Level1ChangeMessage"/>. Shifts
7+
/// <see cref="Level1Fields.BestBidPrice"/> down and
8+
/// <see cref="Level1Fields.BestAskPrice"/> up by the configured percent.
9+
/// Stateless.
10+
/// </summary>
11+
public sealed class Level1SpreadWidener
12+
{
13+
private readonly decimal _bidFactor;
14+
private readonly decimal _askFactor;
15+
16+
public Level1SpreadWidener(decimal percent)
17+
{
18+
Percent = percent;
19+
20+
if (percent > 0m)
21+
{
22+
var p = percent / 100m;
23+
_bidFactor = 1m - p;
24+
_askFactor = 1m + p;
25+
}
26+
else
27+
{
28+
_bidFactor = 1m;
29+
_askFactor = 1m;
30+
}
31+
}
32+
33+
public decimal Percent { get; }
34+
35+
public bool IsEnabled => Percent > 0m;
36+
37+
public Level1ChangeMessage Apply(Level1ChangeMessage msg)
38+
{
39+
if (msg is null)
40+
return null;
41+
42+
if (!IsEnabled)
43+
return msg;
44+
45+
var copy = msg.TypedClone();
46+
47+
if (copy.Changes.TryGetValue(Level1Fields.BestBidPrice, out var bidObj) && bidObj is decimal bid && bid > 0m)
48+
copy.Changes[Level1Fields.BestBidPrice] = bid * _bidFactor;
49+
50+
if (copy.Changes.TryGetValue(Level1Fields.BestAskPrice, out var askObj) && askObj is decimal ask && ask > 0m)
51+
copy.Changes[Level1Fields.BestAskPrice] = ask * _askFactor;
52+
53+
return copy;
54+
}
55+
}

Algo/OrderBookSpreadWidener.cs

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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+
}

Tests/Level1SpreadWidenerTests.cs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
namespace StockSharp.Tests;
2+
3+
[TestClass]
4+
public class Level1SpreadWidenerTests : BaseTestClass
5+
{
6+
[TestMethod]
7+
public void ZeroPercent_IsDisabled()
8+
{
9+
var w = new Level1SpreadWidener(0m);
10+
w.IsEnabled.AreEqual(false);
11+
w.Percent.AreEqual(0m);
12+
}
13+
14+
[TestMethod]
15+
public void NegativePercent_IsDisabled()
16+
{
17+
new Level1SpreadWidener(-5m).IsEnabled.AreEqual(false);
18+
}
19+
20+
[TestMethod]
21+
public void PositivePercent_IsEnabled()
22+
{
23+
var w = new Level1SpreadWidener(2m);
24+
w.IsEnabled.AreEqual(true);
25+
w.Percent.AreEqual(2m);
26+
}
27+
28+
[TestMethod]
29+
public void ZeroPercent_ReturnsOriginalUntouched()
30+
{
31+
var msg = new Level1ChangeMessage();
32+
msg.Add(Level1Fields.BestBidPrice, 100m);
33+
msg.Add(Level1Fields.BestAskPrice, 101m);
34+
35+
var result = new Level1SpreadWidener(0m).Apply(msg);
36+
37+
ReferenceEquals(result, msg).AreEqual(true);
38+
((decimal)msg.Changes[Level1Fields.BestBidPrice]).AreEqual(100m);
39+
}
40+
41+
[TestMethod]
42+
public void PositivePercent_ReturnsCloneWithShiftedPrices_OriginalIntact()
43+
{
44+
var msg = new Level1ChangeMessage();
45+
msg.Add(Level1Fields.BestBidPrice, 100m);
46+
msg.Add(Level1Fields.BestAskPrice, 100m);
47+
48+
var result = new Level1SpreadWidener(2m).Apply(msg);
49+
50+
ReferenceEquals(result, msg).AreEqual(false);
51+
((decimal)result.Changes[Level1Fields.BestBidPrice]).AreEqual(98m);
52+
((decimal)result.Changes[Level1Fields.BestAskPrice]).AreEqual(102m);
53+
54+
// Original untouched.
55+
((decimal)msg.Changes[Level1Fields.BestBidPrice]).AreEqual(100m);
56+
((decimal)msg.Changes[Level1Fields.BestAskPrice]).AreEqual(100m);
57+
}
58+
59+
[TestMethod]
60+
public void OnlyBid_LeavesAskAlone()
61+
{
62+
var msg = new Level1ChangeMessage();
63+
msg.Add(Level1Fields.BestBidPrice, 100m);
64+
msg.Add(Level1Fields.LastTradePrice, 99m);
65+
66+
var result = new Level1SpreadWidener(1m).Apply(msg);
67+
68+
((decimal)result.Changes[Level1Fields.BestBidPrice]).AreEqual(99m);
69+
((decimal)result.Changes[Level1Fields.LastTradePrice]).AreEqual(99m);
70+
result.Changes.ContainsKey(Level1Fields.BestAskPrice).AreEqual(false);
71+
}
72+
73+
[TestMethod]
74+
public void ZeroPrice_SkipsThatSide()
75+
{
76+
var msg = new Level1ChangeMessage();
77+
msg.Add(Level1Fields.BestBidPrice, 0m);
78+
msg.Add(Level1Fields.BestAskPrice, 100m);
79+
80+
var result = new Level1SpreadWidener(5m).Apply(msg);
81+
82+
((decimal)result.Changes[Level1Fields.BestBidPrice]).AreEqual(0m);
83+
((decimal)result.Changes[Level1Fields.BestAskPrice]).AreEqual(105m);
84+
}
85+
86+
[TestMethod]
87+
public void NullMessage_ReturnsNull()
88+
{
89+
new Level1SpreadWidener(1m).Apply(null).AreEqual(null);
90+
}
91+
}

0 commit comments

Comments
 (0)