Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96767cdfa4 | ||
|
|
a2987f0102 | ||
|
|
fbbef6acf7 | ||
|
|
e38177586f | ||
|
|
3e886babe3 | ||
|
|
6a8092c934 | ||
|
|
6f01bde0f5 | ||
|
|
ab7a5c6bfb | ||
|
|
bf404bc691 | ||
|
|
8ba8d55bc2 | ||
|
|
4614b60218 | ||
|
|
95d05fa20f | ||
|
|
c0a8dde7db | ||
|
|
b98eee18f5 | ||
|
|
6ccfbda5aa | ||
|
|
6b6f34288d | ||
|
|
eb0d0c5244 | ||
|
|
19fef8492d | ||
|
|
7521b01022 | ||
|
|
d0faa95d7a | ||
|
|
36aa2caa2a | ||
|
|
97e7343110 | ||
|
|
5dccc2e9c3 | ||
|
|
365012102e | ||
|
|
c3d14c8e31 | ||
|
|
5cc80db09d | ||
|
|
12c2deb833 | ||
|
|
2222fb8e3e | ||
|
|
266fdf93e5 | ||
|
|
fd121bd85b | ||
|
|
1ffaf7c05b | ||
|
|
5a87288aaa | ||
|
|
a1d62e552c | ||
|
|
80656a0890 | ||
|
|
188daf2b84 | ||
|
|
5908adcb09 | ||
|
|
36cf021b70 | ||
|
|
d45e0e08a2 | ||
|
|
888e751243 | ||
|
|
bbe3350b4e | ||
|
|
80ea6ad16e | ||
|
|
0bc0e821f3 | ||
|
|
1b82d07a0d | ||
|
|
a83404f20f |
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 \
|
||||
|
||||
+1
-1
Submodule externals updated: c7478ef5cd...2755fd6b8f.
@@ -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();
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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
@@ -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/
|
||||
@@ -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
@@ -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."""
|
||||
+69
@@ -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)
|
||||
+269
@@ -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.
|
||||
@@ -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
@@ -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) "$(MSBuildThisFileDirectory)../../../tools/generate_venues.py"" />
|
||||
</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user