honeymaker 0.12.3 → 0.12.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/native-gems.yml +100 -0
- data/.github/workflows/release.yml +36 -0
- data/.github/workflows/test.yml +19 -4
- data/Cargo.lock +1078 -0
- data/Cargo.toml +14 -0
- data/README.md +39 -0
- data/Rakefile +28 -16
- data/crates/honeymaker/Cargo.toml +18 -0
- data/crates/honeymaker/src/encode.rs +106 -0
- data/crates/honeymaker/src/kraken/lookup.rs +140 -0
- data/crates/honeymaker/src/kraken/mod.rs +5 -0
- data/crates/honeymaker/src/kraken/normalize.rs +536 -0
- data/crates/honeymaker/src/kraken/requests.rs +286 -0
- data/crates/honeymaker/src/kraken/sign.rs +166 -0
- data/crates/honeymaker/src/kraken/trades.rs +201 -0
- data/crates/honeymaker/src/lib.rs +10 -0
- data/crates/honeymaker/src/num.rs +115 -0
- data/crates/honeymaker/src/semantics.rs +100 -0
- data/crates/honeymaker/src/types.rs +85 -0
- data/crates/honeymaker/tests/kraken_stage2.rs +592 -0
- data/crates/honeymaker/tests/kraken_trades_pager.rs +307 -0
- data/crates/honeymaker/tests/kraken_vectors.rs +78 -0
- data/crates/honeymaker/tests/vectors/decimal.json +146 -0
- data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
- data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
- data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
- data/crates/honeymaker-client/Cargo.toml +30 -0
- data/crates/honeymaker-client/examples/probe.rs +147 -0
- data/crates/honeymaker-client/src/kraken.rs +514 -0
- data/crates/honeymaker-client/src/lib.rs +4 -0
- data/crates/honeymaker-client/src/transport/connect.rs +583 -0
- data/crates/honeymaker-client/src/transport/io_timeout.rs +352 -0
- data/crates/honeymaker-client/src/transport/response.rs +169 -0
- data/crates/honeymaker-client/src/transport.rs +467 -0
- data/crates/honeymaker-client/tests/classification.rs +203 -0
- data/crates/honeymaker-client/tests/kraken_client.rs +949 -0
- data/crates/honeymaker-client/tests/response.rs +522 -0
- data/crates/honeymaker-client/tests/support/mod.rs +550 -0
- data/crates/honeymaker-client/tests/transport.rs +828 -0
- data/ext/honeymaker_native/Cargo.toml +16 -0
- data/ext/honeymaker_native/extconf.rb +6 -0
- data/ext/honeymaker_native/src/convert.rs +150 -0
- data/ext/honeymaker_native/src/kraken.rs +362 -0
- data/ext/honeymaker_native/src/lib.rs +90 -0
- data/ext/honeymaker_native/src/ruby_decimal.rs +90 -0
- data/lib/honeymaker/clients/bingx.rb +10 -6
- data/lib/honeymaker/clients/bitrue.rb +43 -13
- data/lib/honeymaker/clients/bitvavo.rb +9 -4
- data/lib/honeymaker/clients/bybit.rb +19 -2
- data/lib/honeymaker/clients/coinbase.rb +3 -2
- data/lib/honeymaker/clients/gemini.rb +10 -0
- data/lib/honeymaker/clients/hyperliquid.rb +62 -3
- data/lib/honeymaker/clients/kraken.rb +6 -1
- data/lib/honeymaker/clients/mexc.rb +16 -2
- data/lib/honeymaker/exchanges/coinbase.rb +5 -4
- data/lib/honeymaker/exchanges/mexc.rb +8 -3
- data/lib/honeymaker/native/kraken.rb +263 -0
- data/lib/honeymaker/native.rb +42 -0
- data/lib/honeymaker/version.rb +1 -1
- data/lib/honeymaker.rb +12 -2
- data/script/parity/kraken_client_live.rb +191 -0
- data/script/parity/kraken_live.rb +70 -0
- data/script/parity/kraken_transport.rb +604 -0
- data/script/parity/resolve.c +49 -0
- data/script/release/push_gems.sh +28 -0
- data/script/vectors/kraken.rb +50 -0
- data/script/vectors/kraken_stage2.rb +146 -0
- data/test/fixtures/coinbase_products.json +1 -1
- data/test/honeymaker/clients/bingx_client_test.rb +19 -6
- data/test/honeymaker/clients/bitrue_client_test.rb +48 -0
- data/test/honeymaker/clients/bitvavo_client_test.rb +15 -0
- data/test/honeymaker/clients/bybit_client_test.rb +25 -0
- data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
- data/test/honeymaker/clients/gemini_client_test.rb +15 -0
- data/test/honeymaker/clients/hyperliquid_client_test.rb +120 -4
- data/test/honeymaker/clients/kraken_client_test.rb +15 -0
- data/test/honeymaker/clients/mexc_client_test.rb +12 -0
- data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
- data/test/honeymaker/exchanges/mexc_test.rb +14 -2
- data/test/native/backend_switch_test.rb +53 -0
- data/test/native/convert_test.rb +137 -0
- data/test/native/gem_isolation_test.rb +17 -0
- data/test/native/loader_test.rb +29 -0
- data/test/native/lockstep_test.rb +23 -0
- data/test/native/signing_smoke_test.rb +28 -0
- data/test/parity/kraken_client_parity_test.rb +398 -0
- data/test/parity/kraken_exchange_parity_test.rb +172 -0
- data/test/release/branch_hardening_test.rb +34 -0
- data/test/release/push_gems_test.rb +79 -0
- data/test/smoke.rb +24 -0
- data/test/support/kraken_signing_smoke.rb +21 -0
- data/test/support/parity_helper.rb +62 -0
- data/test/support/venue_double.rb +100 -0
- metadata +84 -4
data/Cargo.toml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[workspace]
|
|
2
|
+
members = ["crates/honeymaker", "crates/honeymaker-client", "ext/honeymaker_native"]
|
|
3
|
+
resolver = "3"
|
|
4
|
+
|
|
5
|
+
[workspace.package]
|
|
6
|
+
version = "0.12.5" # lockstep with lib/honeymaker/version.rb (test/native/lockstep_test.rb)
|
|
7
|
+
edition = "2024"
|
|
8
|
+
rust-version = "1.94"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
repository = "https://github.com/deltabadger/honeymaker"
|
|
11
|
+
|
|
12
|
+
[profile.release]
|
|
13
|
+
lto = true
|
|
14
|
+
codegen-units = 1
|
data/README.md
CHANGED
|
@@ -16,6 +16,45 @@ Binance, Binance US, Kraken, Kraken Futures, Coinbase, Bybit, KuCoin, Bitget, ME
|
|
|
16
16
|
gem "honeymaker"
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
## Native extension
|
|
20
|
+
|
|
21
|
+
Prebuilt native gems support Ruby 3.4 and 4.0 on `x86_64-linux`, `aarch64-linux`,
|
|
22
|
+
`x86_64-darwin`, `arm64-darwin`, and `x64-mingw-ucrt`. A source gem is also published.
|
|
23
|
+
Building from source needs Rust 1.94; in a checkout, run `bundle install` followed by
|
|
24
|
+
`bundle exec rake compile`.
|
|
25
|
+
|
|
26
|
+
The Ruby backend remains the default. Set `HONEYMAKER_NATIVE=kraken` before requiring
|
|
27
|
+
`honeymaker` to enable the Rust backend for Kraken. `HONEYMAKER_NATIVE` accepts a
|
|
28
|
+
comma-separated list of exchange names; Kraken is currently the supported native backend.
|
|
29
|
+
Other exchanges continue to use Ruby. Unset the variable to return to the Ruby backend.
|
|
30
|
+
|
|
31
|
+
### Releases
|
|
32
|
+
|
|
33
|
+
Run `bundle exec rake release` for a patch release, `bundle exec rake release:minor`
|
|
34
|
+
for a minor release, or `bundle exec rake release:major` for a major release. The task
|
|
35
|
+
bumps the gem and Cargo workspace versions together, updates both lockfiles, compiles,
|
|
36
|
+
tests, commits the four version/lock files, and pushes the commit and a `v<version>` tag.
|
|
37
|
+
`bundle exec rake _tag_release` resumes the push without another version bump.
|
|
38
|
+
|
|
39
|
+
The tag triggers `.github/workflows/release.yml`, which calls the native-gem workflow
|
|
40
|
+
to build and smoke-test all five platform gems and the source gem. Configure the
|
|
41
|
+
GitHub `rubygems` environment with Jan as its required reviewer and configure RubyGems
|
|
42
|
+
trusted publishing for this repository's `release.yml` workflow and `rubygems` environment.
|
|
43
|
+
After Jan approves, CI publishes using trusted publishing, with platform gems first
|
|
44
|
+
and the source gem last.
|
|
45
|
+
|
|
46
|
+
Recovery:
|
|
47
|
+
|
|
48
|
+
- **Publish failed partway:** open Actions → the same workflow run → **Re-run failed jobs**.
|
|
49
|
+
The artifacts are retained, and the push loop skips gems already published with an
|
|
50
|
+
identical checksum. It stops on any mismatch; never override that.
|
|
51
|
+
- **Workflow cancelled or never finished building:** choose **Re-run all jobs** on the
|
|
52
|
+
tag's run. `gh workflow run` is not a substitute, because it isn't a tag-push event.
|
|
53
|
+
- **Bump commit pushed but tag push failed:** run
|
|
54
|
+
`git tag -a v<version> -m v<version> && git push origin v<version>`.
|
|
55
|
+
`_tag_release` is a no-op once the tag exists locally. If the tag already exists locally,
|
|
56
|
+
retry just `git push origin v<version>`.
|
|
57
|
+
|
|
19
58
|
## Usage
|
|
20
59
|
|
|
21
60
|
### Market Data
|
data/Rakefile
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "bundler/gem_tasks"
|
|
4
3
|
require "rake/testtask"
|
|
5
4
|
|
|
5
|
+
if ENV["HONEYMAKER_RAKEFILE_LIB_ONLY"] != "1" && File.exist?(File.join(__dir__, "honeymaker.gemspec"))
|
|
6
|
+
require "bundler/gem_tasks"
|
|
7
|
+
require "rb_sys/extensiontask"
|
|
8
|
+
|
|
9
|
+
GEMSPEC = Gem::Specification.load(File.join(__dir__, "honeymaker.gemspec"))
|
|
10
|
+
RbSys::ExtensionTask.new("honeymaker_native", GEMSPEC) do |ext|
|
|
11
|
+
ext.lib_dir = "lib/honeymaker"
|
|
12
|
+
# Core-only edits must rebuild the extension too (the default pattern covers ext/ only).
|
|
13
|
+
ext.source_pattern = "{**/*,../../crates/honeymaker/**/*,../../Cargo}.{rs,toml,lock}"
|
|
14
|
+
ext.cross_compile = true
|
|
15
|
+
ext.cross_platform = %w[x86_64-linux aarch64-linux x86_64-darwin arm64-darwin x64-mingw-ucrt]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
6
19
|
Rake::TestTask.new(:test) do |t|
|
|
7
20
|
t.libs << "test"
|
|
8
21
|
t.libs << "lib"
|
|
9
22
|
t.test_files = FileList["test/**/*_test.rb"]
|
|
10
23
|
end
|
|
11
24
|
|
|
12
|
-
task default:
|
|
25
|
+
task default: %i[compile test]
|
|
13
26
|
|
|
14
27
|
VERSION_FILE = "lib/honeymaker/version.rb"
|
|
15
28
|
|
|
@@ -26,40 +39,39 @@ def bump_version(segment)
|
|
|
26
39
|
end.join(".")
|
|
27
40
|
content = File.read(VERSION_FILE)
|
|
28
41
|
File.write(VERSION_FILE, content.sub(/VERSION = ".+"/, "VERSION = \"#{new_version}\""))
|
|
42
|
+
File.write("Cargo.toml", File.read("Cargo.toml").sub(/(\[workspace\.package\][^\[]*?^version = )"[^"]+"/m, "\\1\"#{new_version}\""))
|
|
29
43
|
puts "Bumped version to #{new_version}"
|
|
30
44
|
end
|
|
31
45
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
# but the final "Pushed <gem> <ver>" log line still prints the old cached
|
|
36
|
-
# version. Fix: bump first, then invoke bundler's release in a subprocess so
|
|
37
|
-
# it re-reads the gemspec fresh.
|
|
38
|
-
|
|
39
|
-
Rake::Task[:release].clear
|
|
46
|
+
if ENV["HONEYMAKER_RAKEFILE_LIB_ONLY"] != "1" && File.exist?(File.join(__dir__, "honeymaker.gemspec"))
|
|
47
|
+
Rake::Task[:release].clear
|
|
48
|
+
end
|
|
40
49
|
|
|
41
50
|
def do_release(segment)
|
|
42
51
|
bump_version(segment)
|
|
43
52
|
sh "bundle install"
|
|
53
|
+
sh "cargo update --workspace --offline"
|
|
54
|
+
Rake::Task[:compile].invoke
|
|
44
55
|
Rake::Task[:test].invoke
|
|
45
|
-
sh %(git add
|
|
46
|
-
sh "bundle exec rake
|
|
56
|
+
sh %(git add #{VERSION_FILE} Cargo.toml Cargo.lock Gemfile.lock && git commit -m "v#{current_version}")
|
|
57
|
+
sh "bundle exec rake _tag_release"
|
|
47
58
|
end
|
|
48
59
|
|
|
49
|
-
|
|
60
|
+
# Pushes the bump commit and the v* tag; .github/workflows/release.yml builds and publishes.
|
|
61
|
+
task _tag_release: %w[release:guard_clean release:source_control_push]
|
|
50
62
|
|
|
51
|
-
desc "Bump patch,
|
|
63
|
+
desc "Bump patch, compile, test, and push a release tag for CI"
|
|
52
64
|
task :release do
|
|
53
65
|
do_release(:patch)
|
|
54
66
|
end
|
|
55
67
|
|
|
56
68
|
namespace :release do
|
|
57
|
-
desc "Bump minor,
|
|
69
|
+
desc "Bump minor, compile, test, and push a release tag for CI"
|
|
58
70
|
task :minor do
|
|
59
71
|
do_release(:minor)
|
|
60
72
|
end
|
|
61
73
|
|
|
62
|
-
desc "Bump major,
|
|
74
|
+
desc "Bump major, compile, test, and push a release tag for CI"
|
|
63
75
|
task :major do
|
|
64
76
|
do_release(:major)
|
|
65
77
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "honeymaker"
|
|
3
|
+
description = "Unified clients for cryptocurrency exchange APIs"
|
|
4
|
+
version.workspace = true
|
|
5
|
+
edition.workspace = true
|
|
6
|
+
rust-version.workspace = true
|
|
7
|
+
license.workspace = true
|
|
8
|
+
repository.workspace = true
|
|
9
|
+
publish = false # until deltabadger-rs consumes it (spec §2.6)
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
base64 = "0.22"
|
|
13
|
+
bigdecimal = "0.4"
|
|
14
|
+
hmac = "0.12"
|
|
15
|
+
regex = "1"
|
|
16
|
+
serde_json = { version = "1", features = ["preserve_order", "arbitrary_precision"] }
|
|
17
|
+
sha2 = "0.10"
|
|
18
|
+
thiserror = "2"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
//! The encoders the legacy Ruby used, byte for byte (tests/vectors).
|
|
2
|
+
|
|
3
|
+
/// URI.encode_www_form: keep A-Z a-z 0-9 * - . _, space → +, everything else %XX (uppercase).
|
|
4
|
+
pub fn www_form(pairs: &[(String, String)]) -> String {
|
|
5
|
+
fn enc(s: &str) -> String {
|
|
6
|
+
let mut o = String::with_capacity(s.len());
|
|
7
|
+
for &b in s.as_bytes() {
|
|
8
|
+
match b {
|
|
9
|
+
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'*' | b'-' | b'.' | b'_' => {
|
|
10
|
+
o.push(b as char)
|
|
11
|
+
}
|
|
12
|
+
b' ' => o.push('+'),
|
|
13
|
+
_ => o.push_str(&format!("%{b:02X}")),
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
o
|
|
17
|
+
}
|
|
18
|
+
pairs
|
|
19
|
+
.iter()
|
|
20
|
+
.map(|(k, v)| format!("{}={}", enc(k), enc(v)))
|
|
21
|
+
.collect::<Vec<_>>()
|
|
22
|
+
.join("&")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Base64.decode64 = String#unpack1("m"): a literal port of ruby/pack.c's lenient branch
|
|
26
|
+
/// (skips bytes outside the alphabet; '=' in the third/fourth slot ends the input).
|
|
27
|
+
pub fn ruby_decode64(input: &[u8]) -> Vec<u8> {
|
|
28
|
+
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
29
|
+
let table = |c: u8| -> i32 {
|
|
30
|
+
T.iter()
|
|
31
|
+
.position(|&x| x == c)
|
|
32
|
+
.map(|p| p as i32)
|
|
33
|
+
.unwrap_or(-1)
|
|
34
|
+
};
|
|
35
|
+
let n = input.len();
|
|
36
|
+
let at = |i: usize| -> u8 { if i < n { input[i] } else { 0 } }; // C strings end in NUL
|
|
37
|
+
let mut out = Vec::with_capacity(n * 3 / 4 + 3);
|
|
38
|
+
let mut s = 0usize;
|
|
39
|
+
let (mut a, mut b, mut c): (i32, i32, i32) = (-1, -1, 0);
|
|
40
|
+
while s < n {
|
|
41
|
+
b = -1;
|
|
42
|
+
c = -1;
|
|
43
|
+
loop {
|
|
44
|
+
a = table(at(s));
|
|
45
|
+
if !(a == -1 && s < n) {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
s += 1;
|
|
49
|
+
}
|
|
50
|
+
if s >= n {
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
s += 1;
|
|
54
|
+
loop {
|
|
55
|
+
b = table(at(s));
|
|
56
|
+
if !(b == -1 && s < n) {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
s += 1;
|
|
60
|
+
}
|
|
61
|
+
if s >= n {
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
s += 1;
|
|
65
|
+
loop {
|
|
66
|
+
c = table(at(s));
|
|
67
|
+
if !(c == -1 && s < n) {
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
if at(s) == b'=' {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
s += 1;
|
|
74
|
+
}
|
|
75
|
+
if at(s) == b'=' || s >= n {
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
s += 1;
|
|
79
|
+
let mut d;
|
|
80
|
+
loop {
|
|
81
|
+
d = table(at(s));
|
|
82
|
+
if !(d == -1 && s < n) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
if at(s) == b'=' {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
s += 1;
|
|
89
|
+
}
|
|
90
|
+
if at(s) == b'=' || s >= n {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
s += 1;
|
|
94
|
+
out.push(((a << 2) | (b >> 4)) as u8);
|
|
95
|
+
out.push(((b << 4) | (c >> 2)) as u8);
|
|
96
|
+
out.push(((c << 6) | d) as u8);
|
|
97
|
+
a = -1;
|
|
98
|
+
}
|
|
99
|
+
if a != -1 && b != -1 {
|
|
100
|
+
out.push(((a << 2) | (b >> 4)) as u8);
|
|
101
|
+
if c != -1 {
|
|
102
|
+
out.push(((b << 4) | (c >> 2)) as u8);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
out
|
|
106
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
//! Find an order by client order id: OpenOrders, then every ClosedOrders page since `start`.
|
|
2
|
+
//! `Absent` comes only from a complete scan; any other end is a refusal, an unreadable page or
|
|
3
|
+
//! `Incomplete`, never a partial answer reported as complete.
|
|
4
|
+
//!
|
|
5
|
+
//! Why this order cannot miss an order placed before the scan began: if it is open when
|
|
6
|
+
//! OpenOrders is read, it is found there. Otherwise it closed earlier and sits in the closed list,
|
|
7
|
+
//! which is newest first, so orders closing during the scan only push it to a higher offset, onto a
|
|
8
|
+
//! page not yet read. Paging can re-read an order; it cannot skip one. Because a re-read row is
|
|
9
|
+
//! not coverage, absence needs the distinct ids read to reach Kraken's count.
|
|
10
|
+
use super::normalize::{Finished, NormalizedOrder, listed_orders};
|
|
11
|
+
use super::requests::Param;
|
|
12
|
+
use crate::num::{NormError, Num};
|
|
13
|
+
use crate::semantics::{self, string_eq};
|
|
14
|
+
use serde_json::Value;
|
|
15
|
+
use std::collections::{BTreeMap, HashSet};
|
|
16
|
+
|
|
17
|
+
/// Kraken answers 50 closed orders a page; with its `cl_ord_id` filter honoured, one page answers.
|
|
18
|
+
/// ponytail: no pacing between pages. A scan this long trips Kraken's rate limit and ends as a
|
|
19
|
+
/// refusal, which callers treat as "not known yet". Add pacing if a live account ever needs it.
|
|
20
|
+
pub const MAX_CLOSED_PAGES: u32 = 50;
|
|
21
|
+
|
|
22
|
+
// Keep the specified public API: Found returns the normalized order by value.
|
|
23
|
+
#[allow(clippy::large_enum_variant)]
|
|
24
|
+
pub enum Step<D> {
|
|
25
|
+
Call(&'static str, BTreeMap<String, Param>),
|
|
26
|
+
Found(NormalizedOrder<D>),
|
|
27
|
+
Absent,
|
|
28
|
+
Venue(Vec<Value>),
|
|
29
|
+
Unreadable,
|
|
30
|
+
Incomplete(String),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pub struct ClientIdLookup {
|
|
34
|
+
cl_ord_id: String,
|
|
35
|
+
start: i64,
|
|
36
|
+
closed: bool,
|
|
37
|
+
/// Raw rows served so far: the `ofs` of the next request.
|
|
38
|
+
ofs: u64,
|
|
39
|
+
pages: u32,
|
|
40
|
+
/// Distinct closed-order ids read: absence needs these to cover Kraken's count.
|
|
41
|
+
seen: HashSet<String>,
|
|
42
|
+
/// The largest `count` any page reported: the bar for absence (R22).
|
|
43
|
+
count: u64,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
impl ClientIdLookup {
|
|
47
|
+
pub fn new(cl_ord_id: &str, start: i64) -> Self {
|
|
48
|
+
Self {
|
|
49
|
+
cl_ord_id: cl_ord_id.to_string(),
|
|
50
|
+
start,
|
|
51
|
+
closed: false,
|
|
52
|
+
ofs: 0,
|
|
53
|
+
pages: 0,
|
|
54
|
+
seen: HashSet::new(),
|
|
55
|
+
count: 0,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
pub fn first<D>(&self) -> Step<D> {
|
|
60
|
+
Step::Call(
|
|
61
|
+
"open_orders",
|
|
62
|
+
BTreeMap::from([("cl_ord_id".to_string(), Param::One(self.cl_ord_id.clone()))]),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fn closed_page<D>(&self) -> Step<D> {
|
|
67
|
+
Step::Call(
|
|
68
|
+
"closed_orders",
|
|
69
|
+
BTreeMap::from([
|
|
70
|
+
("cl_ord_id".to_string(), Param::One(self.cl_ord_id.clone())),
|
|
71
|
+
("start".to_string(), Param::One(self.start.to_string())),
|
|
72
|
+
("ofs".to_string(), Param::One(self.ofs.to_string())),
|
|
73
|
+
]),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Feed the parsed body answering the last `Call`.
|
|
78
|
+
pub fn feed<D: Num>(&mut self, data: &Value) -> Result<Step<D>, NormError<D::Error>> {
|
|
79
|
+
let key = if self.closed { "closed" } else { "open" };
|
|
80
|
+
let (orders, count) = match listed_orders::<D>(data, key)? {
|
|
81
|
+
Finished::Ok(x) => x,
|
|
82
|
+
Finished::Venue(e) => return Ok(Step::Venue(e)),
|
|
83
|
+
Finished::Unreadable => return Ok(Step::Unreadable),
|
|
84
|
+
};
|
|
85
|
+
let n = orders.len() as u64;
|
|
86
|
+
let ids: Vec<String> = orders
|
|
87
|
+
.iter()
|
|
88
|
+
.map(|o| semantics::to_s(&o.order_id).unwrap_or_else(|_| o.order_id.to_string()))
|
|
89
|
+
.collect();
|
|
90
|
+
// Ruby's `echo.to_s == cl_ord_id`: a numeric echo matches its text; string_eq keeps the
|
|
91
|
+
// binary-string form (to_s has no answer for it) matching too.
|
|
92
|
+
if let Some(o) = orders.into_iter().find(|o| {
|
|
93
|
+
o.cl_ord_id.as_ref().is_some_and(|c| {
|
|
94
|
+
string_eq(c, &self.cl_ord_id)
|
|
95
|
+
|| semantics::to_s(c).is_ok_and(|t| t == self.cl_ord_id)
|
|
96
|
+
})
|
|
97
|
+
}) {
|
|
98
|
+
return Ok(Step::Found(o));
|
|
99
|
+
}
|
|
100
|
+
if !self.closed {
|
|
101
|
+
self.closed = true;
|
|
102
|
+
return Ok(self.closed_page());
|
|
103
|
+
}
|
|
104
|
+
let Some(page_total) = count.as_ref().and_then(Value::as_u64) else {
|
|
105
|
+
return Ok(Step::Incomplete(format!(
|
|
106
|
+
"ClosedOrders count unreadable: {count:?}"
|
|
107
|
+
)));
|
|
108
|
+
};
|
|
109
|
+
// Held to the largest count seen: a shrinking count never lowers the bar (R22).
|
|
110
|
+
self.count = self.count.max(page_total);
|
|
111
|
+
let total = self.count;
|
|
112
|
+
self.seen.extend(ids);
|
|
113
|
+
self.pages += 1;
|
|
114
|
+
self.ofs += n;
|
|
115
|
+
let distinct = self.seen.len() as u64;
|
|
116
|
+
// Absence needs every distinct closed order read; a duplicate row is not coverage
|
|
117
|
+
// (review round 3, ruling 1).
|
|
118
|
+
if distinct >= total {
|
|
119
|
+
return Ok(Step::Absent);
|
|
120
|
+
}
|
|
121
|
+
if n == 0 {
|
|
122
|
+
return Ok(Step::Incomplete(format!(
|
|
123
|
+
"ClosedOrders page {} was empty after {distinct} of {total} orders",
|
|
124
|
+
self.pages
|
|
125
|
+
)));
|
|
126
|
+
}
|
|
127
|
+
if self.ofs >= total {
|
|
128
|
+
return Ok(Step::Incomplete(format!(
|
|
129
|
+
"ClosedOrders served {total} rows but only {distinct} distinct orders"
|
|
130
|
+
)));
|
|
131
|
+
}
|
|
132
|
+
if self.pages >= MAX_CLOSED_PAGES {
|
|
133
|
+
return Ok(Step::Incomplete(format!(
|
|
134
|
+
"more than {MAX_CLOSED_PAGES} ClosedOrders pages since {}",
|
|
135
|
+
self.start
|
|
136
|
+
)));
|
|
137
|
+
}
|
|
138
|
+
Ok(self.closed_page())
|
|
139
|
+
}
|
|
140
|
+
}
|