Compare commits

...
44 Commits
Author SHA1 Message Date
arham 96767cdfa4 rakurai excanges should automatically work on sim 2026-10-21 14:26:19 +00:00
arham a2987f0102 add reconect to binance futures 2026-10-21 04:30:05 +00:00
arham fbbef6acf7 timestamp for bbo works 2026-10-21 04:29:06 +00:00
arham e38177586f minor fixes and deployment updated public websocket 2026-10-21 04:08:14 +00:00
arham 3e886babe3 spreader working in comand bridge 2026-10-21 04:06:16 +00:00
arham 6a8092c934 docs: document ILINK3 order lifecycle 2026-09-30 07:16:43 +00:00
arham 6f01bde0f5 Add public Spark and BlueOrb WebSocket gateway 2026-09-30 07:15:51 +00:00
arham ab7a5c6bfb feat: add Python Spark command client 2026-09-30 06:59:05 +00:00
arham bf404bc691 feat: add Spark command bridge service 2026-09-30 06:57:40 +00:00
arham 8ba8d55bc2 feat: add versioned Spark command protocol 2026-09-30 06:49:31 +00:00
arham 4614b60218 fix: make error reporting better in webocket api 2026-08-28 02:30:25 +00:00
arham 95d05fa20f add: phoenix md for rakurai 2026-08-28 02:30:03 +00:00
arham c0a8dde7db Add automated venue addition for rakurai exchanges 2026-08-28 02:29:38 +00:00
arham b98eee18f5 add: venue addition is now automatic we dont need to add the venue across the db look at VENUES.md for more info 2026-08-28 02:27:47 +00:00
arham 6ccfbda5aa build: libraries to make rakurai v12 build 2026-08-26 13:31:12 +00:00
arham 6b6f34288d build: in new container build would fail because of missing python package this fixes it 2026-08-26 13:30:28 +00:00
arham eb0d0c5244 update rakurai package 2026-08-24 13:36:53 +00:00
John Colvin 19fef8492d Update externals to remove useless nugets with filenames that are too long
Too long for Windows to clone externals (at least for the Windows Server
box running jenkins)
2026-08-12 14:05:07 -04:00
arham 7521b01022 bug fixes for api stream 2026-08-09 22:13:09 +00:00
arham d0faa95d7a add: fee adjusted stream 2026-08-09 22:12:56 +00:00
arham 36aa2caa2a fractional quantities spreader working 2026-07-30 05:12:58 +00:00
arham 97e7343110 fix: add add raydium and orca to crypto in spreader 2026-07-26 19:28:21 +00:00
arham 5dccc2e9c3 fix: synth name 2026-07-26 19:27:30 +00:00
arham 365012102e basic create synthetic working 2026-07-22 10:25:41 +00:00
arham c3d14c8e31 api server works in default docker 2026-07-02 04:50:44 +00:00
arham 5cc80db09d config change 2026-07-02 04:50:01 +00:00
arham 12c2deb833 market orders for hedge order in spreader 2026-07-02 04:49:44 +00:00
arham 2222fb8e3e add okx to conf 2026-06-30 23:37:26 +00:00
arham 266fdf93e5 fix null check 2026-06-30 23:36:59 +00:00
arham fd121bd85b emnative order hadles limit orders now 2026-06-19 03:13:13 +00:00
arham 1ffaf7c05b Add: rakurai dex execution 2026-06-19 03:12:52 +00:00
arham 5a87288aaa quantity fix 2026-06-15 18:33:37 +00:00
arham a1d62e552c Add:orca raydoum to risk ui 2026-06-11 13:40:10 +00:00
arham 80656a0890 Fix: orca and raydium show up in the api server 2026-06-08 04:10:49 +00:00
arham 188daf2b84 fix: error in cpp 2026-05-31 23:39:38 +00:00
arham 5908adcb09 add: cpp to dex handler 2026-05-15 13:36:09 +00:00
arham 36cf021b70 Add: libs for rakurai to work 2026-05-15 13:35:32 +00:00
arham d45e0e08a2 fix: compiler error 2026-05-15 13:00:25 +00:00
arham 888e751243 raydium orca working 2026-04-24 06:35:50 +00:00
arham bbe3350b4e Add: raydium and orca to frond end 2026-04-24 02:33:24 -04:00
arham 80ea6ad16e add orca nad radium to benue options 2026-04-23 23:46:14 +00:00
arham 0bc0e821f3 basic rakurai hookup done 2026-04-23 17:07:51 +00:00
arham 1b82d07a0d fix: fixed api rounding issues 2026-04-23 17:07:51 +00:00
arham a83404f20f Add:: added orca and raydium 2026-04-23 17:07:51 +00:00
128 changed files with 10570 additions and 410 deletions

No files matched your search

+30
View File
@@ -51,6 +51,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bts.Net", "src\cs\Bts.Net\B
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bts.IO.Compression", "src\cs\Bts.IO.Compression\Bts.IO.Compression.csproj", "{BC277499-8215-4F9F-B9AB-07B6391110BF}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "client", "client", "{70E3DCF5-A3C3-45A0-8F18-B01B2D446532}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "spark_command_bridge", "src\client\spark_command_bridge\spark_command_bridge.csproj", "{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
@@ -571,6 +575,30 @@ Global
{BC277499-8215-4F9F-B9AB-07B6391110BF}.RelWithDebInfo|Any CPU.Build.0 = Debug|Any CPU
{BC277499-8215-4F9F-B9AB-07B6391110BF}.RelWithDebInfo|x86.ActiveCfg = Debug|Any CPU
{BC277499-8215-4F9F-B9AB-07B6391110BF}.RelWithDebInfo|x86.Build.0 = Debug|Any CPU
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.Debug|x64.ActiveCfg = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.Debug|x64.Build.0 = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.Debug|Any CPU.ActiveCfg = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.Debug|Any CPU.Build.0 = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.Debug|x86.ActiveCfg = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.Debug|x86.Build.0 = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.MinSizeRel|x64.ActiveCfg = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.MinSizeRel|x64.Build.0 = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.MinSizeRel|Any CPU.ActiveCfg = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.MinSizeRel|Any CPU.Build.0 = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.MinSizeRel|x86.ActiveCfg = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.MinSizeRel|x86.Build.0 = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.Release|x64.ActiveCfg = Release|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.Release|x64.Build.0 = Release|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.Release|Any CPU.ActiveCfg = Release|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.Release|Any CPU.Build.0 = Release|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.Release|x86.ActiveCfg = Release|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.Release|x86.Build.0 = Release|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.RelWithDebInfo|x64.ActiveCfg = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.RelWithDebInfo|x64.Build.0 = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.RelWithDebInfo|Any CPU.ActiveCfg = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.RelWithDebInfo|Any CPU.Build.0 = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.RelWithDebInfo|x86.ActiveCfg = Debug|x64
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED}.RelWithDebInfo|x86.Build.0 = Debug|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -592,6 +620,8 @@ Global
{385C4948-E080-56A6-E29E-8B0F4913175C} = {267F81EF-A8AB-49FD-9F56-D7EC3C1F7C7C}
{43FB297B-7A4D-4835-A878-E6DF345B580C} = {385C4948-E080-56A6-E29E-8B0F4913175C}
{BC277499-8215-4F9F-B9AB-07B6391110BF} = {385C4948-E080-56A6-E29E-8B0F4913175C}
{70E3DCF5-A3C3-45A0-8F18-B01B2D446532} = {267F81EF-A8AB-49FD-9F56-D7EC3C1F7C7C}
{9CD13227-B4D7-45BC-B1C5-1D2ECD2E3AED} = {70E3DCF5-A3C3-45A0-8F18-B01B2D446532}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {422D1620-0166-4C26-9A81-CA8A2EB1AB58}
+7 -1
View File
@@ -40,7 +40,13 @@ function(init_python_module)
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/stubs/${_name}.pyi
DEPENDS ${_name} ${module_proj}
COMMAND PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR} pybind11-stubgen --numpy-array-use-type-var --ignore-invalid-expressions '.*' ${_name}_module
COMMAND
${CMAKE_COMMAND} -E env
"PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR}"
${Python3_EXECUTABLE} -m pybind11_stubgen
--numpy-array-use-type-var
--ignore-invalid-expressions '.*'
${_name}_module
COMMENT "Generating py stubs"
)
add_custom_target(
+6
View File
@@ -19,6 +19,9 @@ services:
- "${SPARKD__CONFIG_PORT}:${SPARKD__CONFIG_PORT}"
- "${SPARKD__QUICKBUS_PORT}:${SPARKD__QUICKBUS_PORT}"
- "${SPARKD__SIM_FIX_PORT}:${SPARKD__SIM_FIX_PORT}"
- 8008:8008
- 8080:8080
- 9443:9443
entrypoint: ${SPARKD__MAIN_ENTRYPOINT}
deploy:
resources:
@@ -72,6 +75,9 @@ services:
- "${SPARKD__CONFIG_PORT}:${SPARKD__CONFIG_PORT}"
- "${SPARKD__QUICKBUS_PORT}:${SPARKD__QUICKBUS_PORT}"
- "${SPARKD__SIM_FIX_PORT}:${SPARKD__SIM_FIX_PORT}"
- 8008:8008
- 8080:8080
- 9443:9443
entrypoint: ${SPARKD__MAIN_ENTRYPOINT}
deploy:
restart_policy:
@@ -19,6 +19,13 @@ RUN add-apt-repository ppa:ubuntu-toolchain-r/test \
RUN apt-get update \
&& apt-get install -y dotnet8
RUN apt-get update && apt-get install -y libpq-dev
RUN apt-get update && apt-get install -y libsodium-dev
RUN apt-get update && apt-get install -y fish
RUN apt-get update && apt-get install -y less
RUN apt-get update && apt-get install -y vim
RUN locale-gen en_US.UTF-8
ARG docker_user="bts"
@@ -98,7 +98,7 @@ RUN apt-get update && apt-get install -y libpcap0.8-dev
RUN apt-get update && apt-get -y install libzstd-dev
# Rakurai
ARG RAKURAI_DEB_URL="https://api.bitbucket.org/2.0/repositories/bluetradesys/spark/downloads/rakurai-dex-connector_0.0.5_amd64.deb"
ARG RAKURAI_DEB_URL="https://api.bitbucket.org/2.0/repositories/bluetradesys/spark/downloads/rakurai-dex-connector_0.0.12_amd64.deb"
RUN --mount=type=secret,id=bitbucket_access_token \
set -eux \
&& apt-get update \
@@ -15,18 +15,21 @@ static class ArgumentComparer {
}
var obj1 = value1.GetValue(env);
var obj2 = value2.GetValue(env);
if (obj1 is null) {
if (obj2 is null) {
if (isNullLike(obj1)) {
if (isNullLike(obj2)) {
return 0;
}
return -1;
}
if (obj2 is null) {
if (isNullLike(obj2)) {
return 1;
}
return compareObjs(obj1, obj2);
return compareObjs(obj1!, obj2!);
}
static bool isNullLike(object? value) =>
value is null || DBNull.Value.Equals(value);
static int compareObjs(object obj1, object obj2) {
try {
var typ1 = obj1.GetType();
@@ -15,7 +15,7 @@ namespace bts.BlueOrb {
private int _size = 0;
public TimeSeriesStream(ViewStream stream)
: this(stream, Int32.MaxValue) { }
: this(stream, 1000) { }
public TimeSeriesStream(ViewStream stream, int size) {
_stream = stream;
@@ -44,7 +44,7 @@ namespace bts.BlueOrb {
ar.setValue("id", _id++);
_table.add_row(ar);
}
var dp_out = BlueDataUtility.build_dp(_table, myLastValue as DataPacket);
var dp_out = BlueDataUtility.build_dp_inc(_table, myLastValue as DataPacket);
myFireEvent(dp_out);
}
}
@@ -117,6 +117,9 @@ namespace BCG {
addWorker(op.w);
else
removeWorker(op.w);
}
catch (InvalidOperationException ex) {
bts.Log.Gen.error($"ViewStreamDatabase worker collection was modified during enumeration: {ex}");
} finally {
_pending_worker_ops.Clear();
_is_worker_call_in_progress = false;
@@ -1,5 +1,7 @@
namespace bts.BlueOrb {
using System;
using System.Collections.Generic;
using System.IO;
using BCG;
@@ -10,7 +12,14 @@ namespace bts.BlueOrb {
[LiveMarketDataStream]
public class CrossExchangeSymbolMatcherStream : ViewStream {
public CrossExchangeSymbolMatcherStream([MarketDataClientParam] IMarketDataClient mdClient, IWorkScheduler scheduler) {
const string CanonicalSymbolColumn = "canonical_symbol";
readonly CrossExchangeSymbolAliasResolver symbolAliasResolver;
public CrossExchangeSymbolMatcherStream([MarketDataClientParam] IMarketDataClient mdClient, IWorkScheduler scheduler)
: this(mdClient, scheduler, null) { }
public CrossExchangeSymbolMatcherStream([MarketDataClientParam] IMarketDataClient mdClient, IWorkScheduler scheduler, string? symbolAliasConfigFile) {
symbolAliasResolver = CrossExchangeSymbolAliasResolver.Load(ResolveSymbolAliasConfigFile(symbolAliasConfigFile));
ViewStream r;
ViewStream okx_usdc;
ViewStream okx_usdt;
@@ -24,6 +33,9 @@ namespace bts.BlueOrb {
r = new InstrumentsStream(mdClient, scheduler);
r = new DateTimeExpanstionStream(r,
"expiration");
r = new MapStream(r,
_add_canonical_symbol_schema,
_add_canonical_symbol_row);
okx_usdc = new FilterStream(r,
"venue='Okx' and sec~='USDC'");
okx_usdc = new AddColumnsStream(okx_usdc,
@@ -63,7 +75,7 @@ namespace bts.BlueOrb {
r,
"");
r = new FilterStream(r,
"left_venue!=right_venue and left_symbol=right_symbol and left_cur=right_cur and left_sec_type=right_sec_type");
"left_venue!=right_venue and left_canonical_symbol=right_canonical_symbol and left_cur=right_cur and left_sec_type=right_sec_type");
perps = new FilterStream(r,
"left_expiration_year=2100 and right_expiration_year=2100");
perps = new FilterStream(perps,
@@ -78,5 +90,127 @@ namespace bts.BlueOrb {
mySetRealSource(r);
}
static string ResolveSymbolAliasConfigFile(string? symbolAliasConfigFile) {
if (!string.IsNullOrWhiteSpace(symbolAliasConfigFile)) {
return symbolAliasConfigFile;
}
var home = Environment.GetEnvironmentVariable("HOME");
if (!string.IsNullOrWhiteSpace(home)) {
var sparkdConf = Path.Combine(home, ".local", "share", "bts", "sparkd", "conf", "cross_exchange_symbol_aliases.conf");
if (File.Exists(sparkdConf)) {
return sparkdConf;
}
}
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
if (!string.IsNullOrWhiteSpace(appData)) {
var appDataConf = Path.Combine(appData, "bts", "conf", "cross_exchange_symbol_aliases.conf");
if (File.Exists(appDataConf)) {
return appDataConf;
}
}
return !string.IsNullOrWhiteSpace(home)
? Path.Combine(home, ".local", "share", "bts", "sparkd", "conf", "cross_exchange_symbol_aliases.conf")
: "cross_exchange_symbol_aliases.conf";
}
IBlueTableSchema _add_canonical_symbol_schema(IBlueTableSchema schema) =>
BlueDataUtility.add_column(schema, CanonicalSymbolColumn, typeof(string));
IBlueRow _add_canonical_symbol_row(IBlueRow row) {
var rv = new MutableBlueRow(_add_canonical_symbol_schema(row.schema), row);
rv.setValue(CanonicalSymbolColumn, symbolAliasResolver.Canonicalize(row.get<string>("symbol")));
return rv;
}
}
public sealed class CrossExchangeSymbolAliasResolver {
static readonly System.Text.RegularExpressions.Regex TokenRegex =
new(@"[A-Za-z0-9]+|[^A-Za-z0-9]+", System.Text.RegularExpressions.RegexOptions.Compiled);
readonly IReadOnlyDictionary<string, string> aliases;
CrossExchangeSymbolAliasResolver(IReadOnlyDictionary<string, string> aliases) =>
this.aliases = aliases;
public static CrossExchangeSymbolAliasResolver Load(string configFilePath) {
if (!File.Exists(configFilePath)) {
Log.Gen.warn($"CrossExchangeSymbolMatcherStream alias config not found: {configFilePath}");
return new CrossExchangeSymbolAliasResolver(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase));
}
var aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var rawLine in File.ReadLines(configFilePath)) {
var line = StripComments(rawLine).Trim();
if (line.Length == 0) {
continue;
}
if (!TryParseAlias(line, out var alias, out var canonical)) {
Log.Gen.warn($"CrossExchangeSymbolMatcherStream skipping invalid alias line: '{rawLine}'");
continue;
}
aliases[NormalizeToken(alias)] = NormalizeToken(canonical);
}
return new CrossExchangeSymbolAliasResolver(aliases);
}
public string Canonicalize(string? symbol) {
if (string.IsNullOrWhiteSpace(symbol)) {
return string.Empty;
}
var rv = new System.Text.StringBuilder(symbol.Length);
foreach (System.Text.RegularExpressions.Match match in TokenRegex.Matches(symbol)) {
var token = match.Value;
if (IsToken(token)) {
var normalized = NormalizeToken(token);
rv.Append(aliases.TryGetValue(normalized, out var canonical) ? canonical : normalized);
} else {
rv.Append(token);
}
}
return rv.ToString();
}
static bool TryParseAlias(string line, out string alias, out string canonical) {
var match = System.Text.RegularExpressions.Regex.Match(line, @"^(?<alias>.+?)\s*(?:=|:|->)\s*(?<canonical>.+)$");
if (match.Success) {
alias = match.Groups["alias"].Value.Trim();
canonical = match.Groups["canonical"].Value.Trim();
return alias.Length > 0 && canonical.Length > 0;
}
var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2) {
alias = parts[0];
canonical = parts[1];
return true;
}
alias = string.Empty;
canonical = string.Empty;
return false;
}
static string StripComments(string line) {
var commentIdx = line.IndexOf('#');
var slashIdx = line.IndexOf("//", StringComparison.Ordinal);
if (slashIdx >= 0 && (commentIdx < 0 || slashIdx < commentIdx)) {
commentIdx = slashIdx;
}
return commentIdx >= 0 ? line[..commentIdx] : line;
}
static bool IsToken(string token) =>
token.Length > 0 && char.IsLetterOrDigit(token[0]);
static string NormalizeToken(string token) =>
token.Trim().ToUpperInvariant();
}
}
@@ -0,0 +1,31 @@
namespace bts.BlueOrb {
using BCG;
using Bts.DataStreamCore;
using Bts.PubSub;
using Spark.Api.MarketData;
[LiveMarketDataStream]
public class FeeAdjustedCrossExchangeArbDiscoveryStream : ViewStream {
public FeeAdjustedCrossExchangeArbDiscoveryStream(
[MarketDataClientParam] IMarketDataClient mdClient,
IWorkScheduler scheduler) {
ViewStream r = new CrossExchangeArbDiscoveryStream(mdClient, scheduler);
r = new AddColumnsStream(r, "fee:0.0002");
r = new AddColumnsStream(r, "capital_available:100000");
r = new MathStream(r, """
left_leg_cost = left_bid_price*left_bid_qty*left_cpp;
right_leg_cost = right_bid_price*right_bid_qty*right_cpp;
total_cost = left_leg_cost + right_leg_cost;
total_fees = total_cost * fee;
fee_adjusted_arb = arb-total_fees;
possible_arb = fee_adjusted_arb*(Min(capital_available,total_cost)/total_cost);
""");
r = new FilterStream(r, "arb>0");
r = new TimeSeriesStream(r);
r = new ProjectStream(r,
"spread_name, left_sec_name,left_venue,right_sec_name,right_venue,arb, fee_adjusted_arb, timestamp");
mySetRealSource(r);
}
}
}
+13 -1
View File
@@ -17,7 +17,9 @@ type Tab =
| PositionManagementTab
| MarginTab
let venues = [| Venue.BINANCE; Venue.BinanceUs; Venue.BinanceUsdsFutures; Venue.BYBIT; Venue.CBOE; Venue.COINBASE; Venue.DERIBIT; Venue.GATEIO; Venue.OKX; Venue.GLBX; Venue.ICE; Venue.TAIFEX; Venue.TWSE; Venue.SIM; |]
// BEGIN GENERATED ADMIN VENUES
let venues = [| Venue.BINANCE; Venue.BinanceUs; Venue.BinanceUsdsFutures; Venue.BYBIT; Venue.CBOE; Venue.COINBASE; Venue.DERIBIT; Venue.GATEIO; Venue.GLBX; Venue.ICE; Venue.OKX; Venue.ORCA; Venue.PHOENIX; Venue.RAYDIUM; Venue.SIM; Venue.TAIFEX; Venue.TWSE; |]
// END GENERATED ADMIN VENUES
let securityTypes = [| SecurityType.SPOT; SecurityType.FUTURE; SecurityType.OPTION; SecurityType.SPREAD; |]
@@ -94,6 +96,16 @@ type OkxExchangeField =
| Origin of CustomerOrFirm
| ClearingAccount of string
type OrcaExchangeField =
| CTI of CTICode
| Origin of CustomerOrFirm
| ClearingAccount of string
type RaydiumExchangeField =
| CTI of CTICode
| Origin of CustomerOrFirm
| ClearingAccount of string
type SIMExchangeField =
| CTI of CTICode
| Origin of CustomerOrFirm
@@ -801,8 +801,11 @@ let venueToString (venue:bts.api.Venue) =
| bts.api.Venue.GATEIO -> "GATEIO"
| bts.api.Venue.FTX -> "FTX"
| bts.api.Venue.OKX -> "OKX"
| bts.api.Venue.ORCA -> "ORCA"
| bts.api.Venue.RAYDIUM -> "RAYDIUM"
| bts.api.Venue.TAIFEX -> "TAIFEX"
| bts.api.Venue.TWSE -> "TWSE"
| value -> sprintf "%O" value
let exchange_display expirationNameList =
let s = sprintf "%O" expirationNameList.product.exchange
+47 -4
View File
@@ -37,6 +37,45 @@ protobuf_generate_cpp(
)
add_executable(sparkd ${${PROJECT_NAME}_SOURCES} ${${PROJECT_NAME}_PROTO_CXX_SOURCES})
find_package(PkgConfig REQUIRED)
pkg_check_modules(
emumba-deribit-connector-library
REQUIRED
IMPORTED_TARGET
emumba-deribit-connector
)
pkg_check_modules(
rakurai-dex-connector-library
REQUIRED
IMPORTED_TARGET
rakurai-dex-connector
)
# Rakurai's static dex SDK uses hidapi's libudev backend. Keep this explicit so
# existing CMake build trees also pick up the dependency when the connector's
# pkg-config metadata changes between package versions.
find_library(RAKURAI_UDEV_LIBRARY NAMES udev libudev.so.1 REQUIRED)
# The Rakurai package currently publishes a bare `udev` dependency. Minimal
# runtime images only contain libudev.so.1, so replace that entry with the
# resolved library while preserving its position after Rakurai's static SDK.
get_target_property(
RAKURAI_LINK_LIBRARIES
PkgConfig::rakurai-dex-connector-library
INTERFACE_LINK_LIBRARIES
)
set(RAKURAI_RESOLVED_LINK_LIBRARIES)
foreach(RAKURAI_LINK_LIBRARY IN LISTS RAKURAI_LINK_LIBRARIES)
if(RAKURAI_LINK_LIBRARY STREQUAL "udev" OR RAKURAI_LINK_LIBRARY STREQUAL "-ludev")
list(APPEND RAKURAI_RESOLVED_LINK_LIBRARIES ${RAKURAI_UDEV_LIBRARY})
else()
list(APPEND RAKURAI_RESOLVED_LINK_LIBRARIES ${RAKURAI_LINK_LIBRARY})
endif()
endforeach()
set_property(
TARGET PkgConfig::rakurai-dex-connector-library
PROPERTY INTERFACE_LINK_LIBRARIES ${RAKURAI_RESOLVED_LINK_LIBRARIES}
)
# include paths
target_include_directories(
sparkd
@@ -44,11 +83,9 @@ target_include_directories(
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>
$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}>/include
$<BUILD_INTERFACE:${emumba-deribit-connector-library_INCLUDE_DIRS}>
$<BUILD_INTERFACE:${rakurai-dex-connector-library_INCLUDE_DIRS}>
)
find_package(PkgConfig REQUIRED)
pkg_check_modules(emumba-deribit-connector-library REQUIRED emumba-deribit-connector)
# This dir contains rapidjsonConfig.cmake
list(APPEND CMAKE_PREFIX_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake_modules)
find_package(rapidjson REQUIRED)
@@ -127,7 +164,8 @@ target_link_libraries(
python_interop
python_interop_module
databento::databento
${emumba-deribit-connector-library_LIBRARIES}
PkgConfig::emumba-deribit-connector-library
PkgConfig::rakurai-dex-connector-library
tbb
quickfix
benchmark
@@ -140,6 +178,11 @@ target_link_libraries(
-static-libstdc++
-pthread)
# Both vendor static connector archives currently export the same global
# `print_books` symbol. Allow that duplicate so sparkd can link until the
# upstream packages make the symbol internal or rename it.
target_link_options(${PROJECT_NAME} PRIVATE -Wl,--allow-multiple-definition)
# Onixs
target_link_libraries(
${PROJECT_NAME}
+3
View File
@@ -40,6 +40,9 @@ ARG SOURCE_DIR
ARG PYTHON_REQUIREMENTS="requirements.txt"
ARG SPARKD_ENTRYPOINT
ENV DOTNET_ENVIRONMENT=Development
RUN dotnet dev-certs https --trust
USER "root"
RUN apt-get update && apt-get install -y liblttng-ust-dev
RUN apt-get update && apt-get install -y libpcap0.8-dev
@@ -0,0 +1,3 @@
SPARKD__CONFIG_DIR=src/app_sparkd/configurations/dex_dev
SPARKD__PYTHON_REQUIREMENTS=src/app_sparkd/configurations/dex_dev/requirements.txt
SPARKD__SHM_SIZE=2gb
@@ -0,0 +1,2 @@
* Connects to raydium and orca for dex
* Execution is SIM
@@ -0,0 +1,22 @@
{
"app_logging": {
"severity": "trace",
"file": false,
"console": true
},
"dex": [
{
"id": "orca_clmm",
"pool_ids": ["Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", "4HppGTweoGQ8ZZ6UcCgwJKfi5mJD9Dqwy6htCpnbfBLW"
]
}
],
"cluster_mode": false,
"wallet_pub": "HFru3Rc3Rf6UjZtrYPsBSKvmq5uyha2DWefV77rFXU64",
"wallet_pvt": [168,123,234,72,218,209,180,225,42,193,240,65,11,15,105,91,95,255,231,214,188,119,90,45,119,144,177,207,203,245,250,27,241,137,138,138,103,247,64,134,70,212,107,134,75,97,87,167,53,32,169,108,75,126,254,17,213,216,74,14,245,28,231,161],
"md_server_uri_1": "ws://185.191.117.159:8130",
"node_oms_uri_1": "ws://185.191.117.159:8133",
"md_server_uri_2": "ws://189.1.171.199:8130",
"node_oms_uri_2": "ws://189.1.171.199:8133",
"core": 1
}
@@ -0,0 +1,6 @@
{
"account": "DEX_DEV",
"operator_id": "DEX_DEV",
"json_conf_file_name": "config.json",
"default_priority_fee": 1000
}
@@ -0,0 +1,6 @@
{
"account": "DEX_DEV",
"operator_id": "DEX_DEV",
"json_conf_file_name": "config.json",
"default_priority_fee": 1000
}
@@ -0,0 +1,5 @@
numpy==2.2.6
pybit==5.11.0
requests==2.31.0
websocket-client==1.8.0
websockets==15.0.1
@@ -0,0 +1,76 @@
JetFIX {
value {
connection {
name: "orca_1"
venue: VENUE_ORCA
max_order_msgs_per_window: 300
window_duration_msecs: 1000
}
connection {
name: "okx_1"
venue: VENUE_OKX
max_order_msgs_per_window: 300
window_duration_msecs: 1000
}
}
}
PublisherOptions {
value {
network_interface: "0.0.0.0"
heartbeat_ms: 5000
heartbeat_boot_seconds: 60
skip_options: true
hot_initial: 1024
hot_growth: 1024
cold_initial: 8192
cold_growth: 2048
channel_prefix: ""
disable_heartbeat: true
}
}
MarketDataConfig {
value {
dex {
venue: VENUE_ORCA
num_depth_levels: 10
json_conf_file_name: "config.json"
security_criteria {
clauses {
security_types: SECURITY_TYPE_SPOT
symbols: @SymbolLists.list[Binance].symbol
name_regexps: "USDC|USDT$"
}
}
}
okx {
num_depth_levels: 5
url_rest_base: "https://www.okx.com"
url_ws_base: "wss://ws.okx.com:8443"
security_criteria {
clauses {
security_types: SECURITY_TYPE_SPOT
symbols: @SymbolLists.list[Okx].symbol
}
}
is_application_ping_pong_enabled: true
application_ping_interval_ms: 20000
application_pong_timeout_ms: 10000
is_protocol_ping_pong_enabled: true
protocol_ping_interval_ms: 60000
protocol_pong_timeout_ms: 30000
}
dex {
venue: VENUE_RAYDIUM
num_depth_levels: 10
json_conf_file_name: "config.json"
security_criteria {
clauses {
security_types: SECURITY_TYPE_SPOT
symbols: @SymbolLists.list[Binance].symbol
name_regexps: "USDC|USDT$"
}
}
}
}
}
@@ -0,0 +1,84 @@
SymbolLists {
value {
list {
key: "Binance"
symbol: "SOL"
symbol: "USDC"
#symbol: "BNB"
symbol: "BTC"
#symbol: "DOGE"
#symbol: "EIGEN"
symbol: "ETH"
symbol: "XRP"
}
list {
key: "ORCA"
#symbol: "AAVE"
#symbol: "AVAX"
#symbol: "BNB"
symbol: "BTC"
symbol: "USDC"
symbol: "SOL"
symbol: "ETH"
symbol: "XRP"
}
list {
key: "RAYDIUM"
symbol: "SOL"
symbol: "USDC"
symbol: "ETH"
symbol: "BTC"
symbol: "XRP"
}
list {
key: "PHOENIX"
symbol: "SOL"
}
list {
key: "BinanceUsdsFutures"
symbol: "SOL"
symbol: "BTC"
symbol: "USDC"
symbol: "ETH"
symbol: "XRP"
}
list {
key: "Okx"
symbol: "SOL"
symbol: "USDC"
symbol: "BTC"
symbol: "ETH"
symbol: "XRP"
}
}
}
Symbols {
value {
# venues {
# name: "VENUE_BINANCE"
# spot: @SymbolLists.list[Binance].symbol
# }
#venues {
# name: "VENUE_BINANCE_USDS_FUTURES"
# future: @SymbolLists.list[BinanceUsdsFutures].symbol
#}
venues {
name: "VENUE_ORCA"
spot: @SymbolLists.list[ORCA].symbol
}
# venues {
# name: "VENUE_RAYDIUM"
# spot: @SymbolLists.list[RAYDIUM].symbol
# }
venues {
name: "VENUE_PHOENIX"
future: @SymbolLists.list[PHOENIX].symbol
}
venues {
name: "VENUE_OKX"
spot: @SymbolLists.list[Okx].symbol
# future: @SymbolLists.list[Okx].symbol
}
}
}
+3
View File
@@ -42,7 +42,10 @@ roll_log() {
# Acquire sudo permission once for the script (needed for a few calls)
echo bts | sudo -S -v
data_root="${HOME:-/home/bts}/.local/share/bts/sparkd"
api_server_data_root="${HOME:-/home/bts}/.local/share/bts/spark_api_server"
sudo -n chown -R bts:bts "$data_root"
sudo -n mkdir -p "$api_server_data_root"
sudo -n chown -R bts:bts "$api_server_data_root"
load_env "$data_root/conf/.env.local"
init_timezone "$TZ"
roll_log "$data_root/data/logging.sparkd/log"
@@ -1,74 +1,34 @@
#include "VenueOption.h"
#include "spark/domain_model/Security.pb.h"
#include <algorithm>
#include <cctype>
namespace bts::cmds::md {
#define NAME_PAIR(val) \
{ name(val), val }
EnumOptionNames<api::Venue> VenueOption::names() {
return {
NAME_PAIR(api::VENUE_BTS),
NAME_PAIR(api::VENUE_GLBX),
NAME_PAIR(api::VENUE_ICE),
NAME_PAIR(api::VENUE_SIM),
NAME_PAIR(api::VENUE_SIMICE),
NAME_PAIR(api::VENUE_DERIBIT),
NAME_PAIR(api::VENUE_FTX),
NAME_PAIR(api::VENUE_BINANCE),
NAME_PAIR(api::VENUE_BINANCE_COIN_FUTURES),
NAME_PAIR(api::VENUE_BINANCE_US),
NAME_PAIR(api::VENUE_COINBASE),
NAME_PAIR(api::VENUE_BINANCE_USDS_FUTURES),
NAME_PAIR(api::VENUE_CBOE),
NAME_PAIR(api::VENUE_TAIFEX),
NAME_PAIR(api::VENUE_TWSE),
NAME_PAIR(api::VENUE_BYBIT),
NAME_PAIR(api::VENUE_GATEIO),
NAME_PAIR(api::VENUE_OKX)};
EnumOptionNames<api::Venue> result;
const auto *descriptor = api::Venue_descriptor();
for (int i = 0; i < descriptor->value_count(); ++i) {
const auto venue = static_cast<api::Venue>(descriptor->value(i)->number());
if (venue != api::VENUE_UNSET) {
result.emplace(name(venue), venue);
}
}
return result;
}
std::string VenueOption::name(api::Venue venue) {
switch (venue) {
case api::VENUE_BTS:
return "bts";
case api::VENUE_GLBX:
return "glbx";
case api::VENUE_ICE:
return "ice";
case api::VENUE_SIM:
return "sim";
case api::VENUE_SIMICE:
return "simice";
case api::VENUE_DERIBIT:
return "deribit";
case api::VENUE_FTX:
return "ftx";
case api::VENUE_BINANCE:
return "binance";
case api::VENUE_BINANCE_COIN_FUTURES:
return "binance_coin_futures";
case api::VENUE_BINANCE_US:
return "binance_us";
case api::VENUE_COINBASE:
return "coinbase";
case api::VENUE_BINANCE_USDS_FUTURES:
return "binance_usds_futures";
case api::VENUE_CBOE:
return "cboe";
case api::VENUE_OKX:
return "okx";
case api::VENUE_TAIFEX:
return "taifex";
case api::VENUE_TWSE:
return "twse";
case api::VENUE_BYBIT:
return "bybit";
case api::VENUE_GATEIO:
return "gateio";
default:
if (!api::Venue_IsValid(venue)) {
return std::to_string(static_cast<int32_t>(venue));
}
std::string result = api::Venue_Name(venue);
constexpr auto prefix_size = sizeof("VENUE_") - 1;
result.erase(0, prefix_size);
std::transform(result.begin(), result.end(), result.begin(), [](char value) {
return static_cast<char>(std::tolower(static_cast<unsigned char>(value)));
});
return result;
}
} // namespace bts::cmds::md
@@ -4,6 +4,7 @@
#include "binance/spots/spots_gateway.h"
#include "binance/usds_futures/usds_futures_gateway.h"
#include "okx_execution/okx_execution_gateway.h"
#include "sparkd/execution/rakurai/rakurai_execution_gateway.h"
#include "onixs_fix/OnixFIXAdapter.h"
#include "python_interop/plugin_manager.h"
#include "spark/api/domain/Venue.h"
@@ -119,6 +120,18 @@ struct ExecutionGatewayProvider::PImpl {
conn, conn.venue(), std::make_unique<OnixFIXAdapter>(o), o);
case api::VENUE_OKX:
return make_gw<OkxExecutionGateway>(conn, o);
case api::VENUE_ORCA:
case api::VENUE_RAYDIUM: {
auto cfg_j = load_config_json(conn.name());
bts_assert_msgf(
cfg_j,
"Failed to load execution gateway config. name: {}",
conn.name());
auto cfg = cfg_j->get<rakurai::GatewayConfig>();
cfg.expand_env();
return make_gw<rakurai::RakuraiExecutionGateway>(
conn, o, std::move(cfg), conn);
}
case api::VENUE_BINANCE: {
auto cfg =
parse_config_json<binance::GatewayConfig>(conn.name());
@@ -0,0 +1,742 @@
#include "sparkd/execution/rakurai/rakurai_execution_gateway.h"
#include "spark/config/config.h"
#include "spark/config/protos/system_config.pb.h"
#include "spark/domain/DomainSecurityUtils.h"
#include "spark/domain/Price.h"
#include "spark/domain/Quantity.h"
#include "spark/domain_model/Security.h"
#include "spark/logging/logging.h"
#include "spark/md/SecurityOracle.h"
#include "spark/orderman/CancelOrderMsg.h"
#include "spark/orderman/CancelReplaceOrderMsg.h"
#include "spark/orderman/CreateOrderMsg.h"
#include "spark/orderman/RFQMsg.h"
#include "spark/orderman/events/NewAdminDataEvent.h"
#include "spark/orderman/events/NewSecuritiesEvent.h"
#include "spark/orderman/orderman_utils/CancelReject.h"
#include "spark/orderman/orderman_utils/ExecutionReport.h"
#include "spark/orderman/orderman_utils/orderman_common.h"
#include "spark/utils/bts_assert.h"
#include "sparkd/rakurai/RakuraiSharedRuntime.h"
#include <boost/filesystem/path.hpp>
#include <cmath>
#include <cstdint>
#include <deque>
#include <limits>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
namespace bts::orderman::rakurai {
namespace {
#define LOG_FN(level, fn, ...) \
LOG(OrderMan, level, "RakuraiExecutionGateway::" fn ". " __VA_ARGS__)
#define WARN_FN(fn, ...) LOG_FN(Warn, fn, __VA_ARGS__)
using ConnectorNewOrder = ::rakurai::dex_connector::order_entry::new_order;
using ConnectorOrderState = ::rakurai::dex_connector::order_entry::order_state;
using ConnectorSide = ::rakurai::dex_connector::order_entry::side;
std::optional<std::string> extract_short_pool_id(const api::Security& security) {
std::string desc;
if (!domain::DomainSecurityUtils::get_security_desc(security, desc)) {
return std::nullopt;
}
const auto pos = desc.rfind('-');
if (pos == std::string::npos || pos + 1 >= desc.size()) {
return std::nullopt;
}
return desc.substr(pos + 1);
}
api::orderman::NativeOrderStatus to_order_status(ConnectorOrderState state) {
using Status = ::rakurai::dex_connector::data_types::txn_status_t;
switch (state.tx_status) {
case Status::accepted:
return api::orderman::NATIVE_ORDER_STATUS_NEW;
case Status::confirmed:
return state.is_pass ? api::orderman::NATIVE_ORDER_STATUS_FILLED
: api::orderman::NATIVE_ORDER_STATUS_REJECTED;
case Status::failed:
return api::orderman::NATIVE_ORDER_STATUS_REJECTED;
case Status::pending:
return api::orderman::NATIVE_ORDER_STATUS_PENDING_NEW;
}
return api::orderman::NATIVE_ORDER_STATUS_UNDEFINED;
}
api::orderman::ExecType to_exec_type(ConnectorOrderState state) {
using Status = ::rakurai::dex_connector::data_types::txn_status_t;
switch (state.tx_status) {
case Status::accepted:
return api::orderman::EXEC_TYPE_NEW;
case Status::confirmed:
return state.is_pass ? api::orderman::EXEC_TYPE_FILL
: api::orderman::EXEC_TYPE_REJECTED;
case Status::failed:
return api::orderman::EXEC_TYPE_REJECTED;
case Status::pending:
return api::orderman::EXEC_TYPE_PENDING_NEW;
}
return api::orderman::EXEC_TYPE_UNDEFINED;
}
std::string txn_status_name(
::rakurai::dex_connector::data_types::txn_status_t status) {
using Status = ::rakurai::dex_connector::data_types::txn_status_t;
switch (status) {
case Status::accepted:
return "accepted";
case Status::confirmed:
return "confirmed";
case Status::failed:
return "failed";
case Status::pending:
return "pending";
}
return "unknown";
}
std::string json_for_log(const nlohmann::json& j) {
try {
return j.dump();
} catch (const std::exception& ex) {
return fmt::format("<json dump failed: {}>", ex.what());
}
}
} // namespace
struct RakuraiExecutionGateway::PImpl {
struct ActiveOrder {
CreateOrderMsg create_msg;
trace::CorID cor_id;
std::array<std::uint8_t, 32> pool_id{};
double quantity_scale = 1.0;
std::uint64_t raw_quantity = 0;
std::uint64_t priority_fee = 0;
std::uint64_t client_id = 0;
std::uint64_t txn_id = 0;
};
struct BufferedReport {
std::shared_ptr<const ExecutionReport> report;
trace::CorID cor_id;
};
explicit PImpl(
md::SecurityOracle,
GatewayConfig cfg,
config::JetFIX_Connection jetfix_connection)
: config(std::move(cfg)),
jetfix_connection(std::make_shared<config::JetFIX_Connection>(
std::move(jetfix_connection))) {
config.expand_env();
venue = this->jetfix_connection->venue();
bts_assert(venue == api::VENUE_ORCA || venue == api::VENUE_RAYDIUM);
LOG_FN(
Info,
"ctor",
"session:{} venue:{} account:{} operator_id:{} json_conf_file_name:{} default_priority_fee:{} connector_config:{}",
this->jetfix_connection->name(),
api::Venue_Name(venue),
config.account,
config.operator_id,
config.json_conf_file_name,
config.default_priority_fee,
json_for_log(config.connector_config));
}
GatewayConfig config;
std::shared_ptr<config::JetFIX_Connection> jetfix_connection;
api::Venue venue = api::VENUE_UNSET;
std::mutex mutex;
std::unordered_map<OrderKey, ActiveOrder> active_orders;
std::unordered_map<std::uint64_t, OrderKey> client_id_to_order_key;
std::deque<OrderKey> pending_client_bindings;
std::deque<BufferedReport> buffered_reports;
std::size_t order_update_subscription_id = 0;
std::size_t status_subscription_id = 0;
bool started = false;
bool connected = false;
OnExeReport exe_report;
OnGwStatusChange gw_status_change;
OnCancelReject cancel_reject;
OnExchangePositionState exchange_position_state;
OnExchangeOpenOrdersState exchange_open_orders;
void start() {
{
std::lock_guard<std::mutex> lock(mutex);
if (started) {
LOG_FN(
Info, "start", "already started. venue:{}",
api::Venue_Name(venue));
return;
}
started = true;
}
LOG_FN(
Info,
"start",
"starting. session:{} venue:{} account:{} operator_id:{} json_conf_file_name:{} default_priority_fee:{}",
jetfix_connection->name(),
api::Venue_Name(venue),
config.account,
config.operator_id,
config.json_conf_file_name,
config.default_priority_fee);
auto& runtime = bts::rakurai::RakuraiSharedRuntime::instance();
order_update_subscription_id =
runtime.add_order_update_subscriber([this](ConnectorOrderState state) {
this->on_order_update(state);
});
status_subscription_id =
runtime.add_status_subscriber([this](bool is_connected_now) {
this->on_status_change(is_connected_now);
});
try {
if (!config.json_conf_file_name.empty()) {
const auto config_path =
boost::filesystem::path{
config::get<config::SystemConfig>().config_path()} /
config.json_conf_file_name;
LOG_FN(
Info,
"start",
"ensuring shared runtime from file. path:{}",
config_path.string());
runtime.ensure_started_from_file(config_path.string());
} else {
LOG_FN(
Info,
"start",
"ensuring shared runtime from inline config. config:{}",
json_for_log(config.connector_config));
runtime.ensure_started_from_json(
config.connector_config, jetfix_connection->name());
}
LOG_FN(
Info,
"start",
"shared runtime start request submitted. order_update_sub_id:{} status_sub_id:{} connected_now:{}",
order_update_subscription_id,
status_subscription_id,
runtime.is_connected());
} catch (...) {
runtime.remove_order_update_subscriber(order_update_subscription_id);
runtime.remove_status_subscriber(status_subscription_id);
order_update_subscription_id = 0;
status_subscription_id = 0;
{
std::lock_guard<std::mutex> lock(mutex);
started = false;
}
throw;
}
}
void stop() {
LOG_FN(
Info,
"stop",
"stopping. order_update_sub_id:{} status_sub_id:{}",
order_update_subscription_id,
status_subscription_id);
auto& runtime = bts::rakurai::RakuraiSharedRuntime::instance();
if (order_update_subscription_id != 0) {
runtime.remove_order_update_subscriber(order_update_subscription_id);
order_update_subscription_id = 0;
}
if (status_subscription_id != 0) {
runtime.remove_status_subscriber(status_subscription_id);
status_subscription_id = 0;
}
}
void on_status_change(bool is_connected_now) {
LOG_FN(
Info,
"on_status_change",
"status update. venue:{} old_connected:{} new_connected:{} active_orders:{} pending_bindings:{} buffered_reports:{}",
api::Venue_Name(venue),
connected,
is_connected_now,
active_orders.size(),
pending_client_bindings.size(),
buffered_reports.size());
{
std::lock_guard<std::mutex> lock(mutex);
connected = is_connected_now;
}
if (gw_status_change) {
gw_status_change(is_connected_now);
}
}
void on_order_update(ConnectorOrderState order_state) {
std::lock_guard<std::mutex> lock(mutex);
LOG_FN(
Info,
"on_order_update",
"incoming order update. client_id:{} txn_id:{} txn_status:{} is_pass:{} active_orders:{} pending_bindings:{} mapped_clients:{}",
order_state.client_id,
order_state.txn_id,
txn_status_name(order_state.tx_status),
order_state.is_pass,
active_orders.size(),
pending_client_bindings.size(),
client_id_to_order_key.size());
auto key_it = client_id_to_order_key.find(order_state.client_id);
if (key_it == client_id_to_order_key.end()) {
if (pending_client_bindings.empty()) {
LOG_FN(
Warn,
"on_order_update",
"dropping order update with unknown client_id:{} because there are no pending client bindings",
order_state.client_id);
return;
}
const auto key = pending_client_bindings.front();
pending_client_bindings.pop_front();
client_id_to_order_key.emplace(order_state.client_id, key);
LOG_FN(
Info,
"on_order_update",
"bound client_id:{} to order_key:{}",
order_state.client_id,
key.value());
key_it = client_id_to_order_key.find(order_state.client_id);
}
auto order_it = active_orders.find(key_it->second);
if (order_it == active_orders.end()) {
LOG_FN(
Warn,
"on_order_update",
"dropping order update for client_id:{} mapped_order_key:{} because active order was not found",
order_state.client_id,
key_it->second.value());
return;
}
auto& active = order_it->second;
active.client_id = order_state.client_id;
active.txn_id = order_state.txn_id;
auto report = build_report_locked(active, order_state);
buffered_reports.push_back({std::move(report), active.cor_id});
LOG_FN(
Info,
"on_order_update",
"buffered execution report. order_key:{} client_id:{} txn_id:{} buffered_reports:{} order_status:{} exec_type:{}",
active.create_msg.key.value(),
active.client_id,
active.txn_id,
buffered_reports.size(),
api::orderman::NativeOrderStatus_Name(
buffered_reports.back().report->order_status),
api::orderman::ExecType_Name(buffered_reports.back().report->exec_type));
const auto order_status = buffered_reports.back().report->order_status;
if (order_status == api::orderman::NATIVE_ORDER_STATUS_FILLED ||
order_status == api::orderman::NATIVE_ORDER_STATUS_REJECTED) {
LOG_FN(
Info,
"on_order_update",
"removing terminal active order. order_key:{} client_id:{} status:{}",
active.create_msg.key.value(),
order_state.client_id,
api::orderman::NativeOrderStatus_Name(order_status));
client_id_to_order_key.erase(order_state.client_id);
active_orders.erase(order_it);
}
}
void send_create(const CreateOrderMsg& msg, trace::CorID cor_id) {
std::lock_guard<std::mutex> lock(mutex);
std::string security_desc = "<null>";
if (msg.security != nullptr) {
std::string desc;
if (domain::DomainSecurityUtils::get_security_desc(*msg.security, desc)) {
security_desc = std::move(desc);
} else {
security_desc = "<unresolved>";
}
}
LOG_FN(
Info,
"send_create",
"create request. order_key:{} venue:{} side:{} qty:{} price:{} tif:{} order_type:{} connected:{} security_present:{} desc:{}",
msg.key.value(),
api::Venue_Name(msg.venue()),
api::Side_Name(msg.side),
msg.quantity.full_value(),
msg.price,
api::orderman::TimeInForce_Name(msg.time_in_force),
api::orderman::NativeOrderType_Name(msg.order_type),
connected,
msg.security != nullptr,
security_desc);
if (msg.security == nullptr) {
enqueue_reject_locked(msg, cor_id, "Rakurai order missing security");
return;
}
if (msg.venue() != venue) {
enqueue_reject_locked(
msg,
cor_id,
fmt::format(
"Rakurai gateway venue mismatch. expected:{} actual:{}",
api::Venue_Name(venue),
api::Venue_Name(msg.venue())));
return;
}
if (msg.order_type != api::orderman::NATIVE_ORDER_TYPE_MARKET) {
enqueue_reject_locked(
msg,
cor_id,
"Rakurai DEX gateway currently supports market orders only");
return;
}
if (!connected) {
enqueue_reject_locked(
msg, cor_id, "Rakurai execution session is not connected");
return;
}
const auto short_pool_id = extract_short_pool_id(*msg.security);
if (!short_pool_id.has_value()) {
enqueue_reject_locked(
msg, cor_id, "Unable to derive Rakurai pool alias from security");
return;
}
const auto metadata =
bts::rakurai::RakuraiSharedRuntime::instance().find_pool_metadata(
venue, *short_pool_id);
if (!metadata.has_value()) {
LOG_FN(
Warn,
"send_create",
"pool metadata missing. venue:{} short_pool_id:{}",
api::Venue_Name(venue),
*short_pool_id);
enqueue_reject_locked(
msg,
cor_id,
"Rakurai pool metadata not available yet; wait for pool definitions");
return;
}
const auto raw_qty_f =
msg.quantity.full_value() * metadata->quantity_scale;
if (!std::isfinite(raw_qty_f) || raw_qty_f <= 0.0) {
enqueue_reject_locked(
msg, cor_id, "Rakurai order quantity must be positive");
return;
}
ConnectorNewOrder order;
order.order_def.pool_id = metadata->security.pool_id;
order.order_def.dex = metadata->security.venue;
order.order_def.order_side =
msg.side == api::SIDE_BUY ? ConnectorSide::buy : ConnectorSide::sell;
order.qty = static_cast<std::uint64_t>(std::llround(raw_qty_f));
order.priority_fee = config.default_priority_fee;
LOG_FN(
Info,
"send_create",
"launching new order. order_key:{} short_pool_id:{} raw_qty:{} priority_fee:{} dex:{} quantity_scale:{}",
msg.key.value(),
*short_pool_id,
order.qty,
order.priority_fee,
static_cast<int>(metadata->security.venue),
metadata->quantity_scale);
try {
bts::rakurai::RakuraiSharedRuntime::instance().launch_new_order(order);
} catch (const std::exception& ex) {
WARN_FN(
"send_create",
"launch_new_order threw. order_key:{} reason:{}",
msg.key.value(),
std::string(ex.what()));
enqueue_reject_locked(msg, cor_id, ex.what());
return;
}
active_orders.emplace(
msg.key,
ActiveOrder{
msg,
cor_id,
metadata->security.pool_id,
metadata->quantity_scale,
order.qty,
order.priority_fee});
pending_client_bindings.push_back(msg.key);
LOG_FN(
Info,
"send_create",
"create request accepted locally. order_key:{} active_orders:{} pending_bindings:{}",
msg.key.value(),
active_orders.size(),
pending_client_bindings.size());
}
void send_cancel_reject(OrderKey key, const std::string& reason) {
WARN_FN(
"send_cancel_reject",
"cancel reject. order_key:{} reason:{}",
key.value(),
reason);
if (cancel_reject) {
cancel_reject({key, CancelRejectReason::Other, reason});
}
}
std::shared_ptr<const ExecutionReport> build_report_locked(
const ActiveOrder& active,
ConnectorOrderState state) const {
auto report = std::make_shared<ExecutionReport>();
report->account_name = account_name_for(active.create_msg);
report->operator_id = operator_id_for(active.create_msg);
report->order_key = active.create_msg.key;
report->msg_seq_num = 0;
report->security = active.create_msg.security;
report->leg_security = nullptr;
report->side = active.create_msg.side;
report->time_in_force = active.create_msg.time_in_force;
report->order_type = active.create_msg.order_type;
report->manual_order_indicator = active.create_msg.manual_order_indicator;
report->exchange = venue;
report->cfg = jetfix_connection.get();
report->exchange_order_id = ExchangeOrderID64(
active.client_id != 0 ? active.client_id : state.txn_id);
report->quantity = active.create_msg.quantity;
report->price = active.create_msg.price;
report->stop_price = active.create_msg.stop_px;
report->max_show = active.create_msg.display_quantity;
report->transact_time =
ExchangeTimeStamp64(static_cast<std::uint64_t>(datetime::unix_nano_now()));
report->exec_type = to_exec_type(state);
report->order_status = to_order_status(state);
report->filled_quantity =
report->order_status == api::orderman::NATIVE_ORDER_STATUS_FILLED
? active.create_msg.quantity
: Quantity(0.0f);
report->last_shares = report->filled_quantity;
report->last_price = active.create_msg.price;
report->avg_price = active.create_msg.price;
report->msg = fmt::format(
"Rakurai txn_status={} txn_id={} pass={}",
txn_status_name(state.tx_status),
state.txn_id,
state.is_pass);
LOG_FN(
Info,
"build_report_locked",
"built report. order_key:{} client_id:{} txn_id:{} exec_type:{} order_status:{} message:{}",
active.create_msg.key.value(),
active.client_id,
state.txn_id,
api::orderman::ExecType_Name(report->exec_type),
api::orderman::NativeOrderStatus_Name(report->order_status),
report->msg);
return report;
}
void enqueue_reject_locked(
const CreateOrderMsg& msg,
trace::CorID cor_id,
const std::string& reason) {
auto report = std::make_shared<ExecutionReport>();
report->account_name = account_name_for(msg);
report->operator_id = operator_id_for(msg);
report->order_key = msg.key;
report->msg_seq_num = 0;
report->security = msg.security;
report->leg_security = nullptr;
report->side = msg.side;
report->time_in_force = msg.time_in_force;
report->order_type = msg.order_type;
report->manual_order_indicator = msg.manual_order_indicator;
report->exchange = venue;
report->cfg = jetfix_connection.get();
report->exchange_order_id = ExchangeOrderID64(0);
report->quantity = msg.quantity;
report->filled_quantity = Quantity(0.0f);
report->last_shares = Quantity(0.0f);
report->price = msg.price;
report->stop_price = msg.stop_px;
report->max_show = msg.display_quantity;
report->last_price = Price(std::numeric_limits<double>::quiet_NaN());
report->avg_price = Price(std::numeric_limits<double>::quiet_NaN());
report->transact_time =
ExchangeTimeStamp64(static_cast<std::uint64_t>(datetime::unix_nano_now()));
report->exec_type = api::orderman::EXEC_TYPE_REJECTED;
report->order_status = api::orderman::NATIVE_ORDER_STATUS_REJECTED;
report->msg = reason;
buffered_reports.push_back({std::move(report), cor_id});
WARN_FN(
"enqueue_reject_locked",
"buffered reject. order_key:{} reason:{} buffered_reports:{}",
msg.key.value(),
reason,
buffered_reports.size());
}
AccountName account_name_for(const CreateOrderMsg& msg) const {
const auto account = msg.routing_record != nullptr &&
!msg.routing_record->account().empty()
? msg.routing_record->account()
: config.account;
AccountName result;
result->copy_from(account);
return result;
}
OperatorID operator_id_for(const CreateOrderMsg& msg) const {
const auto operator_id =
msg.routing_record != nullptr &&
!msg.routing_record->operator_id().empty()
? msg.routing_record->operator_id()
: config.operator_id;
OperatorID result;
orderman::to_array(operator_id, &result.value());
return result;
}
};
RakuraiExecutionGateway::RakuraiExecutionGateway(
md::SecurityOracle sec_oracle,
GatewayConfig config,
config::JetFIX_Connection jetfix_connection)
: me_(std::make_unique<PImpl>(
std::move(sec_oracle),
std::move(config),
std::move(jetfix_connection))) {}
RakuraiExecutionGateway::~RakuraiExecutionGateway() { me_->stop(); }
void RakuraiExecutionGateway::start() { me_->start(); }
void RakuraiExecutionGateway::dispatch_incoming_fix_messages() {
std::deque<PImpl::BufferedReport> reports;
{
std::lock_guard<std::mutex> lock(me_->mutex);
reports.swap(me_->buffered_reports);
}
if (!reports.empty()) {
LOG_FN(
Info,
"dispatch_incoming_fix_messages",
"dispatching buffered reports. count:{} has_callback:{}",
reports.size(),
me_->exe_report != nullptr);
}
if (!me_->exe_report) {
return;
}
for (auto& buffered : reports) {
LOG_FN(
Info,
"dispatch_incoming_fix_messages",
"publishing execution report. order_key:{} exec_type:{} order_status:{} msg:{}",
buffered.report->order_key.value(),
api::orderman::ExecType_Name(buffered.report->exec_type),
api::orderman::NativeOrderStatus_Name(buffered.report->order_status),
buffered.report->msg);
me_->exe_report(std::move(buffered.report), buffered.cor_id);
}
}
void RakuraiExecutionGateway::on_securities(
const mercury::Update<NewSecuritiesEvent>& update) {
LOG_FN(
Info,
"on_securities",
"received securities update. type:{} has_events:{} count:{}",
static_cast<int>(update.event_type),
update.events != nullptr,
update.events != nullptr ? update.events->size() : 0);
}
void RakuraiExecutionGateway::on_admin_data(
const mercury::Update<NewAdminDataEvent>& update) {
LOG_FN(
Info,
"on_admin_data",
"received admin data update. type:{} has_events:{} count:{}",
static_cast<int>(update.event_type),
update.events != nullptr,
update.events != nullptr ? update.events->size() : 0);
}
void RakuraiExecutionGateway::send_fix(const jetfix::OutFIXPtr&, trace::CorID) {
WARN_FN("send_fix", "FIX transport is not supported for Rakurai");
}
void RakuraiExecutionGateway::send_msgs(
std::vector<OrderMsg> const& msgs,
trace::CorID cor_id) {
LOG_FN(
Info,
"send_msgs",
"received outbound message batch. count:{}",
msgs.size());
for (const auto& msg : msgs) {
std::visit(
[&](const auto& order_msg) {
using T = std::decay_t<decltype(order_msg)>;
if constexpr (std::is_same_v<T, CreateOrderMsg>) {
me_->send_create(order_msg, cor_id);
} else if constexpr (std::is_same_v<T, CancelOrderMsg>) {
me_->send_cancel_reject(
order_msg.key,
"Rakurai DEX gateway does not support order cancel");
} else if constexpr (std::is_same_v<T, CancelReplaceOrderMsg>) {
me_->send_cancel_reject(
order_msg.create_fields.key,
"Rakurai DEX gateway does not support order replace");
} else if constexpr (std::is_same_v<T, RFQMsg>) {
WARN_FN("send_msgs", "RFQ is not supported for Rakurai");
}
},
msg);
}
}
OnExeReport& RakuraiExecutionGateway::on_exe_report() { return me_->exe_report; }
OnGwStatusChange& RakuraiExecutionGateway::on_gw_status_change() {
return me_->gw_status_change;
}
OnCancelReject& RakuraiExecutionGateway::on_cancel_reject() {
return me_->cancel_reject;
}
OnExchangePositionState& RakuraiExecutionGateway::on_exchange_position_state() {
return me_->exchange_position_state;
}
OnExchangeOpenOrdersState& RakuraiExecutionGateway::on_exchange_open_orders() {
return me_->exchange_open_orders;
}
} // namespace bts::orderman::rakurai
@@ -0,0 +1,76 @@
#pragma once
#include "spark/io/JsonReader.h"
#include "spark/orderman/gateways/IExecutionGateway.h"
#include "spark/os/os.h"
#include <memory>
#include <nlohmann/json.hpp>
namespace bts::config {
class JetFIX_Connection;
}
namespace bts::md {
class SecurityOracle;
}
namespace bts::orderman::rakurai {
struct GatewayConfig {
std::string account;
std::string operator_id;
std::string json_conf_file_name;
std::uint64_t default_priority_fee = 1000;
nlohmann::json connector_config;
void expand_env() {
bts::os::expand_env_in_place(account);
bts::os::expand_env_in_place(operator_id);
bts::os::expand_env_in_place(json_conf_file_name);
}
};
inline void from_json(const nlohmann::json& j, GatewayConfig& cfg) {
cfg.connector_config = j;
const bts::io::JsonReader jr{&j};
jr.read_or("account", cfg.account);
jr.read_or("operator_id", cfg.operator_id);
jr.read_or("json_conf_file_name", cfg.json_conf_file_name);
jr.read_or("default_priority_fee", cfg.default_priority_fee);
}
class RakuraiExecutionGateway : public IExecutionGateway {
public:
explicit RakuraiExecutionGateway(
bts::md::SecurityOracle sec_oracle,
GatewayConfig config,
bts::config::JetFIX_Connection jetfix_connection);
~RakuraiExecutionGateway() override;
void start() override;
void dispatch_incoming_fix_messages() override;
void on_securities(
const bts::mercury::Update<bts::orderman::NewSecuritiesEvent>&)
override;
void on_admin_data(
const bts::mercury::Update<bts::orderman::NewAdminDataEvent>&) override;
void send_fix(const bts::jetfix::OutFIXPtr&, bts::trace::CorID) override;
void send_msgs(
std::vector<bts::orderman::OrderMsg> const&,
bts::trace::CorID) override;
bts::orderman::OnExeReport& on_exe_report() override;
bts::orderman::OnGwStatusChange& on_gw_status_change() override;
bts::orderman::OnCancelReject& on_cancel_reject() override;
bts::orderman::OnExchangePositionState&
on_exchange_position_state() override;
bts::orderman::OnExchangeOpenOrdersState& on_exchange_open_orders() override;
private:
struct PImpl;
std::unique_ptr<PImpl> me_;
};
} // namespace bts::orderman::rakurai
@@ -25,6 +25,7 @@
#include "sparkd/md/ccapi/CCApiMarketDataProvider.h"
#include "sparkd/md/databento/DatabentoMarketDataProvider.h"
#include "sparkd/md/emumba/EmumbaMarketDataProvider.h"
#include "sparkd/md/rakurai/RakuraiMarketDataProvider.h"
#include "sparkd/md/taifex/TaifexMarketDataProvider.h"
#include <bits/stdint-uintn.h>
@@ -81,6 +82,8 @@ struct MarketDataGateway::PImpl {
std::make_unique<OnixsMarketDataProvider>(this->config));
providers.push_back(std::make_unique<DatabentoMarketDataProvider>(
this->config, this->sec_id_store));
providers.push_back(std::make_unique<RakuraiMarketDataProvider>(
this->config, this->sec_id_store));
providers.push_back(std::make_unique<TaifexMarketDataProvider>(
this->config, this->sec_id_store));
providers.push_back(std::make_unique<PyMarketDataProvider>(
@@ -0,0 +1,791 @@
#include "sparkd/md/rakurai/RakuraiDexDataHandler.h"
#include "spark/api/domain/SecurityType.pb.h"
#include "spark/domain/Book.h"
#include "spark/domain/Price.h"
#include "spark/domain/Quantity.h"
#include "spark/domain/Security.h"
#include "spark/logging/logging.h"
#include "spark/persistence/SqliteStore.h"
#include "spark/utils/PBUtils.h"
#include "sparkd/md/MarketDataError.h"
#include "sparkd/rakurai/RakuraiVenueRegistry.h"
#include <dex_handler.h>
#include <dex_listener.h>
#include <dex_utils.h>
#include <rapidjson/document.h>
#include <rapidjson/istreamwrapper.h>
#include <algorithm>
#include <array>
#include <cstdint>
#include <csignal>
#include <execinfo.h>
#include <fstream>
#include <iomanip>
#include <list>
#include <memory>
#include <mutex>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <unistd.h>
#include <vector>
#include <arpa/inet.h>
#include <netdb.h>
#include <sys/socket.h>
#include <sys/types.h>
namespace bts::md::rakurai {
namespace {
using ConnectorVenue = ::rakurai::dex_connector::data_types::venue;
using ConnectorSecurity = ::rakurai::dex_connector::data_types::security;
using ConnectorBookLevel = ::rakurai::dex_connector::data_types::book_level;
using ConnectorDexConfig = ::rakurai::dex_connector::configuration::dex_config;
using ::bts::rakurai::connector_quote_currency;
using ::bts::rakurai::connector_security_type;
using ::bts::rakurai::connector_venue_name;
using ::bts::rakurai::venue_matches;
struct ParsedWsUri {
std::string scheme;
std::string host;
std::string port;
std::string path;
};
struct WsProbeResult {
bool tcp_connected = false;
bool sent_request = false;
bool received_response = false;
std::string summary;
};
std::optional<ParsedWsUri> parse_ws_uri(const std::string& uri) {
const auto scheme_pos = uri.find("://");
if (scheme_pos == std::string::npos) {
return std::nullopt;
}
ParsedWsUri parsed;
parsed.scheme = uri.substr(0, scheme_pos);
auto remainder = uri.substr(scheme_pos + 3);
const auto path_pos = remainder.find('/');
if (path_pos == std::string::npos) {
parsed.path = "/";
} else {
parsed.path = remainder.substr(path_pos);
remainder = remainder.substr(0, path_pos);
}
const auto colon_pos = remainder.rfind(':');
if (colon_pos == std::string::npos) {
parsed.host = remainder;
parsed.port = parsed.scheme == "wss" ? "443" : "80";
} else {
parsed.host = remainder.substr(0, colon_pos);
parsed.port = remainder.substr(colon_pos + 1);
}
if (parsed.host.empty() || parsed.port.empty()) {
return std::nullopt;
}
return parsed;
}
WsProbeResult probe_ws_endpoint(const std::string& uri) {
WsProbeResult result;
const auto parsed = parse_ws_uri(uri);
if (!parsed.has_value()) {
result.summary = "invalid websocket URI";
return result;
}
if (parsed->scheme != "ws") {
result.summary = "probe only supports ws:// URIs";
return result;
}
struct addrinfo hints {};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
struct addrinfo* addr_list = nullptr;
const int gai_rc = ::getaddrinfo(
parsed->host.c_str(), parsed->port.c_str(), &hints, &addr_list);
if (gai_rc != 0) {
result.summary =
std::string("DNS resolution failed: ") + ::gai_strerror(gai_rc);
return result;
}
int fd = -1;
for (auto* ai = addr_list; ai != nullptr; ai = ai->ai_next) {
fd = ::socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
if (fd < 0) {
continue;
}
timeval timeout {};
timeout.tv_sec = 3;
timeout.tv_usec = 0;
::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
if (::connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) {
result.tcp_connected = true;
break;
}
::close(fd);
fd = -1;
}
::freeaddrinfo(addr_list);
if (fd < 0) {
result.summary = "TCP connect failed";
return result;
}
const std::string request =
"GET " + parsed->path + " HTTP/1.1\r\n" +
"Host: " + parsed->host + ":" + parsed->port + "\r\n" +
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n";
const ssize_t sent = ::send(fd, request.data(), request.size(), 0);
if (sent == static_cast<ssize_t>(request.size())) {
result.sent_request = true;
} else {
result.summary = "failed to send websocket probe request";
::close(fd);
return result;
}
std::array<char, 1024> buffer {};
const ssize_t received = ::recv(fd, buffer.data(), buffer.size() - 1, 0);
::close(fd);
if (received <= 0) {
result.summary = "no HTTP response received";
return result;
}
result.received_response = true;
std::string response(buffer.data(), static_cast<std::size_t>(received));
const auto line_end = response.find("\r\n");
if (line_end != std::string::npos) {
response.resize(line_end);
}
result.summary = response;
return result;
}
void validate_ws_endpoint_or_throw(
const std::string& label, const std::string& uri) {
const auto probe = probe_ws_endpoint(uri);
LOG(md, Info,
"Rakurai websocket probe. endpoint:{} uri:{} tcp_connected:{} sent_request:{} received_response:{} summary:{}",
label,
uri,
probe.tcp_connected,
probe.sent_request,
probe.received_response,
probe.summary);
if (!probe.tcp_connected || !probe.sent_request || !probe.received_response) {
throw std::runtime_error(
std::string("Rakurai websocket endpoint probe failed for ") + label +
" (" + uri + "): " + probe.summary);
}
if (probe.summary.rfind("HTTP/1.1 101", 0) != 0 &&
probe.summary.rfind("HTTP/1.0 101", 0) != 0) {
throw std::runtime_error(
std::string("Rakurai websocket endpoint returned unexpected handshake for ") +
label + " (" + uri + "): " + probe.summary);
}
}
void write_crash_text(std::string_view text) {
if(::write(STDERR_FILENO, text.data(), text.size()) ==-1) {
// If write fails, there's not much we can do, but we don't want to get
// stuck in a loop trying to write. So we just ignore the error.
};
}
void rakurai_crash_signal_handler(int signo, siginfo_t* info, void* /*ucontext*/) {
constexpr int max_frames = 64;
void* frames[max_frames];
const int frame_count = ::backtrace(frames, max_frames);
write_crash_text("\n=== Rakurai crash signal caught ===\n");
if (signo == SIGSEGV) {
write_crash_text("signal: SIGSEGV\n");
} else if (signo == SIGBUS) {
write_crash_text("signal: SIGBUS\n");
} else {
write_crash_text("signal: unknown\n");
}
if (info != nullptr) {
if (info->si_addr != nullptr) {
write_crash_text("fault address captured\n");
}
if (info->si_code != 0) {
write_crash_text("signal code captured\n");
}
}
write_crash_text("backtrace:\n");
::backtrace_symbols_fd(frames, frame_count, STDERR_FILENO);
_Exit(128 + signo);
}
void install_rakurai_crash_signal_handlers() {
static std::once_flag once;
std::call_once(once, []() {
struct sigaction sa {};
sa.sa_flags = SA_SIGINFO | SA_RESETHAND;
sa.sa_sigaction = rakurai_crash_signal_handler;
sigemptyset(&sa.sa_mask);
sigaction(SIGSEGV, &sa, nullptr);
sigaction(SIGBUS, &sa, nullptr);
});
}
std::string bytes_to_hex(const std::array<std::uint8_t, 32>& bytes) {
std::ostringstream oss;
oss << std::hex << std::setfill('0');
for (std::uint8_t byte : bytes) {
oss << std::setw(2) << static_cast<int>(byte);
}
return oss.str();
}
std::string short_hex(const std::array<std::uint8_t, 32>& bytes) {
const auto hex = bytes_to_hex(bytes);
return hex.substr(0, std::min<std::size_t>(hex.size(), 12));
}
std::string token_display_name(const ::rakurai::dex_connector::data_types::token_info& token) {
return token.name.empty() ? short_hex(token.mint_address) : token.name;
}
std::string build_security_desc(
const ConnectorSecurity& security,
std::string_view short_pool_id,
std::string_view currency) {
return fmt::format(
"{}-{}-{}",
token_display_name(security.token_a),
currency,
short_pool_id);
}
std::string security_currency(const ConnectorSecurity& security) {
const auto configured_currency = connector_quote_currency(security.venue);
return configured_currency.empty()
? token_display_name(security.token_b)
: std::string(configured_currency);
}
double quantity_scale_from_decimals(std::uint8_t decimals) {
double scale = 1.0;
for (std::uint8_t i = 0; i < decimals; ++i) {
scale *= 10.0;
}
return scale;
}
domain::BookLevel to_book_level(
const ConnectorBookLevel& source,
double quantity_scale) {
domain::BookLevel level;
level.price = domain::Price::from_double(source.price);
const auto normalized_quantity =
quantity_scale > 0.0
? static_cast<double>(source.quantity) / quantity_scale
: static_cast<double>(source.quantity);
level.total_quantity =
domain::Quantity(static_cast<float>(normalized_quantity));
level.direct_quantity = level.total_quantity;
level.n_orders = 0;
return level;
}
std::list<ConnectorDexConfig> parse_dex_configs(
const rapidjson::Document& config,
api::domain::Venue venue_filter) {
std::list<ConnectorDexConfig> dex_config_list;
if (!config.HasMember("dex") || !config["dex"].IsArray()) {
return dex_config_list;
}
for (const auto& dex_value : config["dex"].GetArray()) {
if (!dex_value.IsObject() || !dex_value.HasMember("id")) {
continue;
}
const std::string dex = dex_value["id"].GetString();
auto dex_config =
bts::rakurai::make_connector_dex_config(dex, venue_filter);
if (!dex_config.has_value()) {
LOG(md, Warn, "Skipping unsupported Rakurai dex config '{}'", dex);
continue;
}
if (dex_value.HasMember("pool_ids") && dex_value["pool_ids"].IsArray()) {
for (const auto& pool_value : dex_value["pool_ids"].GetArray()) {
dex_config->interested_pools.emplace_back(
::rakurai::dex_connector::solanaAddressToBytes(
pool_value.GetString()));
}
}
if (dex_value.HasMember("symbols") && dex_value["symbols"].IsArray()) {
for (const auto& symbol : dex_value["symbols"].GetArray()) {
dex_config->interested_symbols.push_back(symbol.GetString());
}
}
if (dex_value.HasMember("interested_symbols") &&
dex_value["interested_symbols"].IsArray()) {
for (const auto& symbol : dex_value["interested_symbols"].GetArray()) {
dex_config->interested_symbols.push_back(symbol.GetString());
}
}
dex_config_list.push_back(std::move(*dex_config));
}
return dex_config_list;
}
class ListenerAdapter final : public ::rakurai::dex_connector::dex_listener {
public:
ListenerAdapter(
std::shared_ptr<IMarketDataListener> listener,
std::shared_ptr<persistence::IDGenerator> sec_id_store,
api::domain::Venue venue,
std::uint32_t num_depth_levels,
const std::vector<std::string>& excluded_securities)
: listener_(std::move(listener)),
sec_id_store_(std::move(sec_id_store)),
venue_(venue),
depth_limit_(
num_depth_levels == 0
? domain::Book::max_depth()
: std::min<std::size_t>(
num_depth_levels, domain::Book::max_depth())),
excluded_securities_(
excluded_securities.begin(), excluded_securities.end()) {}
std::string get_or_add_short_pool_id(const std::string& pool_id) {
constexpr std::string_view kPoolAliasNamespace = "rakurai_pool_alias";
const auto [exists, rowid, _] =
pool_alias_store_.retrieve_one(std::string(kPoolAliasNamespace), pool_id);
if (exists) {
return std::to_string(rowid);
}
const auto new_id =
pool_alias_store_.insert(std::string(kPoolAliasNamespace), pool_id, "");
return std::to_string(new_id);
}
void on_pool_definition(
ConnectorVenue connector_venue,
const std::vector<ConnectorSecurity*>& securities) override {
std::lock_guard<std::mutex> lock(callback_mutex_);
LOG(md, Info,
"Rakurai listener on_pool_definition start. venue:{} connector_venue:{} securities:{}",
int(venue_),
std::string(connector_venue_name(connector_venue)),
securities.size());
if (!venue_matches(venue_, connector_venue)) {
LOG(md, Info,
"Rakurai listener on_pool_definition skipped. venue:{} connector_venue:{}",
int(venue_),
std::string(connector_venue_name(connector_venue)));
return;
}
std::vector<const domain::Security*> emitted;
emitted.reserve(securities.size());
for (const ConnectorSecurity* security : securities) {
if (security == nullptr || !venue_matches(venue_, security->venue)) {
continue;
}
const auto pool_id = bytes_to_hex(security->pool_id);
const auto short_pool_id = get_or_add_short_pool_id(pool_id);
const auto symbol = token_display_name(security->token_a);
// const auto token_a_cpp = security->token_a.decimals;
const auto currency = security_currency(*security);
const auto desc = build_security_desc(*security, short_pool_id, currency);
if (excluded_securities_.contains(pool_id) ||
excluded_securities_.contains(short_pool_id) ||
excluded_securities_.contains(desc)) {
continue;
}
domain::Security emitted_security;
const auto security_type = connector_security_type(security->venue);
if (security_type == api::domain::SECURITY_TYPE_FUTURE) {
auto& future =
emitted_security.security.emplace<domain::FutureSecurity>();
future.expiration = core::TimeStamp::from_ymd(2100, 1, 1);
} else {
emitted_security.security = domain::SpotSecurity{};
}
emitted_security.security_common.venue = venue_;
emitted_security.security_common.exchange_sec_id = short_pool_id;
emitted_security.security_common.symbol = symbol;
emitted_security.security_common.currency = currency;
emitted_security.security_common.asset = symbol;
emitted_security.security_common.group =
connector_venue_name(security->venue);
emitted_security.security_common.desc = desc;
emitted_security.security_common.min_price_increment =
security->min_tick > 0.0 ? security->min_tick : 0.0;
emitted_security.security_common.min_quantity_increment =
static_cast<double>(security->min_trade_amount);
emitted_security.security_common.min_quantity =
static_cast<double>(security->min_trade_amount);
*emitted_security.security_common.id = sec_id_store_->get_or_add(
venue_, security_type, pool_id);
auto [it, is_new] = securities_.try_emplace(
pool_id, std::make_unique<domain::Security>(std::move(emitted_security)));
if (!is_new) {
*it->second = std::move(emitted_security);
}
pool_to_security_id_[pool_id] = it->second->id();
pool_quantity_scales_[pool_id] =
quantity_scale_from_decimals(security->token_a.decimals);
emitted.push_back(it->second.get());
}
if (emitted.empty()) {
LOG(md, Info,
"Rakurai listener on_pool_definition produced no securities. venue:{} connector_venue:{}",
int(venue_),
std::string(connector_venue_name(connector_venue)));
return;
}
LOG(md, Info,
"Rakurai listener on_pool_definition emitting securities. venue:{} connector_venue:{} emitted:{} tracked:{}",
int(venue_),
std::string(connector_venue_name(connector_venue)),
emitted.size(),
securities_.size());
listener_->on_securities(emitted);
LOG(md, Info,
"Rakurai listener on_pool_definition complete. venue:{} connector_venue:{} emitted:{}",
int(venue_),
std::string(connector_venue_name(connector_venue)),
emitted.size());
}
void on_book(
ConnectorVenue connector_venue,
const std::array<std::uint8_t, 32>& pool_id,
const std::vector<ConnectorBookLevel>& bids,
const std::vector<ConnectorBookLevel>& asks) override {
std::lock_guard<std::mutex> lock(callback_mutex_);
LOG(md, Diag,
"Rakurai listener on_book start. venue:{} connector_venue:{} pool:{} bids:{} asks:{}",
int(venue_),
std::string(connector_venue_name(connector_venue)),
short_hex(pool_id),
bids.size(),
asks.size());
if (!venue_matches(venue_, connector_venue)) {
return;
}
const auto pool_key = bytes_to_hex(pool_id);
const auto it = pool_to_security_id_.find(pool_key);
if (it == pool_to_security_id_.end()) {
LOG(md, Warn,
"Received Rakurai book before security definition for pool {}",
pool_key);
return;
}
const auto quantity_scale_it = pool_quantity_scales_.find(pool_key);
const auto quantity_scale =
quantity_scale_it != pool_quantity_scales_.end()
? quantity_scale_it->second
: 1.0;
auto book = domain::Book::empty(it->second);
const auto bid_depth = std::min<std::size_t>(depth_limit_, bids.size());
const auto ask_depth = std::min<std::size_t>(depth_limit_, asks.size());
for (std::size_t i = 0; i < bid_depth; ++i) {
book.bids()[i] = to_book_level(bids[i], quantity_scale);
}
for (std::size_t i = 0; i < ask_depth; ++i) {
book.asks()[i] = to_book_level(asks[i], quantity_scale);
}
listener_->on_book(book);
LOG(md, Diag,
"Rakurai listener on_book complete. venue:{} connector_venue:{} pool:{}",
int(venue_),
std::string(connector_venue_name(connector_venue)),
short_hex(pool_id));
}
void on_order_update(
::rakurai::dex_connector::order_entry::order_state order_state) override {
std::lock_guard<std::mutex> lock(callback_mutex_);
LOG(md, Info,
"Rakurai order update. client_id:{} txn_id:{} status:{}",
order_state.client_id,
order_state.txn_id,
static_cast<int>(order_state.tx_status));
}
void on_ata_verification_status(
const std::array<std::uint8_t, 32>& token_id,
const std::string& ata_address,
::rakurai::dex_connector::ata_status status) override {
std::lock_guard<std::mutex> lock(callback_mutex_);
LOG(md, Info,
"Rakurai ATA verification. token:{} ata:{} status:{}",
short_hex(token_id),
ata_address,
static_cast<int>(status));
}
private:
std::shared_ptr<IMarketDataListener> listener_;
std::shared_ptr<persistence::IDGenerator> sec_id_store_;
api::domain::Venue venue_;
std::size_t depth_limit_;
std::mutex callback_mutex_;
std::unordered_set<std::string> excluded_securities_;
persistence::SqliteStore pool_alias_store_{"rakurai_pool_alias"};
std::unordered_map<std::string, std::unique_ptr<domain::Security>> securities_;
std::unordered_map<std::string, domain::SecurityID> pool_to_security_id_;
std::unordered_map<std::string, double> pool_quantity_scales_;
};
} // namespace
void RakuraiDexDataHandler::run(
std::shared_ptr<IMarketDataListener> listener,
std::shared_ptr<persistence::IDGenerator> sec_id_store,
api::domain::Venue venue,
const std::string& config_path,
std::uint32_t num_depth_levels,
const std::vector<std::string>& excluded_securities) {
try {
install_rakurai_crash_signal_handlers();
LOG(md, Info,
"Rakurai DEX handler run start. venue:{} config:{} depth:{} excluded:{}",
int(venue),
config_path,
num_depth_levels,
excluded_securities.size());
std::ifstream config_file(config_path);
if (!config_file.is_open()) {
throw std::runtime_error("Unable to open Rakurai config: " + config_path);
}
rapidjson::IStreamWrapper isw{config_file};
rapidjson::Document config;
if (config.ParseStream(isw).HasParseError()) {
throw std::runtime_error(
"Unable to parse Rakurai config: " + config_path);
}
LOG(md, Info,
"Rakurai DEX handler config parsed. venue:{} config:{}",
int(venue),
config_path);
::rakurai::dex_connector::configuration::logging_config logging_config;
::rakurai::dex_connector::configuration::session_config session_config;
::rakurai::dex_connector::configuration::wallet wallet_config;
bool cluster_mode = false;
if (config.HasMember("app_logging") && config["app_logging"].IsObject()) {
const auto& app_logging = config["app_logging"];
if (app_logging.HasMember("severity")) {
logging_config.severity = app_logging["severity"].GetString();
}
if (app_logging.HasMember("console")) {
logging_config.is_console_enabled = app_logging["console"].GetBool();
}
if (app_logging.HasMember("file")) {
logging_config.is_file_enabled = app_logging["file"].GetBool();
}
if (app_logging.HasMember("queuesize")) {
logging_config.queue_size = app_logging["queuesize"].GetUint();
}
}
if (config.HasMember("cluster_mode")) {
cluster_mode = config["cluster_mode"].GetBool();
}
auto add_uri = [&](const char* md_key, const char* oms_key) {
if (!config.HasMember(md_key) || !config.HasMember(oms_key)) {
throw std::runtime_error(
std::string("Incomplete Rakurai uri config in ") + config_path);
}
::rakurai::dex_connector::configuration::uri_config uri_config;
uri_config.md_server_uri = config[md_key].GetString();
uri_config.node_oms_uri = config[oms_key].GetString();
session_config.uri.push_back(uri_config);
};
add_uri("md_server_uri_1", "node_oms_uri_1");
if (cluster_mode) {
add_uri("md_server_uri_2", "node_oms_uri_2");
}
LOG(md, Info,
"Rakurai DEX handler uri config prepared. venue:{} cluster_mode:{} uri_count:{}",
int(venue),
cluster_mode,
session_config.uri.size());
std::size_t uri_index = 1;
for (const auto& uri_config : session_config.uri) {
const auto index = std::to_string(uri_index);
validate_ws_endpoint_or_throw(
"md_server_uri_" + index, uri_config.md_server_uri);
validate_ws_endpoint_or_throw(
"node_oms_uri_" + index, uri_config.node_oms_uri);
++uri_index;
}
if (config.HasMember("wallet_pub")) {
wallet_config.wallet_pub_key =
::rakurai::dex_connector::solanaAddressToBytes(
config["wallet_pub"].GetString());
}
if (config.HasMember("wallet_pvt") && config["wallet_pvt"].IsArray()) {
const auto& wallet_pvt = config["wallet_pvt"];
if (wallet_pvt.Size() != wallet_config.wallet_pvt_key.size()) {
throw std::runtime_error(
"Rakurai wallet_pvt must contain 64 integer entries");
}
for (rapidjson::SizeType i = 0; i < wallet_pvt.Size(); ++i) {
wallet_config.wallet_pvt_key[i] =
static_cast<std::uint8_t>(wallet_pvt[i].GetUint());
}
} else {
throw std::runtime_error("Missing Rakurai wallet_pvt in " + config_path);
}
if (config.HasMember("core")) {
session_config.core = config["core"].GetUint();
}
LOG(md, Info,
"Rakurai DEX handler session config prepared. venue:{} core:{} console:{} file:{} queue:{} severity:{}",
int(venue),
session_config.core,
logging_config.is_console_enabled,
logging_config.is_file_enabled,
logging_config.queue_size,
logging_config.severity);
auto dex_configs = parse_dex_configs(config, venue);
if (dex_configs.empty()) {
LOG(md, Warn,
"No Rakurai DEX subscriptions matched venue {} in {}",
int(venue),
config_path);
return;
}
LOG(md, Info,
"Rakurai DEX subscriptions prepared. venue:{} dex_configs:{}",
int(venue),
dex_configs.size());
ListenerAdapter adapter(
listener,
sec_id_store,
venue,
num_depth_levels,
excluded_securities);
LOG(md, Info,
"Rakurai listener adapter constructed. venue:{} listener_present:{} sec_id_store_present:{}",
int(venue),
listener != nullptr,
sec_id_store != nullptr);
if (config.HasMember("reconnect_retries")) {
session_config.reconnect_retries = config["reconnect_retries"].GetUint();
}
if (config.HasMember("reconnect_interval_sec")) {
session_config.reconnect_interval_sec =
config["reconnect_interval_sec"].GetUint();
}
if (config.HasMember("tip_transfer_account")) {
session_config.tip_transfer_account =
config["tip_transfer_account"].GetString();
}
::rakurai::dex_connector::dex_handler handler(
dex_configs,
wallet_config,
logging_config,
session_config,
cluster_mode,
&adapter);
LOG(md, Info,
"Rakurai dex_handler constructed. venue:{} uri_count:{} dex_configs:{} cluster_mode:{}",
int(venue),
session_config.uri.size(),
dex_configs.size(),
cluster_mode);
auto trading_session =
std::make_unique<
::rakurai::dex_connector::trading_session_interface>(
handler.create_new_trading_session());
LOG(md, Info,
"Rakurai trading session created. venue:{} session_present:{}",
int(venue),
trading_session != nullptr);
LOG(md, Info,
"Rakurai dex_handler about to run. venue:{}",
int(venue));
handler.run();
LOG(md, Info,
"Rakurai dex_handler run returned. venue:{}",
int(venue));
} catch (const std::exception& ex) {
LOG(md, Error, "Rakurai DEX handler failed: {}", std::string(ex.what()));
if (listener) {
MarketDataError error;
error.type = MarketDataError::DISCONNECTION;
error.message = ex.what();
listener->on_error(error);
}
}
}
} // namespace bts::md::rakurai
@@ -0,0 +1,25 @@
#pragma once
#include "spark/api/domain/Venue.pb.h"
#include "spark/md/IMarketDataListener.h"
#include "spark/persistence/IDGenerator.h"
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
namespace bts::md::rakurai {
class RakuraiDexDataHandler {
public:
static void run(
std::shared_ptr<IMarketDataListener> listener,
std::shared_ptr<persistence::IDGenerator> sec_id_store,
api::domain::Venue venue,
const std::string& config_path,
std::uint32_t num_depth_levels,
const std::vector<std::string>& excluded_securities);
};
} // namespace bts::md::rakurai
@@ -0,0 +1,74 @@
#include "sparkd/md/rakurai/RakuraiDexSubscription.h"
#include "spark/config/config.h"
#include "spark/config/protos/system_config.pb.h"
#include "spark/logging/logging.h"
#include "sparkd/rakurai/RakuraiSharedRuntime.h"
#include <boost/filesystem/path.hpp>
#include <memory>
#include <string>
#include <utility>
#include <vector>
namespace bts::md {
using Path = boost::filesystem::path;
using bts::config::SystemConfig;
struct RakuraiDexSubscription::PImpl {
api::domain::Venue venue;
std::string json_conf_file_name;
std::uint32_t num_depth_levels = 0;
std::vector<std::string> excluded_securities;
std::shared_ptr<persistence::IDGenerator> sec_id_store;
std::size_t subscription_id = 0;
};
RakuraiDexSubscription::RakuraiDexSubscription(
api::domain::Venue venue,
api::md::DexMarketDataConfig config,
std::shared_ptr<persistence::IDGenerator> sec_id_store)
: me_(std::make_unique<PImpl>()) {
me_->venue = venue;
me_->json_conf_file_name = config.json_conf_file_name();
me_->num_depth_levels = config.num_depth_levels();
me_->excluded_securities.assign(
config.excluded_securities().begin(),
config.excluded_securities().end());
me_->sec_id_store = std::move(sec_id_store);
}
RakuraiDexSubscription::~RakuraiDexSubscription() = default;
void RakuraiDexSubscription::start(
std::shared_ptr<IMarketDataListener> listener) {
const auto config_path =
Path{config::get<SystemConfig>().config_path()} / me_->json_conf_file_name;
LOG(md, Info,
"Starting Rakurai DEX market data. venue:{} config:{}",
int(me_->venue),
config_path.string());
me_->subscription_id =
rakurai::RakuraiSharedRuntime::instance().add_market_data_subscriber(
std::move(listener),
me_->sec_id_store,
me_->venue,
me_->num_depth_levels,
me_->excluded_securities);
rakurai::RakuraiSharedRuntime::instance().ensure_started_from_file(
config_path.string());
}
void RakuraiDexSubscription::stop() {
if (me_->subscription_id != 0) {
rakurai::RakuraiSharedRuntime::instance().remove_market_data_subscriber(
me_->subscription_id);
me_->subscription_id = 0;
}
}
} // namespace bts::md
@@ -0,0 +1,31 @@
#pragma once
#include "spark/api/domain/Venue.pb.h"
#include "spark/api/md/DexMarketDataConfig.pb.h"
#include "spark/md/IMarketDataSource.h"
#include <memory>
namespace bts::persistence {
class IDGenerator;
}
namespace bts::md {
class RakuraiDexSubscription : public IMarketDataSource {
public:
RakuraiDexSubscription(
api::domain::Venue venue,
api::md::DexMarketDataConfig config,
std::shared_ptr<persistence::IDGenerator> sec_id_store);
~RakuraiDexSubscription() override;
void start(std::shared_ptr<IMarketDataListener> listener) override;
void stop() override;
private:
struct PImpl;
std::unique_ptr<PImpl> me_;
};
} // namespace bts::md
@@ -0,0 +1,55 @@
#include "RakuraiMarketDataProvider.h"
#include "spark/api/domain/Venue.pb.h"
#include "spark/api/md/MarketDataConfig.pb.h"
#include "spark/md/IMarketDataSource.h"
#include "spark/persistence/IDGenerator.h"
#include "spark/logging/logging.h"
#include "sparkd/md/rakurai/RakuraiDexSubscription.h"
#include "sparkd/rakurai/RakuraiVenueRegistry.h"
#include <memory>
#include <vector>
namespace bts::md {
using bts::api::md::MarketDataConfig;
using bts::persistence::IDGenerator;
using std::pair;
using std::shared_ptr;
using std::string;
using std::vector;
struct RakuraiMarketDataProvider::PImpl {
MarketDataConfig config;
shared_ptr<IDGenerator> sec_id_store;
PImpl(MarketDataConfig config, shared_ptr<IDGenerator> sec_id_store)
: config(std::move(config)), sec_id_store(std::move(sec_id_store)) {}
};
RakuraiMarketDataProvider::RakuraiMarketDataProvider(
MarketDataConfig config, shared_ptr<IDGenerator> sec_id_store)
: me_(std::make_unique<PImpl>(std::move(config), std::move(sec_id_store))) {
}
RakuraiMarketDataProvider::~RakuraiMarketDataProvider() = default;
vector<pair<api::domain::Venue, shared_ptr<IMarketDataSource>>>
RakuraiMarketDataProvider::get_sources() {
vector<pair<api::domain::Venue, shared_ptr<IMarketDataSource>>> rv;
for (const auto &dex : me_->config.dex()) {
if (bts::rakurai::is_rakurai_venue(dex.venue())) {
rv.push_back(
{dex.venue(),
std::make_shared<RakuraiDexSubscription>(
dex.venue(), dex, me_->sec_id_store)});
} else {
LOG(md, Warn,
"Skipping unsupported Rakurai dex venue:{}",
int(dex.venue()));
}
}
return rv;
}
} // namespace bts::md
@@ -0,0 +1,34 @@
#pragma once
#include "spark/md/IMarketDataProvider.h"
#include <memory>
namespace bts::persistence {
class IDGenerator;
}
namespace bts::api::md {
class MarketDataConfig;
}
namespace bts::md {
class RakuraiMarketDataProvider : public IMarketDataProvider {
public:
explicit RakuraiMarketDataProvider(
api::md::MarketDataConfig config,
std::shared_ptr<persistence::IDGenerator> sec_id_store);
~RakuraiMarketDataProvider();
std::vector<
std::pair<api::domain::Venue, std::shared_ptr<IMarketDataSource>>>
get_sources() override;
private:
struct PImpl;
std::unique_ptr<PImpl> me_;
};
} // namespace bts::md
@@ -0,0 +1,837 @@
#include "sparkd/rakurai/RakuraiSharedRuntime.h"
#include "spark/api/domain/SecurityType.pb.h"
#include "spark/domain/Book.h"
#include "spark/domain/Price.h"
#include "spark/domain/Quantity.h"
#include "spark/domain/Security.h"
#include "spark/logging/logging.h"
#include "spark/md/IMarketDataListener.h"
#include "spark/persistence/IDGenerator.h"
#include "spark/persistence/SqliteStore.h"
#include "sparkd/md/MarketDataError.h"
#include "sparkd/rakurai/RakuraiVenueRegistry.h"
#include <boost/filesystem/path.hpp>
#include <dex_handler.h>
#include <dex_listener.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <fstream>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
namespace bts::rakurai {
namespace {
using ConnectorSecurity = ::rakurai::dex_connector::data_types::security;
using ConnectorBookLevel = ::rakurai::dex_connector::data_types::book_level;
std::string bytes_to_hex(const std::array<std::uint8_t, 32>& bytes) {
static constexpr char kHex[] = "0123456789abcdef";
std::string out;
out.reserve(bytes.size() * 2);
for (std::uint8_t byte : bytes) {
out.push_back(kHex[(byte >> 4) & 0x0f]);
out.push_back(kHex[byte & 0x0f]);
}
return out;
}
std::string short_hex(const std::array<std::uint8_t, 32>& bytes) {
const auto hex = bytes_to_hex(bytes);
return hex.substr(0, std::min<std::size_t>(hex.size(), 12));
}
std::string token_display_name(
const ::rakurai::dex_connector::data_types::token_info& token) {
return token.name.empty() ? short_hex(token.mint_address) : token.name;
}
std::string build_security_desc(
const ConnectorSecurity& security,
std::string_view short_pool_id,
std::string_view currency) {
return fmt::format(
"{}-{}-{}",
token_display_name(security.token_a),
currency,
short_pool_id);
}
std::string security_currency(const ConnectorSecurity& security) {
const auto configured_currency = connector_quote_currency(security.venue);
return configured_currency.empty()
? token_display_name(security.token_b)
: std::string(configured_currency);
}
double quantity_scale_from_decimals(std::uint8_t decimals) {
double scale = 1.0;
for (std::uint8_t i = 0; i < decimals; ++i) {
scale *= 10.0;
}
return scale;
}
domain::BookLevel to_book_level(
const ConnectorBookLevel& source,
double quantity_scale) {
domain::BookLevel level;
level.price = domain::Price::from_double(source.price);
const auto normalized_quantity =
quantity_scale > 0.0
? static_cast<double>(source.quantity) / quantity_scale
: static_cast<double>(source.quantity);
level.total_quantity =
domain::Quantity(static_cast<float>(normalized_quantity));
level.direct_quantity = level.total_quantity;
level.n_orders = 0;
return level;
}
std::list<ConnectorDexConfig> parse_dex_configs(const nlohmann::json& config) {
std::list<ConnectorDexConfig> dex_config_list;
const auto dex_it = config.find("dex");
if (dex_it == config.end() || !dex_it->is_array()) {
return dex_config_list;
}
for (const auto& dex_value : *dex_it) {
if (!dex_value.is_object()) {
continue;
}
const std::string dex = dex_value.value("id", "");
auto dex_config = make_connector_dex_config(dex);
if (!dex_config.has_value()) {
LOG(md, Warn, "Skipping unsupported Rakurai dex config '{}'", dex);
continue;
}
const auto pools_it = dex_value.find("pool_ids");
if (pools_it != dex_value.end() && pools_it->is_array()) {
for (const auto& pool_value : *pools_it) {
dex_config->interested_pools.emplace_back(
::rakurai::dex_connector::solanaAddressToBytes(
pool_value.get<std::string>()));
}
}
const auto symbols_it = dex_value.find("symbols");
if (symbols_it != dex_value.end() && symbols_it->is_array()) {
for (const auto& symbol : *symbols_it) {
dex_config->interested_symbols.push_back(symbol.get<std::string>());
}
}
const auto interested_symbols_it = dex_value.find("interested_symbols");
if (interested_symbols_it != dex_value.end() &&
interested_symbols_it->is_array()) {
for (const auto& symbol : *interested_symbols_it) {
dex_config->interested_symbols.push_back(symbol.get<std::string>());
}
}
dex_config_list.push_back(std::move(*dex_config));
}
return dex_config_list;
}
nlohmann::json load_json_file(const std::string& path) {
LOG(md, Info, "Rakurai shared runtime loading config file: {}", path);
std::ifstream config_file(path);
if (!config_file.is_open()) {
throw std::runtime_error("Unable to open Rakurai config: " + path);
}
nlohmann::json config;
config_file >> config;
const auto nested_config_it = config.find("json_conf_file_name");
if (nested_config_it != config.end() && nested_config_it->is_string() &&
!config.contains("dex")) {
const auto nested_name = nested_config_it->get<std::string>();
auto nested_path = boost::filesystem::path(nested_name);
if (nested_path.is_relative()) {
nested_path = boost::filesystem::path(path).parent_path() / nested_path;
}
LOG(md, Info,
"Rakurai shared runtime resolved wrapper config '{}' -> '{}'. wrapper:{}",
path,
nested_path.string(),
config.dump());
return load_json_file(nested_path.string());
}
LOG(md, Info,
"Rakurai shared runtime loaded connector config from '{}': {}",
path,
config.dump());
return config;
}
} // namespace
struct RakuraiSharedRuntime::PImpl {
struct MarketDataSubscriber {
std::size_t id = 0;
std::shared_ptr<md::IMarketDataListener> listener;
std::shared_ptr<persistence::IDGenerator> sec_id_store;
api::domain::Venue venue = api::domain::VENUE_UNSPECIFIED;
std::size_t depth_limit = domain::Book::max_depth();
std::unordered_set<std::string> excluded_securities;
std::unordered_map<std::string, std::unique_ptr<domain::Security>>
securities;
std::unordered_map<std::string, domain::SecurityID> pool_to_security_id;
std::unordered_map<std::string, double> pool_quantity_scales;
std::mutex mutex;
};
struct ListenerAdapter final : public ::rakurai::dex_connector::dex_listener {
explicit ListenerAdapter(PImpl& owner) : owner(owner) {}
void on_pool_definition(
ConnectorVenue venue,
const std::vector<ConnectorSecurity*>& securities) override {
owner.on_pool_definition(venue, securities);
}
void on_book(
ConnectorVenue venue,
const std::array<std::uint8_t, 32>& pool_id,
const std::vector<ConnectorBookLevel>& bids,
const std::vector<ConnectorBookLevel>& asks) override {
owner.on_book(venue, pool_id, bids, asks);
}
void on_order_update(
::rakurai::dex_connector::order_entry::order_state order_state)
override {
owner.on_order_update(order_state);
}
void on_ata_verification_status(
const std::array<std::uint8_t, 32>& token_id,
const std::string& ata_address,
::rakurai::dex_connector::ata_status status) override {
LOG(md, Info,
"Rakurai ATA verification. token:{} ata:{} status:{}",
short_hex(token_id),
ata_address,
static_cast<int>(status));
}
PImpl& owner;
};
mutable std::mutex mutex;
std::optional<nlohmann::json> config;
std::string config_label;
std::optional<std::thread> run_thread;
std::unique_ptr<::rakurai::dex_connector::dex_handler> handler;
std::unique_ptr<::rakurai::dex_connector::trading_session_interface>
trading_session;
persistence::SqliteStore pool_alias_store{"rakurai_pool_alias"};
ListenerAdapter listener{*this};
std::unordered_map<std::size_t, std::shared_ptr<MarketDataSubscriber>>
market_data_subscribers;
std::unordered_map<std::size_t, OrderUpdateCallback> order_update_subscribers;
std::unordered_map<std::size_t, StatusCallback> status_subscribers;
std::unordered_map<std::string, PoolMetadata> pool_metadata;
std::unordered_map<std::string, std::string> short_pool_to_full_pool;
std::unordered_set<std::string> expected_pool_ids;
std::unordered_set<std::string> resolved_pool_ids;
std::size_t next_subscriber_id = 1;
bool started = false;
bool connected = false;
~PImpl() {
if (run_thread.has_value() && run_thread->joinable()) {
run_thread->detach();
}
}
std::size_t next_id_locked() { return next_subscriber_id++; }
void ensure_started(const nlohmann::json& cfg, const std::string& label) {
std::lock_guard<std::mutex> lock(mutex);
LOG(md, Info,
"Rakurai shared runtime ensure_started requested. label:{} config:{}",
label,
cfg.dump());
if (!config.has_value()) {
config = cfg;
config_label = label;
} else if (*config != cfg) {
throw std::runtime_error(
"Rakurai shared runtime already configured with a different config source: " +
config_label + " vs " + label);
}
if (started) {
LOG(md, Info,
"Rakurai shared runtime already started. label:{} connected:{}",
config_label,
connected);
return;
}
started = true;
LOG(md, Info,
"Rakurai shared runtime launching thread. label:{}",
config_label);
run_thread.emplace([this]() { run(); });
}
std::size_t add_market_data_subscriber(
std::shared_ptr<md::IMarketDataListener> listener_in,
std::shared_ptr<persistence::IDGenerator> sec_id_store,
api::domain::Venue venue,
std::uint32_t num_depth_levels,
const std::vector<std::string>& excluded_securities) {
auto sub = std::make_shared<MarketDataSubscriber>();
sub->listener = std::move(listener_in);
sub->sec_id_store = std::move(sec_id_store);
sub->venue = venue;
sub->depth_limit =
num_depth_levels == 0
? domain::Book::max_depth()
: std::min<std::size_t>(
num_depth_levels, domain::Book::max_depth());
sub->excluded_securities.insert(
excluded_securities.begin(), excluded_securities.end());
std::vector<PoolMetadata> snapshot;
{
std::lock_guard<std::mutex> lock(mutex);
sub->id = next_id_locked();
market_data_subscribers.emplace(sub->id, sub);
snapshot.reserve(pool_metadata.size());
for (const auto& [_, metadata] : pool_metadata) {
if (venue_matches(venue, metadata.security.venue)) {
snapshot.push_back(metadata);
}
}
}
replay_pool_definitions(*sub, snapshot);
return sub->id;
}
void remove_market_data_subscriber(std::size_t id) {
std::lock_guard<std::mutex> lock(mutex);
market_data_subscribers.erase(id);
}
std::size_t add_order_update_subscriber(OrderUpdateCallback callback) {
std::lock_guard<std::mutex> lock(mutex);
const auto id = next_id_locked();
order_update_subscribers.emplace(id, std::move(callback));
return id;
}
void remove_order_update_subscriber(std::size_t id) {
std::lock_guard<std::mutex> lock(mutex);
order_update_subscribers.erase(id);
}
std::size_t add_status_subscriber(StatusCallback callback) {
bool current_connected = false;
std::size_t id = 0;
{
std::lock_guard<std::mutex> lock(mutex);
id = next_id_locked();
status_subscribers.emplace(id, callback);
current_connected = connected;
}
callback(current_connected);
return id;
}
void remove_status_subscriber(std::size_t id) {
std::lock_guard<std::mutex> lock(mutex);
status_subscribers.erase(id);
}
bool is_connected() const {
std::lock_guard<std::mutex> lock(mutex);
return connected;
}
void launch_new_order(const RakuraiSharedRuntime::NewOrder& order) {
std::lock_guard<std::mutex> lock(mutex);
if (!connected || trading_session == nullptr) {
throw std::runtime_error("Rakurai execution session is not connected");
}
// Rakurai 0.0.12 changed this API to take a mutable order reference.
// Keep our subscriber-facing API const and give the connector its own copy.
auto connector_order = order;
trading_session->launch_new_order(connector_order);
}
std::optional<PoolMetadata> find_pool_metadata(
api::Venue venue,
const std::string& short_pool_id) const {
std::lock_guard<std::mutex> lock(mutex);
const auto pool_it = short_pool_to_full_pool.find(short_pool_id);
if (pool_it == short_pool_to_full_pool.end()) {
return std::nullopt;
}
const auto metadata_it = pool_metadata.find(pool_it->second);
if (metadata_it == pool_metadata.end() ||
!venue_matches(venue, metadata_it->second.security.venue)) {
return std::nullopt;
}
return metadata_it->second;
}
void run() {
try {
nlohmann::json connector_config;
{
std::lock_guard<std::mutex> lock(mutex);
connector_config = *config;
}
auto dex_configs = parse_dex_configs(connector_config);
if (dex_configs.empty()) {
throw std::runtime_error("No Rakurai dex pools configured");
}
LOG(md, Info,
"Rakurai shared runtime parsed config. dex_configs:{} config:{}",
dex_configs.size(),
connector_config.dump());
{
std::lock_guard<std::mutex> lock(mutex);
expected_pool_ids.clear();
resolved_pool_ids.clear();
for (const auto& dex_config : dex_configs) {
for (const auto& pool_id : dex_config.interested_pools) {
expected_pool_ids.insert(bytes_to_hex(pool_id));
}
}
}
::rakurai::dex_connector::configuration::logging_config logging_config;
if (connector_config.contains("app_logging")) {
const auto& app_logging = connector_config.at("app_logging");
logging_config.severity =
app_logging.value("severity", logging_config.severity);
logging_config.is_console_enabled =
app_logging.value("console", logging_config.is_console_enabled);
logging_config.is_file_enabled =
app_logging.value("file", logging_config.is_file_enabled);
logging_config.queue_size =
app_logging.value("queuesize", logging_config.queue_size);
}
::rakurai::dex_connector::configuration::wallet wallet_config;
wallet_config.wallet_pub_key =
::rakurai::dex_connector::solanaAddressToBytes(
connector_config.at("wallet_pub").get<std::string>());
const auto& wallet_pvt = connector_config.at("wallet_pvt");
if (!wallet_pvt.is_array() ||
wallet_pvt.size() != wallet_config.wallet_pvt_key.size()) {
throw std::runtime_error(
"Rakurai wallet_pvt must contain 64 integer entries");
}
for (std::size_t i = 0; i < wallet_config.wallet_pvt_key.size(); ++i) {
wallet_config.wallet_pvt_key[i] =
wallet_pvt.at(i).get<std::uint8_t>();
}
::rakurai::dex_connector::configuration::session_config session_config;
auto add_uri = [&](const char* md_key, const char* oms_key) {
::rakurai::dex_connector::configuration::uri_config uri;
uri.md_server_uri =
connector_config.at(md_key).get<std::string>();
uri.node_oms_uri =
connector_config.at(oms_key).get<std::string>();
session_config.uri.push_back(std::move(uri));
};
add_uri("md_server_uri_1", "node_oms_uri_1");
const bool cluster_mode =
connector_config.value("cluster_mode", false);
if (cluster_mode) {
add_uri("md_server_uri_2", "node_oms_uri_2");
}
session_config.core = connector_config.value("core", session_config.core);
session_config.reconnect_retries = connector_config.value(
"reconnect_retries", session_config.reconnect_retries);
session_config.reconnect_interval_sec = connector_config.value(
"reconnect_interval_sec", session_config.reconnect_interval_sec);
session_config.tip_transfer_account = connector_config.value(
"tip_transfer_account", session_config.tip_transfer_account);
{
std::lock_guard<std::mutex> lock(mutex);
handler = std::make_unique<::rakurai::dex_connector::dex_handler>(
dex_configs,
wallet_config,
logging_config,
session_config,
cluster_mode,
&listener);
trading_session = std::make_unique<
::rakurai::dex_connector::trading_session_interface>(
handler->create_new_trading_session());
connected = false;
}
handler->run();
} catch (const std::exception& ex) {
LOG(md, Error, "Rakurai shared runtime failed: {}", std::string(ex.what()));
notify_md_error(ex.what());
}
{
std::lock_guard<std::mutex> lock(mutex);
connected = false;
}
notify_status(false);
}
void notify_status(bool is_connected_now) {
std::vector<StatusCallback> callbacks;
{
std::lock_guard<std::mutex> lock(mutex);
for (const auto& [_, callback] : status_subscribers) {
callbacks.push_back(callback);
}
}
for (auto& callback : callbacks) {
callback(is_connected_now);
}
}
void notify_md_error(const std::string& message) {
std::vector<std::shared_ptr<md::IMarketDataListener>> listeners;
{
std::lock_guard<std::mutex> lock(mutex);
for (const auto& [_, sub] : market_data_subscribers) {
listeners.push_back(sub->listener);
}
}
for (const auto& listener_ptr : listeners) {
if (!listener_ptr) {
continue;
}
md::MarketDataError error;
error.type = md::MarketDataError::DISCONNECTION;
error.message = message;
listener_ptr->on_error(error);
}
}
void load_aliases_locked() {
if (!short_pool_to_full_pool.empty()) {
return;
}
constexpr std::string_view kPoolAliasNamespace = "rakurai_pool_alias";
for (const auto& [ns, rows] : pool_alias_store.retrieve_all()) {
if (ns != kPoolAliasNamespace) {
continue;
}
for (const auto& [full_pool_id, alias] : rows) {
if (!alias.empty()) {
short_pool_to_full_pool.emplace(alias, full_pool_id);
}
}
}
}
std::string get_or_add_short_pool_id_locked(const std::string& pool_id) {
constexpr std::string_view kPoolAliasNamespace = "rakurai_pool_alias";
const auto [exists, rowid, _] =
pool_alias_store.retrieve_one(std::string(kPoolAliasNamespace), pool_id);
if (exists) {
const auto alias = std::to_string(rowid);
short_pool_to_full_pool.emplace(alias, pool_id);
return alias;
}
const auto new_id =
pool_alias_store.insert(std::string(kPoolAliasNamespace), pool_id, "");
const auto alias = std::to_string(new_id);
short_pool_to_full_pool.emplace(alias, pool_id);
return alias;
}
void on_pool_definition(
ConnectorVenue connector_venue,
const std::vector<ConnectorSecurity*>& securities) {
std::vector<std::shared_ptr<MarketDataSubscriber>> subscribers;
std::vector<PoolMetadata> touched;
bool should_notify_ready = false;
std::size_t resolved_pool_count = 0;
std::size_t expected_pool_count = 0;
{
std::lock_guard<std::mutex> lock(mutex);
load_aliases_locked();
for (const auto* security : securities) {
if (security == nullptr) {
continue;
}
const auto pool_id = bytes_to_hex(security->pool_id);
pool_metadata[pool_id] = PoolMetadata{
*security,
pool_id,
get_or_add_short_pool_id_locked(pool_id),
quantity_scale_from_decimals(security->token_a.decimals)};
resolved_pool_ids.insert(pool_id);
touched.push_back(pool_metadata.at(pool_id));
}
const bool has_trading_session = trading_session != nullptr;
const bool has_pool_definitions = !resolved_pool_ids.empty();
const bool all_expected_pools_resolved =
expected_pool_ids.empty()
? has_pool_definitions
: std::all_of(
expected_pool_ids.begin(),
expected_pool_ids.end(),
[this](const std::string& pool_id) {
return resolved_pool_ids.contains(pool_id);
});
if (!connected && has_trading_session && all_expected_pools_resolved) {
connected = true;
should_notify_ready = true;
resolved_pool_count = resolved_pool_ids.size();
expected_pool_count = expected_pool_ids.size();
}
for (const auto& [_, sub] : market_data_subscribers) {
if (venue_matches(sub->venue, connector_venue)) {
subscribers.push_back(sub);
}
}
}
if (should_notify_ready) {
LOG(md, Info,
"Rakurai shared runtime ready. resolved_pools:{} expected_pools:{}",
resolved_pool_count,
expected_pool_count);
notify_status(true);
}
for (const auto& sub : subscribers) {
replay_pool_definitions(*sub, touched);
}
}
void replay_pool_definitions(
MarketDataSubscriber& sub,
const std::vector<PoolMetadata>& metadata_list) {
std::lock_guard<std::mutex> lock(sub.mutex);
std::vector<const domain::Security*> emitted;
for (const auto& metadata : metadata_list) {
if (!venue_matches(sub.venue, metadata.security.venue)) {
continue;
}
const auto& security = metadata.security;
const auto& pool_id = metadata.full_pool_id;
const auto symbol = token_display_name(security.token_a);
const auto currency = security_currency(security);
const auto desc =
build_security_desc(security, metadata.short_pool_id, currency);
if (sub.excluded_securities.contains(pool_id) ||
sub.excluded_securities.contains(metadata.short_pool_id) ||
sub.excluded_securities.contains(desc)) {
continue;
}
domain::Security emitted_security;
const auto security_type = connector_security_type(security.venue);
if (security_type == api::domain::SECURITY_TYPE_FUTURE) {
auto& future =
emitted_security.security.emplace<domain::FutureSecurity>();
future.expiration = core::TimeStamp::from_ymd(2100, 1, 1);
} else {
emitted_security.security = domain::SpotSecurity{};
}
emitted_security.security_common.venue = sub.venue;
emitted_security.security_common.exchange_sec_id = metadata.short_pool_id;
emitted_security.security_common.symbol = symbol;
emitted_security.security_common.currency = currency;
emitted_security.security_common.asset = symbol;
emitted_security.security_common.group =
connector_venue_name(security.venue);
emitted_security.security_common.desc = desc;
emitted_security.security_common.min_price_increment =
security.min_tick > 0.0 ? security.min_tick : 0.0;
emitted_security.security_common.min_quantity_increment =
static_cast<double>(security.min_trade_amount);
emitted_security.security_common.min_quantity =
static_cast<double>(security.min_trade_amount);
*emitted_security.security_common.id = sub.sec_id_store->get_or_add(
sub.venue, security_type, pool_id);
auto [it, is_new] = sub.securities.try_emplace(
pool_id, std::make_unique<domain::Security>(std::move(emitted_security)));
if (!is_new) {
*it->second = std::move(emitted_security);
}
sub.pool_to_security_id[pool_id] = it->second->id();
sub.pool_quantity_scales[pool_id] = metadata.quantity_scale;
emitted.push_back(it->second.get());
}
if (!emitted.empty() && sub.listener) {
sub.listener->on_securities(emitted);
}
}
void on_book(
ConnectorVenue connector_venue,
const std::array<std::uint8_t, 32>& pool_id,
const std::vector<ConnectorBookLevel>& bids,
const std::vector<ConnectorBookLevel>& asks) {
std::vector<std::shared_ptr<MarketDataSubscriber>> subscribers;
{
std::lock_guard<std::mutex> lock(mutex);
for (const auto& [_, sub] : market_data_subscribers) {
if (venue_matches(sub->venue, connector_venue)) {
subscribers.push_back(sub);
}
}
}
const auto pool_key = bytes_to_hex(pool_id);
for (const auto& sub : subscribers) {
std::lock_guard<std::mutex> lock(sub->mutex);
const auto it = sub->pool_to_security_id.find(pool_key);
if (it == sub->pool_to_security_id.end()) {
continue;
}
const auto quantity_scale_it = sub->pool_quantity_scales.find(pool_key);
const auto quantity_scale =
quantity_scale_it != sub->pool_quantity_scales.end()
? quantity_scale_it->second
: 1.0;
auto book = domain::Book::empty(it->second);
const auto bid_depth =
std::min<std::size_t>(sub->depth_limit, bids.size());
const auto ask_depth =
std::min<std::size_t>(sub->depth_limit, asks.size());
for (std::size_t i = 0; i < bid_depth; ++i) {
book.bids()[i] = to_book_level(bids[i], quantity_scale);
}
for (std::size_t i = 0; i < ask_depth; ++i) {
book.asks()[i] = to_book_level(asks[i], quantity_scale);
}
if (sub->listener) {
sub->listener->on_book(book);
}
}
}
void on_order_update(
::rakurai::dex_connector::order_entry::order_state order_state) {
std::vector<OrderUpdateCallback> callbacks;
{
std::lock_guard<std::mutex> lock(mutex);
for (const auto& [_, callback] : order_update_subscribers) {
callbacks.push_back(callback);
}
}
for (auto& callback : callbacks) {
callback(order_state);
}
}
};
RakuraiSharedRuntime& RakuraiSharedRuntime::instance() {
static RakuraiSharedRuntime runtime;
return runtime;
}
RakuraiSharedRuntime::RakuraiSharedRuntime()
: me_(std::make_unique<PImpl>()) {}
RakuraiSharedRuntime::~RakuraiSharedRuntime() = default;
std::size_t RakuraiSharedRuntime::add_market_data_subscriber(
std::shared_ptr<md::IMarketDataListener> listener,
std::shared_ptr<persistence::IDGenerator> sec_id_store,
api::domain::Venue venue,
std::uint32_t num_depth_levels,
const std::vector<std::string>& excluded_securities) {
return me_->add_market_data_subscriber(
std::move(listener),
std::move(sec_id_store),
venue,
num_depth_levels,
excluded_securities);
}
void RakuraiSharedRuntime::remove_market_data_subscriber(std::size_t id) {
me_->remove_market_data_subscriber(id);
}
std::size_t RakuraiSharedRuntime::add_order_update_subscriber(
OrderUpdateCallback callback) {
return me_->add_order_update_subscriber(std::move(callback));
}
void RakuraiSharedRuntime::remove_order_update_subscriber(std::size_t id) {
me_->remove_order_update_subscriber(id);
}
std::size_t RakuraiSharedRuntime::add_status_subscriber(StatusCallback callback) {
return me_->add_status_subscriber(std::move(callback));
}
void RakuraiSharedRuntime::remove_status_subscriber(std::size_t id) {
me_->remove_status_subscriber(id);
}
void RakuraiSharedRuntime::ensure_started_from_file(
const std::string& config_path) {
me_->ensure_started(load_json_file(config_path), config_path);
}
void RakuraiSharedRuntime::ensure_started_from_json(
const nlohmann::json& config,
const std::string& config_label) {
me_->ensure_started(config, config_label);
}
bool RakuraiSharedRuntime::is_connected() const { return me_->is_connected(); }
void RakuraiSharedRuntime::launch_new_order(const NewOrder& order) {
me_->launch_new_order(order);
}
std::optional<RakuraiSharedRuntime::PoolMetadata>
RakuraiSharedRuntime::find_pool_metadata(
api::Venue venue,
const std::string& short_pool_id) const {
return me_->find_pool_metadata(venue, short_pool_id);
}
} // namespace bts::rakurai
@@ -0,0 +1,78 @@
#pragma once
#include "spark/api/domain/Venue.pb.h"
#include "spark/domain_model/Security.pb.h"
#include <dex_utils.h>
#include <order_entry_common.h>
#include <cstddef>
#include <functional>
#include <memory>
#include <nlohmann/json_fwd.hpp>
#include <optional>
#include <string>
#include <vector>
namespace bts::md {
class IMarketDataListener;
}
namespace bts::persistence {
class IDGenerator;
}
namespace bts::rakurai {
class RakuraiSharedRuntime {
public:
using OrderUpdate = ::rakurai::dex_connector::order_entry::order_state;
using NewOrder = ::rakurai::dex_connector::order_entry::new_order;
using OrderUpdateCallback = std::function<void(OrderUpdate)>;
using StatusCallback = std::function<void(bool)>;
struct PoolMetadata {
::rakurai::dex_connector::data_types::security security;
std::string full_pool_id;
std::string short_pool_id;
double quantity_scale = 1.0;
};
static RakuraiSharedRuntime& instance();
std::size_t add_market_data_subscriber(
std::shared_ptr<md::IMarketDataListener> listener,
std::shared_ptr<persistence::IDGenerator> sec_id_store,
api::domain::Venue venue,
std::uint32_t num_depth_levels,
const std::vector<std::string>& excluded_securities);
void remove_market_data_subscriber(std::size_t id);
std::size_t add_order_update_subscriber(OrderUpdateCallback callback);
void remove_order_update_subscriber(std::size_t id);
std::size_t add_status_subscriber(StatusCallback callback);
void remove_status_subscriber(std::size_t id);
void ensure_started_from_file(const std::string& config_path);
void ensure_started_from_json(
const nlohmann::json& config,
const std::string& config_label);
bool is_connected() const;
void launch_new_order(const NewOrder& order);
std::optional<PoolMetadata> find_pool_metadata(
api::Venue venue,
const std::string& short_pool_id) const;
private:
RakuraiSharedRuntime();
~RakuraiSharedRuntime();
RakuraiSharedRuntime(const RakuraiSharedRuntime&) = delete;
RakuraiSharedRuntime& operator=(const RakuraiSharedRuntime&) = delete;
struct PImpl;
std::unique_ptr<PImpl> me_;
};
} // namespace bts::rakurai
@@ -0,0 +1,178 @@
// Generated by tools/generate_venues.py. Do not edit.
#pragma once
#include "spark/api/domain/SecurityType.pb.h"
#include "spark/api/domain/Venue.pb.h"
#include "spark/domain_model/Security.pb.h"
#include <dex_utils.h>
#include <optional>
#include <string_view>
namespace bts::rakurai {
using ConnectorDexConfig = ::rakurai::dex_connector::configuration::dex_config;
using ConnectorPoolType = ::rakurai::dex_connector::data_types::pool_type;
using ConnectorVenue = ::rakurai::dex_connector::data_types::venue;
inline std::string_view connector_venue_name(ConnectorVenue venue) {
switch (venue) {
case ConnectorVenue::orca_clmm:
return "orca_clmm";
case ConnectorVenue::orca_amm:
return "orca_amm";
case ConnectorVenue::raydium_amm:
return "raydium_amm";
case ConnectorVenue::raydium_cpmm:
return "raydium_cpmm";
case ConnectorVenue::raydium_clmm:
return "raydium_clmm";
case ConnectorVenue::phoenix:
return "phoenix";
default:
return "invalid";
}
}
inline bool venue_matches(
api::domain::Venue spark_venue, ConnectorVenue connector_venue) {
switch (spark_venue) {
case api::domain::VENUE_ORCA:
return connector_venue == ConnectorVenue::orca_clmm ||
connector_venue == ConnectorVenue::orca_amm;
case api::domain::VENUE_PHOENIX:
return connector_venue == ConnectorVenue::phoenix;
case api::domain::VENUE_RAYDIUM:
return connector_venue == ConnectorVenue::raydium_amm ||
connector_venue == ConnectorVenue::raydium_cpmm ||
connector_venue == ConnectorVenue::raydium_clmm;
default:
return false;
}
}
inline bool venue_matches(
api::Venue spark_venue, ConnectorVenue connector_venue) {
switch (spark_venue) {
case api::VENUE_ORCA:
return connector_venue == ConnectorVenue::orca_clmm ||
connector_venue == ConnectorVenue::orca_amm;
case api::VENUE_PHOENIX:
return connector_venue == ConnectorVenue::phoenix;
case api::VENUE_RAYDIUM:
return connector_venue == ConnectorVenue::raydium_amm ||
connector_venue == ConnectorVenue::raydium_cpmm ||
connector_venue == ConnectorVenue::raydium_clmm;
default:
return false;
}
}
inline bool is_rakurai_venue(api::domain::Venue venue) {
switch (venue) {
case api::domain::VENUE_ORCA:
case api::domain::VENUE_PHOENIX:
case api::domain::VENUE_RAYDIUM:
return true;
default:
return false;
}
}
inline api::domain::SecurityType connector_security_type(
ConnectorVenue venue) {
switch (venue) {
case ConnectorVenue::orca_clmm:
return api::domain::SECURITY_TYPE_SPOT;
case ConnectorVenue::orca_amm:
return api::domain::SECURITY_TYPE_SPOT;
case ConnectorVenue::raydium_amm:
return api::domain::SECURITY_TYPE_SPOT;
case ConnectorVenue::raydium_cpmm:
return api::domain::SECURITY_TYPE_SPOT;
case ConnectorVenue::raydium_clmm:
return api::domain::SECURITY_TYPE_SPOT;
case ConnectorVenue::phoenix:
return api::domain::SECURITY_TYPE_FUTURE;
default:
return api::domain::SECURITY_TYPE_UNSPECIFIED;
}
}
inline std::string_view connector_quote_currency(ConnectorVenue venue) {
switch (venue) {
case ConnectorVenue::phoenix:
return "USD";
default:
return {};
}
}
inline std::optional<ConnectorDexConfig> make_connector_dex_config(
std::string_view id,
std::optional<api::domain::Venue> venue_filter = std::nullopt) {
if (id == "orca_clmm") {
if (venue_filter.has_value() &&
*venue_filter != api::domain::VENUE_ORCA) {
return std::nullopt;
}
ConnectorDexConfig config;
config.dex_name = ConnectorVenue::orca_clmm;
config.pool_typ = ConnectorPoolType::clmm;
return config;
}
if (id == "orca_amm") {
if (venue_filter.has_value() &&
*venue_filter != api::domain::VENUE_ORCA) {
return std::nullopt;
}
ConnectorDexConfig config;
config.dex_name = ConnectorVenue::orca_amm;
config.pool_typ = ConnectorPoolType::amm;
return config;
}
if (id == "raydium_amm") {
if (venue_filter.has_value() &&
*venue_filter != api::domain::VENUE_RAYDIUM) {
return std::nullopt;
}
ConnectorDexConfig config;
config.dex_name = ConnectorVenue::raydium_amm;
config.pool_typ = ConnectorPoolType::amm;
return config;
}
if (id == "raydium_cpmm") {
if (venue_filter.has_value() &&
*venue_filter != api::domain::VENUE_RAYDIUM) {
return std::nullopt;
}
ConnectorDexConfig config;
config.dex_name = ConnectorVenue::raydium_cpmm;
config.pool_typ = ConnectorPoolType::cpmm;
return config;
}
if (id == "raydium_clmm") {
if (venue_filter.has_value() &&
*venue_filter != api::domain::VENUE_RAYDIUM) {
return std::nullopt;
}
ConnectorDexConfig config;
config.dex_name = ConnectorVenue::raydium_clmm;
config.pool_typ = ConnectorPoolType::clmm;
return config;
}
if (id == "phoenix") {
if (venue_filter.has_value() &&
*venue_filter != api::domain::VENUE_PHOENIX) {
return std::nullopt;
}
ConnectorDexConfig config;
config.dex_name = ConnectorVenue::phoenix;
config.pool_typ = ConnectorPoolType::perp;
return config;
}
return std::nullopt;
}
} // namespace bts::rakurai
+283
View File
@@ -0,0 +1,283 @@
# Independent release and deployment guide
This guide creates and deploys the five new artifacts around an existing
`sparkd` deployment. It deliberately does not create one shared Compose stack.
Each owning team can build, publish, deploy, restart, and roll back its component
without rebuilding the others.
## Chosen deployment model
| Component | Release artifact | Deployment unit |
| --- | --- | --- |
| `sparkd` | Existing Spark artifact/image | Existing Spark scripts; unchanged |
| Spark command bridge | Self-contained Linux tarball | Independent `systemd` service |
| BlueOrb RSS | Self-contained Linux tarball | Independent `systemd` service |
| `blueorb-surface-python` | Python wheel and source distribution | Published library consumed by clients |
| `spark-command-client` | Python wheel and source distribution | Published library consumed by the gateway |
| `spark-public-websocket` | Python wheel and source distribution | Independent virtual environment and `systemd` service |
The .NET tarballs are self-contained: the target host does not need the .NET
runtime. They are directories rather than single-file executables because both
services may carry native libraries. The Python gateway receives its two client
libraries as wheels and has no source-level dependency on either backend.
Every component uses its own semantic version. Record the compatible set for an
environment using [`deploy/release-manifest.env.example`](deploy/release-manifest.env.example).
Do not infer compatibility from matching version numbers.
## Build prerequisites
- Linux x86-64 build host for `linux-x64` artifacts.
- .NET 8 SDK matching each repository's `global.json`.
- Python 3.10 or newer.
- `python -m pip install build` in the release-build environment.
- `tar` and `sha256sum`.
- Access to the released `Bts.Spark.Sdk` NuGet packages.
Build from clean, reviewed commits. Tags should use the owning component name,
for example `spark-command-bridge/v0.1.0` rather than one repository-wide tag.
## 1. Build `blueorb-surface-python`
The version comes from `/home/bts/blueorb/BlueOrb.Surface.Python/pyproject.toml`.
Bump that value before creating a new release.
```sh
cd /home/bts/blueorb/BlueOrb.Surface.Python
PYTHON=python3.10 ./build-release.sh 0.1.0
```
Outputs:
```text
dist/blueorb_surface_python-0.1.0-py3-none-any.whl
dist/blueorb_surface_python-0.1.0.tar.gz
dist/blueorb-surface-python-0.1.0.sha256
```
## 2. Build `spark-command-client`
The version comes from `spark_command_client/pyproject.toml` and must be bumped
before a new release.
```sh
cd /workspaces/spark/src/client/spark_command_client
PYTHON=python3.10 ./build-release.sh 0.1.0
```
Outputs are written to that component's `dist/` directory with a checksum file.
## 3. Build `spark-public-websocket`
```sh
cd /workspaces/spark/src/client/spark_public_websocket
PYTHON=python3.10 ./build-release.sh 0.1.0
```
The wheel declares compatible `blueorb-surface-python` and
`spark-command-client` ranges. A deployment must still pin exact wheel versions
in its release manifest. The same `dist/` directory contains a versioned
`spark-public-websocket-deploy-0.1.0.tar.gz` with its service and environment
templates.
## 4. Build the self-contained BlueOrb RSS artifact
The RSS version is passed to the .NET assembly metadata and artifact name:
```sh
cd /home/bts/blueorb/RemoteStreamServer
./build-release.sh 0.1.0 linux-x64
```
Outputs:
```text
dist/blueorb-rss-0.1.0-linux-x64.tar.gz
dist/blueorb-rss-0.1.0-linux-x64.tar.gz.sha256
```
The extracted directory contains `RemoteStreamServer` and all managed/native
runtime files required to start it without an installed .NET runtime.
## 5. Build the self-contained Spark command bridge artifact
The bridge must use the same released Spark SDK family as the target `sparkd`.
`SPARK_NUGET_SOURCE` can be a package-feed URL or a directory containing the
four released BTS NuGet packages.
```sh
cd /workspaces/spark/src/client/spark_command_bridge
SPARK_NUGET_SOURCE=/home/bts/blueorb/third_party/nuget \
./build-release.sh \
0.1.0 \
3.2.3-defi-2-gefcbb26de1 \
linux-x64
```
Outputs:
```text
dist/spark-command-bridge-0.1.0-linux-x64.tar.gz
dist/spark-command-bridge-0.1.0-linux-x64.tar.gz.sha256
```
## Verify and publish artifacts
Verify every archive before publishing it:
```sh
sha256sum -c artifact-name.sha256
```
Publish Python distributions to the team's package index. Publish .NET tarballs
and checksum files to the binary-release store. Keep immutable artifacts for
every deployed version; never replace an existing version in place.
Before publication, install the three wheels together in a clean Python 3.10
environment and run the package tests. Extract each .NET archive on a clean
compatible Linux host and start it with non-production configuration.
## Deploy the Spark command bridge independently
The existing `sparkd` deployment must expose a configuration-server address
reachable by the bridge. Addresses returned by that configuration service must
also be reachable from the bridge host.
Create the service identity and directories once:
```sh
sudo useradd --system --home /var/lib/spark-command-bridge \
--shell /usr/sbin/nologin sparkbridge
sudo mkdir -p /opt/spark-command-bridge/releases /etc/spark-command-bridge
```
Install a version:
```sh
sudo tar -xzf spark-command-bridge-0.1.0-linux-x64.tar.gz \
-C /opt/spark-command-bridge/releases
sudo ln -sfn \
/opt/spark-command-bridge/releases/spark-command-bridge-0.1.0-linux-x64 \
/opt/spark-command-bridge/current
sudo chown -R sparkbridge:sparkbridge /opt/spark-command-bridge
```
Install configuration and the unit template:
```sh
sudo cp /opt/spark-command-bridge/current/deploy/spark-command-bridge.env.example \
/etc/spark-command-bridge/spark-command-bridge.env
sudo chmod 600 /etc/spark-command-bridge/spark-command-bridge.env
sudo cp /opt/spark-command-bridge/current/deploy/spark-command-bridge.service \
/etc/systemd/system/
```
Edit the environment file with the real configuration-server URL and
credentials, then start the service:
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now spark-command-bridge
sudo systemctl status spark-command-bridge
journalctl -u spark-command-bridge -f
```
Only port `50051` needs to be reachable by the WebSocket service. It should not
be exposed publicly.
## Deploy BlueOrb RSS independently
```sh
sudo useradd --system --home /var/lib/blueorb-rss \
--shell /usr/sbin/nologin blueorb
sudo mkdir -p /opt/blueorb-rss/releases /etc/blueorb-rss
sudo tar -xzf blueorb-rss-0.1.0-linux-x64.tar.gz \
-C /opt/blueorb-rss/releases
sudo ln -sfn \
/opt/blueorb-rss/releases/blueorb-rss-0.1.0-linux-x64 \
/opt/blueorb-rss/current
sudo chown -R blueorb:blueorb /opt/blueorb-rss
sudo cp /home/bts/blueorb/blueorb.config.example.json \
/etc/blueorb-rss/blueorb.config.json
sudo cp /opt/blueorb-rss/current/deploy/blueorb-rss.env.example \
/etc/blueorb-rss/blueorb-rss.env
sudo cp /opt/blueorb-rss/current/deploy/blueorb-rss.service \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now blueorb-rss
sudo systemctl status blueorb-rss
journalctl -u blueorb-rss -f
```
Edit `/etc/blueorb-rss/blueorb.config.json` for the target environment before
starting RSS. Port `5085` should be reachable by the WebSocket service, but not
published to untrusted networks.
## Deploy the public WebSocket independently
Place the exact three approved wheels on the host or download them from the
package index. Create one immutable virtual environment per gateway release:
```sh
sudo useradd --system --home /var/lib/spark-public-websocket \
--shell /usr/sbin/nologin sparkwebsocket
sudo mkdir -p /opt/spark-public-websocket/releases /etc/spark-public-websocket
sudo python3.10 -m venv /opt/spark-public-websocket/releases/0.1.0
sudo /opt/spark-public-websocket/releases/0.1.0/bin/python -m pip install \
./blueorb_surface_python-0.1.0-py3-none-any.whl \
./spark_command_client-0.1.0-py3-none-any.whl \
./spark_public_websocket-0.1.0-py3-none-any.whl
sudo ln -sfn /opt/spark-public-websocket/releases/0.1.0 \
/opt/spark-public-websocket/current
sudo chown -R sparkwebsocket:sparkwebsocket /opt/spark-public-websocket
tar -xzf spark-public-websocket-deploy-0.1.0.tar.gz
sudo cp deploy/spark-public-websocket.env.example \
/etc/spark-public-websocket/spark-public-websocket.env
sudo cp deploy/spark-public-websocket.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now spark-public-websocket
sudo systemctl status spark-public-websocket
journalctl -u spark-public-websocket -f
```
Set `BLUEORB_ADDRESS` and `SPARK_COMMAND_ADDRESS` to the independently deployed
backend addresses. Port `8090` is the only new public listener.
## Smoke test the deployed path
Run the existing client from any machine allowed to reach the WebSocket:
```sh
python examples/stream_docs.py --url ws://websocket-host:8090/v1/ws
```
Then exercise `enter_order`, `update_order`, `delete_order`, and
`create_synthetic_spread` using non-production Spark identifiers. The release is
not functionally complete until all four commands traverse the WebSocket,
bridge, Quickbus, and real `sparkd`.
## Upgrade and rollback
Never overwrite `/opt/<component>/current`. Extract or create the new version
under `releases/`, atomically repoint `current`, and restart only that service.
Rollback uses the same operation with the previous release directory.
Python library releases do not restart RSS or the bridge. Backend releases do
not require rebuilding the gateway unless their public contract compatibility
changes.
## Still required before production exposure
- Real `sparkd` end-to-end and failure-path verification.
- A decision on durable versus process-local order idempotency.
- Authentication, authorization, TLS, secret management, and rate limiting.
- Health monitoring, correlation IDs, structured timings, and alerts.
- Independent restart, outage, rollback, load, and latency tests.
@@ -0,0 +1 @@
dist/
@@ -0,0 +1,27 @@
using Spark.Command.V1;
namespace Spark.Command.Bridge;
public sealed record BridgeOrderResult(
string OrderId,
CommandStatus Status,
string Message,
string SparkError);
public interface ISparkCommandBackend {
bool IsReady { get; }
string ReadinessMessage { get; }
Task<BridgeOrderResult> EnterOrderAsync(
EnterOrderRequest request,
CancellationToken cancellationToken);
Task<BridgeOrderResult> UpdateOrderAsync(
UpdateOrderRequest request,
CancellationToken cancellationToken);
Task<BridgeOrderResult> DeleteOrderAsync(
DeleteOrderRequest request,
CancellationToken cancellationToken);
Task<string> CreateSyntheticSpreadAsync(
CreateSyntheticSpreadRequest request,
CancellationToken cancellationToken);
}
@@ -0,0 +1,46 @@
using Bts.Spark.Sdk;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Spark.Api.Services;
using Spark.Core.Services;
using Spark.Command.Bridge;
var builder = WebApplication.CreateBuilder(args);
var listen = builder.Configuration["SPARK_COMMAND_LISTEN"] ?? "http://0.0.0.0:50051";
var configServer = builder.Configuration["SPARK_CONFIG_SERVER"]
?? "http://127.0.0.1:8000/config";
var user = builder.Configuration["SPARK_USER"] ?? "bts";
var password = builder.Configuration["SPARK_PASSWORD"] ?? "";
builder.WebHost.UseUrls(listen);
builder.WebHost.ConfigureKestrel(options =>
options.ConfigureEndpointDefaults(endpoint => endpoint.Protocols = HttpProtocols.Http2));
builder.Services.AddGrpc();
builder.Services
.AddSpark(options => options.ConfigServer = configServer)
.AddSparkApi(
isMarketDataEnabled: true,
isOrdersEnabled: true,
isPositionsEnabled: true,
isAdminEnabled: true,
credentials => {
credentials.User = user;
credentials.Password = password;
},
marketData => {
marketData.HotPollInterval = TimeSpan.FromMilliseconds(5);
marketData.ColdPollInterval = TimeSpan.FromSeconds(1);
marketData.TradePollInterval = TimeSpan.FromMilliseconds(5);
marketData.RelayThrottle = TimeSpan.FromMilliseconds(5);
marketData.SecurityPollInterval = TimeSpan.FromSeconds(1);
})
.AddSimpleSparkClient(isOrderServiceEnabled: true);
builder.Services.AddSingleton<ISparkCommandBackend, SparkCommandBackend>();
var app = builder.Build();
app.MapGrpcService<SparkCommandGrpcService>();
app.MapGet("/", () => "Spark command bridge is running. Use spark.command.v1.SparkCommandService over gRPC.");
Console.WriteLine($"Spark command bridge listening on {listen}");
Console.WriteLine($"Spark configuration server: {configServer}");
await app.RunAsync();
+64
View File
@@ -0,0 +1,64 @@
# Spark command bridge
Standalone gRPC service for the command-only public Spark surface. It connects
directly to `sparkd` through `Bts.Spark.Sdk`; it has no dependency on BlueOrb,
RSS, or the public WebSocket server.
## Run
```sh
SPARK_CONFIG_SERVER=http://127.0.0.1:8000/config \
SPARK_USER=bts \
SPARK_PASSWORD=... \
SPARK_COMMAND_LISTEN=http://0.0.0.0:50051 \
dotnet run --project src/client/spark_command_bridge/spark_command_bridge.csproj
```
The bridge accepts numeric Spark security and order keys as public string IDs.
It maintains Spark security and order context internally and exposes no stream
or query RPCs.
Limit orders on synthetic securities use `CustomDef.Spreader` with the current
synthetic security and execution strategy, preserving the authenticated user and
account. Their finite prices may be zero or negative. Synthetic market orders
and submissions without an available execution strategy are rejected explicitly.
Order updates also accept finite zero and negative prices.
Synthetic spread creation reads `SyntheticSpreadConfig` from the same Spark
configuration as the C# WebSocket server. Presets resolve in order: exact first
leg, venue wildcard, then global wildcard. Omitted contract, strategy, and leg
fields use the matching preset; explicit values (including `false`) override it.
`client_order_id` idempotency is currently process-local. Durable idempotency is
required before production rollout.
## Publish
The supported repeatable release command creates a versioned self-contained
tarball and checksum. It requires the released Spark SDK package feed:
```sh
SPARK_NUGET_SOURCE=/path/to/spark/nuget-feed \
./src/client/spark_command_bridge/build-release.sh \
0.1.0 \
<released-spark-version> \
linux-x64
```
See [`../RELEASE_AND_DEPLOYMENT.md`](../RELEASE_AND_DEPLOYMENT.md) for the
independent `systemd` deployment and rollback procedure.
The equivalent low-level publish operation is:
The bridge can be published as a self-contained executable. Use the Spark SDK
package build (`UseProjectRefs=false`) so its native runtime assets are included:
```sh
dotnet publish src/client/spark_command_bridge/spark_command_bridge.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-p:UseProjectRefs=false \
-p:SparkVersion=<released-spark-version> \
-p:PublishSingleFile=true
```
@@ -0,0 +1,460 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Reactive.Linq;
using Bts.Spark.Sdk;
using Google.Protobuf;
using Spark.Api.MarketData;
using Spark.Api.Reactive;
using Spark.Api.Admin;
using Spark.Api.Services;
using Spark.Core.Reactive;
using Spark.Command.V1;
using bts.api;
using bts.api.admin;
using bts.api.core;
using bts.api.orderman;
using bts.api.synthetic;
using bts.spark;
using mbts.spark;
using WireSide = Spark.Command.V1.Side;
using SparkSide = bts.api.Side;
using bts.config;
using mbts.config;
namespace Spark.Command.Bridge;
public sealed class SparkCommandBackend : ISparkCommandBackend, IDisposable {
static readonly TimeSpan SyntheticTimeout = TimeSpan.FromSeconds(15);
readonly ISimpleSparkClient client;
readonly SyntheticSecurityClient syntheticClient;
readonly IDisposable securitiesSubscription;
readonly IDisposable strategiesSubscription;
readonly IDisposable accountsSubscription;
readonly ISparkLoginService loginService;
IReadOnlyDictionary<uint, ExecutionStrategyImmutable> strategies =
new Dictionary<uint, ExecutionStrategyImmutable>();
IReadOnlyDictionary<uint, BTSAccountImmutable> accounts =
new Dictionary<uint, BTSAccountImmutable>();
readonly ConcurrentDictionary<string, IdempotentOrder> idempotentOrders = new();
IReadOnlyDictionary<uint, SecurityImmutable> securities =
new Dictionary<uint, SecurityImmutable>();
volatile bool receivedSecurities;
volatile bool receivedStrategies;
volatile bool receivedAccounts;
public SparkCommandBackend(
ISimpleSparkClient client,
IMarketDataClient marketDataClient,
Spark.Api.IServiceProvider sparkServices,
IAdminClient adminClient,
ISparkLoginService loginService) {
this.client = client;
this.loginService = loginService;
accountsSubscription = adminClient.Accounts.Subscribe(update => {
accounts = update.Index;
receivedAccounts = true;
});
var securitySource = marketDataClient.Securities().Cache().ToObservable();
securitiesSubscription = securitySource.Subscribe(update => {
securities = update.Index;
receivedSecurities = true;
});
syntheticClient = new SyntheticSecurityClient(
sparkServices.GetService<SyntheticSecurityService>(),
securitySource);
strategiesSubscription = sparkServices.GetService<SyntheticSecurityService>()
.subscribei_metadata()
.Select(update => update.all.Where(metadata => metadata.HasStrategy)
.ToDictionary(metadata => metadata.SecurityKey, metadata => metadata.Strategy))
.Subscribe(update => {
strategies = update;
receivedStrategies = true;
});
}
public bool IsReady => receivedSecurities && receivedStrategies && receivedAccounts;
public string ReadinessMessage => IsReady
? "Spark command context is ready"
: "Waiting for Spark securities, synthetic strategies, and accounts";
public Task<BridgeOrderResult> EnterOrderAsync(
EnterOrderRequest request,
CancellationToken cancellationToken) {
Require(request.ClientOrderId, "client_order_id");
var fingerprint = Convert.ToBase64String(request.ToByteArray());
var proposed = new IdempotentOrder(
fingerprint,
new Lazy<Task<BridgeOrderResult>>(
() => EnterOrderOnce(request, cancellationToken),
LazyThreadSafetyMode.ExecutionAndPublication));
var operation = idempotentOrders.GetOrAdd(
request.ClientOrderId,
proposed);
if (!string.Equals(operation.Fingerprint, fingerprint, StringComparison.Ordinal)) {
throw new ArgumentException(
"client_order_id is already associated with a different request");
}
return operation.Operation.Value;
}
async Task<BridgeOrderResult> EnterOrderOnce(
EnterOrderRequest request,
CancellationToken cancellationToken) {
Require(request.Account, "account");
var security = ResolveSecurity(
request.SecurityId,
request.HasExchange ? request.Exchange : null,
request.HasExchangeSecurityName ? request.ExchangeSecurityName : null,
"order");
var securityId = security.Id;
var quantity = ParsePositive(request.Quantity, "quantity");
var side = ParseSide(request.Side);
if (security.HasSynthetic) {
if (request.OrderType != OrderType.Limit || !request.HasPrice) {
throw new ArgumentException("synthetic spreads require a limit order and price");
}
if (!strategies.TryGetValue(securityId, out var strategy)) {
throw new InvalidOperationException("Synthetic execution strategy is not available; retry when metadata is ready");
}
var account = accounts.Values.FirstOrDefault(account => account.Name == request.Account);
if (account.Id == 0) {
throw new ArgumentException("Unknown account", "account");
}
var login = await loginService.LoginAsync().AsTask().WaitAsync(cancellationToken);
if (!login.success || !login.user.HasValue) {
throw new InvalidOperationException("Spark user is not authenticated");
}
var user = login.user.Value;
var definition = SpreadOrderSubmission.BuildDefinition(
security, strategy, account, user.name, user.id, side, request.ClientOrderId);
return ToResult(await client.CreateOrder(
definition, quantity, SpreadOrderSubmission.ParsePrice(request.Price))
.WaitAsync(cancellationToken));
}
Task<Response> operation = request.OrderType switch {
OrderType.Market => client.CreateMarketOrder(
request.Account, securityId, side, quantity, request.ClientOrderId),
OrderType.Limit when request.HasPrice => client.CreateLimitOrder(
request.Account,
securityId,
side,
quantity,
ParsePositive(request.Price, "price"),
request.ClientOrderId),
OrderType.Limit => throw new ArgumentException("limit orders require price"),
_ => throw new ArgumentException("order_type must be market or limit")
};
var response = await operation.WaitAsync(cancellationToken);
return ToResult(response);
}
public async Task<BridgeOrderResult> UpdateOrderAsync(
UpdateOrderRequest request,
CancellationToken cancellationToken) {
if (!request.HasQuantity && !request.HasPrice) {
throw new ArgumentException("quantity or price is required");
}
var response = await client.ModifyLimitOrder(
ParseId(request.OrderId, "order_id"),
request.HasQuantity ? ParsePositive(request.Quantity, "quantity") : double.NaN,
request.HasPrice ? ParseDecimal(request.Price, "price") : double.NaN)
.WaitAsync(cancellationToken);
return ToResult(response);
}
public async Task<BridgeOrderResult> DeleteOrderAsync(
DeleteOrderRequest request,
CancellationToken cancellationToken) {
var response = await client.CancelOrder(ParseId(request.OrderId, "order_id"))
.WaitAsync(cancellationToken);
return ToResult(response);
}
public async Task<string> CreateSyntheticSpreadAsync(
CreateSyntheticSpreadRequest request,
CancellationToken cancellationToken) {
var first = ResolveSecurity(request.Leg1, "leg1");
var second = ResolveSecurity(request.Leg2, "leg2");
var (security, strategy) = BuildSyntheticSpread(
request, first, second, Configuration.get<SyntheticSpreadConfig>());
var securityId = await syntheticClient
.create_synthetic_security(new SecurityImmutable(security))
.WaitAsync(SyntheticTimeout, cancellationToken);
var error = await syntheticClient
.update_synthetic_strategy(securityId, new ExecutionStrategyImmutable(strategy))
.WaitAsync(SyntheticTimeout, cancellationToken);
if (error is not null) {
throw new InvalidOperationException(error);
}
return securityId.ToString(CultureInfo.InvariantCulture);
}
internal static (Security security, ExecutionStrategy strategy) BuildSyntheticSpread(
CreateSyntheticSpreadRequest request, SecurityImmutable first,
SecurityImmutable second, SyntheticSpreadConfig config) {
var preset = ResolvePreset(config, first.Exchange, first.ToString() ?? string.Empty)
?? throw new InvalidOperationException($"No synthetic spread preset found for {first.Exchange}:{first}");
var firstDefaults = preset.Leg1 ?? new SyntheticSpreadLegDefaults();
var secondDefaults = preset.Leg2?.Clone() ?? new SyntheticSpreadLegDefaults();
// Match the C# WebSocket server's second-leg multiplier fallback.
secondDefaults.PriceMultiplier = -1;
var firstLeg = BuildLeg(first, request.Leg1, firstDefaults, SparkSide.Buy);
var secondLeg = BuildLeg(second, request.Leg2, secondDefaults, SparkSide.Sell);
var name = request.HasName && !string.IsNullOrWhiteSpace(request.Name)
? request.Name.Trim()
: $"{first.Exchange}:{first}_{second.Exchange}:{second}".ToLowerInvariant();
var symbol = request.HasSymbol && !string.IsNullOrWhiteSpace(request.Symbol)
? request.Symbol.Trim()
: !string.IsNullOrWhiteSpace(preset.Symbol) ? preset.Symbol.Trim()
: !string.IsNullOrWhiteSpace(first.Symbol) ? first.Symbol : Sanitize(name);
var tick = ResolveSpreadDouble(request.HasTickSize, request.TickSize,
preset.TickSize != 0 ? preset.TickSize : config.DefaultTickSize, first.ContractSpec.LowTick, "tick_size");
var cashPerPoint = ResolveSpreadDouble(
request.HasCashPerPoint,
request.CashPerPoint,
preset.CashPerPoint != 0 ? preset.CashPerPoint : config.DefaultCashPerPoint,
first.ContractSpec.CashPerPoint,
"cash_per_point");
var priceMultiplier = ResolveSpreadDouble(
request.HasPriceMultiplier,
request.PriceMultiplier,
preset.PriceMultiplier != 0 ? preset.PriceMultiplier : config.DefaultPriceMultiplier,
first.ContractSpec.PriceMultiplier,
"price_multiplier");
var security = new Security {
Exchange = Venue.Bts,
Symbol = symbol,
Currency = request.HasCurrency && !string.IsNullOrWhiteSpace(request.Currency)
? request.Currency.Trim()
: !string.IsNullOrWhiteSpace(preset.Currency) ? preset.Currency.Trim()
: !string.IsNullOrWhiteSpace(config.DefaultCurrency) ? config.DefaultCurrency.Trim()
: first.Currency,
ContractSpec = BuildContractSpec(
tick,
cashPerPoint,
priceMultiplier,
request.HasMainFraction && request.MainFraction != 0 ? request.MainFraction : 1,
request.HasSubFraction && request.SubFraction != 0 ? request.SubFraction : 100000,
request.HasPriceDisplayFormat && request.PriceDisplayFormat != 0
? request.PriceDisplayFormat
: preset.PriceDisplayFormat != 0 ? preset.PriceDisplayFormat
: config.DefaultPriceDisplayFormat != 0 ? config.DefaultPriceDisplayFormat
: first.ContractSpec.PriceDisplayFormat,
SupportsFractionalQuantity(first) || SupportsFractionalQuantity(second)),
Synthetic = new SyntheticContract {
Name = name,
PriceCalc = (SyntheticContract.Types.PriceCalc)(request.HasPriceCalculation
? (int)request.PriceCalculation
: (int)preset.PriceCalc)
}
};
security.Synthetic.Legs.Add(firstLeg);
security.Synthetic.Legs.Add(secondLeg);
var lean = ResolveSpreadDouble(
request.HasLeanQuantityRatio,
request.LeanQuantityRatio,
preset.LeanQuantityRatio != 0 ? preset.LeanQuantityRatio : config.DefaultLeanQuantityRatio,
0,
"lean_quantity_ratio");
var strategy = new ExecutionStrategy {
CancelHedgesOnCancel = request.HasCancelHedgesOnCancel
? request.CancelHedgesOnCancel : preset.CancelHedgesOnCancel,
HedgePricing = (ExecutionStrategy.Types.HedgePricing)(request.HasHedgePricing
? (int)request.HedgePricing
: (int)preset.HedgePricing),
SpreaderType = (ExecutionStrategy.Types.SpreaderType)(request.HasSpreaderType
? (int)request.SpreaderType
: (int)preset.SpreaderType)
};
strategy.LegParamsMap[first.Id] = BuildLegParameters(request.Leg1, firstDefaults, lean);
strategy.LegParamsMap[second.Id] = BuildLegParameters(request.Leg2, secondDefaults, lean);
return (security, strategy);
}
internal static SyntheticSpreadPreset? ResolvePreset(SyntheticSpreadConfig config,
Venue exchange, string name) => config.Preset.FirstOrDefault(p =>
p.FirstLegExchange == exchange && string.Equals(p.FirstLegExchangeSecurityName,
name.Trim(), StringComparison.OrdinalIgnoreCase))
?? config.Preset.FirstOrDefault(p => p.FirstLegExchange == exchange
&& IsWildcard(p.FirstLegExchangeSecurityName))
?? config.Preset.FirstOrDefault(p => p.FirstLegExchange == Venue.Unset
&& IsWildcard(p.FirstLegExchangeSecurityName));
static bool IsWildcard(string value) => string.IsNullOrWhiteSpace(value) || value.Trim() == "*";
static double ResolveSpreadDouble(bool hasValue, string value, double configured,
double fallback, string field) {
var requested = hasValue ? ParseDecimal(value, field) : configured;
return requested != 0 ? requested : configured != 0 ? configured : fallback;
}
SecurityImmutable ResolveSecurity(SyntheticLeg? leg, string field) {
leg ??= new SyntheticLeg();
return ResolveSecurity(
leg.SecurityId,
leg.HasExchange ? leg.Exchange : null,
leg.HasExchangeSecurityName ? leg.ExchangeSecurityName : null,
field);
}
SecurityImmutable ResolveSecurity(
string? securityId,
string? exchangeValue,
string? exchangeSecurityName,
string field) {
var hasId = !string.IsNullOrWhiteSpace(securityId);
var hasExchange = !string.IsNullOrWhiteSpace(exchangeValue);
var hasName = !string.IsNullOrWhiteSpace(exchangeSecurityName);
if (hasId && (hasExchange || hasName)) {
throw new ArgumentException(
$"{field} must use either security_id or exchange/exchange_security_name");
}
if (hasId) {
var id = ParseId(securityId!, $"{field}.security_id");
return securities.TryGetValue(id, out var security)
? security
: throw new KeyNotFoundException(
$"Unknown {field}.security_id '{securityId}'");
}
if (!hasExchange || !hasName) {
throw new ArgumentException(
$"{field} requires security_id or exchange and exchange_security_name");
}
var exchange = exchangeValue!.Trim();
var name = exchangeSecurityName!.Trim();
foreach (var security in securities.Values) {
if (string.Equals(
security.Exchange.ToString(), exchange, StringComparison.OrdinalIgnoreCase) &&
string.Equals(security.ToString(), name, StringComparison.OrdinalIgnoreCase)) {
return security;
}
}
throw new KeyNotFoundException($"{name} at {exchange} not found in securities");
}
static ContractLeg BuildLeg(
SecurityImmutable security,
SyntheticLeg? request,
SyntheticSpreadLegDefaults defaults,
SparkSide defaultSide) {
request ??= new SyntheticLeg();
return new ContractLeg {
Security = security.CloneToMutable(),
Quantity = request.HasQuantity ? ParsePositive(request.Quantity, "leg quantity")
: defaults.Quantity != 0 ? defaults.Quantity : 1,
PriceMultiplier = request.HasPriceMultiplier
? ParseNonzero(request.PriceMultiplier, "leg price_multiplier")
: defaults.PriceMultiplier != 0 ? defaults.PriceMultiplier : 1,
LegDelta = request.HasLegDelta ? ParseDecimal(request.LegDelta, "leg_delta") : defaults.LegDelta,
Side = request.HasSide ? ParseSide(request.Side)
: defaults.Side != SparkSide.Undefined ? defaults.Side : defaultSide
};
}
static LegSpreaderParams BuildLegParameters(SyntheticLeg? request,
SyntheticSpreadLegDefaults defaults, double lean) {
request ??= new SyntheticLeg();
return new LegSpreaderParams {
IsActiveQuoting = request.HasIsActiveQuoting ? request.IsActiveQuoting : defaults.IsActiveQuoting,
EnableHedging = request.HasEnableHedging ? request.EnableHedging : defaults.EnableHedging,
MarketDataType = (LegSpreaderParams.Types.MarketDataType)(request.HasMarketDataType
? (int)request.MarketDataType
: (int)defaults.MarketDataType),
QuoteParams = new QuoteParams { LeanQuantityRatio = lean },
PreHedgeParams = new PreHedgeParams(),
PostHedgeParams = new PostHedgeParams()
};
}
static ContractSpecification BuildContractSpec(
double tick,
double cashPerPoint,
double priceMultiplier,
uint mainFraction,
uint subFraction,
uint displayFormat,
bool fractional) {
var result = new ContractSpecification {
LowTick = tick,
HighTick = tick,
PriceBreak = double.MaxValue,
CashPerPoint = cashPerPoint,
PriceMultiplier = priceMultiplier,
MainFraction = mainFraction == 0 ? 1 : mainFraction,
SubFraction = subFraction == 0 ? 100000 : subFraction,
PriceDisplayFormat = displayFormat
};
if (fractional) {
result.QuantityInfos.Add(new ContractQuantityInfo {
MinValue = 0.00001,
MaxValue = double.PositiveInfinity,
TickUnit = 1,
TickBase = 100000,
MaxPrice = float.MaxValue
});
}
return result;
}
static bool SupportsFractionalQuantity(SecurityImmutable security) =>
security.HasContractSpec && security.ContractSpec.QuantityInfos.Any(info =>
info.TickBase > 0 && info.TickUnit > 0 && info.TickUnit < info.TickBase);
static BridgeOrderResult ToResult(Response response) {
var accepted = response.ErrorCode == OrderError.Ok;
return new BridgeOrderResult(
response.OrderKey.ToString(CultureInfo.InvariantCulture),
accepted ? CommandStatus.Accepted : CommandStatus.Rejected,
response.Msg ?? string.Empty,
accepted ? string.Empty : response.ErrorCode.ToString());
}
static SparkSide ParseSide(WireSide side) => side switch {
WireSide.Buy => SparkSide.Buy,
WireSide.Sell => SparkSide.Sell,
_ => throw new ArgumentException("side must be buy or sell")
};
static uint ParseId(string? value, string field) =>
uint.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) && parsed > 0
? parsed
: throw new ArgumentException($"{field} must be a positive numeric Spark key");
static double ParsePositive(string value, string field) {
var parsed = ParseDecimal(value, field);
return parsed > 0 ? parsed : throw new ArgumentException($"{field} must be positive");
}
static double ParseNonzero(string value, string field) {
var parsed = ParseDecimal(value, field);
return parsed != 0 ? parsed : throw new ArgumentException($"{field} cannot be zero");
}
static double ParseDecimal(string value, string field) =>
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
&& double.IsFinite(parsed)
? parsed
: throw new ArgumentException($"{field} must be a finite decimal string");
static void Require(string value, string field) {
if (string.IsNullOrWhiteSpace(value)) {
throw new ArgumentException($"{field} is required");
}
}
static string Sanitize(string value) =>
string.Concat(value.Select(character => char.IsLetterOrDigit(character) ? character : '_'));
public void Dispose() {
securitiesSubscription.Dispose();
strategiesSubscription.Dispose();
accountsSubscription.Dispose();
syntheticClient.Dispose();
}
sealed record IdempotentOrder(
string Fingerprint,
Lazy<Task<BridgeOrderResult>> Operation);
}
@@ -0,0 +1,85 @@
using Grpc.Core;
using Spark.Command.V1;
namespace Spark.Command.Bridge;
public sealed class SparkCommandGrpcService : SparkCommandService.SparkCommandServiceBase {
readonly ISparkCommandBackend backend;
readonly ILogger<SparkCommandGrpcService> logger;
public SparkCommandGrpcService(
ISparkCommandBackend backend,
ILogger<SparkCommandGrpcService> logger) {
this.backend = backend;
this.logger = logger;
}
public override Task<HealthResult> Health(HealthRequest request, ServerCallContext context) {
_ = request;
return Task.FromResult(new HealthResult {
Ready = backend.IsReady,
Message = backend.ReadinessMessage
});
}
public override async Task<OrderResult> EnterOrder(
EnterOrderRequest request,
ServerCallContext context) =>
ToWire(await Invoke(
() => backend.EnterOrderAsync(request, context.CancellationToken),
"enter_order"));
public override async Task<OrderResult> UpdateOrder(
UpdateOrderRequest request,
ServerCallContext context) =>
ToWire(await Invoke(
() => backend.UpdateOrderAsync(request, context.CancellationToken),
"update_order"));
public override async Task<OrderResult> DeleteOrder(
DeleteOrderRequest request,
ServerCallContext context) =>
ToWire(await Invoke(
() => backend.DeleteOrderAsync(request, context.CancellationToken),
"delete_order"));
public override async Task<CreateSyntheticSpreadResult> CreateSyntheticSpread(
CreateSyntheticSpreadRequest request,
ServerCallContext context) {
var securityId = await Invoke(
() => backend.CreateSyntheticSpreadAsync(request, context.CancellationToken),
"create_synthetic_spread");
return new CreateSyntheticSpreadResult { SecurityId = securityId };
}
async Task<T> Invoke<T>(Func<Task<T>> operation, string operationName) {
try {
return await operation();
}
catch (ArgumentException error) {
throw new RpcException(new Status(StatusCode.InvalidArgument, error.Message));
}
catch (KeyNotFoundException error) {
throw new RpcException(new Status(StatusCode.NotFound, error.Message));
}
catch (OperationCanceledException) {
throw new RpcException(new Status(
StatusCode.DeadlineExceeded,
$"{operationName} outcome is unknown because its deadline expired"));
}
catch (RpcException) {
throw;
}
catch (Exception error) {
logger.LogError(error, "Spark command {Operation} failed", operationName);
throw new RpcException(new Status(StatusCode.Unavailable, "Spark backend unavailable"));
}
}
static OrderResult ToWire(BridgeOrderResult result) => new() {
OrderId = result.OrderId,
Status = result.Status,
Message = result.Message,
SparkError = result.SparkError
};
}
@@ -0,0 +1,35 @@
using System.Globalization;
using bts.api;
using bts.api.admin;
using bts.api.orderman;
using bts.spark;
namespace Spark.Command.Bridge;
internal static class SpreadOrderSubmission {
internal static OrderDefinition BuildDefinition(
SecurityImmutable security, ExecutionStrategyImmutable strategy,
BTSAccountImmutable account, string userName, uint userId,
Side side, string tag) => new() {
SecurityKey = security.Id,
Account = account.Name,
AccountId = account.Id,
User = userName,
UserId = userId,
Side = side,
TimeInForce = TimeInForce.Gtc,
Tag = tag,
CustomDef = new CustomOrderDefinition {
Spreader = new SpreaderOrderDefinition {
Security = security.CloneToMutable(),
ExecutionStrategy = strategy.CloneToMutable()
}
}
};
internal static double ParsePrice(string value) =>
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var price)
&& double.IsFinite(price)
? price
: throw new ArgumentException("price must be a finite decimal string");
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
echo "usage: $0 <bridge-version> <spark-sdk-version> [runtime-id]" >&2
exit 2
fi
bridge_version=$1
spark_version=$2
runtime_id=${3:-linux-x64}
nuget_source=${SPARK_NUGET_SOURCE:-}
if [ -z "$nuget_source" ]; then
echo "SPARK_NUGET_SOURCE must name the feed or directory containing Bts.Spark.Sdk $spark_version" >&2
exit 2
fi
component_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
project="$component_dir/spark_command_bridge.csproj"
dist_dir="$component_dir/dist"
release_name="spark-command-bridge-$bridge_version-$runtime_id"
publish_dir="$dist_dir/$release_name"
archive="$dist_dir/$release_name.tar.gz"
if [ -e "$publish_dir" ] || [ -e "$archive" ]; then
echo "release output already exists for $release_name; remove it or choose a new version" >&2
exit 1
fi
mkdir -p "$dist_dir"
dotnet restore "$project" \
-r "$runtime_id" \
-p:Configuration=Release \
-p:UseProjectRefs=false \
-p:SparkVersion="$spark_version" \
--source "$nuget_source" \
--source https://api.nuget.org/v3/index.json
dotnet publish "$project" \
-c Release \
-r "$runtime_id" \
--self-contained true \
--no-restore \
-p:UseProjectRefs=false \
-p:SparkVersion="$spark_version" \
-p:Version="$bridge_version" \
-p:PublishSingleFile=false \
-o "$publish_dir"
mkdir -p "$publish_dir/deploy"
cp "$component_dir/deploy/spark-command-bridge.env.example" "$publish_dir/deploy/"
cp "$component_dir/deploy/spark-command-bridge.service" "$publish_dir/deploy/"
tar -C "$dist_dir" -czf "$archive" "$release_name"
(
cd "$dist_dir"
sha256sum "$release_name.tar.gz" > "$release_name.tar.gz.sha256"
)
echo "created $archive"
echo "created $archive.sha256"
@@ -0,0 +1,4 @@
SPARK_COMMAND_LISTEN=http://0.0.0.0:50051
SPARK_CONFIG_SERVER=http://127.0.0.1:8000/config
SPARK_USER=bts
SPARK_PASSWORD=replace-me
@@ -0,0 +1,21 @@
[Unit]
Description=Spark command gRPC bridge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=sparkbridge
Group=sparkbridge
WorkingDirectory=/opt/spark-command-bridge/current
Environment=HOME=/var/lib/spark-command-bridge
EnvironmentFile=/etc/spark-command-bridge/spark-command-bridge.env
ExecStart=/opt/spark-command-bridge/current/spark_command_bridge
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
StateDirectory=spark-command-bridge
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Compile Remove="tests/**/*.cs" />
<InternalsVisibleTo Include="spark_command_bridge_tests" />
<PackageReference Include="Grpc.AspNetCore" Version="2.66.0" />
<PackageReference Include="Grpc.Tools" Version="2.66.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="..\spark_command_protocol\spark_command.proto"
GrpcServices="Server"
Link="Protos\spark_command.proto" />
</ItemGroup>
<Choose>
<When Condition="'$(UseProjectRefs)' == 'true'">
<ItemGroup>
<ProjectReference Include="..\..\cs\Bts.Spark.Sdk\Bts.Spark.Sdk.csproj" />
</ItemGroup>
</When>
<Otherwise>
<ItemGroup>
<PackageReference Include="Bts.Spark.Sdk" Version="$(SparkVersion)" />
</ItemGroup>
</Otherwise>
</Choose>
</Project>
@@ -0,0 +1,123 @@
using Spark.Command.Bridge;
using bts.api;
using bts.api.admin;
using bts.api.orderman;
using bts.spark;
using bts.config;
using Spark.Command.V1;
if (args.Length == 1 && args[0] == "--server-defaults") {
if (!await mbts.config.Configuration.InitializeAsync("http://127.0.0.1:8000/config")) {
throw new Exception("Could not initialize server configuration");
}
Console.WriteLine("SYNTHETIC_DEFAULTS=" + Google.Protobuf.JsonFormatter.Default.Format(
mbts.config.Configuration.get<SyntheticSpreadConfig>()));
Environment.Exit(0);
}
var security = new Security {
Id = 969, Exchange = Venue.Bts,
Synthetic = new SyntheticContract { Name = "test-spread" }
};
security.Synthetic.Legs.Add(new ContractLeg { Security = new Security { Id = 11 } });
security.Synthetic.Legs.Add(new ContractLeg { Security = new Security { Id = 12 } });
var strategy = new ExecutionStrategy {
SpreaderType = ExecutionStrategy.Types.SpreaderType.Sniper,
CancelHedgesOnCancel = true
};
strategy.LegParamsMap[11] = new LegSpreaderParams { EnableHedging = true };
var account = new BTSAccount { Id = 7, Name = "ACC A" };
var definition = SpreadOrderSubmission.BuildDefinition(
new SecurityImmutable(security), new ExecutionStrategyImmutable(strategy),
new BTSAccountImmutable(account), "bts", 3, bts.api.Side.Sell, "spread-regression");
Check(definition.CustomDef.CustomCase == CustomOrderDefinition.CustomOneofCase.Spreader,
"Synthetic orders must select the spreader engine rather than native routing");
Check(definition.SecurityKey == 969 && definition.CustomDef.Spreader.Security.Synthetic.Legs.Count == 2,
"Both synthetic legs must reach the spreader engine");
Check(definition.CustomDef.Spreader.ExecutionStrategy.Equals(strategy),
"Configured execution strategy must be preserved");
Check(definition.AccountId == 7 && definition.UserId == 3 && definition.Side == bts.api.Side.Sell
&& definition.Tag == "spread-regression" && definition.TimeInForce == TimeInForce.Gtc,
"Routing identity, side, tag, and time in force must be preserved");
definition.CustomDef.Spreader.ExecutionStrategy.CancelHedgesOnCancel = false;
Check(strategy.CancelHedgesOnCancel, "Order overrides must not mutate cached strategy");
foreach (var text in new[] { "-0.04", "0", "0.04" }) {
Check(double.IsFinite(SpreadOrderSubmission.ParsePrice(text)), "Finite spread prices must be accepted");
}
foreach (var text in new[] { "NaN", "Infinity", "-Infinity", "1e1000", "invalid" }) {
try {
SpreadOrderSubmission.ParsePrice(text);
throw new Exception($"Invalid price accepted: {text}");
} catch (ArgumentException) { }
}
Console.WriteLine("Spread order regression checks passed.");
var defaultPreset = new SyntheticSpreadPreset {
Currency = "USD", TickSize = 0.00001, CashPerPoint = 1, PriceMultiplier = 1,
CancelHedgesOnCancel = true, HedgePricing = 1, LeanQuantityRatio = 1,
Leg1 = new SyntheticSpreadLegDefaults {
Quantity = 1, PriceMultiplier = 1, Side = bts.api.Side.Buy,
IsActiveQuoting = true, EnableHedging = true
},
Leg2 = new SyntheticSpreadLegDefaults {
Quantity = 1, PriceMultiplier = 1, Side = bts.api.Side.Sell,
IsActiveQuoting = true, EnableHedging = true
}
};
var config = new SyntheticSpreadConfig();
config.Preset.Add(defaultPreset);
var first = new SecurityImmutable(new Security {
Id = 31, Exchange = Venue.Okx, Symbol = "SOL", Currency = "USDC",
Spot = new SpotContract(), Okx = new OkxExchangeData { SecurityDesc = "SOL-USDC" },
ContractSpec = new ContractSpecification { LowTick = 0.01, CashPerPoint = 1, PriceMultiplier = 1, SubFraction = 160 }
});
var second = new SecurityImmutable(new Security {
Id = 50, Exchange = Venue.Orca, Spot = new SpotContract(),
Orca = new OrcaExchangeData { SecurityDesc = "SOL-USDC-5" }
});
var request = new CreateSyntheticSpreadRequest();
var built = SparkCommandBackend.BuildSyntheticSpread(request, first, second, config);
Check(built.strategy.LegParamsMap.Values.All(p => p.IsActiveQuoting && p.EnableHedging),
"Omitted leg flags must enable quoting and hedging like the C# preset");
Check(built.strategy.CancelHedgesOnCancel && (int)built.strategy.HedgePricing == 1,
"Strategy must use C# cancellation and hedge-pricing defaults");
Check(built.security.Currency == "USD" && built.security.ContractSpec.LowTick == 0.00001
&& built.security.ContractSpec.MainFraction == 1 && built.security.ContractSpec.SubFraction == 100000,
"Contract defaults must come from C# configuration, not first-leg metadata");
Check(built.security.Synthetic.Legs[1].PriceMultiplier == -1 && defaultPreset.Leg2.PriceMultiplier == 1,
"Second leg must default to -1 without mutating the shared preset");
request.Leg1 = new SyntheticLeg { IsActiveQuoting = false, EnableHedging = false, Quantity = "2", PriceMultiplier = "3" };
request.Leg2 = new SyntheticLeg { PriceMultiplier = "-2" };
request.CancelHedgesOnCancel = false;
request.HedgePricing = HedgePricing.CachedDrivers;
request.Currency = "USDC";
request.TickSize = "0.01";
built = SparkCommandBackend.BuildSyntheticSpread(request, first, second, config);
Check(!built.strategy.LegParamsMap[31].IsActiveQuoting && !built.strategy.LegParamsMap[31].EnableHedging
&& built.strategy.LegParamsMap[50].IsActiveQuoting && !built.strategy.CancelHedgesOnCancel,
"Explicit false flags must override configured true values");
Check(built.security.Currency == "USDC" && built.security.ContractSpec.LowTick == 0.01
&& built.security.Synthetic.Legs[0].Quantity == 2 && built.security.Synthetic.Legs[1].PriceMultiplier == -2,
"Explicit contract and leg overrides must win over presets");
var venuePreset = defaultPreset.Clone();
venuePreset.FirstLegExchange = Venue.Okx;
venuePreset.FirstLegExchangeSecurityName = "*";
var exactPreset = venuePreset.Clone();
exactPreset.FirstLegExchangeSecurityName = "SOL-USDC";
config.Preset.Add(venuePreset);
config.Preset.Add(exactPreset);
Check(ReferenceEquals(SparkCommandBackend.ResolvePreset(config, Venue.Okx, "sol-usdc"), exactPreset),
"Exact preset must take precedence over venue and global wildcards");
Check(ReferenceEquals(SparkCommandBackend.ResolvePreset(config, Venue.Okx, "OTHER"), venuePreset),
"Venue wildcard must take precedence over global wildcard");
Check(ReferenceEquals(SparkCommandBackend.ResolvePreset(config, Venue.Orca, "OTHER"), defaultPreset),
"Global preset must apply to unmatched venues");
try {
SparkCommandBackend.BuildSyntheticSpread(request, first, second, new SyntheticSpreadConfig());
throw new Exception("Missing preset accepted");
} catch (InvalidOperationException) { }
Console.WriteLine("C# synthetic spread default parity checks passed.");
static void Check(bool condition, string message) {
if (!condition) throw new Exception(message);
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../spark_command_bridge.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,3 @@
build/
dist/
src/*.egg-info/
+36
View File
@@ -0,0 +1,36 @@
# Spark command client
An independently packaged async Python client for the standalone Spark command
bridge. It contains generated public bridge messages and does not import
Quickbus, `libspark`, Spark internal protobufs, BlueOrb, or the public WebSocket
server.
```python
from spark_command_client import SparkCommandClient
async with SparkCommandClient("127.0.0.1:50051") as client:
result = await client.enter_order(
client_order_id="strategy-a-1",
account="ACC-A",
security_id="123",
side="buy",
order_type="limit",
quantity="2",
price="100.25",
)
```
The target is a gRPC authority such as `127.0.0.1:50051`, without an HTTP URL
scheme.
## Release
Set the release version in `pyproject.toml`, then build the wheel, source
distribution, and checksums:
```sh
PYTHON=python3.10 ./build-release.sh 0.1.0
```
The complete release and independent deployment process is documented in
[`../RELEASE_AND_DEPLOYMENT.md`](../RELEASE_AND_DEPLOYMENT.md).
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -ne 1 ]; then
echo "usage: $0 <version-from-pyproject>" >&2
exit 2
fi
requested_version=$1
component_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
python_bin=${PYTHON:-python3}
declared_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$component_dir/pyproject.toml")
if [ "$requested_version" != "$declared_version" ]; then
echo "requested version $requested_version does not match pyproject.toml version $declared_version" >&2
exit 1
fi
dist_dir="$component_dir/dist"
wheel_name="spark_command_client-$declared_version-py3-none-any.whl"
sdist_name="spark_command_client-$declared_version.tar.gz"
if [ -e "$dist_dir/$wheel_name" ] || [ -e "$dist_dir/$sdist_name" ]; then
echo "release output already exists for $declared_version; remove it or bump the package version" >&2
exit 1
fi
mkdir -p "$dist_dir"
"$python_bin" -m build --wheel --sdist --outdir "$dist_dir" "$component_dir"
(
cd "$dist_dir"
sha256sum "$wheel_name" "$sdist_name" > "spark-command-client-$declared_version.sha256"
)
echo "created $dist_dir/$wheel_name"
echo "created $dist_dir/$sdist_name"
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "spark-command-client"
version = "0.1.2"
description = "Async Python client for the standalone Spark command bridge"
requires-python = ">=3.10"
dependencies = [
"grpcio>=1.84,<2",
"protobuf>=6.31,<8",
]
[project.optional-dependencies]
test = ["grpcio-tools>=1.84,<2", "pytest>=8,<10", "pytest-asyncio>=0.24,<1"]
[tool.setuptools.packages.find]
where = ["src"]
@@ -0,0 +1,13 @@
from .client import (
BridgeError,
CommandUnavailable,
SparkCommandClient,
create_sdk,
)
__all__ = [
"BridgeError",
"CommandUnavailable",
"SparkCommandClient",
"create_sdk",
]
@@ -0,0 +1,237 @@
"""Async facade over the generated Spark command bridge client."""
from __future__ import annotations
import os
from collections.abc import Mapping
from typing import Any
import grpc
from .generated import spark_command_pb2 as messages
from .generated import spark_command_pb2_grpc as services
class BridgeError(RuntimeError):
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
self.message = message
class CommandUnavailable(BridgeError):
pass
_SIDES = {"buy": messages.SIDE_BUY, "sell": messages.SIDE_SELL}
_ORDER_TYPES = {
"market": messages.ORDER_TYPE_MARKET,
"limit": messages.ORDER_TYPE_LIMIT,
}
_PRICE_CALCULATIONS = {
"standard": messages.PRICE_CALCULATION_STANDARD,
"net_change": messages.PRICE_CALCULATION_NET_CHANGE,
"notional_leg": messages.PRICE_CALCULATION_NOTIONAL_LEG,
}
_HEDGE_PRICING = {
"cached_drivers": messages.HEDGE_PRICING_CACHED_DRIVERS,
"recalculate_crosses": messages.HEDGE_PRICING_RECALCULATE_CROSSES,
}
_SPREADER_TYPES = {
"requote": messages.SPREADER_TYPE_REQUOTE,
"reverse_hedge": messages.SPREADER_TYPE_REVERSE_HEDGE,
"sniper": messages.SPREADER_TYPE_SNIPER,
}
_MARKET_DATA_TYPES = {
"merged": messages.MARKET_DATA_TYPE_MERGED,
"direct": messages.MARKET_DATA_TYPE_DIRECT,
}
class SparkCommandClient:
def __init__(
self,
address: str,
*,
timeout: float = 10.0,
channel: grpc.aio.Channel | None = None,
) -> None:
self._address = address
self._timeout = timeout
self._owns_channel = channel is None
self._channel = channel
self._stub: services.SparkCommandServiceStub | None = None
async def __aenter__(self) -> SparkCommandClient:
return self
async def __aexit__(self, *_: object) -> None:
await self.close()
async def close(self) -> None:
if self._owns_channel and self._channel is not None:
await self._channel.close()
async def health(self) -> dict[str, Any]:
response = await self._call(self._service().Health, messages.HealthRequest())
return {"ready": response.ready, "message": response.message}
async def enter_order(self, request: Any = None, **values: Any) -> dict[str, Any]:
data = _request_values(request, values)
message = messages.EnterOrderRequest(
client_order_id=str(data["client_order_id"]),
account=str(data["account"]),
side=_SIDES[str(data["side"])],
order_type=_ORDER_TYPES[str(data["order_type"])],
quantity=str(data["quantity"]),
)
if data.get("security_id") is not None:
message.security_id = str(data["security_id"])
if data.get("exchange") is not None:
message.exchange = str(data["exchange"])
if data.get("exchange_security_name") is not None:
message.exchange_security_name = str(data["exchange_security_name"])
if data.get("price") is not None:
message.price = str(data["price"])
return _order_result(await self._call(self._service().EnterOrder, message))
async def update_order(self, request: Any = None, **values: Any) -> dict[str, Any]:
data = _request_values(request, values)
message = messages.UpdateOrderRequest(order_id=str(data["order_id"]))
if data.get("quantity") is not None:
message.quantity = str(data["quantity"])
if data.get("price") is not None:
message.price = str(data["price"])
return _order_result(await self._call(self._service().UpdateOrder, message))
async def delete_order(self, request: Any = None, **values: Any) -> dict[str, Any]:
data = _request_values(request, values)
message = messages.DeleteOrderRequest(order_id=str(data["order_id"]))
return _order_result(await self._call(self._service().DeleteOrder, message))
async def create_synthetic_spread(
self, request: Any = None, **values: Any
) -> dict[str, Any]:
data = _request_values(request, values)
message = messages.CreateSyntheticSpreadRequest(
leg1=_leg(data, "leg1", default_side="buy", default_multiplier="1"),
leg2=_leg(data, "leg2", default_side="sell", default_multiplier="-1"),
)
_set_optional(message, data, "name", str)
_set_optional(message, data, "symbol", str)
_set_optional(message, data, "currency", str)
_set_optional(message, data, "tick_size", str)
_set_optional(message, data, "cash_per_point", str)
_set_optional(message, data, "price_multiplier", str)
_set_optional(message, data, "main_fraction", int)
_set_optional(message, data, "sub_fraction", int)
_set_optional(message, data, "price_display_format", int)
_set_enum(message, data, "price_calc", "price_calculation", _PRICE_CALCULATIONS)
_set_optional(message, data, "cancel_hedges_on_cancel", bool)
_set_enum(message, data, "hedge_pricing", "hedge_pricing", _HEDGE_PRICING)
_set_enum(message, data, "spreader_type", "spreader_type", _SPREADER_TYPES)
_set_optional(message, data, "lean_quantity_ratio", str)
response = await self._call(self._service().CreateSyntheticSpread, message)
return {"security_id": response.security_id}
def _service(self) -> services.SparkCommandServiceStub:
if self._channel is None:
self._channel = grpc.aio.insecure_channel(self._address)
if self._stub is None:
self._stub = services.SparkCommandServiceStub(self._channel)
return self._stub
async def _call(self, method: Any, request: Any) -> Any:
try:
return await method(request, timeout=self._timeout)
except grpc.aio.AioRpcError as exc:
code = exc.code()
message = exc.details() or code.name
if code == grpc.StatusCode.DEADLINE_EXCEEDED:
raise BridgeError("outcome_unknown", message) from exc
if code == grpc.StatusCode.UNAVAILABLE:
raise CommandUnavailable("backend_unavailable", message) from exc
if code == grpc.StatusCode.INVALID_ARGUMENT:
raise BridgeError("invalid_params", message) from exc
if code == grpc.StatusCode.NOT_FOUND:
raise BridgeError("not_found", message) from exc
raise BridgeError("bridge_error", message) from exc
def create_sdk() -> SparkCommandClient:
return SparkCommandClient(
os.environ.get("SPARK_COMMAND_ADDRESS", "127.0.0.1:50051"),
timeout=float(os.environ.get("SPARK_COMMAND_TIMEOUT", "10")),
)
def _request_values(request: Any, explicit: Mapping[str, Any]) -> dict[str, Any]:
if request is None:
return dict(explicit)
if explicit:
raise TypeError("pass either a request object or keyword values")
if hasattr(request, "model_dump"):
return request.model_dump(mode="python")
if isinstance(request, Mapping):
return dict(request)
return dict(vars(request))
def _leg(
data: Mapping[str, Any], prefix: str, *, default_side: str, default_multiplier: str
) -> messages.SyntheticLeg:
message = messages.SyntheticLeg(
price_multiplier=str(data.get(f"{prefix}_price_multiplier") or default_multiplier),
side=_SIDES[str(data.get(f"{prefix}_side") or default_side)],
)
security_id = data.get(f"{prefix}_security_id")
if security_id is not None:
message.security_id = str(security_id)
exchange = data.get(f"{prefix}_exchange")
if exchange is not None:
message.exchange = str(exchange)
exchange_security_name = data.get(f"{prefix}_exchange_security_name")
if exchange_security_name is not None:
message.exchange_security_name = str(exchange_security_name)
for suffix, converter in (
("quantity", str),
("leg_delta", str),
("is_active_quoting", bool),
("enable_hedging", bool),
):
key = f"{prefix}_{suffix}"
if data.get(key) is not None:
setattr(message, suffix, converter(data[key]))
market_data_type = data.get(f"{prefix}_market_data_type")
if market_data_type is not None:
message.market_data_type = _MARKET_DATA_TYPES[str(market_data_type)]
return message
def _set_optional(message: Any, data: Mapping[str, Any], key: str, converter: Any) -> None:
if data.get(key) is not None:
setattr(message, key, converter(data[key]))
def _set_enum(
message: Any,
data: Mapping[str, Any],
source_key: str,
target_key: str,
values: Mapping[str, int],
) -> None:
if data.get(source_key) is not None:
setattr(message, target_key, values[str(data[source_key])])
def _order_result(response: Any) -> dict[str, Any]:
statuses = {
messages.COMMAND_STATUS_ACCEPTED: "accepted",
messages.COMMAND_STATUS_REJECTED: "rejected",
messages.COMMAND_STATUS_UNKNOWN: "pending",
}
return {
"order_id": response.order_id,
"status": statuses.get(response.status, "pending"),
"message": response.message or None,
}
@@ -0,0 +1 @@
"""Generated gRPC modules for spark.command.v1."""
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: spark_command.proto
# Protobuf Python Version: 7.35.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
7,
35,
1,
'',
'spark_command.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13spark_command.proto\x12\x10spark.command.v1\"\xbd\x02\n\x11\x45nterOrderRequest\x12\x17\n\x0f\x63lient_order_id\x18\x01 \x01(\t\x12\x0f\n\x07\x61\x63\x63ount\x18\x02 \x01(\t\x12\x13\n\x0bsecurity_id\x18\x03 \x01(\t\x12$\n\x04side\x18\x04 \x01(\x0e\x32\x16.spark.command.v1.Side\x12/\n\norder_type\x18\x05 \x01(\x0e\x32\x1b.spark.command.v1.OrderType\x12\x10\n\x08quantity\x18\x06 \x01(\t\x12\x12\n\x05price\x18\x07 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08\x65xchange\x18\x08 \x01(\tH\x01\x88\x01\x01\x12#\n\x16\x65xchange_security_name\x18\t \x01(\tH\x02\x88\x01\x01\x42\x08\n\x06_priceB\x0b\n\t_exchangeB\x19\n\x17_exchange_security_name\"h\n\x12UpdateOrderRequest\x12\x10\n\x08order_id\x18\x01 \x01(\t\x12\x15\n\x08quantity\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x12\n\x05price\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x0b\n\t_quantityB\x08\n\x06_price\"&\n\x12\x44\x65leteOrderRequest\x12\x10\n\x08order_id\x18\x01 \x01(\t\"v\n\x0bOrderResult\x12\x10\n\x08order_id\x18\x01 \x01(\t\x12/\n\x06status\x18\x02 \x01(\x0e\x32\x1f.spark.command.v1.CommandStatus\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\x13\n\x0bspark_error\x18\x04 \x01(\t\"\xf5\x03\n\x0cSyntheticLeg\x12\x13\n\x0bsecurity_id\x18\x01 \x01(\t\x12\x15\n\x08quantity\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x1d\n\x10price_multiplier\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x16\n\tleg_delta\x18\x04 \x01(\tH\x02\x88\x01\x01\x12)\n\x04side\x18\x05 \x01(\x0e\x32\x16.spark.command.v1.SideH\x03\x88\x01\x01\x12\x1e\n\x11is_active_quoting\x18\x06 \x01(\x08H\x04\x88\x01\x01\x12\x1b\n\x0e\x65nable_hedging\x18\x07 \x01(\x08H\x05\x88\x01\x01\x12?\n\x10market_data_type\x18\x08 \x01(\x0e\x32 .spark.command.v1.MarketDataTypeH\x06\x88\x01\x01\x12\x15\n\x08\x65xchange\x18\t \x01(\tH\x07\x88\x01\x01\x12#\n\x16\x65xchange_security_name\x18\n \x01(\tH\x08\x88\x01\x01\x42\x0b\n\t_quantityB\x13\n\x11_price_multiplierB\x0c\n\n_leg_deltaB\x07\n\x05_sideB\x14\n\x12_is_active_quotingB\x11\n\x0f_enable_hedgingB\x13\n\x11_market_data_typeB\x0b\n\t_exchangeB\x19\n\x17_exchange_security_name\"\xec\x06\n\x1c\x43reateSyntheticSpreadRequest\x12,\n\x04leg1\x18\x01 \x01(\x0b\x32\x1e.spark.command.v1.SyntheticLeg\x12,\n\x04leg2\x18\x02 \x01(\x0b\x32\x1e.spark.command.v1.SyntheticLeg\x12\x11\n\x04name\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06symbol\x18\x04 \x01(\tH\x01\x88\x01\x01\x12\x15\n\x08\x63urrency\x18\x05 \x01(\tH\x02\x88\x01\x01\x12\x16\n\ttick_size\x18\x06 \x01(\tH\x03\x88\x01\x01\x12\x1b\n\x0e\x63\x61sh_per_point\x18\x07 \x01(\tH\x04\x88\x01\x01\x12\x1d\n\x10price_multiplier\x18\x08 \x01(\tH\x05\x88\x01\x01\x12\x1a\n\rmain_fraction\x18\t \x01(\rH\x06\x88\x01\x01\x12\x19\n\x0csub_fraction\x18\n \x01(\rH\x07\x88\x01\x01\x12!\n\x14price_display_format\x18\x0b \x01(\rH\x08\x88\x01\x01\x12\x42\n\x11price_calculation\x18\x0c \x01(\x0e\x32\".spark.command.v1.PriceCalculationH\t\x88\x01\x01\x12$\n\x17\x63\x61ncel_hedges_on_cancel\x18\r \x01(\x08H\n\x88\x01\x01\x12:\n\rhedge_pricing\x18\x0e \x01(\x0e\x32\x1e.spark.command.v1.HedgePricingH\x0b\x88\x01\x01\x12:\n\rspreader_type\x18\x0f \x01(\x0e\x32\x1e.spark.command.v1.SpreaderTypeH\x0c\x88\x01\x01\x12 \n\x13lean_quantity_ratio\x18\x10 \x01(\tH\r\x88\x01\x01\x42\x07\n\x05_nameB\t\n\x07_symbolB\x0b\n\t_currencyB\x0c\n\n_tick_sizeB\x11\n\x0f_cash_per_pointB\x13\n\x11_price_multiplierB\x10\n\x0e_main_fractionB\x0f\n\r_sub_fractionB\x17\n\x15_price_display_formatB\x14\n\x12_price_calculationB\x1a\n\x18_cancel_hedges_on_cancelB\x10\n\x0e_hedge_pricingB\x10\n\x0e_spreader_typeB\x16\n\x14_lean_quantity_ratio\"2\n\x1b\x43reateSyntheticSpreadResult\x12\x13\n\x0bsecurity_id\x18\x01 \x01(\t\"\x0f\n\rHealthRequest\".\n\x0cHealthResult\x12\r\n\x05ready\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t*9\n\x04Side\x12\x14\n\x10SIDE_UNSPECIFIED\x10\x00\x12\x0c\n\x08SIDE_BUY\x10\x01\x12\r\n\tSIDE_SELL\x10\x02*T\n\tOrderType\x12\x1a\n\x16ORDER_TYPE_UNSPECIFIED\x10\x00\x12\x15\n\x11ORDER_TYPE_MARKET\x10\x01\x12\x14\n\x10ORDER_TYPE_LIMIT\x10\x02*\x85\x01\n\rCommandStatus\x12\x1e\n\x1a\x43OMMAND_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n\x17\x43OMMAND_STATUS_ACCEPTED\x10\x01\x12\x1b\n\x17\x43OMMAND_STATUS_REJECTED\x10\x02\x12\x1a\n\x16\x43OMMAND_STATUS_UNKNOWN\x10\x03*x\n\x10PriceCalculation\x12\x1e\n\x1aPRICE_CALCULATION_STANDARD\x10\x00\x12 \n\x1cPRICE_CALCULATION_NET_CHANGE\x10\x01\x12\"\n\x1ePRICE_CALCULATION_NOTIONAL_LEG\x10\x02*W\n\x0cHedgePricing\x12 \n\x1cHEDGE_PRICING_CACHED_DRIVERS\x10\x00\x12%\n!HEDGE_PRICING_RECALCULATE_CROSSES\x10\x01*d\n\x0cSpreaderType\x12\x19\n\x15SPREADER_TYPE_REQUOTE\x10\x00\x12\x1f\n\x1bSPREADER_TYPE_REVERSE_HEDGE\x10\x01\x12\x18\n\x14SPREADER_TYPE_SNIPER\x10\x02*J\n\x0eMarketDataType\x12\x1b\n\x17MARKET_DATA_TYPE_MERGED\x10\x00\x12\x1b\n\x17MARKET_DATA_TYPE_DIRECT\x10\x01\x32\xd2\x03\n\x13SparkCommandService\x12P\n\nEnterOrder\x12#.spark.command.v1.EnterOrderRequest\x1a\x1d.spark.command.v1.OrderResult\x12R\n\x0bUpdateOrder\x12$.spark.command.v1.UpdateOrderRequest\x1a\x1d.spark.comLine truncated
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'spark_command_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
_globals['DESCRIPTOR']._loaded_options = None
_globals['DESCRIPTOR']._serialized_options = b'\252\002\020Spark.Command.V1'
_globals['_SIDE']._serialized_start=2127
_globals['_SIDE']._serialized_end=2184
_globals['_ORDERTYPE']._serialized_start=2186
_globals['_ORDERTYPE']._serialized_end=2270
_globals['_COMMANDSTATUS']._serialized_start=2273
_globals['_COMMANDSTATUS']._serialized_end=2406
_globals['_PRICECALCULATION']._serialized_start=2408
_globals['_PRICECALCULATION']._serialized_end=2528
_globals['_HEDGEPRICING']._serialized_start=2530
_globals['_HEDGEPRICING']._serialized_end=2617
_globals['_SPREADERTYPE']._serialized_start=2619
_globals['_SPREADERTYPE']._serialized_end=2719
_globals['_MARKETDATATYPE']._serialized_start=2721
_globals['_MARKETDATATYPE']._serialized_end=2795
_globals['_ENTERORDERREQUEST']._serialized_start=42
_globals['_ENTERORDERREQUEST']._serialized_end=359
_globals['_UPDATEORDERREQUEST']._serialized_start=361
_globals['_UPDATEORDERREQUEST']._serialized_end=465
_globals['_DELETEORDERREQUEST']._serialized_start=467
_globals['_DELETEORDERREQUEST']._serialized_end=505
_globals['_ORDERRESULT']._serialized_start=507
_globals['_ORDERRESULT']._serialized_end=625
_globals['_SYNTHETICLEG']._serialized_start=628
_globals['_SYNTHETICLEG']._serialized_end=1129
_globals['_CREATESYNTHETICSPREADREQUEST']._serialized_start=1132
_globals['_CREATESYNTHETICSPREADREQUEST']._serialized_end=2008
_globals['_CREATESYNTHETICSPREADRESULT']._serialized_start=2010
_globals['_CREATESYNTHETICSPREADRESULT']._serialized_end=2060
_globals['_HEALTHREQUEST']._serialized_start=2062
_globals['_HEALTHREQUEST']._serialized_end=2077
_globals['_HEALTHRESULT']._serialized_start=2079
_globals['_HEALTHRESULT']._serialized_end=2125
_globals['_SPARKCOMMANDSERVICE']._serialized_start=2798
_globals['_SPARKCOMMANDSERVICE']._serialized_end=3264
# @@protoc_insertion_point(module_scope)
@@ -0,0 +1,269 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
from . import spark_command_pb2 as spark__command__pb2
GRPC_GENERATED_VERSION = '1.84.0'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ ' but the generated code in spark_command_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class SparkCommandServiceStub:
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.EnterOrder = channel.unary_unary(
'/spark.command.v1.SparkCommandService/EnterOrder',
request_serializer=spark__command__pb2.EnterOrderRequest.SerializeToString,
response_deserializer=spark__command__pb2.OrderResult.FromString,
_registered_method=True)
self.UpdateOrder = channel.unary_unary(
'/spark.command.v1.SparkCommandService/UpdateOrder',
request_serializer=spark__command__pb2.UpdateOrderRequest.SerializeToString,
response_deserializer=spark__command__pb2.OrderResult.FromString,
_registered_method=True)
self.DeleteOrder = channel.unary_unary(
'/spark.command.v1.SparkCommandService/DeleteOrder',
request_serializer=spark__command__pb2.DeleteOrderRequest.SerializeToString,
response_deserializer=spark__command__pb2.OrderResult.FromString,
_registered_method=True)
self.CreateSyntheticSpread = channel.unary_unary(
'/spark.command.v1.SparkCommandService/CreateSyntheticSpread',
request_serializer=spark__command__pb2.CreateSyntheticSpreadRequest.SerializeToString,
response_deserializer=spark__command__pb2.CreateSyntheticSpreadResult.FromString,
_registered_method=True)
self.Health = channel.unary_unary(
'/spark.command.v1.SparkCommandService/Health',
request_serializer=spark__command__pb2.HealthRequest.SerializeToString,
response_deserializer=spark__command__pb2.HealthResult.FromString,
_registered_method=True)
class SparkCommandServiceServicer:
"""Missing associated documentation comment in .proto file."""
def EnterOrder(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def UpdateOrder(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def DeleteOrder(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def CreateSyntheticSpread(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def Health(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_SparkCommandServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'EnterOrder': grpc.unary_unary_rpc_method_handler(
servicer.EnterOrder,
request_deserializer=spark__command__pb2.EnterOrderRequest.FromString,
response_serializer=spark__command__pb2.OrderResult.SerializeToString,
),
'UpdateOrder': grpc.unary_unary_rpc_method_handler(
servicer.UpdateOrder,
request_deserializer=spark__command__pb2.UpdateOrderRequest.FromString,
response_serializer=spark__command__pb2.OrderResult.SerializeToString,
),
'DeleteOrder': grpc.unary_unary_rpc_method_handler(
servicer.DeleteOrder,
request_deserializer=spark__command__pb2.DeleteOrderRequest.FromString,
response_serializer=spark__command__pb2.OrderResult.SerializeToString,
),
'CreateSyntheticSpread': grpc.unary_unary_rpc_method_handler(
servicer.CreateSyntheticSpread,
request_deserializer=spark__command__pb2.CreateSyntheticSpreadRequest.FromString,
response_serializer=spark__command__pb2.CreateSyntheticSpreadResult.SerializeToString,
),
'Health': grpc.unary_unary_rpc_method_handler(
servicer.Health,
request_deserializer=spark__command__pb2.HealthRequest.FromString,
response_serializer=spark__command__pb2.HealthResult.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'spark.command.v1.SparkCommandService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('spark.command.v1.SparkCommandService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class SparkCommandService:
"""Missing associated documentation comment in .proto file."""
@staticmethod
def EnterOrder(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/spark.command.v1.SparkCommandService/EnterOrder',
spark__command__pb2.EnterOrderRequest.SerializeToString,
spark__command__pb2.OrderResult.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def UpdateOrder(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/spark.command.v1.SparkCommandService/UpdateOrder',
spark__command__pb2.UpdateOrderRequest.SerializeToString,
spark__command__pb2.OrderResult.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def DeleteOrder(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/spark.command.v1.SparkCommandService/DeleteOrder',
spark__command__pb2.DeleteOrderRequest.SerializeToString,
spark__command__pb2.OrderResult.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def CreateSyntheticSpread(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/spark.command.v1.SparkCommandService/CreateSyntheticSpread',
spark__command__pb2.CreateSyntheticSpreadRequest.SerializeToString,
spark__command__pb2.CreateSyntheticSpreadResult.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def Health(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/spark.command.v1.SparkCommandService/Health',
spark__command__pb2.HealthRequest.SerializeToString,
spark__command__pb2.HealthResult.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@@ -0,0 +1,110 @@
import asyncio
from concurrent.futures import ThreadPoolExecutor
import grpc
from spark_command_client import SparkCommandClient
from spark_command_client.generated import spark_command_pb2 as messages
from spark_command_client.generated import spark_command_pb2_grpc as services
class FakeBridge(services.SparkCommandServiceServicer):
def __init__(self):
self.entered = []
self.spreads = []
def Health(self, request, context):
return messages.HealthResult(ready=True, message="ready")
def EnterOrder(self, request, context):
self.entered.append(request)
return messages.OrderResult(
order_id="101",
status=messages.COMMAND_STATUS_ACCEPTED,
)
def UpdateOrder(self, request, context):
return messages.OrderResult(
order_id=request.order_id,
status=messages.COMMAND_STATUS_ACCEPTED,
)
def DeleteOrder(self, request, context):
return messages.OrderResult(
order_id=request.order_id,
status=messages.COMMAND_STATUS_ACCEPTED,
)
def CreateSyntheticSpread(self, request, context):
self.spreads.append(request)
return messages.CreateSyntheticSpreadResult(security_id="303")
def test_async_client_maps_all_commands():
bridge = FakeBridge()
server = grpc.server(ThreadPoolExecutor(max_workers=2))
services.add_SparkCommandServiceServicer_to_server(bridge, server)
port = server.add_insecure_port("127.0.0.1:0")
server.start()
async def run():
async with SparkCommandClient(f"127.0.0.1:{port}") as client:
assert (await client.health())["ready"]
entered = await client.enter_order(
client_order_id="client-1",
account="ACC-A",
security_id="11",
side="buy",
order_type="limit",
quantity="2",
price="100.25",
)
assert entered == {"order_id": "101", "status": "accepted", "message": None}
assert bridge.entered[0].price == "100.25"
named_order = await client.enter_order(
client_order_id="client-2",
account="ACC-A",
exchange="okx",
exchange_security_name="SOL-USDC",
side="buy",
order_type="limit",
quantity="1",
price="1.00",
)
assert named_order["status"] == "accepted"
assert bridge.entered[1].security_id == ""
assert bridge.entered[1].exchange == "okx"
assert bridge.entered[1].exchange_security_name == "SOL-USDC"
updated = await client.update_order(order_id="101", quantity="3")
assert updated["order_id"] == "101"
deleted = await client.delete_order(order_id="101")
assert deleted["status"] == "accepted"
spread = await client.create_synthetic_spread(
leg1_security_id="11",
leg2_security_id="12",
tick_size="0.01",
leg2_price_multiplier="-1",
)
assert spread == {"security_id": "303"}
assert bridge.spreads[0].leg1.security_id == "11"
assert bridge.spreads[0].leg2.price_multiplier == "-1"
named_spread = await client.create_synthetic_spread(
leg1_exchange="okx",
leg1_exchange_security_name="SOL-USDC",
leg2_exchange="orca",
leg2_exchange_security_name="SOL-USDC-1",
)
assert named_spread == {"security_id": "303"}
assert bridge.spreads[1].leg1.exchange == "okx"
assert bridge.spreads[1].leg1.exchange_security_name == "SOL-USDC"
assert bridge.spreads[1].leg2.exchange == "orca"
assert bridge.spreads[1].leg2.exchange_security_name == "SOL-USDC-1"
try:
asyncio.run(run())
finally:
server.stop(grace=0).wait()
@@ -0,0 +1,58 @@
# Spark command protocol
This directory owns the versioned, command-only gRPC boundary between the
standalone Spark command bridge and its generated clients. It intentionally
does not import or expose Spark's internal protobuf messages.
Version 1 supports order entry, update, cancellation, synthetic-spread
creation, and health. It has no query or public subscription RPCs.
Decimal values are encoded as invariant-culture strings at this boundary.
`security_id` and `order_id` are public strings; when an ID is supplied, the
bridge accepts the numeric Spark keys produced by the existing Spark APIs and
keeps their representation out of the Python gateway.
## Security locators
`EnterOrderRequest` supports two ways to identify its security:
- `security_id`
- `exchange` together with `exchange_security_name`
A request must use exactly one locator form. It must not combine
`security_id` with either named-locator field, and both named-locator fields
are required when that form is used. The bridge resolves the named form
case-insensitively against its live Spark security cache before submitting the
order. Existing `security_id` clients remain wire-compatible.
For example, a named-security limit order uses these protocol fields:
```text
client_order_id: "strategy-a-0001"
account: "ACC A"
exchange: "okx"
exchange_security_name: "SOL-USDC"
side: SIDE_BUY
order_type: ORDER_TYPE_LIMIT
quantity: "2"
price: "100.25"
```
`SyntheticLeg` uses the same mutually exclusive locator rule for each leg of
`CreateSyntheticSpreadRequest`.
## Regenerating bindings
The bridge project generates its C# service types during `dotnet build`. Refresh
the committed Python bindings whenever `spark_command.proto` changes:
```sh
python -m grpc_tools.protoc \
-I src/client/spark_command_protocol \
--python_out=src/client/spark_command_client/src/spark_command_client/generated \
--grpc_python_out=src/client/spark_command_client/src/spark_command_client/generated \
src/client/spark_command_protocol/spark_command.proto
sed -i \
's/^import spark_command_pb2 as spark__command__pb2$/from . import spark_command_pb2 as spark__command__pb2/' \
src/client/spark_command_client/src/spark_command_client/generated/spark_command_pb2_grpc.py
```
@@ -0,0 +1,126 @@
syntax = "proto3";
package spark.command.v1;
option csharp_namespace = "Spark.Command.V1";
service SparkCommandService {
rpc EnterOrder(EnterOrderRequest) returns (OrderResult);
rpc UpdateOrder(UpdateOrderRequest) returns (OrderResult);
rpc DeleteOrder(DeleteOrderRequest) returns (OrderResult);
rpc CreateSyntheticSpread(CreateSyntheticSpreadRequest)
returns (CreateSyntheticSpreadResult);
rpc Health(HealthRequest) returns (HealthResult);
}
enum Side {
SIDE_UNSPECIFIED = 0;
SIDE_BUY = 1;
SIDE_SELL = 2;
}
enum OrderType {
ORDER_TYPE_UNSPECIFIED = 0;
ORDER_TYPE_MARKET = 1;
ORDER_TYPE_LIMIT = 2;
}
enum CommandStatus {
COMMAND_STATUS_UNSPECIFIED = 0;
COMMAND_STATUS_ACCEPTED = 1;
COMMAND_STATUS_REJECTED = 2;
COMMAND_STATUS_UNKNOWN = 3;
}
enum PriceCalculation {
PRICE_CALCULATION_STANDARD = 0;
PRICE_CALCULATION_NET_CHANGE = 1;
PRICE_CALCULATION_NOTIONAL_LEG = 2;
}
enum HedgePricing {
HEDGE_PRICING_CACHED_DRIVERS = 0;
HEDGE_PRICING_RECALCULATE_CROSSES = 1;
}
enum SpreaderType {
SPREADER_TYPE_REQUOTE = 0;
SPREADER_TYPE_REVERSE_HEDGE = 1;
SPREADER_TYPE_SNIPER = 2;
}
enum MarketDataType {
MARKET_DATA_TYPE_MERGED = 0;
MARKET_DATA_TYPE_DIRECT = 1;
}
message EnterOrderRequest {
string client_order_id = 1;
string account = 2;
string security_id = 3;
Side side = 4;
OrderType order_type = 5;
string quantity = 6;
optional string price = 7;
optional string exchange = 8;
optional string exchange_security_name = 9;
}
message UpdateOrderRequest {
string order_id = 1;
optional string quantity = 2;
optional string price = 3;
}
message DeleteOrderRequest {
string order_id = 1;
}
message OrderResult {
string order_id = 1;
CommandStatus status = 2;
string message = 3;
string spark_error = 4;
}
message SyntheticLeg {
string security_id = 1;
optional string quantity = 2;
optional string price_multiplier = 3;
optional string leg_delta = 4;
optional Side side = 5;
optional bool is_active_quoting = 6;
optional bool enable_hedging = 7;
optional MarketDataType market_data_type = 8;
optional string exchange = 9;
optional string exchange_security_name = 10;
}
message CreateSyntheticSpreadRequest {
SyntheticLeg leg1 = 1;
SyntheticLeg leg2 = 2;
optional string name = 3;
optional string symbol = 4;
optional string currency = 5;
optional string tick_size = 6;
optional string cash_per_point = 7;
optional string price_multiplier = 8;
optional uint32 main_fraction = 9;
optional uint32 sub_fraction = 10;
optional uint32 price_display_format = 11;
optional PriceCalculation price_calculation = 12;
optional bool cancel_hedges_on_cancel = 13;
optional HedgePricing hedge_pricing = 14;
optional SpreaderType spreader_type = 15;
optional string lean_quantity_ratio = 16;
}
message CreateSyntheticSpreadResult {
string security_id = 1;
}
message HealthRequest {}
message HealthResult {
bool ready = 1;
string message = 2;
}
@@ -0,0 +1,3 @@
build/
dist/
src/*.egg-info/
@@ -0,0 +1,137 @@
# WebSocket protocol v1
Connect to `/v1/ws`. A library host may inject an authentication provider that
validates the handshake before acceptance. The bundled development executable
currently uses an anonymous principal. Every client request is one JSON text
message with a unique string or integer `id`:
```json
{"jsonrpc":"2.0","id":"req-1","method":"ping","params":{}}
```
Successful responses retain that ID:
```json
{"jsonrpc":"2.0","id":"req-1","result":{"pong":true}}
```
Failures have an error code and message:
```json
{"jsonrpc":"2.0","id":"req-1","error":{"code":-32602,"message":"invalid fields: quantity","data":{"code":"invalid_params"}}}
```
Requests larger than 64 KiB are rejected. An individual connection may have at most 32 active subscriptions. Send one JSON object per WebSocket text frame. Binary frames close the connection with code 1003.
## BlueOrb subscriptions
The `subscription` method forwards unrestricted DSL through the independently
packaged BlueOrb Python client to RemoteStreamServer:
```json
{"jsonrpc":"2.0","id":"blueorb-1","method":"subscription","params":{"subscription_id":"query-1","dsl":"phoenix_bbo(\"SOL\")"}}
```
The acknowledgement is queued before the first packet:
```json
{"jsonrpc":"2.0","id":"blueorb-1","result":{"subscription_id":"query-1"}}
```
Packets use the same `subscription` method name but, as server notifications,
have no request `id`:
```json
{"jsonrpc":"2.0","method":"subscription","params":{"subscription_id":"query-1","packet":{"table":"phoenix_bbo","schema":{"name":"phoenix_bbo","columns":[]},"current":[],"added":[],"removed":[],"kind":"snapshot","sequence":1}}}
```
Packet kinds are `snapshot` and `delta`. Rows are objects keyed by schema column
name. Decimal values are JSON strings, datetimes are ISO-8601 strings, and other
values retain their JSON boolean, integer, floating-point, string, or null type.
The packet contains BlueOrb's reconstructed `current` table plus the `added` and
`removed` changes.
`unsubscribe` and `unsubscribe_all` affect BlueOrb subscriptions on the current
connection. Messages already queued before an unsubscribe acknowledgement may
still be delivered; the client should ignore them by subscription ID.
This handoff intentionally allows any DSL accepted by RemoteStreamServer. The
development server uses an anonymous identity and does not apply operator,
instrument, filesystem, or configuration restrictions. It must therefore be
treated as a trusted-development interface until security is added.
## Orders
Price and quantity must be finite decimal strings; JSON numbers are rejected to avoid floating-point rounding. Quantity must be positive. Synthetic spread prices may be zero or negative; native limit-order submission still requires a positive price in the bridge. A limit order requires a price; a market order must omit price. Synthetic securities require limit orders and are submitted with their configured spreader execution strategy. `client_order_id` is required for idempotent submission.
```json
{"jsonrpc":"2.0","id":"req-4","method":"enter_order","params":{"client_order_id":"strategy-a-0001","account":"ACC A","exchange":"okx","exchange_security_name":"SOL-USDC","side":"buy","order_type":"limit","quantity":"2","price":"100.25"}}
```
The response means the command was accepted by Spark, **not** that the order was
filled or accepted by an exchange. Order/fill streaming is outside this release:
```json
{"jsonrpc":"2.0","id":"req-4","result":{"order_id":"456","status":"accepted","message":null}}
```
Update quantity and/or price:
```json
{"jsonrpc":"2.0","id":"req-5","method":"update_order","params":{"order_id":"456","quantity":"3","price":"100.50"}}
```
Request cancellation (the method name follows the existing API vocabulary; order history is not deleted):
```json
{"jsonrpc":"2.0","id":"req-6","method":"delete_order","params":{"order_id":"456"}}
```
For either operation, the result has the same `order_id`, `status`, and optional `message` fields as `enter_order`. Unsupported order types, venues, or modifications must produce an error rather than silently changing meaning.
If a submission times out after reaching the SDK, its outcome may be unknown. The SDK must return `outcome_unknown` and the client must query/reconcile using `client_order_id` before retrying. Do not blindly submit a second order.
## Create a synthetic spread
`create_synthetic_spread` defines a two-leg synthetic security. It is the public v1 counterpart of the C# WebSocket server's `CREATE_SYNTHETIC_SPREAD` operation. Each leg can be identified either by `security_id` or by an `exchange` and `exchange_security_name` pair. A leg must use exactly one locator form. Named string enum values are used instead of internal protobuf numbers.
Only the two leg locators are required. Omitted fields are resolved by the SDK/backend from its configured synthetic-spread preset and security metadata; the gateway does not choose defaults.
```json
{"jsonrpc":"2.0","id":"req-7","method":"create_synthetic_spread","params":{"leg1_security_id":"123","leg2_security_id":"456","name":"calendar-spread","tick_size":"0.01","leg1_quantity":"1","leg1_side":"buy","leg2_quantity":"1","leg2_price_multiplier":"-1","leg2_side":"sell","spreader_type":"requote"}}
```
The exchange/name form is compatible with the original C# WebSocket request:
```json
{"jsonrpc":"2.0","id":"req-8","method":"create_synthetic_spread","params":{"leg1_exchange":"okx","leg1_exchange_security_name":"SOL-USDC","leg2_exchange":"orca","leg2_exchange_security_name":"SOL-USDC-1"}}
```
A successful response returns the public ID assigned to the synthetic security:
```json
{"jsonrpc":"2.0","id":"req-7","result":{"security_id":"789"}}
```
Optional contract fields are `name`, `symbol`, `currency`, `tick_size`, `cash_per_point`, `price_multiplier`, `main_fraction`, `sub_fraction`, `price_display_format`, and `price_calc`. Strategy fields are `cancel_hedges_on_cancel`, `hedge_pricing`, `spreader_type`, and `lean_quantity_ratio`. Each leg accepts fields prefixed with `leg1_` or `leg2_`: `quantity`, `price_multiplier`, `leg_delta`, `side`, `is_active_quoting`, `enable_hedging`, and `market_data_type`.
Decimal fields follow the same precision rule as orders: they must be finite JSON strings. Quantities, tick size, cash per point, the contract price multiplier, and lean quantity ratio must be positive. Leg price multipliers may be negative but cannot be zero.
Valid named values are:
| Field | Values |
| --- | --- |
| `price_calc` | `standard`, `net_change`, `notional_leg` |
| `hedge_pricing` | `cached_drivers`, `recalculate_crosses` |
| `spreader_type` | `requote`, `reverse_hedge`, `sniper` |
| `leg1_side`, `leg2_side` | `buy`, `sell` |
| `leg1_market_data_type`, `leg2_market_data_type` | `merged`, `direct` |
The gateway forwards this call through `spark-command-client`; the standalone
bridge creates the Spark synthetic security and updates its execution strategy.
## Error codes and lifecycle
The numeric `error.code` follows JSON-RPC 2.0: `-32700` for `invalid_json`, `-32600` for `invalid_request`, `-32601` for `method_not_found`, `-32602` for `invalid_params`, `-32603` for `internal_error`, and `-32000` for application/server errors. The stable symbolic code is `error.data.code`. The gateway generates `duplicate_subscription`, `unknown_subscription`, `subscription_limit`, and `request_too_large` as application errors. The SDK may additionally return `not_found`, `backend_unavailable`, `rejected`, or `outcome_unknown`. An unexpected exception is logged server-side and exposed only as `internal_error`.
Authentication failures close the handshake with policy-violation code 1008. If the outgoing queue fills, the connection closes with code 1013; clients must reconnect and rebuild their subscriptions. The gateway makes no durable-delivery guarantee for WebSocket events.
+504
View File
@@ -0,0 +1,504 @@
# Spark public WebSocket gateway (Python)
This package is the client-facing WebSocket API for Spark commands and BlueOrb
streams. It is deliberately a standalone Python project so the WebSocket team
can build, test, release, and extend it without loading or changing `sparkd`,
Quickbus, the Spark C# SDK, or the BlueOrb implementation.
This README is the maintainer guide. The exact wire contract is also recorded
in [PROTOCOL.md](PROTOCOL.md), the deployed topology and current status in
[SYSTEM_ARCHITECTURE_AND_STATUS.md](SYSTEM_ARCHITECTURE_AND_STATUS.md), and the
available BlueOrb DSL operators in [STREAM_CATALOG.md](STREAM_CATALOG.md).
## System overview and ownership boundary
The gateway exposes one endpoint, `ws://<host>:8090/v1/ws`, and translates its
JSON messages into calls to two separately maintained Python client packages:
```mermaid
flowchart LR
Client["Client application"]
subgraph Gateway["spark-public-websocket — owned here"]
WS["/v1/ws<br/>JSON-RPC validation and routing"]
Life["Connection, subscription,<br/>queue, and error lifecycle"]
BOA["BlueOrb async adapter"]
SA["Spark command adapter"]
WS --> Life
Life --> BOA
Life --> SA
end
BO["blueorb-surface-python<br/>(in-process dependency)"]
SC["spark-command-client<br/>(in-process dependency)"]
RSS["blueorb-rss :5085<br/>BlueOrb-owned service"]
Bridge["spark-command-bridge :50051<br/>Spark-owned service"]
Sparkd["sparkd / Quickbus<br/>Spark-owned backend"]
Client -->|"WebSocket JSON :8090"| WS
BOA --> BO -->|"streaming gRPC"| RSS
SA --> SC -->|"unary gRPC"| Bridge
RSS -.->|"Spark-backed sources"| Sparkd
Bridge --> Sparkd
```
Only port 8090 is the public client surface. Ports 5085 and 50051 are internal
service APIs. Spark configuration on port 8000 and Quickbus are also backend
interfaces and are not part of this package's public contract.
| Team | Owns | Contract used by this gateway |
| --- | --- | --- |
| WebSocket team | This package, public JSON contract, validation, authentication hook, connection lifecycle, adapters, and releases | `/v1/ws` |
| BlueOrb team | DSL engine, RemoteStreamServer/RSS, packet semantics, and Python surface | `blueorb-surface-python` |
| Spark team | `sparkd`, command bridge, Protobuf contract, and generated Python client | `spark-command-client` |
The separation is important: add public transport behavior here, but add
BlueOrb operators in BlueOrb and Spark command implementation in the bridge or
Spark. This package contains no backend implementation and imports neither
`libspark` nor Spark's internal protobuf messages.
## What is in this package
| Path | Responsibility |
| --- | --- |
| `src/spark_public_websocket/app.py` | FastAPI endpoint, JSON-RPC dispatch, queues, subscriptions, and connection cleanup |
| `src/spark_public_websocket/models.py` | Public request/result/packet models and their field validation |
| `src/spark_public_websocket/blueorb.py` | Converts BlueOrb's blocking callback API into an async packet iterator |
| `src/spark_public_websocket/spark.py` | Adapts `spark-command-client` results and errors to public models |
| `src/spark_public_websocket/sdk.py` | Narrow dependency-injection protocols for Spark and authentication |
| `src/spark_public_websocket/server.py` | Runtime dependency construction, CLI flags, environment variables, and development authentication |
| `tests/test_app.py` | Public contract tests using in-memory fakes |
| `tests/test_blueorb.py` | Callback-to-async adapter and cancellation tests |
| `tests/test_*_package_integration.py` | WebSocket-to-real-client-package-to-fake-gRPC integration tests |
`create_app()` is the reusable library boundary. `spark-public-websocket` (the
console command) is the supplied development/service host.
## Install and run locally
Python 3.10 or newer is required. Install the two backend client wheels and this
package into a virtual environment:
```sh
python -m pip install /path/to/blueorb_surface_python-0.1.0-py3-none-any.whl
python -m pip install /path/to/spark_command_client-0.1.2-py3-none-any.whl
python -m pip install -e '/workspaces/spark/src/client/spark_public_websocket[test]'
```
Start the gateway against local backend services:
```sh
spark-public-websocket \
--blueorb-address http://127.0.0.1:5085 \
--spark-command-address 127.0.0.1:50051
```
The listener defaults to `127.0.0.1:8090`. The equivalent environment
configuration is:
```sh
PUBLIC_WS_HOST=127.0.0.1
PUBLIC_WS_PORT=8090
BLUEORB_ADDRESS=http://127.0.0.1:5085
SPARK_COMMAND_ADDRESS=127.0.0.1:50051
spark-public-websocket
```
For isolated gateway development, either backend can be disabled. Calls routed
to a disabled Spark backend return `backend_unavailable`; BlueOrb subscription
requests do the same when BlueOrb is disabled.
```sh
spark-public-websocket --without-blueorb
spark-public-websocket --without-spark
```
Both client packages are declared installation dependencies. The Spark client
is imported when this package loads, even with `--without-spark`; the BlueOrb
client is imported when BlueOrb is enabled.
Use `--spark-sdk-factory module:function` (or `SPARK_SDK_FACTORY`) to inject a
different `PublicSparkSdk` implementation.
## WebSocket message model
The endpoint uses a small JSON-RPC 2.0-style protocol. Every client request must
be one JSON object in one **text** WebSocket frame and must contain:
- `jsonrpc`: exactly `"2.0"`;
- `id`: a non-boolean string or integer; clients should keep IDs unique among
outstanding requests so they can correlate responses (the gateway does not
enforce uniqueness);
- `method`: one of the methods documented below; and
- `params`: an object, or omitted when the method takes no parameters.
Example request and correlated response:
```json
{"jsonrpc":"2.0","id":"health-1","method":"ping","params":{}}
```
```json
{"jsonrpc":"2.0","id":"health-1","result":{"pong":true}}
```
Errors retain the request ID when one could be parsed:
```json
{"jsonrpc":"2.0","id":"order-1","error":{"code":-32602,"message":"invalid fields: quantity","data":{"code":"invalid_params"}}}
```
Clients should branch on the stable symbolic `error.data.code`. The numeric
`error.code` groups errors according to JSON-RPC:
| Numeric code | Symbolic code/category |
| --- | --- |
| `-32700` | `invalid_json` |
| `-32600` | `invalid_request` |
| `-32601` | `method_not_found` |
| `-32602` | `invalid_params` |
| `-32603` | `internal_error` |
| `-32000` | Gateway or backend application errors |
Request parameter models reject unknown fields; the outer JSON-RPC object is
validated separately and extra top-level fields are currently ignored. Decimal
fields must be finite JSON **strings**, not JSON numbers, to avoid precision
loss (for example, `"100.25"`).
## Public methods at a glance
| Method | Direction | Owner of operation | Purpose |
| --- | --- | --- | --- |
| `ping` | request/response | Gateway | Test the WebSocket request loop |
| `subscription` | request/response, then server notifications | BlueOrb | Start an unrestricted DSL stream |
| `unsubscribe` | request/response | Gateway | Stop one connection-local BlueOrb subscription |
| `unsubscribe_all` | request/response | Gateway | Stop every BlueOrb subscription on this connection |
| `enter_order` | request/response | Spark | Submit a market or limit order |
| `update_order` | request/response | Spark | Change an existing order's quantity and/or price |
| `delete_order` | request/response | Spark | Request order cancellation; it does not erase history |
| `create_synthetic_spread` | request/response | Spark | Define a two-leg synthetic security and strategy |
There is no public `get`, `spark_subscriptions`, book-list request, REST command
API, or direct Quickbus API. Spark commands are unary; order and fill event
streaming is not part of the command API. BlueOrb DSL may expose Spark-backed
streams such as `spark_orders`, depending on the RSS deployment.
## BlueOrb subscription requests and events
Start a subscription by supplying a connection-unique `subscription_id` and a
BlueOrb DSL expression:
```json
{"jsonrpc":"2.0","id":"start-1","method":"subscription","params":{"subscription_id":"prices-sol","dsl":"phoenix_bbo(\"SOL\")"}}
```
`subscription_id` is 1–128 characters and `dsl` is 1–8192 characters. If the
request validates, the gateway queues an acknowledgement before it starts
forwarding packets:
```json
{"jsonrpc":"2.0","id":"start-1","result":{"subscription_id":"prices-sol"}}
```
Each packet is a server notification. It deliberately has no `id`, because it
is not a response to a new client request:
```json
{
"jsonrpc": "2.0",
"method": "subscription",
"params": {
"subscription_id": "prices-sol",
"packet": {
"table": "phoenix_bbo",
"schema": {
"name": "phoenix_bbo",
"columns": [
{"name": "bid", "ordinal": 0, "type_code": "decimal", "nullable": true}
]
},
"current": [{"bid": "100.25"}],
"added": [{"bid": "100.25"}],
"removed": [],
"kind": "snapshot",
"sequence": 1
}
}
}
```
Packet fields are:
| Field | Meaning |
| --- | --- |
| `table` | BlueOrb table name |
| `schema` | Table name plus ordered column metadata (`name`, `ordinal`, `type_code`, `nullable`) |
| `current` | BlueOrb's reconstructed current table after applying this packet |
| `added` | Rows added by this update |
| `removed` | Rows removed by this update |
| `kind` | `snapshot` or `delta` |
| `sequence` | Non-negative upstream packet sequence |
Rows are objects keyed by schema column name. Decimals are serialized as
strings, datetimes as ISO-8601 strings, and booleans, integers, floating-point
values, strings, and null retain their JSON types.
If a running stream fails, the gateway sends an asynchronous error notification
and removes that subscription from its active map:
```json
{"jsonrpc":"2.0","method":"subscription_error","params":{"subscription_id":"prices-sol","error":{"code":-32000,"message":"BlueOrb RemoteStreamServer is unavailable","data":{"code":"backend_unavailable"}}}}
```
Stop one stream:
```json
{"jsonrpc":"2.0","id":"stop-1","method":"unsubscribe","params":{"subscription_id":"prices-sol"}}
```
```json
{"jsonrpc":"2.0","id":"stop-1","result":{"subscription_id":"prices-sol"}}
```
Stop all streams owned by this WebSocket connection:
```json
{"jsonrpc":"2.0","id":"stop-all","method":"unsubscribe_all","params":{}}
```
```json
{"jsonrpc":"2.0","id":"stop-all","result":{"unsubscribed":"all"}}
```
A connection may have at most 32 active subscriptions. Duplicate IDs return
`duplicate_subscription`, a missing ID on unsubscribe returns
`unknown_subscription`, and exceeding the limit returns `subscription_limit`.
Packets already in the outgoing queue can arrive after an unsubscribe response;
clients should discard late packets using `subscription_id`.
The DSL is currently passed to RSS without an allowlist. Any operator registered
there—including file, configuration, network, publishing, or remote-compute
operators—can potentially be invoked. Do not expose this development endpoint
to an untrusted network until authentication, authorization, and DSL permission
rules are implemented.
## Spark order requests
### `enter_order`
Required fields are `client_order_id`, `account`, exactly one security locator,
`side`, `order_type`, and `quantity`.
| Field | Type and rules |
| --- | --- |
| `client_order_id` | Non-empty string used by the bridge for process-local idempotency |
| `account` | Non-empty account name |
| `security_id` | Non-empty string; mutually exclusive with the exchange/name locator |
| `exchange` + `exchange_security_name` | Both required when `security_id` is omitted |
| `side` | `buy` or `sell` |
| `order_type` | `market` or `limit` |
| `quantity` | Positive finite decimal string |
| `price` | Positive finite decimal string; required for `limit`, forbidden for `market` |
Example limit order using the exchange/name locator:
```json
{"jsonrpc":"2.0","id":"order-1","method":"enter_order","params":{"client_order_id":"strategy-a-0001","account":"ACC-A","exchange":"okx","exchange_security_name":"SOL-USDC","side":"buy","order_type":"limit","quantity":"2","price":"100.25"}}
```
Example market order using a Spark security ID:
```json
{"jsonrpc":"2.0","id":"order-2","method":"enter_order","params":{"client_order_id":"strategy-a-0002","account":"ACC-A","security_id":"42","side":"sell","order_type":"market","quantity":"1.5"}}
```
All three order methods return the same result shape:
```json
{"jsonrpc":"2.0","id":"order-1","result":{"order_id":"456","status":"accepted","message":null}}
```
`status` is `accepted`, `pending`, or `rejected`. An accepted command means
Spark accepted the command; it does not mean the venue accepted or filled the
order.
### `update_order`
Supply `order_id` and at least one of `quantity` or `price`. Both changes use
positive finite decimal strings.
```json
{"jsonrpc":"2.0","id":"update-1","method":"update_order","params":{"order_id":"456","quantity":"3","price":"100.50"}}
```
### `delete_order`
This asks Spark to cancel an order. The historical order is not deleted.
```json
{"jsonrpc":"2.0","id":"cancel-1","method":"delete_order","params":{"order_id":"456"}}
```
If a bridge call times out, `spark-command-client` reports `outcome_unknown`;
the command may still have reached Spark. A client must reconcile using
`client_order_id` before retrying. The bridge's idempotency state is
process-local, so a retry after a bridge restart can create a second order.
## Synthetic spread request
`create_synthetic_spread` defines a two-leg synthetic security. Each leg must use
exactly one locator form: `legN_security_id`, or both `legN_exchange` and
`legN_exchange_security_name`. Those two locators are the only required fields;
omitted settings are filled by the Spark command client and bridge defaults,
with some contract values taken from the leg securities, not by this gateway.
```json
{
"jsonrpc": "2.0",
"id": "spread-1",
"method": "create_synthetic_spread",
"params": {
"leg1_security_id": "123",
"leg2_security_id": "456",
"name": "calendar-spread",
"tick_size": "0.01",
"leg1_quantity": "1",
"leg1_side": "buy",
"leg2_quantity": "1",
"leg2_price_multiplier": "-1",
"leg2_side": "sell",
"spreader_type": "requote"
}
}
```
```json
{"jsonrpc":"2.0","id":"spread-1","result":{"security_id":"789"}}
```
Optional fields are grouped below. All decimal fields must be JSON strings.
| Group | Fields |
| --- | --- |
| Contract | `name`, `symbol`, `currency`, `tick_size`, `cash_per_point`, `price_multiplier`, `main_fraction`, `sub_fraction`, `price_display_format`, `price_calc` |
| Strategy | `cancel_hedges_on_cancel`, `hedge_pricing`, `spreader_type`, `lean_quantity_ratio` |
| Each leg (`leg1_` / `leg2_`) | `quantity`, `price_multiplier`, `leg_delta`, `side`, `is_active_quoting`, `enable_hedging`, `market_data_type` |
Positive values are required for `tick_size`, `cash_per_point`, the contract
`price_multiplier`, `lean_quantity_ratio`, and leg quantities. Leg price
multipliers may be negative but cannot be zero. Leg deltas may be zero or
negative.
| Enum field | Allowed values |
| --- | --- |
| `price_calc` | `standard`, `net_change`, `notional_leg` |
| `hedge_pricing` | `cached_drivers`, `recalculate_crosses` |
| `spreader_type` | `requote`, `reverse_hedge`, `sniper` |
| `leg1_side`, `leg2_side` | `buy`, `sell` |
| `leg1_market_data_type`, `leg2_market_data_type` | `merged`, `direct` |
## Connection lifecycle, concurrency, and limits
For each accepted connection, `app.py` creates one outgoing queue and a map of
BlueOrb subscription tasks. Incoming command requests are dispatched in receive
order. BlueOrb packet forwarding runs in background tasks, so packets and
request responses share the outgoing queue and may interleave.
The BlueOrb package exposes a blocking callback. `BlueOrbAdapter` runs it in a
worker thread, moves packets safely onto the asyncio loop, and sets the backend
cancellation event when a client unsubscribes or disconnects. It has its own
64-packet bounded queue. The connection has a separate 256-message bounded
outgoing queue.
| Limit/behavior | Value |
| --- | --- |
| Maximum incoming text frame | 64 KiB UTF-8 |
| Maximum active BlueOrb subscriptions | 32 per connection |
| BlueOrb adapter packet queue | 64 packets per subscription |
| WebSocket outgoing queue | 256 messages per connection |
| Binary frame behavior | Close with WebSocket code 1003 |
| Outgoing queue overflow | Close with code 1013; reconnect and resubscribe |
| Authentication rejection | Handshake policy violation (code 1008) |
| Delivery guarantee | None; subscriptions are connection-local and not durable |
Disconnect cleanup cancels all subscription tasks, the sender, and the current
receiver. The gateway does not resume subscriptions after reconnecting and does
not persist client state.
## Authentication and production security
`create_app(sdk, authenticator, blueorb)` calls
`authenticator.authenticate(websocket)` **before** accepting the WebSocket and
passes the resulting `Principal` to every backend adapter. The bundled CLI uses
`AnonymousAuthenticator`, and the current adapters do not yet consume the
principal. This preserves the interface for real authentication and
authorization but does not provide them.
Before exposing this service outside a trusted development network, add TLS,
real authentication, per-principal authorization (especially BlueOrb DSL
permissions and trading permissions), rate limiting, audit logging, and network
rules that restrict backend ports.
An application can provide its own composition without modifying the package:
```python
from blueorb import BlueOrbStreamHost
from spark_public_websocket import BlueOrbAdapter, create_app
app = create_app(
sdk=my_spark_sdk,
authenticator=my_authenticator,
blueorb=BlueOrbAdapter(BlueOrbStreamHost("http://127.0.0.1:5085")),
)
```
## Adding functionality safely
For a new public unary method:
1. Define strict request and result models in `models.py`; keep public fields
independent of backend Protobufs and reject unknown fields.
2. Add the narrow operation to `PublicSparkSdk` or define an equivalent owned
interface for the relevant backend.
3. Implement translation in an adapter, including conversion of expected
backend errors to `SdkError(code, message)`.
4. Add dispatch in `app.py`; never expose raw backend exceptions or internal
messages to clients.
5. Add fake-backed contract tests and a package/gRPC integration test.
6. Update this README and `PROTOCOL.md`. If exposure or deployment changes,
update `SYSTEM_ARCHITECTURE_AND_STATUS.md` too.
For new streaming behavior, also define cancellation, backpressure, ordering,
reconnect, and ownership semantics. Keep blocking work off the asyncio event
loop and keep every producer queue bounded.
Changes to BlueOrb DSL syntax/operators belong in BlueOrb. Changes to Spark
command execution or Spark/Quickbus integration belong in the command bridge or
Spark. Coordinate a client-package version bump when either dependency contract
changes; do not import backend implementation as a shortcut.
## Tests, builds, and releases
Run the package's tests and build from this directory:
```sh
cd /workspaces/spark/src/client/spark_public_websocket
python -m pytest tests
python -m build 0.1.3
```
The contract tests require no running backend. Integration tests start fake
local gRPC servers but exercise the real `spark-command-client` and
`blueorb-surface-python` packages, including the full path through `/v1/ws`.
Build the versioned wheel, source distribution, checksums, and deployment bundle
with:
```sh
./build-release.sh 0.1.3
```
Versioned deployment and rollback procedures are in
[RELEASE_AND_DEPLOYMENT.md](../RELEASE_AND_DEPLOYMENT.md). The supplied
`deploy/spark-public-websocket.service` runs the console entry point under a
dedicated system user and reads
`/etc/spark-public-websocket/spark-public-websocket.env`.
@@ -0,0 +1,135 @@
# DSL stream catalog
This catalog records the 77 stream names in the supplied stream inventory. They are DSL/runtime stream definitions, not additional WebSocket methods. The inventory includes data sources, transforms, and sinks. The public `/v1/ws` gateway accepts a DSL expression in the `dsl` field of a `subscription` request and forwards it to RemoteStreamServer without an operator allowlist. Which names work depends on the operators registered by that server. File, remote-compute, configuration, and publishing streams require authentication and permission rules before exposing the gateway to untrusted clients.
Names and descriptions below are copied or lightly edited from the provided catalog; examples are DSL expressions, not WebSocket JSON payloads. The catalog lists capabilities, not a guarantee that every provider is configured or available in each deployment.
## Business.Analytics
| DSL name | Class | Role | Description |
| --- | --- | --- | --- |
| `adx` | `ADXStream` | Transform | Calculates average directional index values over OHLC input rows. |
| `arbitrage_pair_opportunities` | `ArbitragePairOpportunityStream` | Transform | Evaluates arbitrage pair opportunities from configured pair definitions. |
| `arbitrage_pairs` | `ArbitragePairStream` | Source | Emits configured arbitrage pair definitions. |
| `atr` | `ATRStream` | Transform | Calculates average true range over OHLC input rows. |
| `ema` | `EMAStream` | Transform | Calculates an exponential moving average over an input price column. |
| `macd` | `MACDStream` | Transform | Calculates MACD, signal, and histogram values over an input price series. |
| `macd_ui` | `MACDUIStream` | Transform | Builds a UI-friendly MACD stream from an input price series. |
| `parabolic_sar` | `ParabolicSARStream` | Transform | Calculates parabolic SAR values over OHLC input rows. |
| `rsi` | `RSIStream` | Transform | Calculates relative strength index values over an input price series. |
| `rsi_ui` | `RSIUIStream` | Transform | Builds a UI-friendly RSI stream from an input price series. |
| `stochastic` | `StochasticOscillatorStream` | Transform | Calculates stochastic oscillator values over OHLC input rows. |
| `volume_profile` | `VolumeProfileStream` | Transform | Builds a volume profile from input price and volume rows. |
| `vwap` | `VWAPStream` | Transform | Calculates volume-weighted average price over an input price and volume series. |
| `wilder_ma` | `WilderMovingAverageStream` | Transform | Calculates Wilder's moving average over an input value column. |
## Business.Bars
| DSL name | Class | Role | Description |
| --- | --- | --- | --- |
| `bar_builder` | `BarBuilderStream` | Transform | Builds OHLCV bars from an input trade stream. |
| `heikin_ashi` | `HeikinAshiStream` | Transform | Transforms OHLC bars into Heikin-Ashi bars. |
## Business.MarketData
| DSL name | Class | Role | Description |
| --- | --- | --- | --- |
| `bison_markets` | `BisonMarketsStream` | Source | Fetches Bison market metadata for markets matching a status filter. |
| `databento_bbo` | `DatabentoBBOStream` | Source | Streams live Databento best bid and offer for an instrument. |
| `databento_book` | `DatabentoBookStream` | Source | Streams live Databento order-book depth for an instrument. |
| `databento_instrument_definition` | `DatabentoInstrumentDefinitionStream` | Source | Streams Databento instrument definition snapshots for an instrument. |
| `databento_market_data` | `DatabentoMarketDataStream` | Source | Streams live Databento market data for a symbol and schema. |
| `databento_replay_trade` | `DatabentoReplayTradeStream` | Source | Replays Databento trade data from a local file. |
| `databento_statistics` | `DatabentoStatisticsStream` | Source | Streams latest Databento venue statistics for an instrument. |
| `databento_trades` | `DatabentoTradesStream` | Source | Streams live Databento trades for an instrument. |
| `defillama_dex_volumes` | `DeFiLlamaDexVolumesStream` | Source | Fetches DeFiLlama DEX volume data. |
| `geckoterminal_trending_pools` | `GeckoTerminalTrendingPoolsStream` | Source | Fetches GeckoTerminal trending pool data. |
| `hyperliquid_bbo` | `HyperliquidBBOStream` | Source | Streams Hyperliquid best bid and offer for an instrument. |
| `hyperliquid_book` | `HyperliquidBookStream` | Source | Streams Hyperliquid order-book depth for an instrument. |
| `hyperliquid_instrument_definition` | `HyperliquidInstrumentDefinitionStream` | Source | Fetches Hyperliquid instrument definition data. |
| `hyperliquid_market_data` | `HyperliquidMarketDataStream` | Source | Routes a Hyperliquid market-data request to the appropriate Hyperliquid stream. |
| `hyperliquid_trades` | `HyperliquidTradesStream` | Source | Streams Hyperliquid trades for an instrument. |
| `instrument_list` | `InstrumentListStream` | Source | Combines supported instrument definition streams into a normalized instrument list. |
| `live_market_data` | `LiveMarketDataStream` | Source | Routes a live venue market-data request to the configured provider stream. |
| `market_data` | `MarketDataStream` | Source | Routes a provider-neutral market-data request to the configured concrete provider stream. |
| `phoenix_bbo` | `PhoenixBBOStream` | Source | Streams Phoenix best bid and offer for a perp instrument. |
| `phoenix_book` | `PhoenixBookStream` | Source | Streams Phoenix order-book depth for a perp instrument. |
| `phoenix_funding_rate` | `PhoenixFundingRateStream` | Source | Streams Phoenix funding-rate updates for a perp instrument. |
| `phoenix_instrument_definition` | `PhoenixInstrumentDefinitionStream` | Source | Fetches Phoenix instrument definition and exchange market configuration. |
| `phoenix_trades` | `PhoenixTradesStream` | Source | Polls Phoenix recent trades for a perp instrument. |
| `replay_market_data` | `ReplayMarketDataStream` | Source | Replays historical market-data rows from a stored dataset. |
| `replay_trade` | `ReplayTradeStream` | Source | Replays trade rows from a local trade data file. |
| `spark_bbo` | `SparkBBOStream` | Source | Streams Spark best bid and offer for an instrument. |
| `spark_book` | `SparkBookStream` | Source | Streams Spark order-book depth for an instrument. |
| `spark_instrument_definition` | `SparkInstrumentDefinitionStream` | Source | Fetches Spark instrument definition data. |
| `spark_instrument_list` | `InstrumentListStream` | Source | Combines supported instrument definition streams into a normalized instrument list. |
| `spark_instruments` | `SparkInstrumentStream` | Source | Fetches Spark instruments as a market-data source. |
| `spark_orders` | `SparkOrderStream` | Source | Streams Spark order events for an instrument. |
| `spark_trades` | `SparkTradesStream` | Source | Streams Spark trades for an instrument. |
## Business.Risk
| DSL name | Class | Role | Description |
| --- | --- | --- | --- |
| `margin_requirement` | `MarginRequirementStream` | Transform | Estimates from-flat margin requirements for a proposed trade using venue margin data. |
| `phoenix_margin` | `PhoenixMarginStream` | Source | Fetches Phoenix public margin, risk, and leverage-tier configuration for a perp instrument. |
## Config
| DSL name | Class | Role | Description |
| --- | --- | --- | --- |
| `config` | `ConfigStream` | Source | Emits the effective application configuration as rows of section, key, and value data. |
## CoreOperators
| DSL name | Class | Role | Description |
| --- | --- | --- | --- |
| `append` | `AppendStream` | Transform | Appends rows from multiple streams with union-all semantics. |
| `cartesian_product` | `CartesianProductStream` | Transform | Produces every left/right row combination from two input streams. |
| `count` | `CountStream` | Transform | Emits a one-row table containing the current row count of an input stream. |
| `csv_reader` | `CSVReaderStream` | Source | Reads a CSV file into a stream table. |
| `csv_writer` | `CSVWriterStream` | Sink | Writes stream rows to a CSV file. |
| `filter` | `FilterStream` | Transform | Filters rows from an input stream using a row predicate. |
| `group_by` | `GroupByStream` | Transform | Groups rows by one or more columns and applies aggregate reductions. |
| `join` | `JoinStream` | Transform | Performs a left outer join between two input streams. |
| `literal` | `LiteralStream` | Source | Creates a static stream table from literal rows. |
| `map` | `MapStream` | Transform | Adds or replaces columns by evaluating row-wise expressions. |
| `multi_row_rolling_math` | `MultiRowRollingMathStream` | Transform | Calculates rolling math values over multiple rows in an input stream. |
| `name_converter` | `NameConverterStream` | Transform | Converts stream table and column names using the configured naming conversion. |
| `project` | `ProjectStream` | Transform | Selects, hides, or renames columns without computing new values. |
| `rolling_math` | `MultiRowRollingMathStream` | Transform | Calculates rolling math values over multiple rows in an input stream. |
| `sort` | `SortStream` | Transform | Orders all current rows from an input stream by one or more columns. |
| `spread_bbo` | `SpreadBBOStream` | Transform | Combines two BBO streams into a spread BBO view. |
| `stream_docs` | `StreamDocumentationStream` | Source | Emits documentation metadata for streams registered in the active DSL catalog. |
| `time_based_rolling_filter` | `TimeBasedRollingFilterStream` | Transform | Keeps rows whose timestamps fall within a rolling time window. |
| `time_event` | `TimeEventStream` | Source | Emits a time event row on a scheduler-driven interval. |
| `time_series` | `TimeSeriesStream` | Transform | Retains incoming rows as a historical time series, optionally with a maximum row count. |
| `top` | `TopStream` | Transform | Selects the first N rows from an input stream, optionally after sorting. |
## Remote
| DSL name | Class | Role | Description |
| --- | --- | --- | --- |
| `publisher` | `PublisherStream` | Sink | Publishes an input stream to a remote named table through the table broker. |
| `remote` | `RemoteComputeStream` | Source | Subscribes to a remote server-side DSL computation and streams its packets locally. |
| `remote_table` | `RemoteTableStream` | Source | Subscribes to a named remote table through the table broker. |
| `rfs_pub_state` | `RFSPubStateStream` | Source | Subscribes to the remote table broker publication-state table. |
| `rfs_remote_compute_state` | `RFSRemoteComputeStateStream` | Source | Subscribes to the remote compute server state table. |
## Example DSL expressions
These examples come from the supplied inventory. Send an expression as the
`dsl` field of a `/v1/ws` BlueOrb `subscription` request. Whether an expression
can execute depends on the operators registered by the connected
RemoteStreamServer.
```text
hyperliquid_bbo("SOL")
phoenix_book("SOL")
market_data("perp", "SOL", "PHOENIX", "margin")
margin_requirement("perp", "SOL", "PHOENIX", "margin", 145.25, 10.0)
instrument_list()
remote_table("rfs_pub_stats")
```
The inventory also includes two DSL aliases for the same `InstrumentListStream` class (`instrument_list` and `spark_instrument_list`) and two for `MultiRowRollingMathStream` (`multi_row_rolling_math` and `rolling_math`). Both names are listed above as provided.
@@ -0,0 +1,177 @@
# Spark and BlueOrb WebSocket system architecture and status
Last updated: 2026-10-04 (America/New_York)
This document describes the system that is implemented and deployed.
## Deployed system
The deployment consists of four independently operated services:
1. `spark-public-websocket`
2. `blueorb-rss`
3. `spark-command-bridge`
4. `sparkd`
The WebSocket service is the client-facing API. BlueOrb RSS owns DSL execution
and streaming. The command bridge owns the translation from the stable command
contract to the Spark SDK. `sparkd` remains independently built and deployed by
the existing Spark scripts.
```mermaid
flowchart LR
User["Client application or browser"]
subgraph Public["PUBLIC API LAYER"]
WS["spark-public-websocket 0.1.3<br/>0.0.0.0:8090 /v1/ws<br/>JSON-RPC over WebSocket<br/><br/>EXPOSED METHODS<br/>subscription / unsubscribe / unsubscribe_all<br/>enter_order / update_order / delete_order<br/>create_synthetic_spread / ping"]
end
subgraph Packages["PYTHON PACKAGE LAYER — in the WebSocket process"]
BOClient["blueorb-surface-python 0.1.0<br/>BlueOrb client API; not a network listener"]
CommandClient["spark-command-client 0.1.2<br/>generated async gRPC client; not a network listener"]
end
subgraph Internal["INTERNAL SERVICE LAYER — do not expose publicly"]
RSS["blueorb-rss 0.1.3-blueorb464085c-spark4614b60<br/>0.0.0.0:5085<br/>HTTP/2 streaming gRPC<br/><br/>EXPOSED TO GATEWAY<br/>RemoteStreamsTransport / TableBroker<br/>registered BlueOrb DSL operators"]
Bridge["spark-command-bridge 0.1.3-spark4614b60<br/>0.0.0.0:50051<br/>unary gRPC<br/><br/>EXPOSED TO GATEWAY<br/>EnterOrder / UpdateOrder / DeleteOrder<br/>CreateSyntheticSpread / Health"]
end
subgraph Spark["SPARK LAYER"]
Sparkd["sparkd 3.2.3+4614b60<br/>configuration service: :8000<br/>Quickbus and Spark services<br/><br/>NOT A PUBLIC WEBSOCKET CONTRACT"]
end
User -->|"public ws://host:8090/v1/ws"| WS
WS -->|"in-process subscription adapter"| BOClient
WS -->|"in-process command adapter"| CommandClient
BOClient -->|"internal streaming gRPC :5085"| RSS
CommandClient -->|"internal unary gRPC :50051"| Bridge
RSS -.->|"Spark-backed BlueOrb sources"| Sparkd
Bridge -->|"Spark SDK / Quickbus"| Sparkd
```
### Exposure policy
| Layer | Surface | Intended consumers | Exposure |
| --- | --- | --- | --- |
| Public API | WebSocket `/v1/ws` on port 8090 | Client applications | Public development endpoint |
| Public JSON-RPC | BlueOrb subscription methods, four Spark commands, and `ping` | WebSocket clients | Exposed through `/v1/ws` |
| Python packages | `blueorb-surface-python`, `spark-command-client` | WebSocket process and Python developers | Package APIs only; no listeners |
| BlueOrb service | Streaming gRPC on port 5085 | `blueorb-surface-python` | Internal service endpoint |
| Spark command service | Unary gRPC on port 50051 | `spark-command-client` | Internal service endpoint |
| Spark service | Configuration service on port 8000 and Quickbus endpoints | RSS, bridge, Spark tools | Backend/deployment endpoint, not part of the public WebSocket API |
The services currently bind to `0.0.0.0`. Network firewall rules must enforce
the intended exposure: publish 8090 to clients and restrict 5085, 50051, and
Spark backend ports to trusted hosts or the service network.
## Implemented request paths
### BlueOrb streaming
```text
WebSocket subscription request
-> gateway BlueOrb adapter
-> blueorb-surface-python
-> RSS streaming gRPC
-> BlueOrb DSL engine
-> snapshot/delta packets
-> WebSocket subscription notifications
```
The public method is `subscription`. DSL access is intentionally unrestricted
for this development deployment. `stream_docs()` and
`spark_instrument_definition()` have been verified end to end. A filtered
instrument-definition subscription successfully returns synthetic spreads.
### Spark commands
```text
WebSocket command request
-> gateway Spark adapter
-> spark-command-client
-> command-only gRPC contract
-> spark-command-bridge
-> compatible Bts.Spark.Sdk
-> Quickbus / sparkd
-> JSON-RPC result or error
```
Implemented commands are `enter_order`, `update_order`, `delete_order`, and
`create_synthetic_spread`. The bridge does not call RSS and the gateway does
not load native Spark libraries.
Order entry accepts exactly one security locator:
- `security_id`; or
- `exchange` and `exchange_security_name`.
The named form matches the useful lookup behavior of the C# WebSocket. The
bridge resolves it case-insensitively from its live Spark security cache.
Synthetic-spread legs support the same two locator forms.
## Component and artifact status
| Component | Implemented artifact | Deployed version/status |
| --- | --- | --- |
| Public gateway | `spark-public-websocket` wheel | `0.1.3`, active on port 8090 |
| Spark Python client | `spark-command-client` wheel | `0.1.2`, installed in gateway release |
| BlueOrb Python client | `blueorb-surface-python` wheel | `0.1.0`, installed in gateway release |
| Spark command bridge | Self-contained Linux x64 release | `0.1.3-spark4614b60`, active on port 50051 |
| BlueOrb RSS | Self-contained Linux x64 release | `0.1.3-blueorb464085c-spark4614b60`, active on port 5085 |
| Spark daemon | Existing Spark deployment | `3.2.3+4614b60`, running separately |
| Command contract | `spark.command.v1` Protobuf | Generated C# server and Python client bindings |
The bridge and RSS use Spark project artifacts from commit `4614b60`, matching
the deployed `sparkd`. This removes the prior `VENUE_PHOENIX` and configuration
schema incompatibilities.
## Verified behavior
| Capability | Status | Evidence |
| --- | --- | --- |
| Gateway startup and WebSocket listener | Working | `spark-public-websocket.service` active on 8090 |
| RSS startup and streaming endpoint | Working | `blueorb-rss.service` active on 5085 |
| Bridge startup and command endpoint | Working | `spark-command-bridge.service` active on 50051 |
| BlueOrb `stream_docs()` | Working | Acknowledgement, packet, and unsubscribe verified |
| Spark instrument definitions through BlueOrb | Working | Snapshot received through the public WebSocket |
| Synthetic-spread list through BlueOrb | Working | Filter returned four synthetic instruments |
| Synthetic-spread creation | Working | WebSocket result returned a Spark security ID |
| Named security order resolution | Working | Full path reached bridge resolution and returned `not_found` for a deliberate nonexistent security without submitting an order |
| Python contract tests | Working | Gateway and command-client focused tests pass |
| Independent releases and rollback links | Working | Versioned release directories and `current` symlinks are in use |
## Package and team boundaries
| Team | Owns | Interface supplied to another team |
| --- | --- | --- |
| WebSocket team | JSON-RPC contract, connection lifecycle, adapters, public server | `spark-public-websocket` wheel/image and `/v1/ws` |
| BlueOrb team | DSL engine, RSS, Python streaming client | RSS gRPC API and `blueorb-surface-python` wheel |
| Spark team | `sparkd`, command bridge, command Protobuf, generated Python client | Bridge gRPC API and `spark-command-client` wheel |
These boundaries let each team build and release independently. The gateway
contains neither BlueOrb backend code nor Spark/Quickbus implementation code.
## Intentionally unavailable
- `get`
- `spark_subscriptions`
- A public request that lists books
- REST command endpoints
- Public access to Quickbus or Spark internal protobufs
- Embedding the bridge into `sparkd`
- Routing Spark commands through RSS
## Remaining work before production deployment
The development deployment is operational. The remaining work is production
hardening and broader command verification:
1. Decide whether order idempotency must survive bridge restarts; it is
currently process-local.
2. For security at the ws layer add TLS, authentication, authorization, rate limiting, and network firewall
rules before exposing the service to untrusted clients.
3. Finalize how multiple users will be handled at each layer.
Deployment and artifact creation commands are documented in
[`RELEASE_AND_DEPLOYMENT.md`](../RELEASE_AND_DEPLOYMENT.md). The public JSON-RPC
wire contract is documented in [`PROTOCOL.md`](PROTOCOL.md).
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -ne 1 ]; then
echo "usage: $0 <version-from-pyproject>" >&2
exit 2
fi
requested_version=$1
component_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
python_bin=${PYTHON:-python3}
declared_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$component_dir/pyproject.toml")
if [ "$requested_version" != "$declared_version" ]; then
echo "requested version $requested_version does not match pyproject.toml version $declared_version" >&2
exit 1
fi
dist_dir="$component_dir/dist"
wheel_name="spark_public_websocket-$declared_version-py3-none-any.whl"
sdist_name="spark_public_websocket-$declared_version.tar.gz"
deploy_name="spark-public-websocket-deploy-$declared_version.tar.gz"
if [ -e "$dist_dir/$wheel_name" ] || [ -e "$dist_dir/$sdist_name" ] || [ -e "$dist_dir/$deploy_name" ]; then
echo "release output already exists for $declared_version; remove it or bump the package version" >&2
exit 1
fi
mkdir -p "$dist_dir"
"$python_bin" -m build --wheel --sdist --outdir "$dist_dir" "$component_dir"
tar -C "$component_dir" -czf "$dist_dir/$deploy_name" deploy
(
cd "$dist_dir"
sha256sum "$wheel_name" "$sdist_name" "$deploy_name" > "spark-public-websocket-$declared_version.sha256"
)
echo "created $dist_dir/$wheel_name"
echo "created $dist_dir/$sdist_name"
echo "created $dist_dir/$deploy_name"
@@ -0,0 +1,4 @@
PUBLIC_WS_HOST=0.0.0.0
PUBLIC_WS_PORT=8090
BLUEORB_ADDRESS=http://127.0.0.1:5085
SPARK_COMMAND_ADDRESS=127.0.0.1:50051
@@ -0,0 +1,21 @@
[Unit]
Description=Spark and BlueOrb public WebSocket gateway
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=sparkwebsocket
Group=sparkwebsocket
WorkingDirectory=/opt/spark-public-websocket/current
Environment=HOME=/var/lib/spark-public-websocket
EnvironmentFile=/etc/spark-public-websocket/spark-public-websocket.env
ExecStart=/opt/spark-public-websocket/current/bin/spark-public-websocket
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
StateDirectory=spark-public-websocket
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,190 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Spark WebSocket tester</title>
<style>
body { font: 14px system-ui, sans-serif; margin: 2rem; color: #172033; }
.connection, .toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 1rem; }
input, textarea, select, button { font: inherit; padding: .6rem; }
input { flex: 1; min-width: 15rem; }
textarea { box-sizing: border-box; min-height: 22rem; resize: vertical; width: 100%; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; }
#status { font-weight: 600; margin: .75rem 0; }
.connected { color: #08783e; }
.error { color: #b42318; }
.warning { background: #fff4e5; border: 1px solid #f2c879; padding: .75rem; }
pre { background: #f4f6f9; border: 1px solid #d7dce5; min-height: 12rem;
max-height: 32rem; overflow: auto; padding: 1rem; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>Spark WebSocket tester</h1>
<div class="connection">
<input id="url" aria-label="WebSocket URL" value="ws://34.180.103.112:8090/v1/ws">
<button id="connect">Connect</button>
<button id="disconnect" disabled>Disconnect</button>
</div>
<div id="status" role="status">Disconnected</div>
<label for="preset"><strong>Request preset</strong></label>
<div class="toolbar">
<select id="preset"></select>
<button id="load-preset">Load preset</button>
<button id="send" disabled>Send request</button>
<button id="clear">Clear responses</button>
</div>
<p>
Edit the book's venue, instrument type, symbol, and instrument name, or the
orders account, in the JSON below. live_market_data arguments are
(venue, instrument_type, data_kind, symbol, instrument_name).
List presets request instrument definitions through live_market_data with
wildcard selectors; the spreads preset filters for Synthetic instruments.
List requests start live subscriptions and print snapshots and updates.
Use a different subscription_id for each active subscription, or unsubscribe
before reusing it.
</p>
<p class="warning">
<strong>Warning:</strong> create spread, enter order, modify order, and delete
order change live Spark state. Review IDs, account, price, and quantity before sending.
</p>
<label for="request"><strong>Editable JSON request</strong></label>
<textarea id="request" spellcheck="false"></textarea>
<h2>Responses and subscription events</h2>
<pre id="responses"></pre>
<script>
const presets = {
"List all instruments": {
jsonrpc: "2.0", id: "list-instruments-1", method: "subscription",
params: { subscription_id: "list-instruments", dsl: "live_market_data(\"*\", \"*\", \"instrument_definition\", \"*\", \"*\")" }
},
"List spreads": {
jsonrpc: "2.0", id: "list-spreads-1", method: "subscription",
params: {
subscription_id: "list-spreads",
dsl: "filter(live_market_data(\"*\", \"*\", \"instrument_definition\", \"*\", \"*\"), type == \"Synthetic\")"
}
},
"Subscribe to one book": {
jsonrpc: "2.0", id: "book-1", method: "subscription",
params: {
subscription_id: "book-okx-sol-usdc",
dsl: "live_market_data(\"OKX\", \"spot\", \"book\", \"SOL\", \"SOL-USDC\")"
}
},
"List orders (account)": {
jsonrpc: "2.0", id: "list-orders-1", method: "subscription",
params: { subscription_id: "list-orders", dsl: "spark_orders(\"ACC A\")" }
},
"Create spread": {
jsonrpc: "2.0", id: "create-spread-1", method: "create_synthetic_spread",
params: {
leg1_exchange: "okx", leg1_exchange_security_name: "SOL-USDC",
leg2_exchange: "orca", leg2_exchange_security_name: "SOL-USDC-1"
}
},
"Enter order": {
jsonrpc: "2.0", id: "enter-order-1", method: "enter_order",
params: {
client_order_id: "websocket-test-1", account: "ACC A",
exchange: "okx", exchange_security_name: "SOL-USDC", side: "buy",
order_type: "limit", quantity: "1", price: "1.00"
}
},
"Delete order": {
jsonrpc: "2.0", id: "delete-order-1", method: "delete_order",
params: { order_id: "REPLACE_WITH_ORDER_ID" }
},
"Modify order": {
jsonrpc: "2.0", id: "modify-order-1", method: "update_order",
params: { order_id: "REPLACE_WITH_ORDER_ID", quantity: "2", price: "1.01" }
},
"Unsubscribe": {
jsonrpc: "2.0", id: "unsubscribe-1", method: "unsubscribe",
params: { subscription_id: "REPLACE_WITH_SUBSCRIPTION_ID" }
},
"Unsubscribe all": {
jsonrpc: "2.0", id: "unsubscribe-all-1", method: "unsubscribe_all", params: {}
},
"Ping": { jsonrpc: "2.0", id: "ping-1", method: "ping", params: {} }
};
const url = document.querySelector("#url");
const connectButton = document.querySelector("#connect");
const disconnectButton = document.querySelector("#disconnect");
const sendButton = document.querySelector("#send");
const presetSelect = document.querySelector("#preset");
const requestEditor = document.querySelector("#request");
const responses = document.querySelector("#responses");
const status = document.querySelector("#status");
let socket;
for (const name of Object.keys(presets)) presetSelect.add(new Option(name, name));
function loadPreset() {
requestEditor.value = JSON.stringify(presets[presetSelect.value], null, 2);
}
function setState(message, className = "") {
const connected = socket?.readyState === WebSocket.OPEN;
const connecting = socket?.readyState === WebSocket.CONNECTING;
connectButton.disabled = connected || connecting;
disconnectButton.disabled = !connected && !connecting;
sendButton.disabled = !connected;
status.textContent = message;
status.className = className;
}
function append(direction, value) {
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
responses.textContent += `[${new Date().toISOString()}] ${direction}\n${text}\n\n`;
responses.scrollTop = responses.scrollHeight;
}
connectButton.addEventListener("click", () => {
if (socket && socket.readyState < WebSocket.CLOSED) return;
try {
socket = new WebSocket(url.value.trim());
setState("Connecting…");
socket.addEventListener("open", () => setState("Connected", "connected"));
socket.addEventListener("message", event => {
try { append("RECEIVED", JSON.parse(event.data)); }
catch { append("RECEIVED", event.data); }
});
socket.addEventListener("error", () => setState("WebSocket connection error", "error"));
socket.addEventListener("close", event => {
setState(`Disconnected (code ${event.code})`, event.wasClean ? "" : "error");
append("CLOSED", { code: event.code, reason: event.reason, wasClean: event.wasClean });
});
} catch (error) {
setState(error.message, "error");
append("LOCAL ERROR", error.message);
}
});
disconnectButton.addEventListener("click", () => {
socket?.close(1000, "Tester closed");
setState("Disconnecting…");
});
document.querySelector("#load-preset").addEventListener("click", loadPreset);
presetSelect.addEventListener("change", loadPreset);
document.querySelector("#clear").addEventListener("click", () => responses.textContent = "");
sendButton.addEventListener("click", () => {
try {
if (socket?.readyState !== WebSocket.OPEN) throw new Error("Connect before sending a request.");
const request = JSON.parse(requestEditor.value);
socket.send(JSON.stringify(request));
append("SENT", request);
} catch (error) {
append("LOCAL ERROR", error.message);
}
});
loadPreset();
</script>
</body>
</html>
@@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "spark-public-websocket"
version = "0.1.4"
description = "Standalone public WebSocket gateway for Spark and BlueOrb"
requires-python = ">=3.10"
dependencies = [
"blueorb-surface-python>=0.1,<0.2",
"fastapi>=0.115,<0.116",
"pydantic>=2,<3",
"spark-command-client>=0.1.2,<0.2",
"uvicorn>=0.30,<1",
"websockets>=12,<16",
]
[project.optional-dependencies]
test = ["build>=1,<2", "httpx>=0.27,<1", "pytest>=8,<10"]
[project.scripts]
spark-public-websocket = "spark_public_websocket.server:main"
[tool.setuptools.packages.find]
where = ["src"]
@@ -0,0 +1,7 @@
"""Public WebSocket layer with independently injected Spark and BlueOrb clients."""
from .app import create_app
from .blueorb import BlueOrbAdapter, PublicBlueOrb
from .spark import SparkCommandAdapter
__all__ = ["BlueOrbAdapter", "PublicBlueOrb", "SparkCommandAdapter", "create_app"]
@@ -0,0 +1,5 @@
from .server import main
if __name__ == "__main__":
main()
@@ -0,0 +1,275 @@
"""Public WebSocket gateway, with SDK and identity supplied by the host."""
import asyncio
import json
import logging
from contextlib import suppress
from decimal import Decimal
from typing import Any
from fastapi import FastAPI, WebSocket, WebSocketException, status
from fastapi.encoders import jsonable_encoder
from pydantic import ValidationError
from .blueorb import PublicBlueOrb
from .models import (
BlueOrbSubscription,
CreateSyntheticSpread,
DeleteOrder,
EnterOrder,
Principal,
Unsubscribe,
UpdateOrder,
)
from .sdk import PublicSparkSdk, SdkError, WebSocketAuthenticator
_LOG = logging.getLogger(__name__)
_MAX_REQUEST_BYTES = 64 * 1024
_MAX_SUBSCRIPTIONS = 32
_OUTGOING_QUEUE_SIZE = 256
_JSON_RPC_ERROR_CODES = {
"invalid_json": -32700,
"invalid_request": -32600,
"method_not_found": -32601,
"invalid_params": -32602,
"internal_error": -32603,
}
def _wire_error(code: str, message: str) -> dict[str, Any]:
return {
"code": _JSON_RPC_ERROR_CODES.get(code, -32000),
"message": message,
"data": {"code": code},
}
def create_app(
sdk: PublicSparkSdk,
authenticator: WebSocketAuthenticator,
blueorb: PublicBlueOrb | None = None,
) -> FastAPI:
"""Build the public API around the already-implemented Python SDK."""
app = FastAPI(title="Spark and BlueOrb Public WebSocket API", version="1.0")
@app.websocket("/v1/ws")
async def websocket_api(websocket: WebSocket) -> None:
# Authentication must complete before the WebSocket is accepted.
try:
principal = await authenticator.authenticate(websocket)
except WebSocketException:
raise
except Exception as exc:
_LOG.warning("WebSocket authentication failed: %s", type(exc).__name__)
raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION) from exc
await websocket.accept()
await _serve_connection(websocket, principal, sdk, blueorb)
return app
async def _serve_connection(
websocket: WebSocket,
principal: Principal,
sdk: PublicSparkSdk,
blueorb: PublicBlueOrb | None,
) -> None:
outgoing: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=_OUTGOING_QUEUE_SIZE)
overflow = asyncio.Event()
subscriptions: dict[str, asyncio.Task[None]] = {}
def enqueue(message: dict[str, Any]) -> None:
try:
outgoing.put_nowait(message)
except asyncio.QueueFull:
overflow.set()
def error(request_id: str | int | None, code: str, message: str) -> None:
enqueue({"jsonrpc": "2.0", "id": request_id, "error": _wire_error(code, message)})
async def sender() -> None:
while True:
await websocket.send_json(await outgoing.get())
async def forward_blueorb(subscription_id: str, request: BlueOrbSubscription) -> None:
if blueorb is None:
return
try:
async for packet in blueorb.subscribe(principal, request):
enqueue({
"jsonrpc": "2.0",
"method": "subscription",
"params": {
"subscription_id": subscription_id,
"packet": packet.model_dump(mode="json", by_alias=True),
},
})
if overflow.is_set():
return
except asyncio.CancelledError:
# Unsubscribe is normal lifecycle, not a connection-level failure.
return
except SdkError as exc:
enqueue({
"jsonrpc": "2.0",
"method": "subscription_error",
"params": {
"subscription_id": subscription_id,
"error": _wire_error(exc.code, exc.message),
},
})
except Exception:
_LOG.exception("BlueOrb subscription failed: %s", subscription_id)
enqueue({
"jsonrpc": "2.0",
"method": "subscription_error",
"params": {
"subscription_id": subscription_id,
"error": _wire_error("internal_error", "subscription failed"),
},
})
finally:
if subscriptions.get(subscription_id) is asyncio.current_task():
subscriptions.pop(subscription_id, None)
def can_add_subscription(request_id: str | int, subscription_id: str) -> bool:
if subscription_id in subscriptions:
error(request_id, "duplicate_subscription", "subscription_id is already active")
return False
if len(subscriptions) >= _MAX_SUBSCRIPTIONS:
error(request_id, "subscription_limit", "too many subscriptions on this connection")
return False
return True
async def dispatch(message: Any) -> None:
request_id: str | int | None = None
if isinstance(message, dict):
value = message.get("id")
if isinstance(value, (str, int)) and not isinstance(value, bool):
request_id = value
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or request_id is None:
error(request_id, "invalid_request", "expected jsonrpc='2.0' and a string or integer id")
return
method = message.get("method")
params = message.get("params", {})
if not isinstance(params, dict):
error(request_id, "invalid_params", "params must be an object")
return
try:
if method == "subscription":
request = BlueOrbSubscription.model_validate(params)
if blueorb is None:
error(request_id, "backend_unavailable", "BlueOrb package is not configured")
return
if not can_add_subscription(request_id, request.subscription_id):
return
enqueue({"jsonrpc": "2.0", "id": request_id, "result": {"subscription_id": request.subscription_id}})
subscriptions[request.subscription_id] = asyncio.create_task(
forward_blueorb(request.subscription_id, request)
)
return
if method == "unsubscribe":
request = Unsubscribe.model_validate(params)
task = subscriptions.pop(request.subscription_id, None)
if task is None:
error(request_id, "unknown_subscription", "subscription_id is not active")
return
task.cancel()
await asyncio.gather(task, return_exceptions=True)
enqueue({"jsonrpc": "2.0", "id": request_id, "result": {"subscription_id": request.subscription_id}})
return
if method == "unsubscribe_all":
if params:
error(request_id, "invalid_params", "unsubscribe_all does not accept params")
return
active_tasks = list(subscriptions.values())
for task in active_tasks:
task.cancel()
subscriptions.clear()
await asyncio.gather(*active_tasks, return_exceptions=True)
enqueue({"jsonrpc": "2.0", "id": request_id, "result": {"unsubscribed": "all"}})
return
if method == "enter_order":
result = await sdk.enter_order(principal, EnterOrder.model_validate(params))
elif method == "update_order":
result = await sdk.update_order(principal, UpdateOrder.model_validate(params))
elif method == "delete_order":
result = await sdk.delete_order(principal, DeleteOrder.model_validate(params))
elif method == "create_synthetic_spread":
result = await sdk.create_synthetic_spread(
principal, CreateSyntheticSpread.model_validate(params)
)
elif method == "ping":
if params:
error(request_id, "invalid_params", "ping does not accept params")
return
result = {"pong": True}
else:
error(request_id, "method_not_found", "unknown method")
return
enqueue({
"jsonrpc": "2.0",
"id": request_id,
"result": jsonable_encoder(result, custom_encoder={Decimal: str}),
})
except ValidationError as exc:
fields = [".".join(map(str, item["loc"])) for item in exc.errors()]
error(request_id, "invalid_params", "invalid fields: " + ", ".join(fields))
except SdkError as exc:
error(request_id, exc.code, exc.message)
except Exception:
_LOG.exception("Public WebSocket request failed: %s", method)
error(request_id, "internal_error", "request failed")
send_task = asyncio.create_task(sender())
overflow_task = asyncio.create_task(overflow.wait())
receive_task: asyncio.Task[dict[str, Any]] | None = None
try:
while True:
receive_task = asyncio.create_task(websocket.receive())
done, _ = await asyncio.wait(
{receive_task, send_task, overflow_task}, return_when=asyncio.FIRST_COMPLETED
)
if overflow_task in done:
receive_task.cancel()
with suppress(RuntimeError):
await websocket.close(code=1013, reason="client too slow")
break
if send_task in done:
receive_task.cancel()
break
frame = receive_task.result()
if frame["type"] == "websocket.disconnect":
break
raw = frame.get("text")
if raw is None:
await websocket.close(code=1003, reason="text frames only")
break
if len(raw.encode("utf-8")) > _MAX_REQUEST_BYTES:
error(None, "request_too_large", "request exceeds 64 KiB")
continue
try:
await dispatch(json.loads(raw))
except json.JSONDecodeError:
error(None, "invalid_json", "request is not valid JSON")
except asyncio.CancelledError:
# ASGI servers may cancel the connection coroutine as part of a normal
# client disconnect or shutdown. Cleanup below owns child cancellation.
pass
finally:
cleanup_tasks: list[asyncio.Task[Any]] = []
if receive_task is not None and not receive_task.done():
receive_task.cancel()
cleanup_tasks.append(receive_task)
for task in subscriptions.values():
task.cancel()
send_task.cancel()
overflow_task.cancel()
cleanup_tasks.extend(subscriptions.values())
cleanup_tasks.extend((send_task, overflow_task))
# The ASGI host can cancel again while disconnect cleanup is awaiting
# child tasks. This is still a normal disconnect, not a request failure.
with suppress(asyncio.CancelledError):
await asyncio.gather(*cleanup_tasks, return_exceptions=True)
@@ -0,0 +1,163 @@
"""Async boundary around the independently packaged BlueOrb Python surface."""
import asyncio
import logging
from collections.abc import AsyncIterator, Callable
from threading import Event
from typing import Any, Protocol
from .models import (
BlueOrbColumn,
BlueOrbPacket,
BlueOrbSchema,
BlueOrbSubscription,
Principal,
)
from .sdk import SdkError
_LOG = logging.getLogger(__name__)
_PACKET_QUEUE_SIZE = 64
class BlockingBlueOrbHost(Protocol):
"""The public subset supplied by blueorb-surface-python."""
def subscribe(
self,
dsl: str,
callback: Callable[[Any], None],
cancellation_event: Event | None = None,
) -> None: ...
class PublicBlueOrb(Protocol):
"""Async interface consumed by the WebSocket transport."""
def subscribe(
self,
principal: Principal,
request: BlueOrbSubscription,
) -> AsyncIterator[BlueOrbPacket]: ...
class BlueOrbAdapter:
"""Runs BlueOrb's blocking callback API without blocking the ASGI loop."""
def __init__(self, host: BlockingBlueOrbHost) -> None:
self._host = host
async def subscribe(
self,
principal: Principal,
request: BlueOrbSubscription,
) -> AsyncIterator[BlueOrbPacket]:
# Keep identity in this interface even though unrestricted BlueOrb does
# not consume it yet, so authorization can be added without a redesign.
del principal
loop = asyncio.get_running_loop()
packets: asyncio.Queue[BlueOrbPacket] = asyncio.Queue(maxsize=_PACKET_QUEUE_SIZE)
stopped = Event()
finished = asyncio.Event()
worker_error: list[Exception] = []
def offer(packet: Any) -> None:
if stopped.is_set():
return
try:
packets.put_nowait(_to_public_packet(packet))
except asyncio.QueueFull:
worker_error.append(
SdkError("blueorb_backpressure", "BlueOrb packet consumer is too slow")
)
stopped.set()
finished.set()
except Exception as exc:
worker_error.append(exc)
stopped.set()
finished.set()
def on_packet(packet: Any) -> None:
loop.call_soon_threadsafe(offer, packet)
def run() -> None:
try:
self._host.subscribe(request.dsl, on_packet, stopped)
except Exception as exc:
loop.call_soon_threadsafe(worker_error.append, exc)
finally:
loop.call_soon_threadsafe(finished.set)
worker = asyncio.create_task(asyncio.to_thread(run))
try:
while True:
if finished.is_set() and packets.empty():
if worker_error:
raise _translate_error(worker_error[0])
return
packet_task = asyncio.create_task(packets.get())
finished_task = asyncio.create_task(finished.wait())
done, pending = await asyncio.wait(
{packet_task, finished_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
if packet_task in done:
yield packet_task.result()
finally:
stopped.set()
try:
await asyncio.wait_for(asyncio.shield(worker), timeout=2)
except TimeoutError:
_LOG.warning("Timed out waiting for BlueOrb subscription cancellation")
except asyncio.CancelledError:
pass
except Exception:
# The iterator loop reports a translated form of worker errors.
pass
def _to_public_packet(packet: Any) -> BlueOrbPacket:
schema = BlueOrbSchema(
name=packet.schema.name,
columns=[
BlueOrbColumn(
name=column.name,
ordinal=column.ordinal,
type_code=column.type_code,
nullable=column.nullable,
)
for column in packet.schema.columns
],
)
return BlueOrbPacket(
table=packet.table,
schema=schema,
current=packet.current,
added=packet.added,
removed=packet.removed,
kind=packet.kind,
sequence=packet.sequence,
)
def _translate_error(error: Exception) -> SdkError:
if isinstance(error, SdkError):
return error
grpc_code = getattr(error, "code", None)
if callable(grpc_code):
code_value = grpc_code()
code_name = getattr(code_value, "name", str(code_value))
if code_name == "INVALID_ARGUMENT":
return SdkError("invalid_blueorb_query", str(error))
if code_name in {"UNAVAILABLE", "DEADLINE_EXCEEDED"}:
return SdkError("backend_unavailable", "BlueOrb RemoteStreamServer is unavailable")
if code_name == "CANCELLED":
return SdkError("subscription_cancelled", "BlueOrb subscription was cancelled")
return SdkError("blueorb_error", str(error) or type(error).__name__)
@@ -0,0 +1,204 @@
"""Public v1 wire models, independent of Spark's internal protobuf messages."""
from decimal import Decimal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class WireModel(BaseModel):
model_config = ConfigDict(extra="forbid")
BlueOrbPacketKind = Literal["snapshot", "delta"]
class Principal(WireModel):
subject: str
class BlueOrbSubscription(WireModel):
subscription_id: str = Field(min_length=1, max_length=128)
dsl: str = Field(min_length=1, max_length=8192)
class BlueOrbColumn(WireModel):
name: str
ordinal: int = Field(ge=0)
type_code: str
nullable: bool
class BlueOrbSchema(WireModel):
name: str
columns: list[BlueOrbColumn]
class BlueOrbPacket(WireModel):
table: str
stream_schema: BlueOrbSchema = Field(alias="schema", serialization_alias="schema")
current: list[dict[str, Any]]
added: list[dict[str, Any]]
removed: list[dict[str, Any]]
kind: BlueOrbPacketKind
sequence: int = Field(ge=0)
class Unsubscribe(WireModel):
subscription_id: str = Field(min_length=1)
class EnterOrder(WireModel):
client_order_id: str = Field(min_length=1)
account: str = Field(min_length=1)
security_id: str | None = Field(default=None, min_length=1)
exchange: str | None = Field(default=None, min_length=1)
exchange_security_name: str | None = Field(default=None, min_length=1)
side: Literal["buy", "sell"]
order_type: Literal["market", "limit"]
quantity: Decimal = Field(gt=0, allow_inf_nan=False)
price: Decimal | None = Field(default=None, allow_inf_nan=False)
@field_validator("quantity", "price", mode="before")
@classmethod
def require_decimal_string(cls, value: Any) -> Any:
if value is not None and not isinstance(value, str):
raise ValueError("decimal values must be strings")
return value
@model_validator(mode="after")
def check_price(self) -> "EnterOrder":
has_id = self.security_id is not None
has_exchange = self.exchange is not None
has_name = self.exchange_security_name is not None
if has_id and (has_exchange or has_name):
raise ValueError(
"use either security_id or exchange/exchange_security_name"
)
if not has_id and not (has_exchange and has_name):
raise ValueError(
"security_id or exchange and exchange_security_name are required"
)
if self.order_type == "limit" and self.price is None:
raise ValueError("limit orders require price")
if self.order_type == "market" and self.price is not None:
raise ValueError("market orders cannot include price")
return self
class UpdateOrder(WireModel):
order_id: str = Field(min_length=1)
quantity: Decimal | None = Field(default=None, gt=0, allow_inf_nan=False)
price: Decimal | None = Field(default=None, allow_inf_nan=False)
@field_validator("quantity", "price", mode="before")
@classmethod
def require_decimal_string(cls, value: Any) -> Any:
if value is not None and not isinstance(value, str):
raise ValueError("decimal values must be strings")
return value
@model_validator(mode="after")
def require_change(self) -> "UpdateOrder":
if self.quantity is None and self.price is None:
raise ValueError("quantity or price is required")
return self
class DeleteOrder(WireModel):
order_id: str = Field(min_length=1)
class CreateSyntheticSpread(WireModel):
"""Public synthetic-spread request; omitted overrides use SDK/backend presets."""
leg1_security_id: str | None = Field(default=None, min_length=1)
leg1_exchange: str | None = Field(default=None, min_length=1)
leg1_exchange_security_name: str | None = Field(default=None, min_length=1)
leg2_security_id: str | None = Field(default=None, min_length=1)
leg2_exchange: str | None = Field(default=None, min_length=1)
leg2_exchange_security_name: str | None = Field(default=None, min_length=1)
name: str | None = Field(default=None, min_length=1)
symbol: str | None = Field(default=None, min_length=1)
currency: str | None = Field(default=None, min_length=1)
tick_size: Decimal | None = Field(default=None, gt=0, allow_inf_nan=False)
cash_per_point: Decimal | None = Field(default=None, gt=0, allow_inf_nan=False)
price_multiplier: Decimal | None = Field(default=None, gt=0, allow_inf_nan=False)
main_fraction: int | None = Field(default=None, gt=0)
sub_fraction: int | None = Field(default=None, gt=0)
price_display_format: int | None = Field(default=None, ge=0)
price_calc: Literal["standard", "net_change", "notional_leg"] | None = None
cancel_hedges_on_cancel: bool | None = None
hedge_pricing: Literal["cached_drivers", "recalculate_crosses"] | None = None
spreader_type: Literal["requote", "reverse_hedge", "sniper"] | None = None
lean_quantity_ratio: Decimal | None = Field(default=None, gt=0, allow_inf_nan=False)
leg1_quantity: Decimal | None = Field(default=None, gt=0, allow_inf_nan=False)
leg1_price_multiplier: Decimal | None = Field(default=None, allow_inf_nan=False)
leg1_leg_delta: Decimal | None = Field(default=None, allow_inf_nan=False)
leg1_side: Literal["buy", "sell"] | None = None
leg1_is_active_quoting: bool | None = None
leg1_enable_hedging: bool | None = None
leg1_market_data_type: Literal["merged", "direct"] | None = None
leg2_quantity: Decimal | None = Field(default=None, gt=0, allow_inf_nan=False)
leg2_price_multiplier: Decimal | None = Field(default=None, allow_inf_nan=False)
leg2_leg_delta: Decimal | None = Field(default=None, allow_inf_nan=False)
leg2_side: Literal["buy", "sell"] | None = None
leg2_is_active_quoting: bool | None = None
leg2_enable_hedging: bool | None = None
leg2_market_data_type: Literal["merged", "direct"] | None = None
@field_validator(
"tick_size",
"cash_per_point",
"price_multiplier",
"lean_quantity_ratio",
"leg1_quantity",
"leg1_price_multiplier",
"leg1_leg_delta",
"leg2_quantity",
"leg2_price_multiplier",
"leg2_leg_delta",
mode="before",
)
@classmethod
def require_decimal_string(cls, value: Any) -> Any:
if value is not None and not isinstance(value, str):
raise ValueError("decimal values must be strings")
return value
@field_validator("leg1_price_multiplier", "leg2_price_multiplier")
@classmethod
def require_nonzero_leg_multiplier(cls, value: Decimal | None) -> Decimal | None:
if value == 0:
raise ValueError("leg price multipliers cannot be zero")
return value
@model_validator(mode="after")
def require_leg_locators(self) -> "CreateSyntheticSpread":
for prefix in ("leg1", "leg2"):
security_id = getattr(self, f"{prefix}_security_id")
exchange = getattr(self, f"{prefix}_exchange")
name = getattr(self, f"{prefix}_exchange_security_name")
if security_id is not None and (exchange is not None or name is not None):
raise ValueError(
f"{prefix} must use either security_id or exchange/exchange_security_name"
)
if security_id is None and (exchange is None or name is None):
raise ValueError(
f"{prefix} requires security_id or exchange and exchange_security_name"
)
return self
class CreateSyntheticSpreadResult(WireModel):
security_id: str = Field(min_length=1)
class OrderResult(WireModel):
order_id: str
status: Literal["accepted", "pending", "rejected"]
message: str | None = None
@@ -0,0 +1,40 @@
"""The narrow SDK contract required by this gateway.
The actual Python SDK and bridge are provided by a different project.
"""
from typing import Protocol
from fastapi import WebSocket
from .models import (
CreateSyntheticSpread,
CreateSyntheticSpreadResult,
DeleteOrder,
EnterOrder,
OrderResult,
Principal,
UpdateOrder,
)
class SdkError(Exception):
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
self.message = message
class PublicSparkSdk(Protocol):
"""Implementations enforce Spark permissions for the supplied principal."""
async def enter_order(self, principal: Principal, request: EnterOrder) -> OrderResult: ...
async def update_order(self, principal: Principal, request: UpdateOrder) -> OrderResult: ...
async def delete_order(self, principal: Principal, request: DeleteOrder) -> OrderResult: ...
async def create_synthetic_spread(
self, principal: Principal, request: CreateSyntheticSpread
) -> CreateSyntheticSpreadResult: ...
class WebSocketAuthenticator(Protocol):
async def authenticate(self, websocket: WebSocket) -> Principal: ...
@@ -0,0 +1,144 @@
"""Runnable development host for the independently packaged gateway."""
import argparse
import importlib
import logging
import os
from collections.abc import Callable
from typing import Any
from fastapi import WebSocket
from .app import create_app
from .blueorb import BlueOrbAdapter, PublicBlueOrb
from .models import Principal
from .sdk import SdkError
from .spark import SparkCommandAdapter
_LOG = logging.getLogger(__name__)
class AnonymousAuthenticator:
"""Development identity used while external authentication is out of scope."""
async def authenticate(self, websocket: WebSocket) -> Principal:
del websocket
return Principal(subject="anonymous")
class UnavailableSparkSdk:
"""Allows BlueOrb and local protocol work without sparkd or a Spark SDK."""
async def enter_order(self, principal: Principal, request: Any) -> Any:
self._unavailable(principal, request)
async def update_order(self, principal: Principal, request: Any) -> Any:
self._unavailable(principal, request)
async def delete_order(self, principal: Principal, request: Any) -> Any:
self._unavailable(principal, request)
async def create_synthetic_spread(self, principal: Principal, request: Any) -> Any:
self._unavailable(principal, request)
@staticmethod
def _unavailable(principal: Principal, request: Any) -> None:
del principal, request
raise SdkError("backend_unavailable", "Spark SDK is not configured")
def create_runtime_app(
blueorb_address: str | None = "http://localhost:5085",
spark_sdk_factory: str | None = None,
spark_command_address: str | None = "127.0.0.1:50051",
) -> Any:
"""Create a server that can run with BlueOrb even when Spark is absent."""
if spark_sdk_factory:
sdk = _load_factory(spark_sdk_factory)
elif spark_command_address:
sdk = _create_spark(spark_command_address)
else:
sdk = UnavailableSparkSdk()
blueorb = _create_blueorb(blueorb_address)
return create_app(sdk, AnonymousAuthenticator(), blueorb)
def _create_spark(address: str) -> SparkCommandAdapter:
try:
from spark_command_client import SparkCommandClient
except ImportError as exc:
raise RuntimeError(
"Spark commands are enabled but spark-command-client is not installed"
) from exc
return SparkCommandAdapter(SparkCommandClient(address))
def _create_blueorb(address: str | None) -> PublicBlueOrb | None:
if not address:
return None
try:
from blueorb import BlueOrbStreamHost
except ImportError as exc:
raise RuntimeError(
"BlueOrb is enabled but blueorb-surface-python is not installed"
) from exc
return BlueOrbAdapter(BlueOrbStreamHost(address))
def _load_factory(specification: str) -> Any:
module_name, separator, attribute_name = specification.partition(":")
if not separator or not module_name or not attribute_name:
raise ValueError("Spark SDK factory must use module:function syntax")
module = importlib.import_module(module_name)
factory = getattr(module, attribute_name)
if not isinstance(factory, Callable):
raise TypeError(f"Spark SDK factory '{specification}' is not callable")
return factory()
def main() -> None:
parser = argparse.ArgumentParser(description="Run the public Python WebSocket gateway")
parser.add_argument("--host", default=os.environ.get("PUBLIC_WS_HOST", "127.0.0.1"))
parser.add_argument(
"--port",
type=int,
default=int(os.environ.get("PUBLIC_WS_PORT", "8090")),
)
parser.add_argument(
"--blueorb-address",
default=os.environ.get("BLUEORB_ADDRESS", "http://localhost:5085"),
)
parser.add_argument(
"--without-blueorb",
action="store_true",
help="Disable the BlueOrb integration for this server process",
)
parser.add_argument(
"--spark-sdk-factory",
default=os.environ.get("SPARK_SDK_FACTORY"),
help="Optional module:function returning a PublicSparkSdk implementation",
)
parser.add_argument(
"--spark-command-address",
default=os.environ.get("SPARK_COMMAND_ADDRESS", "127.0.0.1:50051"),
help="Spark command bridge gRPC authority",
)
parser.add_argument(
"--without-spark",
action="store_true",
help="Disable Spark commands for isolated BlueOrb/protocol development",
)
parser.add_argument("--log-level", default="info")
args = parser.parse_args()
try:
import uvicorn
except ImportError as exc: # pragma: no cover - packaging failure
raise RuntimeError("Install the server dependencies for uvicorn support") from exc
app = create_runtime_app(
blueorb_address=None if args.without_blueorb else args.blueorb_address,
spark_sdk_factory=args.spark_sdk_factory,
spark_command_address=None if args.without_spark else args.spark_command_address,
)
uvicorn.run(app, host=args.host, port=args.port, log_level=args.log_level)
@@ -0,0 +1,55 @@
"""Adapter from the independently packaged bridge client to gateway models."""
from typing import Any
from spark_command_client import BridgeError, SparkCommandClient
from .models import (
CreateSyntheticSpread,
CreateSyntheticSpreadResult,
DeleteOrder,
EnterOrder,
OrderResult,
Principal,
UpdateOrder,
)
from .sdk import SdkError
class SparkCommandAdapter:
def __init__(self, client: SparkCommandClient):
self._client = client
async def enter_order(
self, principal: Principal, request: EnterOrder
) -> OrderResult:
del principal
return OrderResult.model_validate(await self._invoke(self._client.enter_order, request))
async def update_order(
self, principal: Principal, request: UpdateOrder
) -> OrderResult:
del principal
return OrderResult.model_validate(await self._invoke(self._client.update_order, request))
async def delete_order(
self, principal: Principal, request: DeleteOrder
) -> OrderResult:
del principal
return OrderResult.model_validate(await self._invoke(self._client.delete_order, request))
async def create_synthetic_spread(
self,
principal: Principal,
request: CreateSyntheticSpread,
) -> CreateSyntheticSpreadResult:
del principal
result = await self._invoke(self._client.create_synthetic_spread, request)
return CreateSyntheticSpreadResult.model_validate(result)
@staticmethod
async def _invoke(operation: Any, request: Any) -> Any:
try:
return await operation(request)
except BridgeError as exc:
raise SdkError(exc.code, exc.message) from exc
@@ -0,0 +1,261 @@
"""Contract tests with an in-memory SDK; no Spark bridge is required."""
import asyncio
from contextlib import contextmanager
from decimal import Decimal
import pytest
from pydantic import ValidationError
from fastapi.testclient import TestClient
from spark_public_websocket import create_app
from spark_public_websocket.models import (
BlueOrbColumn,
BlueOrbPacket,
BlueOrbSchema,
CreateSyntheticSpreadResult,
EnterOrder,
OrderResult,
Principal,
)
class TestAuthenticator:
async def authenticate(self, websocket):
return Principal(subject="test-user")
class FakeSdk:
def __init__(self):
self.orders = []
self.synthetic_spreads = []
async def enter_order(self, principal, request):
self.orders.append((principal, request))
return OrderResult(order_id="ord-1", status="pending")
async def update_order(self, principal, request):
return OrderResult(order_id=request.order_id, status="pending")
async def delete_order(self, principal, request):
return OrderResult(order_id=request.order_id, status="pending")
async def create_synthetic_spread(self, principal, request):
self.synthetic_spreads.append((principal, request))
return CreateSyntheticSpreadResult(security_id="sec-synthetic-1")
class FakeBlueOrb:
async def subscribe(self, principal, request):
assert principal.subject == "test-user"
assert request.dsl == 'phoenix_bbo("SOL")'
yield BlueOrbPacket(
table="phoenix_bbo",
schema=BlueOrbSchema(
name="phoenix_bbo",
columns=[
BlueOrbColumn(
name="price",
ordinal=0,
type_code="decimal",
nullable=False,
)
],
),
current=[{"price": Decimal("100.25")}],
added=[{"price": Decimal("100.25")}],
removed=[],
kind="snapshot",
sequence=1,
)
await asyncio.Event().wait()
def client_for(sdk, blueorb=None):
return TestClient(create_app(sdk, TestAuthenticator(), blueorb))
@contextmanager
def websocket_for(sdk, blueorb=None):
with client_for(sdk, blueorb) as client:
with client.websocket_connect("/v1/ws") as websocket:
yield websocket
def test_blueorb_subscription_ack_packet_and_unsubscribe():
with websocket_for(FakeSdk(), FakeBlueOrb()) as ws:
ws.send_json({
"jsonrpc": "2.0",
"id": "query",
"method": "subscription",
"params": {
"subscription_id": "blueorb-1",
"dsl": 'phoenix_bbo("SOL")',
},
})
assert ws.receive_json() == {
"jsonrpc": "2.0",
"id": "query",
"result": {"subscription_id": "blueorb-1"},
}
event = ws.receive_json()
assert event["method"] == "subscription"
assert event["params"]["packet"]["kind"] == "snapshot"
assert "schema" in event["params"]["packet"]
assert "stream_schema" not in event["params"]["packet"]
assert event["params"]["packet"]["current"][0]["price"] == "100.25"
ws.send_json({
"jsonrpc": "2.0",
"id": "stop",
"method": "unsubscribe",
"params": {"subscription_id": "blueorb-1"},
})
assert ws.receive_json()["result"] == {"subscription_id": "blueorb-1"}
def test_blueorb_subscription_requires_configured_package():
with websocket_for(FakeSdk()) as ws:
ws.send_json({
"jsonrpc": "2.0",
"id": "query",
"method": "subscription",
"params": {"subscription_id": "blueorb-1", "dsl": "stream_docs()"},
})
error = ws.receive_json()["error"]
assert error["data"]["code"] == "backend_unavailable"
def test_order_validation_and_identity():
sdk = FakeSdk()
with websocket_for(sdk) as ws:
params = {"client_order_id": "client-1", "account": "acc-1", "security_id": "sec-1",
"side": "buy", "order_type": "limit", "quantity": "2", "price": "100.25"}
ws.send_json({"jsonrpc": "2.0", "id": "order", "method": "enter_order", "params": params})
assert ws.receive_json()["result"]["order_id"] == "ord-1"
principal, request = sdk.orders[0]
assert principal.subject == "test-user"
assert request.price == Decimal("100.25")
ws.send_json({"jsonrpc": "2.0", "id": "bad", "method": "enter_order",
"params": {**params, "price": 100.25}})
assert ws.receive_json()["error"]["data"]["code"] == "invalid_params"
assert len(sdk.orders) == 1
def test_enter_order_accepts_exchange_security_name():
sdk = FakeSdk()
with websocket_for(sdk) as ws:
ws.send_json({
"jsonrpc": "2.0",
"id": "named-order",
"method": "enter_order",
"params": {
"client_order_id": "client-named-1",
"account": "ACC A",
"exchange": "okx",
"exchange_security_name": "SOL-USDC",
"side": "buy",
"order_type": "limit",
"quantity": "1",
"price": "1.00",
},
})
assert ws.receive_json()["result"]["order_id"] == "ord-1"
request = sdk.orders[0][1]
assert request.security_id is None
assert request.exchange == "okx"
assert request.exchange_security_name == "SOL-USDC"
@pytest.mark.parametrize("price", ["-0.04", "0", "0.04"])
def test_spread_prices_reach_sdk_for_enter_and_update(price):
sdk = FakeSdk()
with websocket_for(sdk) as ws:
ws.send_json({
"jsonrpc": "2.0", "id": "spread", "method": "enter_order",
"params": {"client_order_id": "spread-1", "account": "ACC A",
"security_id": "969", "side": "buy", "order_type": "limit",
"quantity": "0.01", "price": price},
})
assert ws.receive_json()["result"]["order_id"] == "ord-1"
assert sdk.orders[-1][1].price == Decimal(price)
ws.send_json({
"jsonrpc": "2.0", "id": "update", "method": "update_order",
"params": {"order_id": "ord-1", "price": price},
})
assert "result" in ws.receive_json()
@pytest.mark.parametrize("price", ["NaN", "Infinity", "-Infinity"])
def test_nonfinite_spread_prices_are_rejected(price):
with pytest.raises(ValidationError):
EnterOrder.model_validate({
"client_order_id": "spread-1", "account": "ACC A",
"security_id": "969", "side": "buy", "order_type": "limit",
"quantity": "0.01", "price": price,
})
def test_removed_and_unknown_methods_are_rejected():
with websocket_for(FakeSdk()) as ws:
for request_id, method in enumerate(("get", "spark_subscriptions", "arbitrary"), 1):
ws.send_json({"jsonrpc": "2.0", "id": request_id, "method": method, "params": {}})
error = ws.receive_json()["error"]
assert error["code"] == -32601
assert error["data"]["code"] == "method_not_found"
def test_create_synthetic_spread_validation_and_identity():
sdk = FakeSdk()
with websocket_for(sdk) as ws:
params = {
"leg1_security_id": "sec-1",
"leg2_security_id": "sec-2",
"tick_size": "0.01",
"leg1_quantity": "2",
"leg2_price_multiplier": "-1",
"spreader_type": "requote",
}
ws.send_json({
"jsonrpc": "2.0",
"id": "spread",
"method": "create_synthetic_spread",
"params": params,
})
assert ws.receive_json()["result"] == {"security_id": "sec-synthetic-1"}
principal, request = sdk.synthetic_spreads[0]
assert principal.subject == "test-user"
assert request.tick_size == Decimal("0.01")
assert request.leg2_price_multiplier == Decimal("-1")
ws.send_json({
"jsonrpc": "2.0",
"id": "bad-spread",
"method": "create_synthetic_spread",
"params": {**params, "tick_size": 0.01},
})
assert ws.receive_json()["error"]["data"]["code"] == "invalid_params"
assert len(sdk.synthetic_spreads) == 1
def test_create_synthetic_spread_accepts_exchange_security_names():
sdk = FakeSdk()
with websocket_for(sdk) as ws:
ws.send_json({
"jsonrpc": "2.0",
"id": "named-spread",
"method": "create_synthetic_spread",
"params": {
"leg1_exchange": "okx",
"leg1_exchange_security_name": "SOL-USDC",
"leg2_exchange": "orca",
"leg2_exchange_security_name": "SOL-USDC-1",
},
})
assert ws.receive_json()["result"] == {"security_id": "sec-synthetic-1"}
request = sdk.synthetic_spreads[0][1]
assert request.leg1_exchange == "okx"
assert request.leg1_exchange_security_name == "SOL-USDC"
assert request.leg2_exchange == "orca"
assert request.leg2_exchange_security_name == "SOL-USDC-1"
@@ -0,0 +1,58 @@
"""Tests for the blocking BlueOrb callback to asyncio adapter."""
import asyncio
from decimal import Decimal
from types import SimpleNamespace
from spark_public_websocket.blueorb import BlueOrbAdapter
from spark_public_websocket.models import BlueOrbSubscription, Principal
class BlockingFakeHost:
def __init__(self):
self.cancelled = False
def subscribe(self, dsl, callback, cancellation_event=None):
assert dsl == 'phoenix_bbo("SOL")'
callback(SimpleNamespace(
table="phoenix_bbo",
schema=SimpleNamespace(
name="phoenix_bbo",
columns=[SimpleNamespace(
name="price",
ordinal=0,
type_code="decimal",
nullable=False,
)],
),
current=[{"price": Decimal("100.25")}],
added=[{"price": Decimal("100.25")}],
removed=[],
kind="snapshot",
sequence=1,
))
if cancellation_event is not None:
cancellation_event.wait(timeout=2)
self.cancelled = cancellation_event.is_set()
def test_adapter_maps_packet_and_cancels_blocking_subscription():
async def run():
host = BlockingFakeHost()
adapter = BlueOrbAdapter(host)
stream = adapter.subscribe(
Principal(subject="test-user"),
BlueOrbSubscription(
subscription_id="blueorb-1",
dsl='phoenix_bbo("SOL")',
),
)
packet = await anext(stream)
assert packet.table == "phoenix_bbo"
assert packet.current == [{"price": Decimal("100.25")}]
await stream.aclose()
assert host.cancelled
asyncio.run(run())
@@ -0,0 +1,103 @@
"""Full Python path: WebSocket -> BlueOrb package -> gRPC stream -> packet."""
import time
from concurrent.futures import ThreadPoolExecutor
import grpc
from blueorb.protocol import (
RemoteStreamPacket,
RemoteStreamSubscribeRequest,
)
from fastapi.testclient import TestClient
from spark_public_websocket.server import create_runtime_app
def _remote_packet():
packet = RemoteStreamPacket(
remote_stream_id="integration-stream",
sequence=1,
kind=1,
)
packet.payload.schema.name = "integration_values"
id_column = packet.payload.schema.columns.add()
id_column.name = "id"
id_column.ordinal = 0
id_column.type_code = 9
id_column.nullable = False
value_column = packet.payload.schema.columns.add()
value_column.name = "value"
value_column.ordinal = 1
value_column.type_code = 15
value_column.nullable = False
row = packet.payload.current.rows.add()
row.values.add().int32_value = 1
row.values.add().decimal_value = "100.25"
packet.payload.added.CopyFrom(packet.payload.current)
return packet
def test_real_blueorb_package_streams_a_grpc_packet_through_websocket():
grpc_server = grpc.server(ThreadPoolExecutor(max_workers=1))
def subscribe(request, context):
assert request.dsl == 'literal("integration_values", {id = 1, value = 100.25})'
yield _remote_packet()
while context.is_active():
time.sleep(0.01)
handler = grpc.unary_stream_rpc_method_handler(
subscribe,
request_deserializer=RemoteStreamSubscribeRequest.FromString,
response_serializer=RemoteStreamPacket.SerializeToString,
)
grpc_server.add_generic_rpc_handlers((
grpc.method_handlers_generic_handler(
"remotestream.RemoteStreamTransport",
{"Subscribe": handler},
),
))
port = grpc_server.add_insecure_port("127.0.0.1:0")
grpc_server.start()
app = create_runtime_app(blueorb_address=f"http://127.0.0.1:{port}")
try:
with TestClient(app) as client:
with client.websocket_connect("/v1/ws") as websocket:
websocket.send_json({
"jsonrpc": "2.0",
"id": "start",
"method": "subscription",
"params": {
"subscription_id": "blueorb-integration",
"dsl": 'literal("integration_values", {id = 1, value = 100.25})',
},
})
assert websocket.receive_json() == {
"jsonrpc": "2.0",
"id": "start",
"result": {"subscription_id": "blueorb-integration"},
}
event = websocket.receive_json()
assert event["method"] == "subscription"
assert event["params"]["subscription_id"] == "blueorb-integration"
assert event["params"]["packet"]["table"] == "integration_values"
assert event["params"]["packet"]["current"] == [
{"id": 1, "value": "100.25"}
]
websocket.send_json({
"jsonrpc": "2.0",
"id": "stop",
"method": "unsubscribe",
"params": {"subscription_id": "blueorb-integration"},
})
assert websocket.receive_json()["result"] == {
"subscription_id": "blueorb-integration"
}
finally:
grpc_server.stop(grace=0).wait()
@@ -0,0 +1,73 @@
"""WebSocket -> packaged client -> gRPC bridge contract integration."""
from concurrent.futures import ThreadPoolExecutor
import grpc
import pytest
from fastapi.testclient import TestClient
from spark_command_client import SparkCommandClient
from spark_command_client.generated import spark_command_pb2 as messages
from spark_command_client.generated import spark_command_pb2_grpc as services
from spark_public_websocket import SparkCommandAdapter, create_app
from spark_public_websocket.models import Principal
class TestAuthenticator:
async def authenticate(self, websocket):
return Principal(subject="integration-user")
class FakeCommandBridge(services.SparkCommandServiceServicer):
def __init__(self):
self.request = None
def EnterOrder(self, request, context):
self.request = request
return messages.OrderResult(
order_id="9001",
status=messages.COMMAND_STATUS_ACCEPTED,
)
@pytest.mark.parametrize("price", ["100.25", "0", "-0.04"])
def test_order_crosses_websocket_package_and_grpc_contract(price):
bridge = FakeCommandBridge()
grpc_server = grpc.server(ThreadPoolExecutor(max_workers=1))
services.add_SparkCommandServiceServicer_to_server(bridge, grpc_server)
port = grpc_server.add_insecure_port("127.0.0.1:0")
grpc_server.start()
sdk = SparkCommandAdapter(SparkCommandClient(f"127.0.0.1:{port}"))
app = create_app(sdk, TestAuthenticator())
try:
with TestClient(app) as client:
with client.websocket_connect("/v1/ws") as websocket:
websocket.send_json({
"jsonrpc": "2.0",
"id": "enter",
"method": "enter_order",
"params": {
"client_order_id": "client-9001",
"account": "ACC-A",
"security_id": "42",
"side": "buy",
"order_type": "limit",
"quantity": "2",
"price": price,
},
})
assert websocket.receive_json() == {
"jsonrpc": "2.0",
"id": "enter",
"result": {
"order_id": "9001",
"status": "accepted",
"message": None,
},
}
assert bridge.request.client_order_id == "client-9001"
assert bridge.request.security_id == "42"
assert bridge.request.price == price
finally:
grpc_server.stop(grace=0).wait()
@@ -288,7 +288,12 @@ namespace bts.websocket_core {
}
// If the message is the end of the message, handle the message and reset the request string
if (result.EndOfMessage) {
gtwy.HandleMessage(new WebsocketIncomingMessage(id, request));
System.Console.WriteLine($"WebSocketHandler received message from {id}: {request}");
try {
gtwy.HandleMessage(new WebsocketIncomingMessage(id, request));
} catch (Exception ex) {
System.Console.WriteLine($"WebSocketHandler failed to dispatch message from {id}: {ex}");
}
request = "";
}
}
@@ -298,6 +303,9 @@ namespace bts.websocket_core {
var log_txt = $"WebsocketGateway.ProcessWebSocketRequestAsync connection closed Error: {ex_str}";
gtwy.HandleDisconnection(id, log_txt);
} catch (OperationCanceledException) {//not sure about this, read that this should just be ignored
} catch (Exception ex) {
System.Console.WriteLine($"WebSocketHandler.ProcessWebSocketRequestAsync failed for {id}: {ex}");
gtwy.HandleDisconnection(id, ex.Message);
}
}
/// <summary>
@@ -15,10 +15,13 @@ namespace bts.websocket_core {
using Spark.Core.Disposables;
using Spark.Core.Reactive;
using mbts.config;
using System.Collections.Concurrent;
using System.Reactive.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
/// <summary>
/// This does all the bookkeeping for connections and data, and passes
@@ -30,6 +33,7 @@ namespace bts.websocket_core {
readonly ISecurityResolver securityResolver;
IReadOnlyDictionary<uint, ExecutionStrategyImmutable> synthStrategies = new Dictionary<uint, ExecutionStrategyImmutable>(0);
IDisposable? synthStrategiesSub;
static readonly TimeSpan SyntheticSpreadCreateTimeout = TimeSpan.FromSeconds(15);
public WebsocketDataSync(Spark.Api.IServiceProvider service_ctx, IMarketDataClient mdClient, ISecurityResolver securityResolver) {
_service_ctx = service_ctx;
@@ -225,17 +229,478 @@ namespace bts.websocket_core {
// broadcast.Start();
}
private uint get_security(Venue v, string sec_name) {
var securites = _securities;
if (securites.Values.TryGetFirst(sec =>
(
sec.Exchange == v &&
sec.ToString() == sec_name),
out var sec
)) {
return sec.Id;
private readonly struct SyntheticSpreadLegRequest {
public readonly Venue Exchange;
public readonly string ExchangeSecurityName;
public readonly double Quantity;
public readonly double PriceMultiplier;
public readonly double LegDelta;
public readonly Side Side;
public readonly bool IsActiveQuoting;
public readonly bool EnableHedging;
public readonly LegSpreaderParams.Types.MarketDataType MarketDataType;
public SyntheticSpreadLegRequest(
Venue exchange,
string exchangeSecurityName,
double quantity,
double priceMultiplier,
double legDelta,
Side side,
bool isActiveQuoting,
bool enableHedging,
LegSpreaderParams.Types.MarketDataType marketDataType) {
Exchange = exchange;
ExchangeSecurityName = exchangeSecurityName;
Quantity = quantity;
PriceMultiplier = priceMultiplier;
LegDelta = legDelta;
Side = side;
IsActiveQuoting = isActiveQuoting;
EnableHedging = enableHedging;
MarketDataType = marketDataType;
}
throw new ArgumentException($"{sec_name} at {v} not found in securites");
}
private static string SanitizeIdentifier(string value) =>
string.Concat(value.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_'));
private static bool TryGetOptionalString(
JsonObject parameters,
string key,
out string? value,
out string message) {
value = null;
if (!WebsocketJsonParsers.TryParse<WebsocketJsonParsers.StringWrapper>(
parameters,
key,
out WebsocketJsonParsers.StringWrapper? wrapper,
out message)) {
return false;
}
value = wrapper?.Val;
return true;
}
private static bool TryGetRequiredString(
JsonObject parameters,
string key,
out string value,
out string message) {
value = string.Empty;
if (!TryGetOptionalString(parameters, key, out string? parsed, out message) ||
string.IsNullOrWhiteSpace(parsed)) {
message = string.IsNullOrWhiteSpace(message) ? $"{key} is required" : message;
return false;
}
value = parsed.Trim();
return true;
}
private static bool TryGetOptionalDouble(
JsonObject parameters,
string key,
double defaultValue,
out double value,
out string message) {
value = defaultValue;
if (!WebsocketJsonParsers.TryParse<double>(
parameters,
key,
out double? parsed,
out message)) {
return false;
}
value = parsed ?? defaultValue;
return true;
}
private static bool TryGetOptionalBool(
JsonObject parameters,
string key,
bool defaultValue,
out bool value,
out string message) {
value = defaultValue;
if (!WebsocketJsonParsers.TryParse<bool>(
parameters,
key,
out bool? parsed,
out message)) {
return false;
}
value = parsed ?? defaultValue;
return true;
}
private static bool TryGetOptionalEnum<TEnum>(
JsonObject parameters,
string key,
TEnum defaultValue,
out TEnum value,
out string message) where TEnum : struct {
value = defaultValue;
if (!WebsocketJsonParsers.TryParse<TEnum>(
parameters,
key,
out TEnum? parsed,
out message)) {
return false;
}
value = parsed ?? defaultValue;
return true;
}
private static bool TryGetRequiredVenue(
JsonObject parameters,
string key,
out Venue value,
out string message) {
value = default;
if (!WebsocketJsonParsers.TryParse<Venue>(
parameters,
key,
out Venue? parsed,
out message) ||
parsed is null) {
message = string.IsNullOrWhiteSpace(message) ? $"{key} is required" : message;
return false;
}
value = parsed.Value;
return true;
}
private static bool IsWildcardSecurityName(string? value) =>
string.IsNullOrWhiteSpace(value) || value.Trim() == "*";
private static bts.config.SyntheticSpreadPreset? ResolvePreset(
bts.config.SyntheticSpreadConfig config,
Venue exchange,
string exchangeSecurityName) {
var normalized = exchangeSecurityName.Trim();
return config.Preset.FirstOrDefault(p =>
p.FirstLegExchange == exchange &&
string.Equals(p.FirstLegExchangeSecurityName, normalized, StringComparison.OrdinalIgnoreCase))
?? config.Preset.FirstOrDefault(p =>
p.FirstLegExchange == exchange &&
IsWildcardSecurityName(p.FirstLegExchangeSecurityName))
?? config.Preset.FirstOrDefault(p =>
p.FirstLegExchange == Venue.Unset &&
IsWildcardSecurityName(p.FirstLegExchangeSecurityName));
}
private static double ResolveDouble(double requested, double configured, double fallback) =>
requested != 0 ? requested : (configured != 0 ? configured : fallback);
private static uint ResolveUInt32(uint requested, uint configured, uint fallback) =>
requested != 0 ? requested : (configured != 0 ? configured : fallback);
private static string BuildSyntheticName(
string? requestedName,
string _configuredName,
ContractLeg leg1,
ContractLeg leg2) {
if (!string.IsNullOrWhiteSpace(requestedName)) {
return requestedName.Trim();
}
var leg1Security = new SecurityImmutable(leg1.Security);
var leg2Security = new SecurityImmutable(leg2.Security);
var leg1Desc = leg1Security.security_desc().ToLowerInvariant();
var leg2Desc = leg2Security.security_desc().ToLowerInvariant();
var leg1Exchange = leg1Security.Exchange.ToString().ToLowerInvariant();
var leg2Exchange = leg2Security.Exchange.ToString().ToLowerInvariant();
return $"{leg1Exchange}:{leg1Desc}_{leg2Exchange}:{leg2Desc}";
}
private static string BuildSyntheticSymbol(
string? requestedSymbol,
string configuredSymbol,
string firstLegSymbol,
string syntheticName) {
if (!string.IsNullOrWhiteSpace(requestedSymbol)) {
return requestedSymbol.Trim();
}
if (!string.IsNullOrWhiteSpace(configuredSymbol)) {
return configuredSymbol.Trim();
}
if (!string.IsNullOrWhiteSpace(firstLegSymbol)) {
return firstLegSymbol.Trim();
}
return SanitizeIdentifier(syntheticName);
}
private static ContractSpecification BuildContractSpec(
double tickSize,
double cashPerPoint,
double priceMultiplier,
uint mainFraction,
uint subFraction,
uint priceDisplayFormat,
bool supportsFractionalQuantity) {
var specification = new ContractSpecification {
LowTick = tickSize,
HighTick = tickSize,
PriceBreak = double.MaxValue,
CashPerPoint = cashPerPoint,
PriceMultiplier = priceMultiplier,
MainFraction = mainFraction,
SubFraction = subFraction,
PriceDisplayFormat = priceDisplayFormat
};
if (supportsFractionalQuantity) {
specification.QuantityInfos.Add(new ContractQuantityInfo {
MinValue = 0.00001,
MaxValue = double.PositiveInfinity,
TickUnit = 1,
TickBase = 100000,
MaxPrice = float.MaxValue
});
}
return specification;
}
private static bool SupportsFractionalQuantity(SecurityImmutable security) =>
security.HasContractSpec && security.ContractSpec.QuantityInfos.Any(info =>
info.TickBase > 0 && info.TickUnit > 0 && info.TickUnit < info.TickBase);
private static ContractLeg BuildLeg(
SecurityImmutable security,
SyntheticSpreadLegRequest leg) =>
new ContractLeg {
Security = security.CloneToMutable(),
Quantity = leg.Quantity,
PriceMultiplier = leg.PriceMultiplier,
LegDelta = leg.LegDelta,
Side = leg.Side
};
private static LegSpreaderParams BuildLegParams(
double leanQuantityRatio,
SyntheticSpreadLegRequest leg) =>
new LegSpreaderParams {
IsActiveQuoting = leg.IsActiveQuoting,
EnableHedging = leg.EnableHedging,
MarketDataType = leg.MarketDataType,
QuoteParams = new QuoteParams {
LeanQuantityRatio = leanQuantityRatio
},
PreHedgeParams = new PreHedgeParams(),
PostHedgeParams = new PostHedgeParams()
};
private static async Task<T> WaitForSyntheticSpreadOperation<T>(Task<T> operation, string operationName) {
try {
return await operation.WaitAsync(SyntheticSpreadCreateTimeout);
} catch (TimeoutException) {
throw new TimeoutException(
$"{operationName} timed out after {SyntheticSpreadCreateTimeout.TotalSeconds:0} seconds");
}
}
private static bool TryGetSyntheticSpreadLeg(
JsonObject parameters,
string legPrefix,
bts.config.SyntheticSpreadLegDefaults defaults,
Side fallbackSide,
out SyntheticSpreadLegRequest leg,
out string message) {
leg = default;
if (!TryGetRequiredVenue(parameters, $"{legPrefix}_exchange", out var exchange, out message) ||
!TryGetRequiredString(parameters, $"{legPrefix}_exchange_security_name", out var secName, out message)) {
return false;
}
double quantity = defaults.Quantity != 0 ? defaults.Quantity : 1.0;
double priceMultiplier = defaults.PriceMultiplier != 0 ? defaults.PriceMultiplier : 1.0;
double legDelta = defaults.LegDelta;
bool isActiveQuoting = defaults.IsActiveQuoting;
bool enableHedging = defaults.EnableHedging;
uint marketDataTypeValue = defaults.MarketDataType;
var side = defaults.Side != Side.Undefined ? defaults.Side : fallbackSide;
if (!TryGetOptionalDouble(parameters, $"{legPrefix}_quantity", quantity, out quantity, out message) ||
!TryGetOptionalDouble(parameters, $"{legPrefix}_price_multiplier", priceMultiplier, out priceMultiplier, out message) ||
!TryGetOptionalDouble(parameters, $"{legPrefix}_leg_delta", legDelta, out legDelta, out message) ||
!TryGetOptionalBool(parameters, $"{legPrefix}_is_active_quoting", isActiveQuoting, out isActiveQuoting, out message) ||
!TryGetOptionalBool(parameters, $"{legPrefix}_enable_hedging", enableHedging, out enableHedging, out message) ||
!TryGetOptionalEnum(parameters, $"{legPrefix}_market_data_type", marketDataTypeValue, out marketDataTypeValue, out message) ||
!TryGetOptionalEnum(parameters, $"{legPrefix}_side", side, out side, out message)) {
return false;
}
leg = new SyntheticSpreadLegRequest(
exchange,
secName,
quantity,
priceMultiplier,
legDelta,
side,
isActiveQuoting,
enableHedging,
(LegSpreaderParams.Types.MarketDataType)marketDataTypeValue);
return true;
}
private bool TryResolveSecurity(
Venue exchange,
string exchangeSecurityName,
out SecurityImmutable security,
out string message) {
if (_securities.Values.TryGetFirst(sec =>
sec.Exchange == exchange &&
sec.ToString() == exchangeSecurityName,
out security)) {
message = string.Empty;
return true;
}
message = $"{exchangeSecurityName} at {exchange} not found in securities";
return false;
}
private async Task<(bool success, uint? id, string status)> CreateSyntheticSpreadAsync(WebsocketRequest req) {
if (req.parameters == null) {
return (false, null, "parameters not present");
}
if (_synth_sec_client is null) {
return (false, null, "synthetic security client is not initialized");
}
var config = Configuration.get<bts.config.SyntheticSpreadConfig>();
if (!TryGetRequiredVenue(req.parameters, "leg1_exchange", out var leg1Exchange, out var message) ||
!TryGetRequiredString(req.parameters, "leg1_exchange_security_name", out var leg1SecurityName, out message) ||
!TryGetRequiredVenue(req.parameters, "leg2_exchange", out var leg2Exchange, out message) ||
!TryGetRequiredString(req.parameters, "leg2_exchange_security_name", out var leg2SecurityName, out message)) {
return (false, null, message);
}
var preset = ResolvePreset(config, leg1Exchange, leg1SecurityName);
if (preset is null) {
return (false, null, $"No synthetic spread preset found for {leg1Exchange}:{leg1SecurityName}");
}
string? requestedName = null;
string? requestedSymbol = null;
string? requestedCurrency = null;
double requestedTickSize = 0;
double requestedCashPerPoint = 0;
double requestedPriceMultiplier = 0;
uint requestedMainFraction = 0;
uint requestedSubFraction = 0;
uint requestedPriceDisplayFormat = 0;
uint requestedPriceCalcValue = preset.PriceCalc;
bool requestedCancelHedgesOnCancel = preset.CancelHedgesOnCancel;
uint requestedHedgePricingValue = preset.HedgePricing;
uint requestedSpreaderTypeValue = preset.SpreaderType;
double requestedLeanQuantityRatio = 0;
if (!TryGetOptionalString(req.parameters, "name", out requestedName, out message) ||
!TryGetOptionalString(req.parameters, "symbol", out requestedSymbol, out message) ||
!TryGetOptionalString(req.parameters, "currency", out requestedCurrency, out message) ||
!TryGetOptionalDouble(req.parameters, "tick_size", preset.TickSize != 0 ? preset.TickSize : config.DefaultTickSize, out requestedTickSize, out message) ||
!TryGetOptionalDouble(req.parameters, "cash_per_point", preset.CashPerPoint != 0 ? preset.CashPerPoint : config.DefaultCashPerPoint, out requestedCashPerPoint, out message) ||
!TryGetOptionalDouble(req.parameters, "price_multiplier", preset.PriceMultiplier != 0 ? preset.PriceMultiplier : config.DefaultPriceMultiplier, out requestedPriceMultiplier, out message) ||
!TryGetOptionalEnum(req.parameters, "main_fraction", 1U, out requestedMainFraction, out message) ||
!TryGetOptionalEnum(req.parameters, "sub_fraction", 100000U, out requestedSubFraction, out message) ||
!TryGetOptionalEnum(req.parameters, "price_display_format", (preset.PriceDisplayFormat != 0 ? preset.PriceDisplayFormat : config.DefaultPriceDisplayFormat), out requestedPriceDisplayFormat, out message) ||
!TryGetOptionalEnum(req.parameters, "price_calc", requestedPriceCalcValue, out requestedPriceCalcValue, out message) ||
!TryGetOptionalBool(req.parameters, "cancel_hedges_on_cancel", requestedCancelHedgesOnCancel, out requestedCancelHedgesOnCancel, out message) ||
!TryGetOptionalEnum(req.parameters, "hedge_pricing", requestedHedgePricingValue, out requestedHedgePricingValue, out message) ||
!TryGetOptionalEnum(req.parameters, "spreader_type", requestedSpreaderTypeValue, out requestedSpreaderTypeValue, out message) ||
!TryGetOptionalDouble(req.parameters, "lean_quantity_ratio", preset.LeanQuantityRatio != 0 ? preset.LeanQuantityRatio : config.DefaultLeanQuantityRatio, out requestedLeanQuantityRatio, out message)) {
return (false, null, message);
}
var leg2Defaults = preset.Leg2 ?? new bts.config.SyntheticSpreadLegDefaults();
leg2Defaults.PriceMultiplier = -1;
if (!TryGetSyntheticSpreadLeg(req.parameters, "leg1", preset.Leg1 ?? new bts.config.SyntheticSpreadLegDefaults(), Side.Buy, out var leg1, out message) ||
!TryGetSyntheticSpreadLeg(req.parameters, "leg2", leg2Defaults, Side.Sell, out var leg2, out message)) {
return (false, null, message);
}
if (!TryResolveSecurity(leg1.Exchange, leg1.ExchangeSecurityName, out var sec1, out message)) {
return (false, null, message);
}
if (!TryResolveSecurity(leg2.Exchange, leg2.ExchangeSecurityName, out var sec2, out message)) {
return (false, null, message);
}
var builtLeg1 = BuildLeg(sec1, leg1);
var builtLeg2 = BuildLeg(sec2, leg2);
string resolvedName = BuildSyntheticName(requestedName, preset.Name, builtLeg1, builtLeg2);
string resolvedSymbol = BuildSyntheticSymbol(requestedSymbol, preset.Symbol, sec1.Symbol, resolvedName);
string resolvedCurrency = !string.IsNullOrWhiteSpace(requestedCurrency)
? requestedCurrency.Trim()
: !string.IsNullOrWhiteSpace(preset.Currency)
? preset.Currency.Trim()
: !string.IsNullOrWhiteSpace(config.DefaultCurrency)
? config.DefaultCurrency.Trim()
: sec1.Currency;
double resolvedTickSize = ResolveDouble(requestedTickSize, preset.TickSize != 0 ? preset.TickSize : config.DefaultTickSize, sec1.ContractSpec.LowTick);
double resolvedCashPerPoint = ResolveDouble(requestedCashPerPoint, preset.CashPerPoint != 0 ? preset.CashPerPoint : config.DefaultCashPerPoint, sec1.ContractSpec.CashPerPoint);
double resolvedPriceMultiplier = ResolveDouble(requestedPriceMultiplier, preset.PriceMultiplier != 0 ? preset.PriceMultiplier : config.DefaultPriceMultiplier, sec1.ContractSpec.PriceMultiplier);
uint resolvedMainFraction = ResolveUInt32(requestedMainFraction, 1, sec1.ContractSpec.MainFraction);
uint resolvedSubFraction = ResolveUInt32(requestedSubFraction, 100000, sec1.ContractSpec.SubFraction);
uint resolvedPriceDisplayFormat = ResolveUInt32(requestedPriceDisplayFormat, preset.PriceDisplayFormat != 0 ? preset.PriceDisplayFormat : config.DefaultPriceDisplayFormat, sec1.ContractSpec.PriceDisplayFormat);
var resolvedPriceCalc = (SyntheticContract.Types.PriceCalc)requestedPriceCalcValue;
var resolvedCancelHedgesOnCancel = requestedCancelHedgesOnCancel;
var resolvedHedgePricing = (ExecutionStrategy.Types.HedgePricing)requestedHedgePricingValue;
var resolvedSpreaderType = (ExecutionStrategy.Types.SpreaderType)requestedSpreaderTypeValue;
var resolvedLeanQuantityRatio = requestedLeanQuantityRatio;
bool supportsFractionalQuantity =
SupportsFractionalQuantity(sec1) || SupportsFractionalQuantity(sec2);
var security = new Security {
Exchange = Venue.Bts,
Symbol = resolvedSymbol,
Currency = resolvedCurrency,
ContractSpec = BuildContractSpec(
resolvedTickSize,
resolvedCashPerPoint,
resolvedPriceMultiplier,
resolvedMainFraction,
resolvedSubFraction,
resolvedPriceDisplayFormat,
supportsFractionalQuantity),
Synthetic = new SyntheticContract {
Name = resolvedName,
PriceCalc = resolvedPriceCalc
}
};
security.Synthetic.Legs.Add(builtLeg1);
security.Synthetic.Legs.Add(builtLeg2);
uint securityKey;
try {
bts.Log.Gen.info($"CREATE_SYNTHETIC_SPREAD creating synthetic '{resolvedName}'");
securityKey = await WaitForSyntheticSpreadOperation(
Synth_Sec_Client.create_synthetic_security(new SecurityImmutable(security)),
"create_synthetic_security");
} catch (Exception ex) {
return (false, null, ex.Message);
}
var strategy = new ExecutionStrategy {
CancelHedgesOnCancel = resolvedCancelHedgesOnCancel,
HedgePricing = resolvedHedgePricing,
SpreaderType = resolvedSpreaderType
};
strategy.LegParamsMap[sec1.Id] = BuildLegParams(resolvedLeanQuantityRatio, leg1);
strategy.LegParamsMap[sec2.Id] = BuildLegParams(resolvedLeanQuantityRatio, leg2);
try {
bts.Log.Gen.info($"CREATE_SYNTHETIC_SPREAD updating strategy for security_key {securityKey}");
var status = await WaitForSyntheticSpreadOperation(
Synth_Sec_Client.update_synthetic_strategy(securityKey, new ExecutionStrategyImmutable(strategy)),
"update_synthetic_strategy");
if (status != null) {
return (false, null, status);
}
} catch (Exception ex) {
return (false, null, ex.Message);
}
return (true, securityKey, "OK");
}
public void handle_connection(WebsocketClientID client_id,
WebsocketIncomingMessage? optional_message) {
@@ -264,10 +729,10 @@ namespace bts.websocket_core {
handle_incorrect_request(id, message.sender_client_id, error);
return;
}
handle_request(req);
var log_txt = $"WebsocketDataSync.handle_client_message message: {req.type}";
var log_txt = $"WebsocketDataSync.handle_client_message message type: {req.type}";
bts.Log.Gen.info(log_txt);
System.Console.WriteLine(log_txt);
handle_request(req);
}
public void handle_request(WebsocketRequest req) {
@@ -296,6 +761,10 @@ namespace bts.websocket_core {
handle_set_theos(req);
break;
}
case WebsocketRequest.REQUEST_TYPES.CREATE_SYNTHETIC_SPREAD: {
handle_create_synthetic_spread(req);
break;
}
case WebsocketRequest.REQUEST_TYPES.GET: {
handle_get(req);
break;
@@ -325,6 +794,19 @@ namespace bts.websocket_core {
public async void handle_delete_order(WebsocketRequest req) { await HandleRestRequest(req,WebsocketRestHandler.DeleteOrderAsync);}
public async void handle_set_theos(WebsocketRequest req) { await HandleRestRequest(req,(req, Orders_Client) => WebsocketRestHandler.SetTheosAsync(req, Orders_Client, _securities));}
public async void handle_get(WebsocketRequest req) { await HandleRestRequest(req,WebsocketRestHandler.Get);}
public async void handle_create_synthetic_spread(WebsocketRequest req) {
try {
System.Console.WriteLine($"CREATE_SYNTHETIC_SPREAD request received id: {req.id}");
var (success, id, status) = await CreateSyntheticSpreadAsync(req);
if (!success) {
handle_incorrect_request(req, status);
return;
}
handle_correct_request(req.id, status, new { security_key = id }, req.client_id);
} catch (Exception ex) {
handle_incorrect_request(req, ex.Message);
}
}
public async void handle_enter_order(WebsocketRequest req) {
await HandleRestRequest(req, (req, Orders_Client) => WebsocketRestHandler.EnterOrderAsync(
req, Orders_Client, _securities, adminService, Dynamic_Descriptors, synthStrategies
@@ -375,7 +857,20 @@ namespace bts.websocket_core {
send_message(ms, cid);
}
public void send_message(object value, WebsocketClientID id) {
var requestJson = System.Text.Json.JsonSerializer.Serialize(value);
string requestJson;
try {
requestJson = System.Text.Json.JsonSerializer.Serialize(value);
}
catch (ArgumentException ex) {
// System.Text.Json rejects NaN and +/-Infinity by default. Do not let
// one malformed market-data update terminate the API server.
var nonFiniteValues = describe_non_finite_numbers(value);
bts.Log.Gen.error(
$"Websocket JSON serialization failed for client {id}. " +
$"Payload type: {value.GetType().FullName}. " +
$"Non-finite values: {nonFiniteValues}. Error: {ex.Message}");
return;
}
Gateway.send_to_clients(
new List<WebsocketOutgoingMessage>() {
new WebsocketOutgoingMessage(id, requestJson)
@@ -383,6 +878,51 @@ namespace bts.websocket_core {
}
private static string describe_non_finite_numbers(object value) {
try {
var diagnosticOptions = new JsonSerializerOptions {
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals
};
using var document = JsonDocument.Parse(
JsonSerializer.Serialize(value, diagnosticOptions));
var values = new List<string>();
find_non_finite_numbers(document.RootElement, "$", values);
return values.Count == 0 ? "not found" : string.Join(", ", values);
}
catch (Exception diagnosticError) {
return $"diagnostic serialization failed: {diagnosticError.Message}";
}
}
private static void find_non_finite_numbers(
JsonElement element,
string path,
List<string> values) {
if (values.Count >= 32) {
return;
}
switch (element.ValueKind) {
case JsonValueKind.String:
var stringValue = element.GetString();
if (stringValue is "NaN" or "Infinity" or "-Infinity") {
values.Add($"{path}={stringValue}");
}
break;
case JsonValueKind.Object:
foreach (var property in element.EnumerateObject()) {
find_non_finite_numbers(
property.Value, $"{path}.{property.Name}", values);
}
break;
case JsonValueKind.Array:
var index = 0;
foreach (var item in element.EnumerateArray()) {
find_non_finite_numbers(item, $"{path}[{index++}]", values);
}
break;
}
}
public void Dispose() {
Disposable.Dispose(ref synthStrategiesSub);
}
@@ -14,6 +14,7 @@ namespace bts.websocket_core {
DELETE_ORDER,
UPDATE_ORDER,
SET_PRECOMPUTED_THEOS,
CREATE_SYNTHETIC_SPREAD,
DELETE,
GET,
PATCH,
@@ -30,10 +31,12 @@ namespace bts.websocket_core {
type = REQUEST_TYPES.UNKOWN;
JsonObject? request;
_message_id = "missing";
json = json.Replace('\'', '"');
try {
request = JsonSerializer.Deserialize<JsonObject>(json);
} catch (JsonException ex) {
message = $"Incorrect Json: {ex.Path}";
bts.Log.Gen.error($"Message: |{json}| is Inccorect json: {ex.Path}");
message = $"Incorrect Json: Path: {ex.Path}";
return false;
}
if (request is null) {
@@ -7,8 +7,14 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors />
<NoWarn>8981</NoWarn>
<VenueGeneratorPython Condition="'$(OS)' == 'Windows_NT'">python</VenueGeneratorPython>
<VenueGeneratorPython Condition="'$(OS)' != 'Windows_NT'">python3</VenueGeneratorPython>
</PropertyGroup>
<Target Name="GenerateVenueRegistry" BeforeTargets="PrepareForBuild">
<Exec Command="$(VenueGeneratorPython) &quot;$(MSBuildThisFileDirectory)../../../tools/generate_venues.py&quot;" />
</Target>
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.24.3" />
<PackageReference Include="System.Collections.Immutable" Version="7.0.0" />
@@ -192,6 +198,9 @@
<Proto Include="../../lib_spark/spark/config/protos/quickbus_cfg.proto" CSName="QuickbusCfg">
<OutDir>$(ProtoOutputPath)/lib_spark/spark/config/protos</OutDir>
</Proto>
<Proto Include="../../lib_spark/spark/config/protos/synthetic_spread.proto" CSName="SyntheticSpread">
<OutDir>$(ProtoOutputPath)/lib_spark/spark/config/protos</OutDir>
</Proto>
<Proto Include="../../lib_spark/spark/config/protos/symbols.proto" CSName="Symbols">
<OutDir>$(ProtoOutputPath)/lib_spark/spark/config/protos</OutDir>
</Proto>
+10
View File
@@ -16,11 +16,19 @@ namespace bts.md {
this.security_id = security_id;
this.bids = bids;
this.asks = asks;
timestamp = null;
}
public Book(uint security_id, List<PriceSize> bids, List<PriceSize> asks, DateTime? timestamp)
: this(security_id, bids, asks) {
this.timestamp = timestamp;
}
public uint security_id { get; }
public List<PriceSize> bids { get; }
public List<PriceSize> asks { get; }
/// <summary>Approximate UTC time of the native Spark book update, not exchange event time.</summary>
public DateTime? timestamp { get; }
public List<PriceSize> this[Side side] =>
side switch {
@@ -37,6 +45,7 @@ namespace bts.md {
public bool Equals(Book other) =>
security_id == other.security_id &&
timestamp == other.timestamp &&
bids.SequenceEqual(other.bids) &&
asks.SequenceEqual(other.asks);
@@ -46,6 +55,7 @@ namespace bts.md {
security_id.GetHashCode();
public override string ToString() => $"bids: [{string.Join(", ", bids)}], asks: [{string.Join(", ", asks)}]";
public Object ToJsonObj() => new {
timestamp = this.timestamp,
bids = this.bids.Select((level) => level.ToJsonObj()),
asks = this.asks.Select((level) => level.ToJsonObj())
};
@@ -0,0 +1,19 @@
namespace Spark.Api.MarketData;
using System;
using System.Diagnostics;
// Native utils::TimeStamp uses CLOCK_MONOTONIC on Linux and QPC on Windows,
// the same clock and units used by Stopwatch on those platforms. These values
// are local to the Spark host; they must be converted before crossing the wire.
internal static class BookUpdateClock {
public static DateTime? to_utc(ulong update_time) {
var now = Stopwatch.GetTimestamp();
var utc_now = DateTime.UtcNow;
if (update_time == 0 || update_time > (ulong)now) {
return null;
}
var age = TimeSpan.FromSeconds((now - (long)update_time) / (double)Stopwatch.Frequency);
return age.Ticks <= utc_now.Ticks ? utc_now - age : null;
}
}
@@ -14,6 +14,7 @@ class PollableBook {
readonly SecurityMD _security_md;
readonly uint _num_depth_levels;
ulong _last_update_time = 0;
System.DateTime? _last_update_utc;
// Reuse the same arrays as an optimization to avoid reallocating memory on each poll.
// Note: this will break if the code isn't called in a thread-safe manner.
NativePriceSize[] _bids;
@@ -30,13 +31,18 @@ class PollableBook {
public BookTuple to_book_tuple() {
var book = new Book(
_security_md.security.Id, _to_book_side(_bids), _to_book_side(_asks));
_security_md.security.Id, _to_book_side(_bids), _to_book_side(_asks), _last_update_utc);
return new(_security_md.security, book, _security_md.quantity_display);
}
public bool poll_changes() =>
_security_md.md.get_depth(
ref _last_update_time, ref _bids, ref _asks, _num_depth_levels);
public bool poll_changes() {
if (!_security_md.md.get_depth(
ref _last_update_time, ref _bids, ref _asks, _num_depth_levels)) {
return false;
}
_last_update_utc = BookUpdateClock.to_utc(_last_update_time);
return true;
}
static List<PriceSize> _to_book_side(NativePriceSize[] native_book_side) =>
native_book_side
+3 -3
View File
@@ -126,12 +126,12 @@ namespace bts.api {
tick_sz = s.ContractSpec.LowTick,
leg_1_symbol = leg_1.Security.Symbol,
leg_1_exchange = leg_1.Security.Exchange.ToString(),
leg_1_min_quantity = leg_1.Security.MinOrderQuantity(),
leg_1_min_quantity = Math.Round(leg_1.Security.MinOrderQuantity(),9),
leg_1_tick_sz = leg_1.Security.ContractSpec.LowTick,
leg_1_cpp = leg_1.Security.ContractSpec.CashPerPoint,
leg_2_symbol = leg_2.Security.Symbol,
leg_2_exchange = leg_2.Security.Exchange.ToString(),
leg_2_min_quantity = leg_2.Security.MinOrderQuantity(),
leg_2_min_quantity = Math.Round(leg_2.Security.MinOrderQuantity(),9),
leg_2_tick_sz = leg_2.Security.ContractSpec.LowTick,
leg_2_cpp = leg_2.Security.ContractSpec.CashPerPoint
};
@@ -141,7 +141,7 @@ namespace bts.api {
security = s.security_desc(),
security_type = sec_type(s),
exchange = s.Exchange.ToString(),
min_quantity = s.MinOrderQuantity(),
min_quantity = Math.Round(s.MinOrderQuantity(),9),
tick_sz = s.ContractSpec.LowTick,
cpp = s.ContractSpec.CashPerPoint
};
@@ -182,6 +182,9 @@ namespace bts.api {
Security.ExchangeDataOneofCase.None => security.Synthetic.Name,
Security.ExchangeDataOneofCase.Bybit => security.Bybit.SecurityDesc,
Security.ExchangeDataOneofCase.Gateio => security.Gateio.SecurityDesc,
Security.ExchangeDataOneofCase.Orca => security.Orca.SecurityDesc,
Security.ExchangeDataOneofCase.Raydium => security.Raydium.SecurityDesc,
Security.ExchangeDataOneofCase.Generic => security.Generic.SecurityDesc,
_ => throw new NotImplementedException("security_desc not implemented for exchange")
};
@@ -218,48 +221,14 @@ namespace bts.api {
};
public static Venue to_venue(this domain.Venue api_venue) =>
api_venue switch {
domain.Venue.Bts => Venue.Bts,
domain.Venue.Sim => Venue.Sim,
domain.Venue.Deribit => Venue.Deribit,
domain.Venue.Ftx => Venue.Ftx,
domain.Venue.Binance => Venue.Binance,
domain.Venue.BinanceCoinFutures => Venue.BinanceCoinFutures,
domain.Venue.BinanceUs => Venue.BinanceUs,
domain.Venue.BinanceUsdsFutures => Venue.BinanceUsdsFutures,
domain.Venue.Glbx => Venue.Glbx,
domain.Venue.Coinbase => Venue.Coinbase,
domain.Venue.Okx => Venue.Okx,
domain.Venue.Cboe => Venue.Cboe,
domain.Venue.Taifex => Venue.Taifex,
domain.Venue.Twse => Venue.Twse,
domain.Venue.Bybit => Venue.Bybit,
domain.Venue.Gateio => Venue.Gateio,
domain.Venue.Unspecified => Venue.Unset,
_ => throw new NotImplementedException(),
};
api_venue == domain.Venue.Unspecified ? Venue.Unset :
Enum.TryParse(api_venue.ToString(), out Venue venue) ? venue :
throw new NotSupportedException($"Domain venue {api_venue} has no legacy venue mapping");
public static domain.Venue to_api_venue(this Venue venue) =>
venue switch {
Venue.Bts => domain.Venue.Bts,
Venue.Sim => domain.Venue.Sim,
Venue.Deribit => domain.Venue.Deribit,
Venue.Ftx => domain.Venue.Ftx,
Venue.Binance => domain.Venue.Binance,
Venue.BinanceCoinFutures => domain.Venue.BinanceCoinFutures,
Venue.BinanceUs => domain.Venue.BinanceUs,
Venue.BinanceUsdsFutures => domain.Venue.BinanceUsdsFutures,
Venue.Coinbase => domain.Venue.Coinbase,
Venue.Glbx => domain.Venue.Glbx,
Venue.Okx => domain.Venue.Okx,
Venue.Cboe => domain.Venue.Cboe,
Venue.Taifex => domain.Venue.Taifex,
Venue.Twse => domain.Venue.Twse,
Venue.Bybit => domain.Venue.Bybit,
Venue.Gateio => domain.Venue.Gateio,
Venue.Unset => domain.Venue.Unspecified,
_ => throw new NotImplementedException(),
};
venue == Venue.Unset ? domain.Venue.Unspecified :
Enum.TryParse(venue.ToString(), out domain.Venue apiVenue) ? apiVenue :
throw new NotSupportedException($"Venue {venue} has no domain venue mapping");
public static TickDisplaySettingsImmutable QuantityDisplay(this SecurityImmutable security) {
var qd = Security.Defaults.quantity_display_settings;
+2
View File
@@ -20,4 +20,6 @@ public readonly record struct BboRecord(
double AskQuantity
) {
public uint Key => SecurityId;
/// <summary>UTC time of the native Spark book update; not exchange event time.</summary>
public DateTime? Timestamp { get; init; }
}
+3 -1
View File
@@ -16,6 +16,8 @@ public readonly record struct BookRecord(
(double Price, double Quantity)[] Asks
) {
public uint Key => SecurityId;
/// <summary>UTC time of the native Spark book update; null when unavailable.</summary>
public DateTime? Timestamp { get; init; }
/// <summary>
/// Gets an L2 depth level - the nth best price in the market and the quantity listed at that price.
@@ -34,6 +36,6 @@ public readonly record struct BookRecord(
public BboRecord ToBboRecord() {
var (bidPrice, bidQty) = this[Side.Buy, 0];
var (askPrice, askQty) = this[Side.Sell, 0];
return new(SecurityId, Security, bidPrice, bidQty, askPrice, askQty);
return new(SecurityId, Security, bidPrice, bidQty, askPrice, askQty) { Timestamp = Timestamp };
}
}
+1 -1
View File
@@ -118,7 +118,7 @@ public class SimpleSparkClient : ISimpleSparkClient, IDisposable {
for (int i = 0; i < asksOut.Length; i++) {
asksOut[i] = (asksIn[i].price, asksIn[i].size);
}
return new(b.security_id, b.security, bidsOut, asksOut);
return new(b.security_id, b.security, bidsOut, asksOut) { Timestamp = b.book.timestamp };
}
static PositionRecord ToPositionRecord((SecurityImmutable? Sec, PositionUnitImmutable Pos) t) {
@@ -260,7 +260,7 @@ struct __attribute((visibility("hidden"))) StreamBatchData {
TRACE_VAR_PARAMS(
ec ? bts::Error : bts::Info, "depth_sub_close",
json_formatter(ec.message()));
on_conn_status(!ec, fmt::format("on_close - {}", ec.message()));
on_conn_status(false, fmt::format("on_close - {}", ec.message()));
}
};
@@ -329,6 +329,9 @@ struct __attribute((visibility("hidden"))) UsdsFuturesMdSubscription_PImpl {
ws_cfg.url = fmt::format(
"{}/stream?streams={}", ws_url, fmt::join(batch.streams, "/"));
ws_cfg.name = "binance_ufuts_md";
ws_cfg.reconnect_on_close = true;
ws_cfg.reconnect_on_fail = true;
ws_cfg.use_async_reconnect = true;
batch.init(
std::make_shared<WebsocketClient>(std::move(ws_cfg), &io_context));
}
@@ -460,6 +463,8 @@ struct __attribute((visibility("hidden"))) UsdsFuturesMdSubscription_PImpl {
}
void run_depth_streams() {
// Keep the context running while disconnected or waiting to retry.
const auto work = boost::asio::make_work_guard(io_context);
for (auto &sb : stream_batches) {
sb->ws_cli->connect();
}
+17
View File
@@ -1,6 +1,10 @@
cmake_minimum_required(VERSION 3.5)
project(spark)
include(GNUInstallDirs)
find_program(VENUE_GENERATOR_PYTHON NAMES python3 python)
if(NOT VENUE_GENERATOR_PYTHON)
message(FATAL_ERROR "Python 3 is required to generate venue definitions")
endif()
include(${CMAKE_SOURCE_DIR}/cmake/safe_dep_glob.cmake)
include(${CMAKE_SOURCE_DIR}/cmake/ProtobufGenerateCpp.cmake)
include(${CMAKE_SOURCE_DIR}/cmake/ProtobufGenerateCppConfig.cmake)
@@ -9,6 +13,19 @@ include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/GenerateConfigProfile.cmake)
include(${CMAKE_SOURCE_DIR}/cmake/InstallBasic.cmake)
include(${CMAKE_SOURCE_DIR}/cmake/PrintList.cmake)
# Venue protobuf enums are generated from one stable-ID registry. Reconfigure
# automatically when it changes so protoc always sees current generated input.
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/spark/domain_model/venues.json
${CMAKE_SOURCE_DIR}/tools/generate_venues.py)
execute_process(
COMMAND ${VENUE_GENERATOR_PYTHON} ${CMAKE_SOURCE_DIR}/tools/generate_venues.py
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
RESULT_VARIABLE _venue_generation_result)
if(NOT _venue_generation_result EQUAL 0)
message(FATAL_ERROR "Failed to generate venue protobuf enums")
endif()
configure_file(
"${CMAKE_SOURCE_DIR}/cmake/version.h.in"
"${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}/version.h"
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more