feat: 添加 EdgeX 交易所适配器及相关客户端实现,支持订单、深度和K线数据处理

This commit is contained in:
discountry
2025-10-04 20:15:41 +08:00
parent ccc3a2bf89
commit 627ed36de4
52 changed files with 15892 additions and 2 deletions
@@ -0,0 +1,466 @@
import asyncio
import binascii
import hashlib
import time
import uuid
from typing import Dict, Any, Optional, Tuple, List, Union
import json
import aiohttp
from Crypto.Hash import keccak
from .signing_adapter import SigningAdapter
# Import field prime for modular arithmetic
try:
from ..crypto.constants import FIELD_PRIME
except ImportError:
# Fallback if crypto module is not available
FIELD_PRIME = 0x800000000000011000000000000000000000000000000000000000000000001
# Constants
LIMIT_ORDER_WITH_FEE_TYPE = 3
class L2Signature:
"""Represents a signature for L2 operations."""
def __init__(self, r: str, s: str, v: str = ""):
self.r = r
self.s = s
self.v = v
class AsyncClient:
"""Async base client with common functionality."""
def __init__(self, base_url: str, account_id: int, stark_pri_key: str,
signing_adapter: Optional[SigningAdapter] = None,
timeout: float = 30.0, connector_limit: int = 100):
"""
Initialize the async internal client.
Args:
base_url: Base URL for API endpoints
account_id: Account ID for authentication
stark_pri_key: Stark private key for signing
signing_adapter: Optional signing adapter to use for cryptographic operations
timeout: Request timeout in seconds
connector_limit: Maximum number of connections in the pool
"""
self.base_url = base_url
self.account_id = account_id
self.stark_pri_key = stark_pri_key
# Use the provided signing adapter (required)
if signing_adapter is None:
raise ValueError("signing_adapter is required")
self.signing_adapter = signing_adapter
# Store configuration for later session creation
self._session = None
self._timeout = timeout
self._connector_limit = connector_limit
self._closed = False
async def __aenter__(self):
"""Async context manager entry."""
await self._ensure_session()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()
async def _ensure_session(self):
"""Ensure the aiohttp session is created."""
if self._session is None or self._session.closed:
# Create connector and session when needed (inside event loop)
timeout_config = aiohttp.ClientTimeout(total=self._timeout)
connector = aiohttp.TCPConnector(
limit=self._connector_limit,
limit_per_host=30,
keepalive_timeout=30,
enable_cleanup_closed=True
)
self._session = aiohttp.ClientSession(
timeout=timeout_config,
connector=connector,
headers={
"Content-Type": "application/json",
"Accept": "application/json"
}
)
async def close(self):
"""Close the HTTP session and cleanup resources."""
if self._session and not self._session.closed:
await self._session.close()
self._closed = True
@property
def session(self) -> aiohttp.ClientSession:
"""Get the HTTP session, ensuring it's created."""
if self._session is None or self._session.closed:
raise RuntimeError("Session not initialized. Use 'async with client:' or call '_ensure_session()'")
return self._session
def get_account_id(self) -> int:
"""Get the account ID."""
return self.account_id
def get_stark_pri_key(self) -> str:
"""Get the stark private key."""
return self.stark_pri_key
def sign(self, message_hash: bytes) -> L2Signature:
"""
Sign a message hash using the client's Stark private key.
Args:
message_hash: The hash of the message to sign
Returns:
L2Signature: The signature components
Raises:
ValueError: If the stark private key is not set or invalid
"""
private_key = self.get_stark_pri_key()
if not private_key:
raise ValueError("stark private key not set")
# Sign the message using the signing adapter
try:
r, s = self.signing_adapter.sign(message_hash, private_key)
return L2Signature(r=r, s=s, v="")
except Exception as e:
raise ValueError(f"failed to sign message: {str(e)}")
def generate_uuid(self) -> str:
"""Generate a UUID for client order IDs."""
return str(uuid.uuid4())
def calc_nonce(self, client_order_id: str) -> int:
"""
Calculate a nonce from a client order ID.
Args:
client_order_id: The client order ID
Returns:
int: The calculated nonce
"""
# Use SHA256 like the Go SDK (not Keccak256)
h = hashlib.sha256()
h.update(client_order_id.encode())
hash_hex = h.hexdigest()
return int(hash_hex[:8], 16)
async def make_authenticated_request(
self,
method: str,
path: str,
data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Make an authenticated HTTP request.
Args:
method: HTTP method (GET, POST, etc.)
path: API path (e.g., '/api/v1/private/order/createOrder')
data: JSON data for POST requests
params: Query parameters for GET requests
Returns:
Dict[str, Any]: Response JSON data
Raises:
ValueError: If the request fails
"""
await self._ensure_session()
# Generate timestamp
timestamp = int(time.time() * 1000)
# Build full URL
url = f"{self.base_url}{path}"
# Generate signature content
sign_content = self._build_signature_content(timestamp, method, path, data, params)
# Sign the content
keccak_hash = keccak.new(digest_bits=256)
keccak_hash.update(sign_content.encode())
content_hash = keccak_hash.digest()
sig = self.sign(content_hash)
# Prepare headers
headers = {
"X-edgeX-Api-Timestamp": str(timestamp),
"X-edgeX-Api-Signature": f"{sig.r}{sig.s}"
}
# Make the request
try:
async with self.session.request(
method=method,
url=url,
json=data,
params=params,
headers=headers
) as response:
if response.status != 200:
try:
error_detail = await response.json()
raise ValueError(f"request failed with status code: {response.status}, response: {error_detail}")
except (aiohttp.ContentTypeError, json.JSONDecodeError):
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
# Check response code
if resp_data.get("code") != "SUCCESS":
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
except aiohttp.ClientError as e:
raise ValueError(f"HTTP request failed: {str(e)}")
def _build_signature_content(
self,
timestamp: int,
method: str,
path: str,
data: Optional[Dict[str, Any]],
params: Optional[Dict[str, Any]]
) -> str:
"""Build the content string for signature generation."""
if data:
# Convert body to sorted string format
body_str = self.get_value(data)
sign_content = f"{timestamp}{method}{path}{body_str}"
else:
# For requests without body, use query parameters if present
if params:
# Sort query parameters as strings (matching Go SDK exactly)
param_pairs = []
for key, value in sorted(params.items()):
param_pairs.append(f"{key}={value}")
query_string = "&".join(param_pairs)
sign_content = f"{timestamp}{method}{path}{query_string}"
else:
sign_content = f"{timestamp}{method}{path}"
return sign_content
def get_value(self, data: Union[Dict[str, Any], List[Any], str, int, float, None]) -> str:
"""
Convert a value to a string representation for signing.
This function recursively processes dictionaries, lists, and primitive types.
Args:
data: The value to convert
Returns:
str: The string representation
"""
if data is None:
return ""
if isinstance(data, str):
return data
if isinstance(data, bool):
# Convert boolean to lowercase string to match Go SDK
return str(data).lower()
if isinstance(data, (int, float)):
return str(data)
if isinstance(data, list):
if len(data) == 0:
return ""
values = [self.get_value(item) for item in data]
return "&".join(values)
if isinstance(data, dict):
# Convert all values to strings and sort by keys
sorted_map = {}
for key, val in data.items():
sorted_map[key] = self.get_value(val)
# Get sorted keys
keys = sorted(sorted_map.keys())
# Build key=value pairs
pairs = [f"{key}={sorted_map[key]}" for key in keys]
return "&".join(pairs)
# Handle other types by converting to string
return str(data)
def calc_limit_order_hash(
self,
synthetic_asset_id: str,
collateral_asset_id: str,
fee_asset_id: str,
is_buy: bool,
amount_synthetic: int,
amount_collateral: int,
amount_fee: int,
nonce: int,
account_id: int,
expire_time: int
) -> bytes:
"""
Calculate the hash for a limit order using StarkEx protocol.
Args:
synthetic_asset_id: The synthetic asset ID (hex string)
collateral_asset_id: The collateral asset ID (hex string)
fee_asset_id: The fee asset ID (hex string)
is_buy: Whether the order is a buy order
amount_synthetic: The synthetic amount
amount_collateral: The collateral amount
amount_fee: The fee amount
nonce: The nonce
account_id: The account ID (position ID)
expire_time: The expiration time
Returns:
bytes: The calculated hash
"""
# Remove 0x prefix if present
if synthetic_asset_id.startswith('0x'):
synthetic_asset_id = synthetic_asset_id[2:]
if collateral_asset_id.startswith('0x'):
collateral_asset_id = collateral_asset_id[2:]
if fee_asset_id.startswith('0x'):
fee_asset_id = fee_asset_id[2:]
# Convert hex strings to integers and ensure they're within the field
asset_id_synthetic = int(synthetic_asset_id, 16) % FIELD_PRIME
asset_id_collateral = int(collateral_asset_id, 16) % FIELD_PRIME
asset_id_fee = int(fee_asset_id, 16) % FIELD_PRIME
# Determine buy/sell assets based on order direction
if is_buy:
asset_id_sell = asset_id_collateral
asset_id_buy = asset_id_synthetic
amount_sell = amount_collateral
amount_buy = amount_synthetic
else:
asset_id_sell = asset_id_synthetic
asset_id_buy = asset_id_collateral
amount_sell = amount_synthetic
amount_buy = amount_collateral
# Use the signing adapter to calculate the Pedersen hash
# First hash: hash(asset_id_sell, asset_id_buy)
msg = self.signing_adapter.pedersen_hash([asset_id_sell, asset_id_buy])
msg_int = int.from_bytes(msg, byteorder='big')
# Second hash: hash(msg, asset_id_fee)
msg = self.signing_adapter.pedersen_hash([msg_int, asset_id_fee])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 0
# packed_message0 = amount_sell * 2^64 + amount_buy * 2^64 + max_amount_fee * 2^32 + nonce
packed_message0 = amount_sell
packed_message0 = (packed_message0 << 64) + amount_buy
packed_message0 = (packed_message0 << 64) + amount_fee
packed_message0 = (packed_message0 << 32) + nonce
packed_message0 = packed_message0 % FIELD_PRIME # Ensure within field
# Third hash: hash(msg, packed_message0)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_message0])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 1
# packed_message1 = LIMIT_ORDER_WITH_FEES * 2^64 + position_id * 2^64 + position_id * 2^64 + position_id * 2^32 + expiration_timestamp * 2^17
packed_message1 = LIMIT_ORDER_WITH_FEE_TYPE
packed_message1 = (packed_message1 << 64) + account_id
packed_message1 = (packed_message1 << 64) + account_id
packed_message1 = (packed_message1 << 64) + account_id
packed_message1 = (packed_message1 << 32) + expire_time
packed_message1 = packed_message1 << 17 # Padding
packed_message1 = packed_message1 % FIELD_PRIME # Ensure within field
# Final hash: hash(msg, packed_message1)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_message1])
return msg
def calc_transfer_hash(
self,
asset_id: int,
asset_id_fee: int,
receiver_public_key: int,
sender_position_id: int,
receiver_position_id: int,
fee_position_id: int,
nonce: int,
amount: int,
max_amount_fee: int,
expiration_timestamp: int
) -> bytes:
"""
Calculate the hash for a transfer using StarkEx protocol.
Args:
asset_id: The asset ID
asset_id_fee: The fee asset ID
receiver_public_key: The receiver's public key
sender_position_id: The sender's position ID
receiver_position_id: The receiver's position ID
fee_position_id: The fee position ID
nonce: The nonce
amount: The transfer amount
max_amount_fee: The maximum fee amount
expiration_timestamp: The expiration timestamp
Returns:
bytes: The calculated hash
"""
# First hash: hash(asset_id, asset_id_fee)
msg = self.signing_adapter.pedersen_hash([asset_id, asset_id_fee])
msg_int = int.from_bytes(msg, byteorder='big')
# Second hash: hash(msg, receiver_public_key)
msg = self.signing_adapter.pedersen_hash([msg_int, receiver_public_key])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 0
# packed_msg0 = sender_position_id * 2^64 + receiver_position_id * 2^64 + fee_position_id * 2^32 + nonce
packed_msg0 = sender_position_id
packed_msg0 = (packed_msg0 << 64) + receiver_position_id
packed_msg0 = (packed_msg0 << 64) + fee_position_id
packed_msg0 = (packed_msg0 << 32) + nonce
packed_msg0 = packed_msg0 % FIELD_PRIME # Ensure within field
# Third hash: hash(msg, packed_msg0)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_msg0])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 1
# packed_msg1 = 4 * 2^64 + amount * 2^64 + max_amount_fee * 2^32 + expiration_timestamp * 2^81
packed_msg1 = 4 # Transfer type
packed_msg1 = (packed_msg1 << 64) + amount
packed_msg1 = (packed_msg1 << 64) + max_amount_fee
packed_msg1 = (packed_msg1 << 32) + expiration_timestamp
packed_msg1 = packed_msg1 << 81 # Padding
packed_msg1 = packed_msg1 % FIELD_PRIME # Ensure within field
# Final hash: hash(msg, packed_msg1)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_msg1])
return msg