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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b379d2fb9b62ca5b6420c3fd1b62eddb2fdd1a4912b6ee13b467199352ffb1a8
|
|
4
|
+
data.tar.gz: 77644a810237149040ff1f7557885ac049017b00ae0aef6c5221050b78c744de
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9a802bbcc841b6a2a5ff36ee0364f81d0f2a0d645611da784fa267b70a9092bbc1277ebd7f834646eb99200b5e73357ed928d2473efdf27cae70295a0b95f245
|
|
7
|
+
data.tar.gz: a0b627edbfa5dd8e770ebd1c36fd9c3fd9a8ee953cc8cb38dc66847451c616b7589691b70a93332eab0a425d93f96905547d3a5286439e955c16650c89d259fa
|
data/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# melaya
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK for the **[Melaya](https://melaya.org)** trading platform — normalized market data, paper + live trading, backtesting, and an AI agentic trading crew across **70+ venues**, powered by an in-house Rust engine.
|
|
4
|
+
|
|
5
|
+
- Zero runtime gem dependencies (stdlib `net/http`, `openssl`, `json` only).
|
|
6
|
+
- Pure Ruby WebSocket client (RFC 6455) — no external gem required for streaming.
|
|
7
|
+
- Full market data, strategies, sim trading, backtesting, and streaming from one client.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Add to your `Gemfile`:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "melaya", path: "path/to/sdk-ruby" # local checkout
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or once published to RubyGems:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem install melaya
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "melaya"
|
|
27
|
+
|
|
28
|
+
melaya = Melaya::Client.new(api_key: ENV["MELAYA_API_KEY"]) # keys are prefixed mk_
|
|
29
|
+
|
|
30
|
+
# REST — normalized ticker from any of 70+ venues
|
|
31
|
+
t = melaya.market.ticker(exchange: "binance", symbol: "BTC/USDT", market: "spot")
|
|
32
|
+
puts t["last"], t["bid"], t["ask"]
|
|
33
|
+
|
|
34
|
+
# Order book
|
|
35
|
+
ob = melaya.market.orderbook(exchange: "bybit", symbol: "BTC/USDT", market: "spot", limit: 20)
|
|
36
|
+
|
|
37
|
+
# Candles
|
|
38
|
+
candles = melaya.market.ohlcv(exchange: "okx", symbol: "ETH/USDT", timeframe: "1h", limit: 200)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Streaming
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# Live ticker (block form — closes when block returns)
|
|
45
|
+
melaya.stream.ticker(exchange: "binance", symbol: "BTC/USDT", market: "spot") do |frame|
|
|
46
|
+
puts frame["last"]
|
|
47
|
+
break # close after first frame
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Liquidation firehose
|
|
51
|
+
melaya.stream.liquidations(exchange: "binance") do |ev|
|
|
52
|
+
puts ev["side"], ev["notional"]
|
|
53
|
+
break
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Trading
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# Account: connected exchange keys and usage
|
|
61
|
+
keys = melaya.account.keys # [{ "apiKeyId" => "BINANCEUSDM_0", "exchange" => "binanceusdm", ... }]
|
|
62
|
+
usage = melaya.account.usage
|
|
63
|
+
|
|
64
|
+
# Strategies — launch immediately. Paper (dry_run: true) needs no exchange key.
|
|
65
|
+
# SDK-launchable strategies are `custom` Rhai definitions.
|
|
66
|
+
result = melaya.strategies.create(
|
|
67
|
+
name: "my-bot",
|
|
68
|
+
strategy_type: "custom",
|
|
69
|
+
exchange: "binanceusdm",
|
|
70
|
+
symbol: "BTC/USDT:USDT",
|
|
71
|
+
market: "FUTURES",
|
|
72
|
+
dry_run: true,
|
|
73
|
+
params: {
|
|
74
|
+
"language" => "rhai",
|
|
75
|
+
"definition" => 'fn evaluate() { emit_long(param("qty")); }',
|
|
76
|
+
"qty" => 0.001,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
sid = result["strategyId"]
|
|
80
|
+
melaya.strategies.pause(sid)
|
|
81
|
+
melaya.strategies.resume(sid)
|
|
82
|
+
trades = melaya.strategies.trades(sid)
|
|
83
|
+
|
|
84
|
+
# Paper trading (sim broker) — synthetic fills, no venue state
|
|
85
|
+
bal = melaya.sim.balance(strategy_id: sid)
|
|
86
|
+
fill = melaya.sim.create_order(
|
|
87
|
+
strategy_id: sid,
|
|
88
|
+
exchange: "binanceusdm",
|
|
89
|
+
symbol: "BTC/USDT:USDT",
|
|
90
|
+
side: "buy",
|
|
91
|
+
type: "market",
|
|
92
|
+
amount: 0.001,
|
|
93
|
+
market: "FUTURES"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Backtest on the Rust engine
|
|
97
|
+
r = melaya.backtest.start(
|
|
98
|
+
"strategyType" => "custom",
|
|
99
|
+
"exchange" => "binance",
|
|
100
|
+
"symbol" => "BTC/USDT",
|
|
101
|
+
"timeframe" => "1h",
|
|
102
|
+
"since_ms" => (Time.now.to_i - 90 * 86400) * 1000,
|
|
103
|
+
"until_ms" => Time.now.to_i * 1000,
|
|
104
|
+
"language" => "rhai",
|
|
105
|
+
"definition" => 'fn evaluate() { emit_long(param("qty")); }',
|
|
106
|
+
"params" => { "qty" => 0.001 }
|
|
107
|
+
)
|
|
108
|
+
job_id = r["job_id"]
|
|
109
|
+
loop do
|
|
110
|
+
j = melaya.backtest.job(job_id)
|
|
111
|
+
break if %w[done error].include?(j["status"])
|
|
112
|
+
sleep 2
|
|
113
|
+
end
|
|
114
|
+
result = melaya.backtest.results(job_id)
|
|
115
|
+
|
|
116
|
+
# Private streaming (ticket minted automatically)
|
|
117
|
+
melaya.stream.strategies do |ev|
|
|
118
|
+
puts ev["type"], ev["strategyId"]
|
|
119
|
+
break
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Always clean up
|
|
123
|
+
melaya.strategies.stop(sid)
|
|
124
|
+
melaya.strategies.delete(sid)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Authentication
|
|
128
|
+
|
|
129
|
+
Create an API key in the dashboard (**melaya.org → Settings → API Keys**). Keys are prefixed `mk_`; the SDK sends it automatically on every REST call and WebSocket connection.
|
|
130
|
+
|
|
131
|
+
Public market-data and account/strategy reads work with the `mk_` key alone. **Live** order placement and live strategy launches additionally require a connected exchange key — connect one in **Settings → Connectors**, then reference it by `apiKeyId`. Paper trading and backtesting never touch a venue and need no exchange credentials.
|
|
132
|
+
|
|
133
|
+
## TLS verification
|
|
134
|
+
|
|
135
|
+
The SDK verifies TLS certificates by default. To disable on a dev box with TLS interception:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
MELAYA_INSECURE_TLS=1 ruby your_script.rb
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Or pass `verify_ssl: false` to the constructor. **Never disable TLS in production.**
|
|
142
|
+
|
|
143
|
+
## API surface
|
|
144
|
+
|
|
145
|
+
| Area | Methods |
|
|
146
|
+
|---|---|
|
|
147
|
+
| Reference | `market.list_exchanges`, `catalog_counts` |
|
|
148
|
+
| Market data | `market.ticker`, `orderbook`, `ohlcv`, `ohlcv_multi`, `trades`, `markets`, `currencies`, `market_constraints`, `status`, `time` |
|
|
149
|
+
| Batch / derivatives | `market.tickers`, `funding_rates`, `funding_rate_history`, `funding_rate_history_multi`, `open_interest`, `open_interest_history`, `open_interest_history_multi`, `instruments`, `liquidation_events` |
|
|
150
|
+
| Prediction markets | `market.prediction_markets` (polymarket, kalshi, drift_pm, sxbet, azuro, overtime) |
|
|
151
|
+
| Account | `account.keys`, `usage`, `api_key_status` |
|
|
152
|
+
| Strategies | `strategies.create`, `list`, `get`, `pause`, `resume`, `stop`, `delete`, `update_params`, `status`, `performance`, `executions`, `trades`, `logs` |
|
|
153
|
+
| AI optimizer | `strategies.ai_opt_start`, `ai_opt_status`, `ai_opt_approve`, `ai_opt_stop`, `ai_opt_runs` |
|
|
154
|
+
| Paper trading | `sim.balance`, `positions`, `open_orders`, `my_trades`, `create_order`, `cancel_order`, `list_accounts` |
|
|
155
|
+
| Backtesting | `backtest.start`, `job`, `results`, `trades`, `sweep`, `list`, `favorites`, `funding_range`, `cancel`, `delete`, `delete_all` |
|
|
156
|
+
| Public streaming | `stream.ticker`, `orderbook`, `ohlcv`, `trades`, `liquidations` |
|
|
157
|
+
| Private streaming | `stream.strategies`, `stream.private` |
|
|
158
|
+
|
|
159
|
+
Full docs: **[melaya.org/docs](https://melaya.org/docs)**.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
[Apache-2.0](../../LICENSE)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Melaya
|
|
4
|
+
# Account API — authenticated reads about your Melaya account.
|
|
5
|
+
#
|
|
6
|
+
# Connected-exchange key references (masked), tier limits, and live usage
|
|
7
|
+
# counters. Requires an mk_ key on the private plane.
|
|
8
|
+
class AccountAPI
|
|
9
|
+
def initialize(http)
|
|
10
|
+
@http = http
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The exchange API keys connected to your account. +api_key+ is masked
|
|
14
|
+
# (display-only); use +api_key_id+ (e.g. BINANCEUSDM_0) when launching
|
|
15
|
+
# strategies or minting a private stream ticket.
|
|
16
|
+
def keys
|
|
17
|
+
@http.get("/api/v1/private/keys")["keys"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Tier, plan limits, and live usage counters (mirrors the dashboard's usage page).
|
|
21
|
+
def usage
|
|
22
|
+
@http.get("/api/v1/private/usage")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Status of your platform API key (tier, max concurrent connections).
|
|
26
|
+
def api_key_status
|
|
27
|
+
@http.get("/api/v1/private/api-key")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Melaya
|
|
4
|
+
# Backtest API — run strategies against historical data on the Rust engine.
|
|
5
|
+
#
|
|
6
|
+
# Start a single run or a parameter sweep (grid / random), poll the job,
|
|
7
|
+
# then pull metrics, the equity curve, and the trade list. All backtests run
|
|
8
|
+
# natively on Melaya's in-house engine — no per-venue SDK in the loop.
|
|
9
|
+
#
|
|
10
|
+
# Maps to https://api.melaya.org/api/v1/private/backtest/* on the private plane.
|
|
11
|
+
class BacktestAPI
|
|
12
|
+
def initialize(http)
|
|
13
|
+
@http = http
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Start a backtest. +mode+ defaults to a single run; pass "grid_sweep" /
|
|
17
|
+
# "random_sweep" with +param_ranges+ to fan out a parameter search.
|
|
18
|
+
# Returns a hash with "job_id" (and optionally "count").
|
|
19
|
+
#
|
|
20
|
+
# @param body [Hash] see BacktestStart type in the reference SDKs
|
|
21
|
+
def start(body)
|
|
22
|
+
@http.post("/api/v1/private/backtest/start", body)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Job status + progress (+status+, +progress_pct+, ...).
|
|
26
|
+
# @param job_id [String]
|
|
27
|
+
def job(job_id)
|
|
28
|
+
@http.get("/api/v1/private/backtest/jobs/#{job_id}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Metrics, equity curve, and OHLCV for a completed job.
|
|
32
|
+
# @param job_id [String]
|
|
33
|
+
def results(job_id)
|
|
34
|
+
@http.get("/api/v1/private/backtest/results/#{job_id}")["result"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The trade list for a completed job (default 500, max 5000 per call).
|
|
38
|
+
# @param job_id [String]
|
|
39
|
+
# @param limit [Integer, nil]
|
|
40
|
+
# @param offset [Integer, nil]
|
|
41
|
+
def trades(job_id, limit: nil, offset: nil)
|
|
42
|
+
@http.get("/api/v1/private/backtest/trades/#{job_id}",
|
|
43
|
+
"limit" => limit, "offset" => offset
|
|
44
|
+
)["trades"]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Ranked children of a sweep parent (default objective: sharpe DESC).
|
|
48
|
+
# @param parent_id [String]
|
|
49
|
+
# @param objective [String, nil]
|
|
50
|
+
# @param limit [Integer, nil]
|
|
51
|
+
def sweep(parent_id, objective: nil, limit: nil)
|
|
52
|
+
@http.get("/api/v1/private/backtest/sweep/#{parent_id}",
|
|
53
|
+
"objective" => objective, "limit" => limit
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Your backtest jobs, newest first.
|
|
58
|
+
# @param limit [Integer, nil]
|
|
59
|
+
# @param offset [Integer, nil]
|
|
60
|
+
def list(limit: nil, offset: nil)
|
|
61
|
+
@http.get("/api/v1/private/backtest",
|
|
62
|
+
"limit" => limit, "offset" => offset
|
|
63
|
+
).dig("data", "jobs")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Your favorited backtest jobs (Forge tier and above).
|
|
67
|
+
# @param limit [Integer, nil]
|
|
68
|
+
# @param offset [Integer, nil]
|
|
69
|
+
def favorites(limit: nil, offset: nil)
|
|
70
|
+
@http.get("/api/v1/private/backtest/favorites",
|
|
71
|
+
"limit" => limit, "offset" => offset
|
|
72
|
+
).dig("data", "jobs")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Earliest funding-rate timestamp available for an exchange+symbol (ms, or nil).
|
|
76
|
+
# @param exchange [String]
|
|
77
|
+
# @param symbol [String]
|
|
78
|
+
def funding_range(exchange:, symbol:)
|
|
79
|
+
@http.get("/api/v1/private/backtest/funding-range",
|
|
80
|
+
"exchange" => exchange, "symbol" => symbol
|
|
81
|
+
)["earliest_ms"]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Cancel an in-flight job.
|
|
85
|
+
# @param job_id [String]
|
|
86
|
+
def cancel(job_id)
|
|
87
|
+
@http.post("/api/v1/private/backtest/#{job_id}/cancel")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Soft-delete a single job.
|
|
91
|
+
# @param job_id [String]
|
|
92
|
+
def delete(job_id)
|
|
93
|
+
@http.delete("/api/v1/private/backtest/#{job_id}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Soft-delete every non-favorited job. Returns a hash with "deleted" count.
|
|
97
|
+
def delete_all
|
|
98
|
+
@http.delete("/api/v1/private/backtest")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Melaya
|
|
4
|
+
# Raised for non-2xx REST responses or ok:false envelopes.
|
|
5
|
+
class MelayaError < StandardError
|
|
6
|
+
attr_reader :status, :code, :body
|
|
7
|
+
|
|
8
|
+
def initialize(message, status: 0, code: nil, body: nil)
|
|
9
|
+
super(message)
|
|
10
|
+
@status = status
|
|
11
|
+
@code = code
|
|
12
|
+
@body = body
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "openssl"
|
|
7
|
+
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
|
|
10
|
+
module Melaya
|
|
11
|
+
# Internal HTTP client. Injects the API key on every call as both
|
|
12
|
+
# a query-param (?apiKey=) and Authorization: Bearer header.
|
|
13
|
+
class HttpClient
|
|
14
|
+
DEFAULT_BASE_URL = "https://api.melaya.org"
|
|
15
|
+
|
|
16
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, verify_ssl: true)
|
|
17
|
+
@api_key = api_key
|
|
18
|
+
@base_uri = URI.parse(base_url.chomp("/"))
|
|
19
|
+
@verify_ssl = verify_ssl
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def get(path, params = {})
|
|
23
|
+
request(:get, path, params: params)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def post(path, body = nil)
|
|
27
|
+
request(:post, path, body: body)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def delete(path, params = {})
|
|
31
|
+
request(:delete, path, params: params)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def build_uri(path, params = {})
|
|
37
|
+
uri = URI.parse("#{@base_uri}#{path}")
|
|
38
|
+
query = { "apiKey" => @api_key }
|
|
39
|
+
params.each { |k, v| query[k.to_s] = v.to_s unless v.nil? }
|
|
40
|
+
uri.query = URI.encode_www_form(query)
|
|
41
|
+
uri
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def request(method, path, params: {}, body: nil)
|
|
45
|
+
uri = build_uri(path, params)
|
|
46
|
+
|
|
47
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
48
|
+
http.use_ssl = uri.scheme == "https"
|
|
49
|
+
http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
50
|
+
http.open_timeout = 15
|
|
51
|
+
http.read_timeout = 60
|
|
52
|
+
|
|
53
|
+
req = case method
|
|
54
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
55
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
56
|
+
when :delete then Net::HTTP::Delete.new(uri)
|
|
57
|
+
else raise ArgumentError, "Unknown HTTP method: #{method}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
req["Authorization"] = "Bearer #{@api_key}"
|
|
61
|
+
req["Accept"] = "application/json"
|
|
62
|
+
|
|
63
|
+
if body
|
|
64
|
+
req["Content-Type"] = "application/json"
|
|
65
|
+
req.body = JSON.generate(body)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
resp = http.request(req)
|
|
69
|
+
parse(resp)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse(resp)
|
|
73
|
+
text = resp.body.to_s.strip
|
|
74
|
+
data = begin
|
|
75
|
+
text.empty? ? nil : JSON.parse(text)
|
|
76
|
+
rescue JSON::ParserError
|
|
77
|
+
text
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if resp.code.to_i >= 400
|
|
81
|
+
code = data.is_a?(Hash) ? data["error"] : nil
|
|
82
|
+
msg = "Melaya API #{resp.code}" + (code ? " (#{code})" : "")
|
|
83
|
+
raise MelayaError.new(msg, status: resp.code.to_i, code: code, body: data)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The API wraps every payload in { "ok": true/false, ... }.
|
|
87
|
+
# ok:false is a request-level failure — raise instead of returning silently.
|
|
88
|
+
if data.is_a?(Hash) && data["ok"] == false
|
|
89
|
+
code = data["error"]
|
|
90
|
+
msg = "Melaya API request failed" + (code ? ": #{code}" : "")
|
|
91
|
+
raise MelayaError.new(msg, status: resp.code.to_i, code: code, body: data)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
data
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Melaya
|
|
4
|
+
# REST market-data API — normalized across all 70+ venues.
|
|
5
|
+
#
|
|
6
|
+
# Every method maps to a documented endpoint under
|
|
7
|
+
# https://api.melaya.org/api/v1/market/*. Unwraps the inner data key from the
|
|
8
|
+
# {ok, <data>} envelope.
|
|
9
|
+
class MarketAPI
|
|
10
|
+
def initialize(http)
|
|
11
|
+
@http = http
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# List the exchanges Melaya supports right now (the source of truth).
|
|
15
|
+
def list_exchanges
|
|
16
|
+
@http.get("/api/v1/market/list-exchanges")["exchanges"]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Best bid/ask, last price, and 24h aggregates for one symbol.
|
|
20
|
+
# @param exchange [String]
|
|
21
|
+
# @param symbol [String]
|
|
22
|
+
# @param market [String, nil] e.g. "spot", "future", "swap"
|
|
23
|
+
def ticker(exchange:, symbol:, market: nil)
|
|
24
|
+
@http.get("/api/v1/market/ticker",
|
|
25
|
+
"exchange" => exchange, "symbol" => symbol, "market" => market
|
|
26
|
+
)["ticker"]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Order book to a given depth.
|
|
30
|
+
def orderbook(exchange:, symbol:, limit: nil, market: nil)
|
|
31
|
+
@http.get("/api/v1/market/orderbook",
|
|
32
|
+
"exchange" => exchange, "symbol" => symbol, "limit" => limit, "market" => market
|
|
33
|
+
)["orderbook"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# OHLCV candles. Each candle is [timestamp, open, high, low, close, volume].
|
|
37
|
+
def ohlcv(exchange:, symbol:, timeframe:, limit: nil, market: nil)
|
|
38
|
+
@http.get("/api/v1/market/ohlcv",
|
|
39
|
+
"exchange" => exchange, "symbol" => symbol, "timeframe" => timeframe,
|
|
40
|
+
"limit" => limit, "market" => market
|
|
41
|
+
)["candles"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Recent public trades.
|
|
45
|
+
def trades(exchange:, symbol:, market: nil)
|
|
46
|
+
@http.get("/api/v1/market/trades",
|
|
47
|
+
"exchange" => exchange, "symbol" => symbol, "market" => market
|
|
48
|
+
)["trades"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Tradable markets on a venue.
|
|
52
|
+
def markets(exchange:)
|
|
53
|
+
@http.get("/api/v1/market/markets", "exchange" => exchange)["markets"]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Listed currencies on a venue. (Not supported on every venue.)
|
|
57
|
+
def currencies(exchange:)
|
|
58
|
+
@http.get("/api/v1/market/currencies", "exchange" => exchange)["currencies"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Operational status: ok / maintenance / degraded.
|
|
62
|
+
def status(exchange:)
|
|
63
|
+
@http.get("/api/v1/market/status", "exchange" => exchange)["status"]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Exchange server time.
|
|
67
|
+
def time(exchange:)
|
|
68
|
+
@http.get("/api/v1/market/time", "exchange" => exchange)["time"]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ── Batch / derivatives (POST) ──────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
# Tickers for many symbols on one venue in a single call. Keyed by symbol.
|
|
74
|
+
def tickers(exchange:, symbols:, market: nil)
|
|
75
|
+
body = compact("exchange" => exchange, "symbols" => symbols, "market" => market)
|
|
76
|
+
@http.post("/api/v1/market/tickers", body)["tickers"]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Latest funding rates for perpetuals. Keyed by symbol.
|
|
80
|
+
def funding_rates(exchange:, symbols:, market: nil)
|
|
81
|
+
body = compact("exchange" => exchange, "symbols" => symbols, "market" => market)
|
|
82
|
+
@http.post("/api/v1/market/funding-rates", body)["rates"]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Funding-rate history.
|
|
86
|
+
def funding_rate_history(exchange:, symbol:, hours: nil, market: nil)
|
|
87
|
+
body = compact("exchange" => exchange, "symbol" => symbol, "hours" => hours, "market" => market)
|
|
88
|
+
@http.post("/api/v1/market/funding-rate-history", body)["history"]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Open interest for one or more perpetuals. Keyed by symbol.
|
|
92
|
+
def open_interest(exchange:, symbols:, market: nil)
|
|
93
|
+
body = compact("exchange" => exchange, "symbols" => symbols, "market" => market)
|
|
94
|
+
@http.post("/api/v1/market/open-interest", body)["openInterest"]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Open-interest history.
|
|
98
|
+
def open_interest_history(exchange:, symbol:, hours: nil, market: nil)
|
|
99
|
+
body = compact("exchange" => exchange, "symbol" => symbol, "hours" => hours, "market" => market)
|
|
100
|
+
@http.post("/api/v1/market/open-interest-history", body)["history"]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Instrument list + trading constraints (tick size, min notional, qty step).
|
|
104
|
+
def instruments(exchange:, market: nil)
|
|
105
|
+
body = compact("exchange" => exchange, "market" => market)
|
|
106
|
+
@http.post("/api/v1/market/instruments", body)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Cross-exchange liquidation events (historical query).
|
|
110
|
+
def liquidation_events(exchange: nil, symbol: nil, since_ms: nil, limit: nil)
|
|
111
|
+
body = compact("exchange" => exchange, "symbol" => symbol, "sinceMs" => since_ms, "limit" => limit)
|
|
112
|
+
@http.post("/api/v1/market/liquidation-events", body)["events"]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Multi-symbol OHLCV in one call. Returns candle arrays keyed by symbol.
|
|
116
|
+
def ohlcv_multi(exchange:, symbols:, timeframe:, limit: nil, market: nil)
|
|
117
|
+
body = compact("exchange" => exchange, "symbols" => symbols, "timeframe" => timeframe,
|
|
118
|
+
"limit" => limit, "market" => market)
|
|
119
|
+
@http.post("/api/v1/market/ohlcv-multi", body)["perSymbol"]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Trading constraints for one symbol (tick size, min notional, qty step, leverage).
|
|
123
|
+
def market_constraints(exchange:, symbol:, market: nil)
|
|
124
|
+
body = compact("exchange" => exchange, "symbol" => symbol, "market" => market)
|
|
125
|
+
@http.post("/api/v1/market/market-constraints", body)["constraints"]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Funding-rate history for one symbol across several venues. Keyed by exchange.
|
|
129
|
+
def funding_rate_history_multi(exchanges:, symbol:, hours: nil)
|
|
130
|
+
body = compact("exchanges" => exchanges, "symbol" => symbol, "hours" => hours)
|
|
131
|
+
@http.post("/api/v1/market/funding-rate-history-multi", body)["perExchange"]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Open-interest history for one symbol across several venues. Keyed by exchange.
|
|
135
|
+
def open_interest_history_multi(exchanges:, symbol:, hours: nil)
|
|
136
|
+
body = compact("exchanges" => exchanges, "symbol" => symbol, "hours" => hours)
|
|
137
|
+
@http.post("/api/v1/market/open-interest-history-multi", body)["perExchange"]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Prediction-market listings for a venue (polymarket, kalshi, drift_pm, sxbet, azuro, overtime).
|
|
141
|
+
def prediction_markets(venue: "polymarket")
|
|
142
|
+
@http.post("/api/v1/market/pm-markets", { "venue" => venue })["markets"]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Live platform catalog counts (agentic tools, subagents, by category). Public.
|
|
146
|
+
def catalog_counts
|
|
147
|
+
@http.get("/api/v1/public/catalog-counts")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def compact(hash)
|
|
153
|
+
hash.reject { |_, v| v.nil? }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
data/lib/melaya/sim.rb
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Melaya
|
|
4
|
+
# Paper-trading (sim broker) API.
|
|
5
|
+
#
|
|
6
|
+
# The sim broker synthesises fills from Melaya's live ticker tape and keeps a
|
|
7
|
+
# virtual wallet per strategy — no venue-side state changes, no exchange
|
|
8
|
+
# credentials needed. Every call is scoped to a +strategy_id+.
|
|
9
|
+
#
|
|
10
|
+
# Create a paper strategy first:
|
|
11
|
+
# result = melaya.strategies.create(name: "test", strategy_type: "custom", ...)
|
|
12
|
+
# sid = result["strategyId"]
|
|
13
|
+
class SimAPI
|
|
14
|
+
def initialize(http)
|
|
15
|
+
@http = http
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Paper accounts (one virtual wallet per paper strategy).
|
|
19
|
+
def list_accounts
|
|
20
|
+
r = @http.get("/api/v1/private/sim/list-accounts")
|
|
21
|
+
r.is_a?(Array) ? r : (r.is_a?(Hash) ? (r["accounts"] || []) : [])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Virtual balance for a paper strategy (equity, realized/unrealized PnL, free/used).
|
|
25
|
+
# @param strategy_id [String]
|
|
26
|
+
# @param asset [String, nil]
|
|
27
|
+
def balance(strategy_id:, asset: nil)
|
|
28
|
+
@http.get("/api/v1/private/sim/balance",
|
|
29
|
+
"strategy_id" => strategy_id, "asset" => asset
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Open paper positions for a strategy.
|
|
34
|
+
def positions(strategy_id:)
|
|
35
|
+
r = @http.get("/api/v1/private/sim/positions", "strategy_id" => strategy_id)
|
|
36
|
+
r.is_a?(Array) ? r : (r.is_a?(Hash) ? (r["positions"] || []) : [])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Resting paper orders for a strategy.
|
|
40
|
+
def open_orders(strategy_id:)
|
|
41
|
+
r = @http.get("/api/v1/private/sim/open-orders", "strategy_id" => strategy_id)
|
|
42
|
+
r.is_a?(Array) ? r : (r.is_a?(Hash) ? (r["orders"] || []) : [])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Filled paper trades for a strategy.
|
|
46
|
+
def my_trades(strategy_id:)
|
|
47
|
+
r = @http.get("/api/v1/private/sim/my-trades", "strategy_id" => strategy_id)
|
|
48
|
+
r.is_a?(Array) ? r : (r.is_a?(Hash) ? (r["trades"] || []) : [])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Place a paper order. Fills synthesise from the live ticker; nothing hits the venue.
|
|
52
|
+
#
|
|
53
|
+
# @param strategy_id [String]
|
|
54
|
+
# @param exchange [String]
|
|
55
|
+
# @param symbol [String]
|
|
56
|
+
# @param side [String] "buy" or "sell"
|
|
57
|
+
# @param amount [Numeric]
|
|
58
|
+
# @param type [String] "market" or "limit" (default "market")
|
|
59
|
+
# @param price [Numeric, nil] required for limit orders
|
|
60
|
+
# @param market [String, nil]
|
|
61
|
+
# @param leverage [Numeric, nil]
|
|
62
|
+
# @param reduce_only [Boolean, nil]
|
|
63
|
+
# @param sl_price [Numeric, nil]
|
|
64
|
+
# @param tp_price [Numeric, nil]
|
|
65
|
+
# @param client_order_id [String, nil]
|
|
66
|
+
# @param params [Hash, nil]
|
|
67
|
+
def create_order(strategy_id:, exchange:, symbol:, side:, amount:,
|
|
68
|
+
type: "market", price: nil, market: nil, leverage: nil,
|
|
69
|
+
reduce_only: nil, sl_price: nil, tp_price: nil,
|
|
70
|
+
client_order_id: nil, params: nil)
|
|
71
|
+
body = {
|
|
72
|
+
"strategy_id" => strategy_id,
|
|
73
|
+
"exchange" => exchange,
|
|
74
|
+
"symbol" => symbol,
|
|
75
|
+
"side" => side,
|
|
76
|
+
"amount" => amount,
|
|
77
|
+
"order_type" => type,
|
|
78
|
+
"orderType" => type,
|
|
79
|
+
"price" => price,
|
|
80
|
+
"market" => market,
|
|
81
|
+
"market_type" => market,
|
|
82
|
+
"leverage" => leverage,
|
|
83
|
+
"reduceOnly" => reduce_only,
|
|
84
|
+
"slPrice" => sl_price,
|
|
85
|
+
"tpPrice" => tp_price,
|
|
86
|
+
"client_order_id" => client_order_id,
|
|
87
|
+
"clientOrderId" => client_order_id,
|
|
88
|
+
"params" => params,
|
|
89
|
+
}.reject { |_, v| v.nil? }
|
|
90
|
+
@http.post("/api/v1/private/sim/create-order", body)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Cancel a resting paper order.
|
|
94
|
+
#
|
|
95
|
+
# @param strategy_id [String]
|
|
96
|
+
# @param order_id [String]
|
|
97
|
+
# @param symbol [String, nil]
|
|
98
|
+
# @param exchange [String, nil]
|
|
99
|
+
def cancel_order(strategy_id:, order_id:, symbol: nil, exchange: nil)
|
|
100
|
+
body = {
|
|
101
|
+
"strategy_id" => strategy_id,
|
|
102
|
+
"order_id" => order_id,
|
|
103
|
+
"orderId" => order_id,
|
|
104
|
+
"symbol" => symbol,
|
|
105
|
+
"exchange" => exchange,
|
|
106
|
+
}.reject { |_, v| v.nil? }
|
|
107
|
+
@http.post("/api/v1/private/sim/cancel-order", body)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|