honeymaker 0.12.4-aarch64-linux
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 +7 -0
- data/.github/workflows/native-gems.yml +100 -0
- data/.github/workflows/release.yml +36 -0
- data/.github/workflows/test.yml +33 -0
- data/LICENSE +21 -0
- data/README.md +219 -0
- data/Rakefile +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/lib/honeymaker/3.4/honeymaker_native.so +0 -0
- data/lib/honeymaker/4.0/honeymaker_native.so +0 -0
- data/lib/honeymaker/client.rb +139 -0
- data/lib/honeymaker/clients/binance.rb +687 -0
- data/lib/honeymaker/clients/binance_us.rb +42 -0
- data/lib/honeymaker/clients/bingx.rb +242 -0
- data/lib/honeymaker/clients/bitget.rb +318 -0
- data/lib/honeymaker/clients/bitrue.rb +245 -0
- data/lib/honeymaker/clients/bitvavo.rb +244 -0
- data/lib/honeymaker/clients/bybit.rb +308 -0
- data/lib/honeymaker/clients/coinbase.rb +326 -0
- data/lib/honeymaker/clients/gemini.rb +189 -0
- data/lib/honeymaker/clients/hyperliquid.rb +232 -0
- data/lib/honeymaker/clients/kraken.rb +379 -0
- data/lib/honeymaker/clients/kraken_futures.rb +78 -0
- data/lib/honeymaker/clients/kucoin.rb +310 -0
- data/lib/honeymaker/clients/mexc.rb +249 -0
- data/lib/honeymaker/exchange.rb +129 -0
- data/lib/honeymaker/exchanges/binance.rb +55 -0
- data/lib/honeymaker/exchanges/binance_us.rb +9 -0
- data/lib/honeymaker/exchanges/bingx.rb +56 -0
- data/lib/honeymaker/exchanges/bitget.rb +52 -0
- data/lib/honeymaker/exchanges/bitrue.rb +53 -0
- data/lib/honeymaker/exchanges/bitvavo.rb +65 -0
- data/lib/honeymaker/exchanges/bybit.rb +57 -0
- data/lib/honeymaker/exchanges/coinbase.rb +69 -0
- data/lib/honeymaker/exchanges/gemini.rb +134 -0
- data/lib/honeymaker/exchanges/hyperliquid.rb +67 -0
- data/lib/honeymaker/exchanges/kraken.rb +122 -0
- data/lib/honeymaker/exchanges/kucoin.rb +52 -0
- data/lib/honeymaker/exchanges/mexc.rb +56 -0
- data/lib/honeymaker/native/kraken.rb +263 -0
- data/lib/honeymaker/native.rb +42 -0
- data/lib/honeymaker/result.rb +46 -0
- data/lib/honeymaker/utils.rb +39 -0
- data/lib/honeymaker/version.rb +5 -0
- data/lib/honeymaker.rb +97 -0
- 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/sig/honeymaker.rbs +4 -0
- data/test/fixtures/binance_book_ticker.json +7 -0
- data/test/fixtures/binance_exchange_info.json +30 -0
- data/test/fixtures/bingx_symbols.json +26 -0
- data/test/fixtures/bingx_ticker.json +12 -0
- data/test/fixtures/bitget_symbols.json +28 -0
- data/test/fixtures/bitget_tickers.json +14 -0
- data/test/fixtures/bitmart_symbols.json +26 -0
- data/test/fixtures/bitmart_ticker.json +12 -0
- data/test/fixtures/bitrue_book_ticker.json +7 -0
- data/test/fixtures/bitrue_exchange_info.json +34 -0
- data/test/fixtures/bitvavo_book_ticker.json +7 -0
- data/test/fixtures/bitvavo_markets.json +30 -0
- data/test/fixtures/bybit_instruments.json +23 -0
- data/test/fixtures/bybit_tickers.json +19 -0
- data/test/fixtures/coinbase_product.json +7 -0
- data/test/fixtures/coinbase_products.json +24 -0
- data/test/fixtures/gemini_pubticker.json +10 -0
- data/test/fixtures/gemini_symbol_detail.json +10 -0
- data/test/fixtures/gemini_symbols.json +1 -0
- data/test/fixtures/hyperliquid_all_mids.json +5 -0
- data/test/fixtures/hyperliquid_spot_meta.json +12 -0
- data/test/fixtures/kraken_asset_pairs.json +43 -0
- data/test/fixtures/kraken_ticker.json +16 -0
- data/test/fixtures/kucoin_orderbook_level1.json +10 -0
- data/test/fixtures/kucoin_symbols.json +17 -0
- data/test/fixtures/mexc_book_ticker.json +7 -0
- data/test/fixtures/mexc_exchange_info.json +64 -0
- data/test/honeymaker/client_test.rb +90 -0
- data/test/honeymaker/clients/binance_client_test.rb +151 -0
- data/test/honeymaker/clients/binance_us_client_test.rb +25 -0
- data/test/honeymaker/clients/bingx_client_test.rb +106 -0
- data/test/honeymaker/clients/bitget_client_test.rb +146 -0
- data/test/honeymaker/clients/bitrue_client_test.rb +69 -0
- data/test/honeymaker/clients/bitvavo_client_test.rb +156 -0
- data/test/honeymaker/clients/bybit_client_test.rb +137 -0
- data/test/honeymaker/clients/coinbase_client_test.rb +153 -0
- data/test/honeymaker/clients/gemini_client_test.rb +100 -0
- data/test/honeymaker/clients/honeymaker_client_registry_test.rb +39 -0
- data/test/honeymaker/clients/hyperliquid_client_test.rb +292 -0
- data/test/honeymaker/clients/kraken_client_test.rb +255 -0
- data/test/honeymaker/clients/kraken_futures_client_test.rb +54 -0
- data/test/honeymaker/clients/kucoin_client_test.rb +149 -0
- data/test/honeymaker/clients/mexc_client_test.rb +98 -0
- data/test/honeymaker/clients/validation_test.rb +167 -0
- data/test/honeymaker/error_chain_test.rb +133 -0
- data/test/honeymaker/exchange_test.rb +138 -0
- data/test/honeymaker/exchanges/binance_test.rb +83 -0
- data/test/honeymaker/exchanges/binance_us_test.rb +53 -0
- data/test/honeymaker/exchanges/bingx_test.rb +65 -0
- data/test/honeymaker/exchanges/bitget_test.rb +65 -0
- data/test/honeymaker/exchanges/bitrue_test.rb +65 -0
- data/test/honeymaker/exchanges/bitvavo_test.rb +65 -0
- data/test/honeymaker/exchanges/bybit_test.rb +55 -0
- data/test/honeymaker/exchanges/coinbase_test.rb +106 -0
- data/test/honeymaker/exchanges/gemini_catalogue_test.rb +118 -0
- data/test/honeymaker/exchanges/gemini_test.rb +184 -0
- data/test/honeymaker/exchanges/hyperliquid_test.rb +64 -0
- data/test/honeymaker/exchanges/kraken_test.rb +191 -0
- data/test/honeymaker/exchanges/kucoin_test.rb +55 -0
- data/test/honeymaker/exchanges/mexc_test.rb +97 -0
- data/test/honeymaker/honeymaker_test.rb +27 -0
- data/test/honeymaker/result_test.rb +42 -0
- data/test/honeymaker/utils_test.rb +64 -0
- 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/fixture_helper.rb +13 -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
- data/test/test_helper.rb +9 -0
- metadata +254 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Honeymaker
|
|
4
|
+
module Exchanges
|
|
5
|
+
class Kraken < Exchange
|
|
6
|
+
BASE_URL = "https://api.kraken.com"
|
|
7
|
+
|
|
8
|
+
ASSET_BLACKLIST = [
|
|
9
|
+
"COPM" # has the same external_id (ecomi) as OMI
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
# Patterns are unanchored (except regional_restriction) because the consumer may
|
|
13
|
+
# pass a sentence-joined error string. transient_nonce is matched first as it is
|
|
14
|
+
# the more specific/actionable case.
|
|
15
|
+
ERROR_PATTERNS = [
|
|
16
|
+
{
|
|
17
|
+
code: :regional_restriction,
|
|
18
|
+
pattern: /\AEAccount:Invalid permissions:(?<asset>\S+) trading restricted for (?<country>\w+)\.?\z/
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
code: :transient_nonce,
|
|
22
|
+
pattern: /EAPI:Invalid nonce/
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
code: :transient_unavailable,
|
|
26
|
+
pattern: /EGeneral:Internal error|EService:(?:Unavailable|Busy|Deadline elapsed)/
|
|
27
|
+
}
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
REAL_COSTMIN = {
|
|
31
|
+
"AUD" => 10,
|
|
32
|
+
"CAD" => 5,
|
|
33
|
+
"CHF" => 5,
|
|
34
|
+
"DAI" => 5,
|
|
35
|
+
"ETH" => 0.002,
|
|
36
|
+
"EUR" => 0.5,
|
|
37
|
+
"GBP" => 5,
|
|
38
|
+
"JPY" => 500,
|
|
39
|
+
"PYUSD" => 5,
|
|
40
|
+
"RLUSD" => 5,
|
|
41
|
+
"USD" => 5,
|
|
42
|
+
"USDC" => 5,
|
|
43
|
+
"USDQ" => 5,
|
|
44
|
+
"USDR" => 5,
|
|
45
|
+
"USDT" => 5,
|
|
46
|
+
"XBT" => 0.00005
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
def get_tickers_info
|
|
50
|
+
with_rescue do
|
|
51
|
+
# aclass_base=all, not a bare call: the default response carries only the "currency" class,
|
|
52
|
+
# so Kraken tokenized equities (xStocks) are invisible without it. One request returns both
|
|
53
|
+
# classes and they are disjoint.
|
|
54
|
+
response = connection.get("/0/public/AssetPairs", { aclass_base: "all" })
|
|
55
|
+
|
|
56
|
+
error = response.body["error"]
|
|
57
|
+
return Result::Failure.new(*error) if error.is_a?(Array) && error.any?
|
|
58
|
+
|
|
59
|
+
# Every tokenized pair is returned TWICE - once under an SPV key (NVDASPVUSD), once under
|
|
60
|
+
# the x key (NVDAxUSD) - sharing one wsname and altname. Currency pairs are never
|
|
61
|
+
# duplicated, so keying by wsname collapses exactly the aliases and nothing else.
|
|
62
|
+
deduped = response.body["result"].each_with_object({}) do |(_, info), acc|
|
|
63
|
+
wsname = info["wsname"]
|
|
64
|
+
next if wsname.nil? || wsname.empty?
|
|
65
|
+
|
|
66
|
+
acc[wsname] ||= info
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
deduped.filter_map do |wsname, info|
|
|
70
|
+
base, quote = wsname.split("/")
|
|
71
|
+
|
|
72
|
+
{
|
|
73
|
+
ticker: info["altname"],
|
|
74
|
+
base: base,
|
|
75
|
+
quote: quote,
|
|
76
|
+
minimum_base_size: info["ordermin"],
|
|
77
|
+
minimum_quote_size: (REAL_COSTMIN[quote] || info["costmin"] || 0).to_s,
|
|
78
|
+
maximum_base_size: nil,
|
|
79
|
+
maximum_quote_size: nil,
|
|
80
|
+
base_decimals: info["lot_decimals"],
|
|
81
|
+
quote_decimals: info["cost_decimals"],
|
|
82
|
+
price_decimals: info["pair_decimals"],
|
|
83
|
+
available: true,
|
|
84
|
+
# Tokenized equities are listed but never tradable through this client: AddOrder needs
|
|
85
|
+
# an asset_class parameter it does not send, and Kraken closes the tokenized order
|
|
86
|
+
# books to EEA clients over the API regardless of that. Listing them anyway is what
|
|
87
|
+
# lets a holding be resolved and valued.
|
|
88
|
+
trading_enabled: if info["aclass_base"] == "tokenized_asset"
|
|
89
|
+
false
|
|
90
|
+
else
|
|
91
|
+
info.key?("status") ? info["status"] == "online" : true
|
|
92
|
+
end
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def get_bid_ask(symbol)
|
|
99
|
+
with_rescue do
|
|
100
|
+
response = connection.get("/0/public/Ticker") do |req|
|
|
101
|
+
req.params = { pair: symbol }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
error = response.body["error"]
|
|
105
|
+
raise StandardError, error.first if error.is_a?(Array) && error.any?
|
|
106
|
+
|
|
107
|
+
_key, data = response.body["result"].first
|
|
108
|
+
{
|
|
109
|
+
bid: BigDecimal(data["b"][0]),
|
|
110
|
+
ask: BigDecimal(data["a"][0])
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def connection
|
|
118
|
+
@connection ||= build_connection(BASE_URL)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Honeymaker
|
|
4
|
+
module Exchanges
|
|
5
|
+
class Kucoin < Exchange
|
|
6
|
+
BASE_URL = "https://api.kucoin.com"
|
|
7
|
+
|
|
8
|
+
def get_tickers_info
|
|
9
|
+
with_rescue do
|
|
10
|
+
response = connection.get("/api/v2/symbols")
|
|
11
|
+
|
|
12
|
+
response.body["data"].filter_map do |product|
|
|
13
|
+
{
|
|
14
|
+
ticker: product["symbol"],
|
|
15
|
+
base: product["baseCurrency"],
|
|
16
|
+
quote: product["quoteCurrency"],
|
|
17
|
+
minimum_base_size: product["baseMinSize"],
|
|
18
|
+
minimum_quote_size: product["quoteMinSize"],
|
|
19
|
+
maximum_base_size: product["baseMaxSize"],
|
|
20
|
+
maximum_quote_size: product["quoteMaxSize"],
|
|
21
|
+
base_decimals: Utils.decimals(product["baseIncrement"]),
|
|
22
|
+
quote_decimals: Utils.decimals(product["quoteIncrement"]),
|
|
23
|
+
price_decimals: Utils.decimals(product["priceIncrement"]),
|
|
24
|
+
available: true,
|
|
25
|
+
trading_enabled: product["enableTrading"]
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def get_bid_ask(symbol)
|
|
32
|
+
with_rescue do
|
|
33
|
+
response = connection.get("/api/v1/market/orderbook/level1") do |req|
|
|
34
|
+
req.params = { symbol: symbol }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
data = response.body["data"]
|
|
38
|
+
{
|
|
39
|
+
bid: BigDecimal(data["bestBid"]),
|
|
40
|
+
ask: BigDecimal(data["bestAsk"])
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def connection
|
|
48
|
+
@connection ||= build_connection(BASE_URL)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Honeymaker
|
|
4
|
+
module Exchanges
|
|
5
|
+
class Mexc < Exchange
|
|
6
|
+
BASE_URL = "https://api.mexc.com"
|
|
7
|
+
|
|
8
|
+
def get_tickers_info
|
|
9
|
+
with_rescue do
|
|
10
|
+
response = connection.get("/api/v3/exchangeInfo")
|
|
11
|
+
|
|
12
|
+
response.body["symbols"].filter_map do |product|
|
|
13
|
+
f = Utils.parse_filters(product["filters"])
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
ticker: product["symbol"],
|
|
17
|
+
base: product["baseAsset"],
|
|
18
|
+
quote: product["quoteAsset"],
|
|
19
|
+
minimum_base_size: f[:lot_size]&.[]("minQty"),
|
|
20
|
+
minimum_quote_size: f[:notional]&.[]("minNotional"),
|
|
21
|
+
maximum_base_size: f[:lot_size]&.[]("maxQty"),
|
|
22
|
+
maximum_quote_size: f[:notional]&.[]("maxNotional"),
|
|
23
|
+
base_decimals: f[:lot_size] ? Utils.decimals(f[:lot_size]["stepSize"]) : product["baseAssetPrecision"],
|
|
24
|
+
quote_decimals: product["quoteAssetPrecision"],
|
|
25
|
+
price_decimals: f[:price] ? Utils.decimals(f[:price]["tickSize"]) : product["quotePrecision"],
|
|
26
|
+
available: true,
|
|
27
|
+
# MEXC encodes status as "1", not Binance's "TRADING" — every symbol it returns is "1".
|
|
28
|
+
# isSpotTradingAllowed is the real gate: ~5% of listed symbols reject a spot order.
|
|
29
|
+
# `!= false` keeps the fail-open default if the key is ever absent, matching the siblings.
|
|
30
|
+
trading_enabled: product["status"] == "1" && product["isSpotTradingAllowed"] != false
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def get_bid_ask(symbol)
|
|
37
|
+
with_rescue do
|
|
38
|
+
response = connection.get("/api/v3/ticker/bookTicker") do |req|
|
|
39
|
+
req.params = { symbol: symbol }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
bid: BigDecimal(response.body["bidPrice"]),
|
|
44
|
+
ask: BigDecimal(response.body["askPrice"])
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def connection
|
|
52
|
+
@connection ||= build_connection(BASE_URL)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Honeymaker
|
|
6
|
+
module Native
|
|
7
|
+
# Kraken on the Rust core. Signatures and argument expressions are copied from Clients::Kraken
|
|
8
|
+
# (a test compares signatures). Transport, body encoding and Results are the inherited,
|
|
9
|
+
# unchanged Ruby: connection + URI.encode_www_form + with_rescue. Rust lays out and signs the
|
|
10
|
+
# requests and normalizes the answers.
|
|
11
|
+
class KrakenClient < Honeymaker::Client
|
|
12
|
+
URL = "https://api.kraken.com"
|
|
13
|
+
RATE_LIMITS = { default: 1000, orders: 1000 }.freeze
|
|
14
|
+
UNREADABLE = "Kraken: unreadable response"
|
|
15
|
+
|
|
16
|
+
def self.reset_nonce_state!
|
|
17
|
+
Native.load!
|
|
18
|
+
Ext::Kraken.reset_nonce_state!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def query_orders_info(txid:, trades: nil, userref: nil, consolidate_taker: true)
|
|
22
|
+
result = post_private("query_orders_info", trades: trades, userref: userref, txid: txid, consolidate_taker: consolidate_taker)
|
|
23
|
+
finish("query_orders_info", result) do |orders|
|
|
24
|
+
(result.data["result"] || {}).each { |id, raw| orders[id][:raw] = raw }
|
|
25
|
+
orders
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add_order(ordertype:, type:, volume:, pair:, userref: nil, cl_ord_id: nil,
|
|
30
|
+
displayvol: nil, price: nil, price2: nil, trigger: nil, leverage: nil,
|
|
31
|
+
reduce_only: nil, stptype: nil, oflags: [], timeinforce: nil,
|
|
32
|
+
starttm: nil, expiretm: nil, close: nil, close_price: nil,
|
|
33
|
+
close_price2: nil, deadline: nil, validate: nil)
|
|
34
|
+
result = post_private("add_order",
|
|
35
|
+
ordertype: ordertype, type: type, volume: volume, pair: pair, userref: userref,
|
|
36
|
+
cl_ord_id: cl_ord_id, displayvol: displayvol, price: price, price2: price2,
|
|
37
|
+
trigger: trigger, leverage: leverage, reduce_only: reduce_only, stptype: stptype,
|
|
38
|
+
oflags: oflags.any? ? oflags.join(",") : nil,
|
|
39
|
+
timeinforce: timeinforce, starttm: starttm, expiretm: expiretm, close: close,
|
|
40
|
+
close_price: close_price, close_price2: close_price2, deadline: deadline, validate: validate)
|
|
41
|
+
finish("add_order", result) { |order_id| { order_id: order_id, raw: result.data } }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def cancel_order(txid: nil, cl_ord_id: nil)
|
|
45
|
+
post_private("cancel_order", txid: txid, cl_ord_id: cl_ord_id)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def get_tradable_asset_pairs(pairs: nil, info: nil, country_code: nil, aclass_base: nil)
|
|
49
|
+
get_public("get_tradable_asset_pairs", pairs: pairs ? pairs.join(",") : nil, info: info,
|
|
50
|
+
country_code: country_code, aclass_base: aclass_base)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get_asset_info(assets: nil, aclass: nil)
|
|
54
|
+
get_public("get_asset_info", assets: assets ? assets.join(",") : nil, aclass: aclass)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def get_ticker_information(pair: nil)
|
|
58
|
+
get_public("get_ticker_information", pair: pair)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def get_extended_balance
|
|
62
|
+
post_private("get_extended_balance")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def get_api_key_info
|
|
66
|
+
post_private("get_api_key_info")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def get_balances
|
|
70
|
+
finish("balances", get_extended_balance) { |balances| balances }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def get_ohlc_data(pair:, interval: nil, since: nil)
|
|
74
|
+
get_public("get_ohlc_data", pair: pair, interval: interval, since: since)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def get_trades_history(type: nil, trades: nil, start: nil, end_time: nil, ofs: nil)
|
|
78
|
+
post_private("get_trades_history", type: type, trades: trades, start: start, end_time: end_time, ofs: ofs)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def get_ledgers(asset: nil, type: nil, start: nil, end_time: nil, ofs: nil)
|
|
82
|
+
post_private("get_ledgers", asset: asset, type: type, start: start, end_time: end_time, ofs: ofs)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The paging loop stays Ruby (verbatim legacy) so dig/to_i/include? keep Ruby's
|
|
86
|
+
# coercions; Rust aggregates. A Rust pager comes with stage 2, when Rust callers need one.
|
|
87
|
+
def closed_orders_from_trades(order_ids:, start: nil, max_pages: 20)
|
|
88
|
+
wanted = Array(order_ids)
|
|
89
|
+
return Result::Success.new({}) if wanted.empty?
|
|
90
|
+
|
|
91
|
+
by_order = Hash.new { |h, k| h[k] = [] }
|
|
92
|
+
seen = {}
|
|
93
|
+
offset = 0
|
|
94
|
+
pages = 0
|
|
95
|
+
loop do
|
|
96
|
+
result = get_trades_history(start: start, ofs: offset)
|
|
97
|
+
return result if result.failure?
|
|
98
|
+
return unreadable unless result.data.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
errors = result.data["error"]
|
|
101
|
+
return Result::Failure.new(*errors) if errors.is_a?(Array) && errors.any?
|
|
102
|
+
|
|
103
|
+
trades = result.data.dig("result", "trades") || {}
|
|
104
|
+
break if trades.empty?
|
|
105
|
+
|
|
106
|
+
trades.each_pair do |trade_id, t|
|
|
107
|
+
# Offset paging over a list that grows at the top can serve a trade twice: count it once.
|
|
108
|
+
next if seen.key?(trade_id)
|
|
109
|
+
|
|
110
|
+
seen[trade_id] = true
|
|
111
|
+
otxid = t["ordertxid"]
|
|
112
|
+
by_order[otxid] << t if wanted.include?(otxid)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Do NOT early-exit when each id has been *seen* — a partial fill can have trades on
|
|
116
|
+
# later pages. Page the whole [start, now] window (bounded by `start` + max_pages).
|
|
117
|
+
pages += 1
|
|
118
|
+
offset += trades.size
|
|
119
|
+
count = result.data.dig("result", "count").to_i
|
|
120
|
+
break if offset >= count || pages >= max_pages
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
Native.load!
|
|
124
|
+
Result::Success.new(Ext::Kraken.aggregate_trades(by_order))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def get_withdraw_addresses(asset: nil, method: nil)
|
|
128
|
+
post_private("get_withdraw_addresses", asset: asset, method: method)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def get_withdraw_methods(asset: nil)
|
|
132
|
+
post_private("get_withdraw_methods", asset: asset)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def withdraw(asset:, key:, amount:, address: nil)
|
|
136
|
+
post_private("withdraw", asset: asset, key: key, amount: amount, address: address)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def get_earn_allocations(ascending: nil, converted_asset: nil, hide_zero_allocations: nil)
|
|
140
|
+
post_private("get_earn_allocations", ascending: ascending, converted_asset: converted_asset,
|
|
141
|
+
hide_zero_allocations: hide_zero_allocations)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def validate_trading_credentials
|
|
147
|
+
result = get_extended_balance
|
|
148
|
+
return Result::Failure.new("Invalid trading credentials") if result.failure?
|
|
149
|
+
|
|
150
|
+
errors = result.data["error"]
|
|
151
|
+
if errors.is_a?(Array) && errors.none?
|
|
152
|
+
Result::Success.new(true)
|
|
153
|
+
else
|
|
154
|
+
Result::Failure.new("Invalid trading credentials")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def validate_read_credentials
|
|
159
|
+
validate_trading_credentials
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def native
|
|
163
|
+
@native ||= begin
|
|
164
|
+
Native.load!
|
|
165
|
+
Ext::Kraken.new(@api_key, @api_secret)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The legacy post_private: Rust lays out the pairs (nonce first) and signs; Ruby encodes.
|
|
170
|
+
def post_private(op, params = {})
|
|
171
|
+
with_rescue do
|
|
172
|
+
path, pairs = native.build_post(op, params.transform_keys(&:to_s))
|
|
173
|
+
response = connection.post do |req|
|
|
174
|
+
req.url path
|
|
175
|
+
req.body = URI.encode_www_form(pairs)
|
|
176
|
+
req.headers = native.sign(req.path, req.body)
|
|
177
|
+
end
|
|
178
|
+
response.body
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The legacy get_public: Rust lays out the params; Faraday encodes them.
|
|
183
|
+
def get_public(op, params = {})
|
|
184
|
+
with_rescue do
|
|
185
|
+
path, query, headers = native.build_get(op, params.transform_keys(&:to_s))
|
|
186
|
+
response = connection.get do |req|
|
|
187
|
+
req.url path
|
|
188
|
+
req.headers = headers
|
|
189
|
+
req.params = query
|
|
190
|
+
end
|
|
191
|
+
response.body
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def finish(op, result)
|
|
196
|
+
return result if result.failure?
|
|
197
|
+
# Guard in Ruby, before any conversion: a binary maintenance page must answer like the
|
|
198
|
+
# hardened legacy, not fail inside the converter.
|
|
199
|
+
return unreadable unless result.data.is_a?(Hash)
|
|
200
|
+
|
|
201
|
+
verdict = native.finish(op, result.data)
|
|
202
|
+
case verdict[0]
|
|
203
|
+
when "ok" then Result::Success.new(yield(verdict[1]))
|
|
204
|
+
when "venue" then Result::Failure.new(*result.data["error"])
|
|
205
|
+
when "unreadable" then unreadable
|
|
206
|
+
else raise ShapeError, "unexpected verdict #{verdict[0].inspect}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def unreadable
|
|
211
|
+
Result::Failure.new(UNREADABLE, data: { unreadable: true })
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
class KrakenExchange < Honeymaker::Exchange
|
|
216
|
+
BASE_URL = "https://api.kraken.com"
|
|
217
|
+
|
|
218
|
+
def classify_error(message)
|
|
219
|
+
return nil if message.nil?
|
|
220
|
+
|
|
221
|
+
Native.load!
|
|
222
|
+
Ext::Kraken.classify_error(message)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def get_tickers_info
|
|
226
|
+
with_rescue do
|
|
227
|
+
response = connection.get("/0/public/AssetPairs", { aclass_base: "all" })
|
|
228
|
+
raise ShapeError, "Kraken: catalogue body is not an object" unless response.body.is_a?(Hash)
|
|
229
|
+
|
|
230
|
+
verdict = native.finish("tickers_info", response.body)
|
|
231
|
+
return Result::Failure.new(*response.body["error"]) if verdict[0] == "venue"
|
|
232
|
+
|
|
233
|
+
verdict[1].each { |t| t[:minimum_quote_size] = t[:minimum_quote_size].to_s }
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def get_bid_ask(symbol)
|
|
238
|
+
with_rescue do
|
|
239
|
+
response = connection.get("/0/public/Ticker") { |req| req.params = { pair: symbol } }
|
|
240
|
+
raise ShapeError, "Kraken: ticker body is not an object" unless response.body.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
verdict = native.finish("bid_ask", response.body)
|
|
243
|
+
raise StandardError, response.body["error"].first if verdict[0] == "venue"
|
|
244
|
+
|
|
245
|
+
verdict[1]
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def native
|
|
252
|
+
@native ||= begin
|
|
253
|
+
Native.load!
|
|
254
|
+
Ext::Kraken.new(nil, nil)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def connection
|
|
259
|
+
@connection ||= build_connection(BASE_URL)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Honeymaker
|
|
4
|
+
# Rust-backed venue logic. A process picks its backend per exchange once, when honeymaker is
|
|
5
|
+
# required: HONEYMAKER_NATIVE=kraken. With the flag off the extension is never loaded.
|
|
6
|
+
module Native
|
|
7
|
+
# A venue payload whose shape legacy would have tripped over with an incidental NoMethodError
|
|
8
|
+
# or TypeError. Still a StandardError, which is all consumers rescue.
|
|
9
|
+
class ShapeError < StandardError; end
|
|
10
|
+
|
|
11
|
+
SUPPORTED = %w[kraken].freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def enabled
|
|
15
|
+
@enabled ||= begin
|
|
16
|
+
names = ENV.fetch("HONEYMAKER_NATIVE", "").split(",").map { |name| name.strip.downcase }.reject(&:empty?).uniq
|
|
17
|
+
unsupported = names - SUPPORTED
|
|
18
|
+
unless unsupported.empty?
|
|
19
|
+
raise ArgumentError, "Unsupported HONEYMAKER_NATIVE names: #{unsupported.join(', ')}; supported names: #{SUPPORTED.join(', ')}"
|
|
20
|
+
end
|
|
21
|
+
names.freeze
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enabled?(name)
|
|
26
|
+
name = name.to_s.strip.downcase
|
|
27
|
+
names = enabled
|
|
28
|
+
SUPPORTED.include?(name) && names.include?(name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def load!
|
|
32
|
+
return if defined?(Ext)
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
require "honeymaker/#{RUBY_VERSION[/\A\d+\.\d+/]}/honeymaker_native" # fat platform gem
|
|
36
|
+
rescue LoadError
|
|
37
|
+
require "honeymaker/honeymaker_native" # compiled from source / rake compile
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Honeymaker
|
|
4
|
+
class Result
|
|
5
|
+
attr_reader :data, :errors
|
|
6
|
+
|
|
7
|
+
def initialize(data:, errors:)
|
|
8
|
+
@data = data
|
|
9
|
+
@errors = errors
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def success?
|
|
13
|
+
errors.empty?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def failure?
|
|
17
|
+
!success?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def or(other)
|
|
21
|
+
return data if success?
|
|
22
|
+
|
|
23
|
+
other
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ==(other)
|
|
27
|
+
return false unless other.is_a?(Result)
|
|
28
|
+
|
|
29
|
+
data == other.data && errors == other.errors
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Success < Result
|
|
33
|
+
def initialize(data = nil)
|
|
34
|
+
@data = data
|
|
35
|
+
@errors = []
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class Failure < Result
|
|
40
|
+
def initialize(*errors, **kwargs)
|
|
41
|
+
@data = kwargs[:data]
|
|
42
|
+
@errors = errors.empty? ? ["Error"] : errors
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Honeymaker
|
|
4
|
+
module Utils
|
|
5
|
+
def self.decimals(num)
|
|
6
|
+
return 0 if num.nil?
|
|
7
|
+
str = num.to_s.sub(/\.?0+$/, "")
|
|
8
|
+
return 0 unless str.include?(".")
|
|
9
|
+
str.split(".").last.length
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# The exception classes behind an error, outermost first, following both Faraday's
|
|
13
|
+
# #wrapped_exception and Ruby's #cause (adapters nest them differently), each named once.
|
|
14
|
+
# Reported on a transport failure so a caller can tell where it happened; see Client#with_rescue.
|
|
15
|
+
def self.error_chain(error, limit: 10)
|
|
16
|
+
names = []
|
|
17
|
+
seen = {}.compare_by_identity # exceptions, not classes: adapters re-wrap in the same class
|
|
18
|
+
queue = [error]
|
|
19
|
+
until queue.empty? || seen.size >= limit
|
|
20
|
+
node = queue.shift
|
|
21
|
+
next if seen.key?(node)
|
|
22
|
+
|
|
23
|
+
seen[node] = true
|
|
24
|
+
names << node.class.name unless names.include?(node.class.name)
|
|
25
|
+
queue << node.wrapped_exception if node.respond_to?(:wrapped_exception) && node.wrapped_exception
|
|
26
|
+
queue << node.cause if node.cause
|
|
27
|
+
end
|
|
28
|
+
names
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.parse_filters(filters)
|
|
32
|
+
{
|
|
33
|
+
price: filters.find { |f| f["filterType"] == "PRICE_FILTER" },
|
|
34
|
+
lot_size: filters.find { |f| f["filterType"] == "LOT_SIZE" },
|
|
35
|
+
notional: filters.find { |f| %w[NOTIONAL MIN_NOTIONAL].include?(f["filterType"]) }
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|