mirror of
https://github.com/we-promise/sure.git
synced 2026-05-31 16:29:03 +00:00
* feat(binance): add full account sync and transaction processing - Fixed a bug that hindered Account setup - Wire up Binance accounts, sync statistics, and unlinked account tracking in the accounts dashboard. - Support setting a sync_start_date during Binance account setup. - Set Binance accounts' opening balance to zero to ensure the ledger builds cleanly from the actual trade history. - Expand the Binance importer and processor to handle Spot, Margin, Earn, P2P, and Futures trades and assets. - Implement TransactionBuilder to parse raw Binance trades, accurately calculating fees, base/quote asset amounts, and market values for proper ledger integration. - Update Binance API timeout (`recvWindow`) to 60,000ms to prevent connection drops. These changes provide comprehensive support for tracking Binance portfolios, ensuring accurate historical ledgers and proper visibility of sync statuses in the frontend dashboard. * refactor(binance): enforce strong params, double-entry safety, and native fiat currency support - Implement strong parameters in BinanceItemsController#complete_account_setup to satisfy Rails security guidelines. - Add robust date parsing with a grace fallback to prevent controller crashes on malformed sync start dates. - Wrap P2P transaction creations inside a database transaction block to guarantee ledger integrity and prevent orphan records. - Optimize P2P deduplication queries by batching checks for both transaction and funding external IDs. - Shift P2P entry persistence from forced USD tracking to native fiat values extracted directly from the Binance API payload. - Update BinanceAccount::ProcessorTest assertions and fixtures to validate native fiat and fee calculation logic. * fix(binance): process sync trades before caching transaction payload - Reorder Binance processor execution to insert trade records into the database prior to updating the `raw_transactions_payload` cache. This guarantees that if a database insertion fails, the cache won't prematurely mark the sync as successful, ensuring the data is retried on the next run. - Move `set_opening_anchor_balance(balance: 0)` out of the generic crypto exchange account builder and apply it specifically during Binance account creation. - Refactor date parsing in BinanceItemsController to explicitly catch `ArgumentError` via a block instead of using a blanket inline `rescue`. - Clean up the `setup_accounts` view template by removing hardcoded default translation strings. * fix(binance): enhance trade sync logic and error propagation - Pass `startTime` (from `sync_start_date`) to spot and futures trade endpoints on initial sync to optimize data fetching. - Include previously synced futures pairs alongside spot pairs when resolving relevant symbols to properly recover sold-out assets. - Re-raise exceptions in processor rescue blocks to prevent silent failures and ensure errors are correctly propagated to background jobs. - Decrease Binance API `recvWindow` from 60000ms to 5000ms to align with recommended default timeout values.
193 lines
6.2 KiB
Ruby
193 lines
6.2 KiB
Ruby
class Provider::Binance
|
|
include HTTParty
|
|
extend SslConfigurable
|
|
|
|
class Error < StandardError; end
|
|
class AuthenticationError < Error; end
|
|
class RateLimitError < Error; end
|
|
class ApiError < Error; end
|
|
class InvalidSymbolError < ApiError; end
|
|
|
|
# Pipelock false positive: This constant and the base_uri below trigger a "Credential in URL"
|
|
# warning because of the presence of @api_key and @api_secret variables in this file.
|
|
# Pipelock incorrectly interprets the '@' in Ruby instance variables as a password delimiter
|
|
# in an URL (e.g. https://user:password@host).
|
|
SPOT_BASE_URL = "https://api.binance.com".freeze
|
|
FUTURES_BASE_URL = "https://fapi.binance.com".freeze
|
|
|
|
base_uri SPOT_BASE_URL
|
|
default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options))
|
|
|
|
attr_reader :api_key, :api_secret
|
|
|
|
def initialize(api_key:, api_secret:)
|
|
@api_key = api_key
|
|
@api_secret = api_secret
|
|
end
|
|
|
|
# Spot wallet — requires signed request
|
|
def get_spot_account
|
|
signed_get("/api/v3/account")
|
|
end
|
|
|
|
# Margin account — requires signed request
|
|
def get_margin_account
|
|
signed_get("/sapi/v1/margin/account")
|
|
end
|
|
|
|
# Simple Earn flexible positions — requires signed request
|
|
def get_simple_earn_flexible
|
|
signed_get("/sapi/v1/simple-earn/flexible/position")
|
|
end
|
|
|
|
# Simple Earn locked positions — requires signed request
|
|
def get_simple_earn_locked
|
|
signed_get("/sapi/v1/simple-earn/locked/position")
|
|
end
|
|
|
|
# Public endpoint — no auth needed
|
|
# symbol e.g. "BTCUSDT"
|
|
# Returns price string or nil on failure
|
|
def get_spot_price(symbol)
|
|
response = self.class.get("/api/v3/ticker/price", query: { symbol: symbol })
|
|
data = handle_response(response)
|
|
data["price"]
|
|
rescue StandardError => e
|
|
Rails.logger.warn("Provider::Binance: failed to fetch price for #{symbol}: #{e.message}")
|
|
nil
|
|
end
|
|
|
|
# Public endpoint — fetch historical kline close price for a date
|
|
# symbol e.g. "BTCUSDT", date e.g. Date or Time
|
|
def get_historical_price(symbol, date)
|
|
timestamp = date.to_time.utc.beginning_of_day.to_i * 1000
|
|
|
|
response = self.class.get("/api/v3/klines", query: {
|
|
symbol: symbol,
|
|
interval: "1d",
|
|
startTime: timestamp,
|
|
limit: 1
|
|
})
|
|
|
|
data = handle_response(response)
|
|
|
|
return nil if data.blank? || data.first.blank?
|
|
|
|
# Binance klines format: [ Open time, Open, High, Low, Close (index 4), ... ]
|
|
data.first[4]
|
|
rescue StandardError => e
|
|
Rails.logger.warn("Provider::Binance: failed to fetch historical price for #{symbol} on #{date}: #{e.message}")
|
|
nil
|
|
end
|
|
|
|
# Signed trade history for a single symbol, e.g. "BTCUSDT".
|
|
# Pass from_id to fetch only trades with id >= from_id (for incremental sync).
|
|
def get_spot_trades(symbol, limit: 1000, from_id: nil)
|
|
params = { "symbol" => symbol, "limit" => limit.to_s }
|
|
params["fromId"] = from_id.to_s if from_id
|
|
signed_get("/api/v3/myTrades", extra_params: params)
|
|
end
|
|
|
|
# USDⓈ-M Futures account — requires signed request
|
|
def get_futures_account
|
|
signed_get("/fapi/v2/account", base_url: FUTURES_BASE_URL)
|
|
end
|
|
|
|
# Futures trade history for a single symbol
|
|
def get_futures_trades(symbol, limit: 1000, from_id: nil)
|
|
params = { "symbol" => symbol, "limit" => limit.to_s }
|
|
params["fromId"] = from_id.to_s if from_id
|
|
signed_get("/fapi/v1/userTrades", extra_params: params, base_url: FUTURES_BASE_URL)
|
|
end
|
|
|
|
# P2P trade history — requires signed request
|
|
# Pass start_timestamp to fetch only recent trades (max 30 days window)
|
|
def get_p2p_trades(start_timestamp: nil, end_timestamp: nil)
|
|
params = { "tradeType" => "BUY" } # default to BUY, will loop in processor for SELL
|
|
params["startTimestamp"] = start_timestamp.to_s if start_timestamp
|
|
params["endTimestamp"] = end_timestamp.to_s if end_timestamp
|
|
signed_get("/sapi/v1/c2c/orderMatch/listUserOrderHistory", extra_params: params)
|
|
end
|
|
|
|
# Internal helper to handle both buy and sell types since API requires specific tradeType or gets default BUY
|
|
def get_all_p2p_trades(start_timestamp: nil, end_timestamp: nil)
|
|
%w[BUY SELL].flat_map do |trade_type|
|
|
page = 1
|
|
rows = 100
|
|
data = []
|
|
loop do
|
|
result = signed_get(
|
|
"/sapi/v1/c2c/orderMatch/listUserOrderHistory",
|
|
extra_params: {
|
|
"tradeType" => trade_type,
|
|
"startTimestamp" => start_timestamp&.to_s,
|
|
"endTimestamp" => end_timestamp&.to_s,
|
|
"page" => page.to_s,
|
|
"rows" => rows.to_s
|
|
}.compact
|
|
)
|
|
batch = result.is_a?(Hash) ? Array(result["data"]) : []
|
|
data.concat(batch)
|
|
break if batch.size < rows
|
|
page += 1
|
|
end
|
|
data
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def signed_get(path, extra_params: {}, base_url: SPOT_BASE_URL)
|
|
params = timestamp_params.merge(extra_params)
|
|
query_string = URI.encode_www_form(params.sort)
|
|
|
|
full_url = "#{base_url}#{path}"
|
|
|
|
response = self.class.get(
|
|
full_url,
|
|
query: "#{query_string}&signature=#{sign(query_string)}",
|
|
headers: auth_headers
|
|
)
|
|
|
|
handle_response(response)
|
|
end
|
|
|
|
def timestamp_params
|
|
{ "timestamp" => (Time.current.to_f * 1000).to_i.to_s, "recvWindow" => "5000" }
|
|
end
|
|
|
|
# HMAC-SHA256 of the query string.
|
|
# Accepts either a Hash of params or a pre-built query string.
|
|
def sign(params)
|
|
query_string = params.is_a?(Hash) ? URI.encode_www_form(params.sort) : params
|
|
OpenSSL::HMAC.hexdigest("sha256", api_secret, query_string)
|
|
end
|
|
|
|
def auth_headers
|
|
{ "X-MBX-APIKEY" => api_key }
|
|
end
|
|
|
|
def handle_response(response)
|
|
parsed = response.parsed_response
|
|
|
|
case response.code
|
|
when 200..299
|
|
parsed
|
|
when 401
|
|
raise AuthenticationError, extract_error_message(parsed) || "Unauthorized"
|
|
when 429
|
|
raise RateLimitError, "Rate limit exceeded"
|
|
else
|
|
msg = extract_error_message(parsed) || "API error: #{response.code}"
|
|
raise InvalidSymbolError, msg if parsed.is_a?(Hash) && parsed["code"] == -1121
|
|
raise ApiError, msg
|
|
end
|
|
end
|
|
|
|
def extract_error_message(parsed)
|
|
return parsed if parsed.is_a?(String)
|
|
return nil unless parsed.is_a?(Hash)
|
|
parsed["msg"] || parsed["message"] || parsed["error"]
|
|
end
|
|
end
|