melaya 0.1.0
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/README.md +163 -0
- data/lib/melaya/account.rb +30 -0
- data/lib/melaya/backtest.rb +101 -0
- data/lib/melaya/errors.rb +15 -0
- data/lib/melaya/http_client.rb +97 -0
- data/lib/melaya/market.rb +156 -0
- data/lib/melaya/sim.rb +110 -0
- data/lib/melaya/strategies.rb +155 -0
- data/lib/melaya/stream.rb +336 -0
- data/lib/melaya/trade.rb +168 -0
- data/lib/melaya/version.rb +5 -0
- data/lib/melaya.rb +79 -0
- data/melaya.gemspec +23 -0
- metadata +54 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Melaya
|
|
4
|
+
# Strategies API — launch, control, and inspect trading strategies.
|
|
5
|
+
#
|
|
6
|
+
# A strategy is a server-managed runner (the Trading Engine, or an Agentic
|
|
7
|
+
# Trading Crew) that trades a universe on a cadence with server-side SL/TP
|
|
8
|
+
# and safety rails. Launch in paper mode (dry_run: true) or live
|
|
9
|
+
# (dry_run: false, which requires a connected exchange key).
|
|
10
|
+
#
|
|
11
|
+
# Maps to https://api.melaya.org/api/v1/strategies/* on the private plane.
|
|
12
|
+
class StrategiesAPI
|
|
13
|
+
def initialize(http)
|
|
14
|
+
@http = http
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Every strategy you own (running, paused, paper, and live).
|
|
18
|
+
def list
|
|
19
|
+
@http.get("/api/v1/strategies/list")["strategies"]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A single strategy by id.
|
|
23
|
+
# @param strategy_id [String]
|
|
24
|
+
def get(strategy_id)
|
|
25
|
+
@http.get("/api/v1/strategies/#{strategy_id}")["strategy"]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Launch a strategy. Pass dry_run: true for paper; live needs api_key_id.
|
|
29
|
+
# Returns the full response hash (includes "strategyId").
|
|
30
|
+
#
|
|
31
|
+
# @param name [String]
|
|
32
|
+
# @param strategy_type [String] e.g. "custom"
|
|
33
|
+
# @param exchange [String]
|
|
34
|
+
# @param market [String, nil]
|
|
35
|
+
# @param symbol [String, nil]
|
|
36
|
+
# @param api_key_id [String, nil]
|
|
37
|
+
# @param params [Hash, nil]
|
|
38
|
+
# @param runtime_mode [String, nil]
|
|
39
|
+
# @param dry_run [Boolean]
|
|
40
|
+
# @param key_bindings [Hash, nil]
|
|
41
|
+
def create(name:, strategy_type:, exchange:, market: nil, symbol: nil,
|
|
42
|
+
api_key_id: nil, params: nil, runtime_mode: nil, dry_run: true,
|
|
43
|
+
key_bindings: nil)
|
|
44
|
+
body = {
|
|
45
|
+
"name" => name,
|
|
46
|
+
"strategyType" => strategy_type,
|
|
47
|
+
"exchange" => exchange,
|
|
48
|
+
"market" => market,
|
|
49
|
+
"symbol" => symbol,
|
|
50
|
+
"apiKeyId" => api_key_id,
|
|
51
|
+
"params" => params,
|
|
52
|
+
"runtimeMode" => runtime_mode,
|
|
53
|
+
"dryRun" => dry_run,
|
|
54
|
+
"keyBindings" => key_bindings,
|
|
55
|
+
}.reject { |_, v| v.nil? }
|
|
56
|
+
@http.post("/api/v1/strategies", body)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Pause a running strategy (stops entering new cycles until resumed).
|
|
60
|
+
def pause(strategy_id)
|
|
61
|
+
@http.post("/api/v1/strategies/#{strategy_id}/pause")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Resume a paused strategy.
|
|
65
|
+
def resume(strategy_id)
|
|
66
|
+
@http.post("/api/v1/strategies/#{strategy_id}/resume")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Stop a strategy and tear down its runner. Cancels any in-flight approvals.
|
|
70
|
+
def stop(strategy_id)
|
|
71
|
+
@http.post("/api/v1/strategies/#{strategy_id}/stop")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Soft-delete a strategy.
|
|
75
|
+
def delete(strategy_id)
|
|
76
|
+
@http.delete("/api/v1/strategies/#{strategy_id}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Update a running strategy's params (e.g. universe, cadence, risk caps).
|
|
80
|
+
# @param strategy_id [String]
|
|
81
|
+
# @param params [Hash]
|
|
82
|
+
def update_params(strategy_id, params)
|
|
83
|
+
@http.post("/api/v1/strategies/#{strategy_id}/update-params", params)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Live runtime status of a strategy's runner (container health, tick count).
|
|
87
|
+
def status(strategy_id)
|
|
88
|
+
@http.get("/api/v1/strategies/#{strategy_id}/status")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Performance series for a strategy (equity, PnL over time).
|
|
92
|
+
def performance(strategy_id)
|
|
93
|
+
@http.get("/api/v1/strategies/#{strategy_id}/performance")["rows"]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Execution (order) rows for a strategy.
|
|
97
|
+
def executions(strategy_id)
|
|
98
|
+
@http.get("/api/v1/strategies/#{strategy_id}/executions")["rows"]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Trade (fill) rows for a strategy.
|
|
102
|
+
def trades(strategy_id)
|
|
103
|
+
@http.get("/api/v1/strategies/#{strategy_id}/trades")["rows"]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Log rows for a strategy (cycle markers, persona messages, errors).
|
|
107
|
+
def logs(strategy_id)
|
|
108
|
+
@http.get("/api/v1/strategies/#{strategy_id}/logs")["rows"]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ── AI parameter optimizer ────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
# Kick off an AI-driven parameter optimization.
|
|
114
|
+
# +param_bounds+ maps each param name to a [min, max] array.
|
|
115
|
+
# +objective+ defaults to "sharpe"; +max_iterations+ is clamped to 1-20.
|
|
116
|
+
# Returns a hash including "runId".
|
|
117
|
+
#
|
|
118
|
+
# @param strategy_id [String]
|
|
119
|
+
# @param param_bounds [Hash] e.g. { "qty" => [0.001, 0.1] }
|
|
120
|
+
# @param objective [String]
|
|
121
|
+
# @param max_iterations [Integer]
|
|
122
|
+
# @param require_approval [Boolean, nil]
|
|
123
|
+
def ai_opt_start(strategy_id, param_bounds:, objective: "sharpe",
|
|
124
|
+
max_iterations: 3, require_approval: nil)
|
|
125
|
+
body = {
|
|
126
|
+
"paramBounds" => param_bounds,
|
|
127
|
+
"objective" => objective,
|
|
128
|
+
"maxIterations" => max_iterations,
|
|
129
|
+
}
|
|
130
|
+
body["requireApproval"] = require_approval unless require_approval.nil?
|
|
131
|
+
@http.post("/api/v1/strategies/#{strategy_id}/ai-opt/start", body)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Current optimization status for a strategy.
|
|
135
|
+
def ai_opt_status(strategy_id)
|
|
136
|
+
@http.get("/api/v1/strategies/#{strategy_id}/ai-opt/status")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Approve and apply the optimizer's proposed params to the running strategy.
|
|
140
|
+
# @param body [Hash] optional extra params
|
|
141
|
+
def ai_opt_approve(strategy_id, body = {})
|
|
142
|
+
@http.post("/api/v1/strategies/#{strategy_id}/ai-opt/approve", body)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Stop an in-progress optimization.
|
|
146
|
+
def ai_opt_stop(strategy_id)
|
|
147
|
+
@http.post("/api/v1/strategies/#{strategy_id}/ai-opt/stop")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Past optimization runs for a strategy.
|
|
151
|
+
def ai_opt_runs(strategy_id)
|
|
152
|
+
@http.get("/api/v1/strategies/#{strategy_id}/ai-opt/runs")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "base64"
|
|
7
|
+
require "digest"
|
|
8
|
+
require "json"
|
|
9
|
+
require "securerandom"
|
|
10
|
+
|
|
11
|
+
require_relative "errors"
|
|
12
|
+
|
|
13
|
+
module Melaya
|
|
14
|
+
# A minimal RFC 6455 WebSocket client built on stdlib TCP+TLS.
|
|
15
|
+
# No external gem required. Supports text frames; yields parsed JSON frames.
|
|
16
|
+
#
|
|
17
|
+
# Usage (block form — closes automatically):
|
|
18
|
+
# MelayaWebSocket.connect(url, verify_ssl: true) do |ws|
|
|
19
|
+
# ws.each_frame { |frame| puts frame.inspect; break }
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# Usage (manual):
|
|
23
|
+
# ws = MelayaWebSocket.new(url, verify_ssl: true)
|
|
24
|
+
# ws.connect
|
|
25
|
+
# ws.each_frame { |f| ... }
|
|
26
|
+
# ws.close
|
|
27
|
+
class MelayaWebSocket
|
|
28
|
+
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
29
|
+
|
|
30
|
+
def self.connect(url, verify_ssl: true, &block)
|
|
31
|
+
ws = new(url, verify_ssl: verify_ssl)
|
|
32
|
+
ws.connect
|
|
33
|
+
if block_given?
|
|
34
|
+
begin
|
|
35
|
+
block.call(ws)
|
|
36
|
+
ensure
|
|
37
|
+
ws.close
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
ws
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(url, verify_ssl: true)
|
|
45
|
+
@uri = URI.parse(url)
|
|
46
|
+
@verify_ssl = verify_ssl
|
|
47
|
+
@socket = nil
|
|
48
|
+
@closed = false
|
|
49
|
+
@buf = String.new("", encoding: "BINARY")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def connect
|
|
53
|
+
tcp = TCPSocket.new(@uri.host, @uri.port)
|
|
54
|
+
|
|
55
|
+
@socket = if @uri.scheme == "wss"
|
|
56
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
57
|
+
ctx.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
58
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
|
|
59
|
+
ssl.hostname = @uri.host
|
|
60
|
+
ssl.connect
|
|
61
|
+
ssl
|
|
62
|
+
else
|
|
63
|
+
tcp
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
handshake
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Iterate over incoming JSON frames. Yields each parsed frame Hash.
|
|
71
|
+
# Blocks until the socket closes or the block calls +break+.
|
|
72
|
+
def each_frame
|
|
73
|
+
loop do
|
|
74
|
+
frame = read_frame
|
|
75
|
+
break if frame.nil? # connection closed
|
|
76
|
+
|
|
77
|
+
next if frame.empty? # ping/pong or non-text
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
yield JSON.parse(frame)
|
|
81
|
+
rescue JSON::ParserError
|
|
82
|
+
next # ignore non-JSON keep-alive text
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
rescue IOError, Errno::ECONNRESET, EOFError
|
|
86
|
+
# socket closed by remote
|
|
87
|
+
ensure
|
|
88
|
+
close
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def close
|
|
92
|
+
return if @closed
|
|
93
|
+
@closed = true
|
|
94
|
+
begin
|
|
95
|
+
# Send a close frame (opcode 0x8) with no body, masked
|
|
96
|
+
send_frame(0x8, "")
|
|
97
|
+
rescue StandardError
|
|
98
|
+
# best-effort
|
|
99
|
+
ensure
|
|
100
|
+
@socket&.close rescue nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def handshake
|
|
107
|
+
key = Base64.strict_encode64(SecureRandom.bytes(16))
|
|
108
|
+
path = @uri.request_uri
|
|
109
|
+
host = @uri.host + (@uri.port && ![80, 443].include?(@uri.port) ? ":#{@uri.port}" : "")
|
|
110
|
+
|
|
111
|
+
request = [
|
|
112
|
+
"GET #{path} HTTP/1.1",
|
|
113
|
+
"Host: #{host}",
|
|
114
|
+
"Upgrade: websocket",
|
|
115
|
+
"Connection: Upgrade",
|
|
116
|
+
"Sec-WebSocket-Key: #{key}",
|
|
117
|
+
"Sec-WebSocket-Version: 13",
|
|
118
|
+
"\r\n",
|
|
119
|
+
].join("\r\n")
|
|
120
|
+
|
|
121
|
+
@socket.write(request)
|
|
122
|
+
|
|
123
|
+
# Read response headers
|
|
124
|
+
response = String.new("", encoding: "BINARY")
|
|
125
|
+
loop do
|
|
126
|
+
line = @socket.readline
|
|
127
|
+
response << line
|
|
128
|
+
break if line == "\r\n"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
unless response.include?("101")
|
|
132
|
+
raise MelayaError.new("WebSocket handshake failed: #{response.lines.first.strip}", status: 0)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
expected = Base64.strict_encode64(Digest::SHA1.digest("#{key}#{GUID}"))
|
|
136
|
+
unless response.include?(expected)
|
|
137
|
+
raise MelayaError.new("WebSocket Sec-WebSocket-Accept mismatch", status: 0)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Read one complete WebSocket frame. Returns the payload string (text) or nil on close.
|
|
142
|
+
# Skips ping frames (sends pong) and continuation frames (not used by Melaya).
|
|
143
|
+
def read_frame
|
|
144
|
+
loop do
|
|
145
|
+
# Need at least 2 bytes for header
|
|
146
|
+
fill_buf(2)
|
|
147
|
+
b0 = @buf.getbyte(0)
|
|
148
|
+
b1 = @buf.getbyte(1)
|
|
149
|
+
@buf = @buf[2..]
|
|
150
|
+
|
|
151
|
+
# fin = (b0 & 0x80) != 0 # we accept single-frame messages
|
|
152
|
+
opcode = b0 & 0x0F
|
|
153
|
+
masked = (b1 & 0x80) != 0
|
|
154
|
+
len = b1 & 0x7F
|
|
155
|
+
|
|
156
|
+
len = case len
|
|
157
|
+
when 126
|
|
158
|
+
fill_buf(2)
|
|
159
|
+
v = @buf[0, 2].unpack1("n")
|
|
160
|
+
@buf = @buf[2..]
|
|
161
|
+
v
|
|
162
|
+
when 127
|
|
163
|
+
fill_buf(8)
|
|
164
|
+
v = @buf[0, 8].unpack1("Q>")
|
|
165
|
+
@buf = @buf[8..]
|
|
166
|
+
v
|
|
167
|
+
else
|
|
168
|
+
len
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
mask_key = nil
|
|
172
|
+
if masked
|
|
173
|
+
fill_buf(4)
|
|
174
|
+
mask_key = @buf[0, 4]
|
|
175
|
+
@buf = @buf[4..]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
fill_buf(len)
|
|
179
|
+
payload = @buf[0, len].dup
|
|
180
|
+
@buf = @buf[len..]
|
|
181
|
+
|
|
182
|
+
if masked && mask_key
|
|
183
|
+
payload.bytes.each_with_index { |b, i| payload.setbyte(i, b ^ mask_key.getbyte(i % 4)) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
case opcode
|
|
187
|
+
when 0x1 # text
|
|
188
|
+
return payload.force_encoding("UTF-8")
|
|
189
|
+
when 0x2 # binary — treat as text (Melaya sends JSON)
|
|
190
|
+
return payload.force_encoding("UTF-8")
|
|
191
|
+
when 0x8 # close
|
|
192
|
+
return nil
|
|
193
|
+
when 0x9 # ping — send pong
|
|
194
|
+
send_frame(0xA, payload)
|
|
195
|
+
next
|
|
196
|
+
when 0xA # pong
|
|
197
|
+
next
|
|
198
|
+
else
|
|
199
|
+
next # ignore unknown opcodes
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Ensure @buf has at least +n+ bytes.
|
|
205
|
+
def fill_buf(n)
|
|
206
|
+
while @buf.bytesize < n
|
|
207
|
+
chunk = @socket.read(n - @buf.bytesize)
|
|
208
|
+
raise EOFError, "connection closed" if chunk.nil? || chunk.empty?
|
|
209
|
+
@buf = @buf + chunk
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Send a WebSocket frame with masking (client->server MUST be masked).
|
|
214
|
+
def send_frame(opcode, payload)
|
|
215
|
+
payload = payload.dup.force_encoding("BINARY")
|
|
216
|
+
len = payload.bytesize
|
|
217
|
+
|
|
218
|
+
header = String.new("", encoding: "BINARY")
|
|
219
|
+
header << (0x80 | opcode).chr(Encoding::BINARY)
|
|
220
|
+
|
|
221
|
+
mask_flag = 0x80 # clients must mask
|
|
222
|
+
if len <= 125
|
|
223
|
+
header << (mask_flag | len).chr(Encoding::BINARY)
|
|
224
|
+
elsif len <= 65535
|
|
225
|
+
header << (mask_flag | 126).chr(Encoding::BINARY)
|
|
226
|
+
header << [len].pack("n")
|
|
227
|
+
else
|
|
228
|
+
header << (mask_flag | 127).chr(Encoding::BINARY)
|
|
229
|
+
header << [len].pack("Q>")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
mask_key = SecureRandom.bytes(4)
|
|
233
|
+
header << mask_key
|
|
234
|
+
|
|
235
|
+
masked_payload = payload.bytes.each_with_index.map { |b, i| b ^ mask_key.getbyte(i % 4) }.pack("C*")
|
|
236
|
+
|
|
237
|
+
@socket.write(header + masked_payload)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# WebSocket streaming API.
|
|
242
|
+
#
|
|
243
|
+
# Each method yields parsed JSON frame hashes to a block, or returns an
|
|
244
|
+
# Enumerator if no block is given. The connection is closed when the block
|
|
245
|
+
# returns or the Enumerator is exhausted.
|
|
246
|
+
#
|
|
247
|
+
# Public stream example:
|
|
248
|
+
# melaya.stream.ticker(exchange: "binance", symbol: "BTC/USDT", market: "spot") do |frame|
|
|
249
|
+
# puts frame["last"]
|
|
250
|
+
# break # close after first frame
|
|
251
|
+
# end
|
|
252
|
+
#
|
|
253
|
+
# Private stream example (ticket minted automatically):
|
|
254
|
+
# melaya.stream.strategies do |ev|
|
|
255
|
+
# puts ev["type"], ev["strategyId"]
|
|
256
|
+
# break
|
|
257
|
+
# end
|
|
258
|
+
class StreamAPI
|
|
259
|
+
DEFAULT_WS_URL = "wss://wss.melaya.org"
|
|
260
|
+
|
|
261
|
+
def initialize(api_key, ws_url, http, verify_ssl: true)
|
|
262
|
+
@api_key = api_key
|
|
263
|
+
@ws_url = ws_url.to_s.chomp("/")
|
|
264
|
+
@http = http
|
|
265
|
+
@verify_ssl = verify_ssl
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Live ticker frames.
|
|
269
|
+
def ticker(exchange:, symbol:, market: nil, &block)
|
|
270
|
+
open_public("/ws/ticker", compact(exchange: exchange, symbol: symbol, market: market), &block)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Live order-book frames.
|
|
274
|
+
def orderbook(exchange:, symbol:, limit: nil, market: nil, &block)
|
|
275
|
+
open_public("/ws/orderbook", compact(exchange: exchange, symbol: symbol, limit: limit, market: market), &block)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Live OHLCV candle frames.
|
|
279
|
+
def ohlcv(exchange:, symbol:, timeframe:, market: nil, &block)
|
|
280
|
+
open_public("/ws/ohlcv", compact(exchange: exchange, symbol: symbol, timeframe: timeframe, market: market), &block)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Live public-trade frames.
|
|
284
|
+
def trades(exchange:, symbol:, market: nil, &block)
|
|
285
|
+
open_public("/ws/public-trades", compact(exchange: exchange, symbol: symbol, market: market), &block)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Cross-exchange liquidation firehose. Omit +exchange+ for all venues.
|
|
289
|
+
def liquidations(exchange: nil, &block)
|
|
290
|
+
open_public("/ws/liquidations", compact(exchange: exchange), &block)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Live strategy events for your account (cycle markers, agent messages,
|
|
294
|
+
# approval requests, executions, status). Mints a ticket, opens /ws/strategies.
|
|
295
|
+
def strategies(&block)
|
|
296
|
+
open_private("/ws/strategies", "strategies", {}, &block)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Live private account feed for one connected exchange key (balance,
|
|
300
|
+
# positions, your orders/fills). Pass +api_key_id+ from +account.keys()+.
|
|
301
|
+
def private(exchange:, market: nil, api_key_id: nil, key_id: nil, symbol: nil, &block)
|
|
302
|
+
params = compact(
|
|
303
|
+
exchange: exchange, market: market,
|
|
304
|
+
apiKeyId: api_key_id, keyId: key_id, symbol: symbol
|
|
305
|
+
)
|
|
306
|
+
open_private("/ws/private", "private", params, &block)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
|
|
311
|
+
def compact(hash)
|
|
312
|
+
hash.reject { |_, v| v.nil? }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def build_url(path, params)
|
|
316
|
+
q = params.merge("apiKey" => @api_key)
|
|
317
|
+
"#{@ws_url}#{path}?#{URI.encode_www_form(q)}"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def open_public(path, params, &block)
|
|
321
|
+
url = build_url(path, params.transform_keys(&:to_s))
|
|
322
|
+
MelayaWebSocket.connect(url, verify_ssl: @verify_ssl) do |ws|
|
|
323
|
+
ws.each_frame(&block)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def open_private(path, stream, params, &block)
|
|
328
|
+
body = { "stream" => stream }.merge(params.transform_keys(&:to_s))
|
|
329
|
+
ticket = @http.post("/api/v1/private/private-ticket", body)["wsTicket"]
|
|
330
|
+
url = "#{@ws_url}#{path}?#{URI.encode_www_form('wsTicket' => ticket)}"
|
|
331
|
+
MelayaWebSocket.connect(url, verify_ssl: @verify_ssl) do |ws|
|
|
332
|
+
ws.each_frame(&block)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
data/lib/melaya/trade.rb
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Melaya
|
|
4
|
+
# Live trading API — credentialed order placement, account state, and
|
|
5
|
+
# position management on a CONNECTED exchange.
|
|
6
|
+
#
|
|
7
|
+
# Every method POSTs to +https://api.melaya.org/api/v1/private/<op>+; the server
|
|
8
|
+
# resolves your connected exchange credential (referenced by +api_key_id+ — see
|
|
9
|
+
# AccountAPI#keys) and forwards the call to the venue through Melaya's in-house
|
|
10
|
+
# Rust engine. Responses share an envelope:
|
|
11
|
+
# +{ ok, exchange, operation, orderId, clientOrderId, payload, data, ... }+.
|
|
12
|
+
#
|
|
13
|
+
# WARNING: these hit the REAL venue with REAL funds. The write methods
|
|
14
|
+
# (create_order, cancel_order, amend_order, cancel_all_orders, cancel_plan_orders,
|
|
15
|
+
# close_position, set_leverage, set_margin_mode, set_position_mode) move money or
|
|
16
|
+
# change account state. For risk-free testing use the sim (paper) broker or a
|
|
17
|
+
# paper strategy instead.
|
|
18
|
+
class TradeAPI
|
|
19
|
+
def initialize(http)
|
|
20
|
+
@http = http
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# ── Account state (reads) ──────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
# Live account balance on a connected venue.
|
|
26
|
+
def balance(exchange:, api_key_id: nil, key_id: nil, market_type: nil, params: nil)
|
|
27
|
+
op("balance", exchange: exchange, apiKeyId: api_key_id, keyId: key_id,
|
|
28
|
+
marketType: market_type, params: params)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Live open positions.
|
|
32
|
+
def positions(exchange:, api_key_id: nil, market_type: nil, symbol: nil, params: nil)
|
|
33
|
+
op("positions", exchange: exchange, apiKeyId: api_key_id,
|
|
34
|
+
marketType: market_type, symbol: symbol, params: params)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Historical positions (venue-dependent).
|
|
38
|
+
def positions_history(exchange:, api_key_id: nil, market_type: nil, symbol: nil)
|
|
39
|
+
op("positions-history", exchange: exchange, apiKeyId: api_key_id,
|
|
40
|
+
marketType: market_type, symbol: symbol)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Resting (open) orders.
|
|
44
|
+
def open_orders(exchange:, api_key_id: nil, market_type: nil, symbol: nil)
|
|
45
|
+
op("open-orders", exchange: exchange, apiKeyId: api_key_id,
|
|
46
|
+
marketType: market_type, symbol: symbol)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# All orders (open + recent).
|
|
50
|
+
def orders(exchange:, api_key_id: nil, market_type: nil, symbol: nil)
|
|
51
|
+
op("orders", exchange: exchange, apiKeyId: api_key_id,
|
|
52
|
+
marketType: market_type, symbol: symbol)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Closed/filled orders.
|
|
56
|
+
def closed_orders(exchange:, api_key_id: nil, market_type: nil, symbol: nil)
|
|
57
|
+
op("closed-orders", exchange: exchange, apiKeyId: api_key_id,
|
|
58
|
+
marketType: market_type, symbol: symbol)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Your trade (fill) history.
|
|
62
|
+
def my_trades(exchange:, api_key_id: nil, market_type: nil, symbol: nil)
|
|
63
|
+
op("my-trades", exchange: exchange, apiKeyId: api_key_id,
|
|
64
|
+
marketType: market_type, symbol: symbol)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Extended trade history (venue-dependent).
|
|
68
|
+
def my_trades_history(exchange:, api_key_id: nil, market_type: nil, symbol: nil)
|
|
69
|
+
op("my-trades-history", exchange: exchange, apiKeyId: api_key_id,
|
|
70
|
+
marketType: market_type, symbol: symbol)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Resting conditional/plan (trigger) orders.
|
|
74
|
+
def plan_orders(exchange:, api_key_id: nil, market_type: nil, symbol: nil)
|
|
75
|
+
op("plan-orders", exchange: exchange, apiKeyId: api_key_id,
|
|
76
|
+
marketType: market_type, symbol: symbol)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Current leverage for a symbol.
|
|
80
|
+
def leverage(exchange:, api_key_id: nil, symbol: nil, market_type: nil)
|
|
81
|
+
op("leverage", exchange: exchange, apiKeyId: api_key_id,
|
|
82
|
+
symbol: symbol, marketType: market_type)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Leverage tiers / brackets for a symbol.
|
|
86
|
+
def leverage_tiers(exchange:, api_key_id: nil, symbol: nil, market_type: nil)
|
|
87
|
+
op("leverage-tiers", exchange: exchange, apiKeyId: api_key_id,
|
|
88
|
+
symbol: symbol, marketType: market_type)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ── Order placement & management (LIVE writes — real funds) ───────────────
|
|
92
|
+
|
|
93
|
+
# Place a live order on the venue. WARNING: real money.
|
|
94
|
+
# stop_price, take_profit_price, and reduce_only are folded into +params+.
|
|
95
|
+
def create_order(exchange:, symbol:, side:, amount:,
|
|
96
|
+
api_key_id: nil, type: "market", price: nil,
|
|
97
|
+
market_type: nil, stop_price: nil, take_profit_price: nil,
|
|
98
|
+
reduce_only: nil, leverage: nil, client_order_id: nil, params: nil)
|
|
99
|
+
p = (params || {}).dup
|
|
100
|
+
p["stopPrice"] = stop_price unless stop_price.nil?
|
|
101
|
+
p["takeProfitPrice"] = take_profit_price unless take_profit_price.nil?
|
|
102
|
+
p["reduceOnly"] = reduce_only unless reduce_only.nil?
|
|
103
|
+
op("create-order",
|
|
104
|
+
exchange: exchange, apiKeyId: api_key_id, symbol: symbol,
|
|
105
|
+
side: side, amount: amount, type: type, price: price,
|
|
106
|
+
marketType: market_type, leverage: leverage,
|
|
107
|
+
clientOrderId: client_order_id, params: p.empty? ? nil : p)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Cancel a live order by id. WARNING.
|
|
111
|
+
def cancel_order(exchange:, api_key_id: nil, order_id: nil, client_order_id: nil,
|
|
112
|
+
symbol: nil, market_type: nil)
|
|
113
|
+
op("cancel-order", exchange: exchange, apiKeyId: api_key_id,
|
|
114
|
+
orderId: order_id, clientOrderId: client_order_id,
|
|
115
|
+
symbol: symbol, marketType: market_type)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Amend (modify) a live order. WARNING.
|
|
119
|
+
def amend_order(exchange:, api_key_id: nil, order_id: nil, symbol: nil,
|
|
120
|
+
amount: nil, price: nil)
|
|
121
|
+
op("amend-order", exchange: exchange, apiKeyId: api_key_id,
|
|
122
|
+
orderId: order_id, symbol: symbol, amount: amount, price: price)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Cancel every open order (optionally scoped to a symbol). WARNING.
|
|
126
|
+
def cancel_all_orders(exchange:, api_key_id: nil, symbol: nil, market_type: nil)
|
|
127
|
+
op("cancel-all-orders", exchange: exchange, apiKeyId: api_key_id,
|
|
128
|
+
symbol: symbol, marketType: market_type)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Cancel resting plan/trigger orders. WARNING.
|
|
132
|
+
def cancel_plan_orders(exchange:, api_key_id: nil, symbol: nil, market_type: nil)
|
|
133
|
+
op("cancel-plan-orders", exchange: exchange, apiKeyId: api_key_id,
|
|
134
|
+
symbol: symbol, marketType: market_type)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Close an open position (market reduce-only). WARNING.
|
|
138
|
+
def close_position(exchange:, symbol:, api_key_id: nil, market_type: nil)
|
|
139
|
+
op("close-position", exchange: exchange, apiKeyId: api_key_id,
|
|
140
|
+
symbol: symbol, marketType: market_type)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Set leverage for a symbol. WARNING.
|
|
144
|
+
def set_leverage(exchange:, symbol:, leverage:, api_key_id: nil, market_type: nil)
|
|
145
|
+
op("set-leverage", exchange: exchange, apiKeyId: api_key_id,
|
|
146
|
+
symbol: symbol, leverage: leverage, marketType: market_type)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Set margin mode (cross/isolated). WARNING.
|
|
150
|
+
def set_margin_mode(exchange:, margin_mode:, api_key_id: nil, symbol: nil, market_type: nil)
|
|
151
|
+
op("set-margin-mode", exchange: exchange, apiKeyId: api_key_id,
|
|
152
|
+
marginMode: margin_mode, symbol: symbol, marketType: market_type)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Set position mode (one-way / hedge). WARNING.
|
|
156
|
+
def set_position_mode(exchange:, api_key_id: nil, hedged: nil, mode: nil, market_type: nil)
|
|
157
|
+
op("set-position-mode", exchange: exchange, apiKeyId: api_key_id,
|
|
158
|
+
hedged: hedged, mode: mode, marketType: market_type)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def op(path_op, **kwargs)
|
|
164
|
+
body = kwargs.reject { |_, v| v.nil? }
|
|
165
|
+
@http.post("/api/v1/private/#{path_op}", body)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|