|
@@ -0,0 +1,260 @@
|
|
|
|
|
+import base64
|
|
|
|
|
+import hashlib
|
|
|
|
|
+import hmac
|
|
|
|
|
+import json
|
|
|
|
|
+from dataclasses import dataclass
|
|
|
|
|
+from decimal import Decimal, ROUND_DOWN
|
|
|
|
|
+from urllib.parse import urlencode
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ORDER_PATH = "/api/v5/trade/order"
|
|
|
|
|
+BATCH_ORDERS_PATH = "/api/v5/trade/batch-orders"
|
|
|
|
|
+CANCEL_ORDER_PATH = "/api/v5/trade/cancel-order"
|
|
|
|
|
+ORDERS_PENDING_PATH = "/api/v5/trade/orders-pending"
|
|
|
|
|
+FILLS_PATH = "/api/v5/trade/fills"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+STATE_JSON_SCHEMA: dict[str, object] = {
|
|
|
|
|
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
|
|
|
+ "title": "OKX read-only order lifecycle state",
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "additionalProperties": False,
|
|
|
|
|
+ "required": [
|
|
|
|
|
+ "schema_version",
|
|
|
|
|
+ "strategy_id",
|
|
|
|
|
+ "symbol",
|
|
|
|
|
+ "bar",
|
|
|
|
|
+ "mode",
|
|
|
|
|
+ "lifecycle",
|
|
|
|
|
+ "updated_at",
|
|
|
|
|
+ "open_orders",
|
|
|
|
|
+ "fills",
|
|
|
|
|
+ ],
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "schema_version": {"const": 1},
|
|
|
|
|
+ "strategy_id": {"type": "string", "minLength": 1},
|
|
|
|
|
+ "symbol": {"type": "string", "pattern": "^[A-Z0-9]+-[A-Z0-9]+-SWAP$"},
|
|
|
|
|
+ "bar": {"type": "string", "minLength": 1},
|
|
|
|
|
+ "mode": {"const": "readonly"},
|
|
|
|
|
+ "lifecycle": {"enum": ["idle", "entry_pending", "partially_filled", "filled", "cancel_pending"]},
|
|
|
|
|
+ "updated_at": {"type": "string", "format": "date-time"},
|
|
|
|
|
+ "open_orders": {
|
|
|
|
|
+ "type": "array",
|
|
|
|
|
+ "items": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "additionalProperties": False,
|
|
|
|
|
+ "required": ["instId", "ordId", "clOrdId", "side", "posSide", "ordType", "px", "sz", "state"],
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "instId": {"type": "string"},
|
|
|
|
|
+ "ordId": {"type": "string"},
|
|
|
|
|
+ "clOrdId": {"type": "string"},
|
|
|
|
|
+ "side": {"enum": ["buy", "sell"]},
|
|
|
|
|
+ "posSide": {"enum": ["long", "short"]},
|
|
|
|
|
+ "ordType": {"const": "post_only"},
|
|
|
|
|
+ "px": {"type": "string"},
|
|
|
|
|
+ "sz": {"type": "string"},
|
|
|
|
|
+ "state": {"enum": ["live", "partially_filled"]},
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ "fills": {
|
|
|
|
|
+ "type": "array",
|
|
|
|
|
+ "items": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "additionalProperties": False,
|
|
|
|
|
+ "required": ["instId", "tradeId", "ordId", "clOrdId", "fillPx", "fillSz", "fillTime", "fee"],
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "instId": {"type": "string"},
|
|
|
|
|
+ "tradeId": {"type": "string"},
|
|
|
|
|
+ "ordId": {"type": "string"},
|
|
|
|
|
+ "clOrdId": {"type": "string"},
|
|
|
|
|
+ "fillPx": {"type": "string"},
|
|
|
|
|
+ "fillSz": {"type": "string"},
|
|
|
|
|
+ "fillTime": {"type": "string"},
|
|
|
|
|
+ "fee": {"type": "string"},
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True)
|
|
|
|
|
+class InstrumentMeta:
|
|
|
|
|
+ ct_val: str
|
|
|
|
|
+ lot_sz: str
|
|
|
|
|
+ min_sz: str
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True)
|
|
|
|
|
+class SignableRequest:
|
|
|
|
|
+ method: str
|
|
|
|
|
+ path: str
|
|
|
|
|
+ params: dict[str, object] | None
|
|
|
|
|
+ body: str
|
|
|
|
|
+ path_with_query: str
|
|
|
|
|
+ pre_hash: str
|
|
|
|
|
+ signature: str
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _decimal(value: object) -> Decimal:
|
|
|
|
|
+ parsed = Decimal(str(value))
|
|
|
|
|
+ if not parsed.is_finite():
|
|
|
|
|
+ raise ValueError("numeric input is invalid")
|
|
|
|
|
+ return parsed
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def format_number(value: object) -> str:
|
|
|
|
|
+ return format(_decimal(value).normalize(), "f")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_contract_size(notional: object, price: object, metadata: InstrumentMeta) -> str:
|
|
|
|
|
+ notional_decimal = _decimal(notional)
|
|
|
|
|
+ price_decimal = _decimal(price)
|
|
|
|
|
+ ct_val_decimal = _decimal(metadata.ct_val)
|
|
|
|
|
+ lot_size = _decimal(metadata.lot_sz)
|
|
|
|
|
+ min_size = _decimal(metadata.min_sz)
|
|
|
|
|
+ if notional_decimal <= 0 or price_decimal <= 0 or ct_val_decimal <= 0 or lot_size <= 0 or min_size <= 0:
|
|
|
|
|
+ raise ValueError("contract sizing input is invalid")
|
|
|
|
|
+ raw_size = notional_decimal / (price_decimal * ct_val_decimal)
|
|
|
|
|
+ size = (raw_size / lot_size).to_integral_value(rounding=ROUND_DOWN) * lot_size
|
|
|
|
|
+ if size < min_size:
|
|
|
|
|
+ raise ValueError("contract size below minimum")
|
|
|
|
|
+ return format_number(size)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_post_only_limit_order_body(
|
|
|
|
|
+ *,
|
|
|
|
|
+ symbol: str,
|
|
|
|
|
+ action: str,
|
|
|
|
|
+ price: object,
|
|
|
|
|
+ size: object,
|
|
|
|
|
+ client_order_id: str,
|
|
|
|
|
+) -> dict[str, str]:
|
|
|
|
|
+ if action not in {"long", "short"}:
|
|
|
|
|
+ raise ValueError("action is invalid")
|
|
|
|
|
+ return {
|
|
|
|
|
+ "instId": symbol,
|
|
|
|
|
+ "tdMode": "isolated",
|
|
|
|
|
+ "side": "buy" if action == "long" else "sell",
|
|
|
|
|
+ "posSide": action,
|
|
|
|
|
+ "ordType": "post_only",
|
|
|
|
|
+ "px": format_number(price),
|
|
|
|
|
+ "sz": format_number(size),
|
|
|
|
|
+ "clOrdId": client_order_id,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_entry_batch_order_body(
|
|
|
|
|
+ *,
|
|
|
|
|
+ symbol: str,
|
|
|
|
|
+ action: str,
|
|
|
|
|
+ reference_price: object,
|
|
|
|
|
+ margin_usdt: object,
|
|
|
|
|
+ leverage: object,
|
|
|
|
|
+ metadata: InstrumentMeta,
|
|
|
|
|
+ client_order_id_prefix: str,
|
|
|
|
|
+) -> list[dict[str, str]]:
|
|
|
|
|
+ reference_price_decimal = _decimal(reference_price)
|
|
|
|
|
+ notional_per_order = _decimal(margin_usdt) * _decimal(leverage) / Decimal("3")
|
|
|
|
|
+ bodies = []
|
|
|
|
|
+ for index, offset in enumerate((Decimal("0.003"), Decimal("0.006"), Decimal("0.009")), start=1):
|
|
|
|
|
+ multiplier = Decimal("1") - offset if action == "long" else Decimal("1") + offset
|
|
|
|
|
+ price = reference_price_decimal * multiplier
|
|
|
|
|
+ size = build_contract_size(notional_per_order, price, metadata)
|
|
|
|
|
+ bodies.append(
|
|
|
|
|
+ build_post_only_limit_order_body(
|
|
|
|
|
+ symbol=symbol,
|
|
|
|
|
+ action=action,
|
|
|
|
|
+ price=price,
|
|
|
|
|
+ size=size,
|
|
|
|
|
+ client_order_id=f"{client_order_id_prefix}-{index}",
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ return bodies
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_cancel_order_body(*, symbol: str, order_id: str | None = None, client_order_id: str | None = None) -> dict[str, str]:
|
|
|
|
|
+ if bool(order_id) == bool(client_order_id):
|
|
|
|
|
+ raise ValueError("exactly one order identifier is required")
|
|
|
|
|
+ body = {"instId": symbol}
|
|
|
|
|
+ if order_id:
|
|
|
|
|
+ body["ordId"] = order_id
|
|
|
|
|
+ if client_order_id:
|
|
|
|
|
+ body["clOrdId"] = client_order_id
|
|
|
|
|
+ return body
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_pending_orders_params(*, symbol: str) -> dict[str, str]:
|
|
|
|
|
+ return {"instType": "SWAP", "instId": symbol}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_fills_params(*, symbol: str) -> dict[str, str]:
|
|
|
|
|
+ return {"instType": "SWAP", "instId": symbol}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def compact_json_body(body: dict[str, object] | list[dict[str, object]] | None) -> str:
|
|
|
|
|
+ if body is None:
|
|
|
|
|
+ return ""
|
|
|
|
|
+ return json.dumps(body, separators=(",", ":"))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_signable_request(
|
|
|
|
|
+ *,
|
|
|
|
|
+ timestamp: str,
|
|
|
|
|
+ method: str,
|
|
|
|
|
+ path: str,
|
|
|
|
|
+ api_secret: str,
|
|
|
|
|
+ params: dict[str, object] | None = None,
|
|
|
|
|
+ body: dict[str, object] | list[dict[str, object]] | None = None,
|
|
|
|
|
+) -> SignableRequest:
|
|
|
|
|
+ method_upper = method.upper()
|
|
|
|
|
+ query = urlencode(params or {})
|
|
|
|
|
+ path_with_query = path if not query else f"{path}?{query}"
|
|
|
|
|
+ serialized_body = compact_json_body(body)
|
|
|
|
|
+ pre_hash = f"{timestamp}{method_upper}{path_with_query}{serialized_body}"
|
|
|
|
|
+ signature = base64.b64encode(hmac.new(api_secret.encode(), pre_hash.encode(), hashlib.sha256).digest()).decode()
|
|
|
|
|
+ return SignableRequest(
|
|
|
|
|
+ method=method_upper,
|
|
|
|
|
+ path=path,
|
|
|
|
|
+ params=params,
|
|
|
|
|
+ body=serialized_body,
|
|
|
|
|
+ path_with_query=path_with_query,
|
|
|
|
|
+ pre_hash=pre_hash,
|
|
|
|
|
+ signature=signature,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_prototype_report() -> dict[str, object]:
|
|
|
|
|
+ metadata = InstrumentMeta(ct_val="0.01", lot_sz="0.1", min_sz="0.1")
|
|
|
|
|
+ batch_body = build_entry_batch_order_body(
|
|
|
|
|
+ symbol="ETH-USDT-SWAP",
|
|
|
|
|
+ action="long",
|
|
|
|
|
+ reference_price="3000",
|
|
|
|
|
+ margin_usdt="90",
|
|
|
|
|
+ leverage="2",
|
|
|
|
|
+ metadata=metadata,
|
|
|
|
|
+ client_order_id_prefix="eth-twap-20260430T000000Z",
|
|
|
|
|
+ )
|
|
|
|
|
+ return {
|
|
|
|
|
+ "scope": "readonly OKX order support prototype",
|
|
|
|
|
+ "network_requests_made": 0,
|
|
|
|
|
+ "paths": {
|
|
|
|
|
+ "single_order": ORDER_PATH,
|
|
|
|
|
+ "batch_orders": BATCH_ORDERS_PATH,
|
|
|
|
|
+ "cancel_order": CANCEL_ORDER_PATH,
|
|
|
|
|
+ "orders_pending": ORDERS_PENDING_PATH,
|
|
|
|
|
+ "fills": FILLS_PATH,
|
|
|
|
|
+ },
|
|
|
|
|
+ "sample_post_only_limit_body": batch_body[0],
|
|
|
|
|
+ "sample_batch_order_body": batch_body,
|
|
|
|
|
+ "sample_cancel_body": build_cancel_order_body(symbol="ETH-USDT-SWAP", client_order_id=batch_body[0]["clOrdId"]),
|
|
|
|
|
+ "sample_pending_params": build_pending_orders_params(symbol="ETH-USDT-SWAP"),
|
|
|
|
|
+ "sample_fills_params": build_fills_params(symbol="ETH-USDT-SWAP"),
|
|
|
|
|
+ "state_json_schema": STATE_JSON_SCHEMA,
|
|
|
|
|
+ "formal_okx_client_readiness": "ready after prototype tests pass",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ print(json.dumps(build_prototype_report(), indent=2, sort_keys=True))
|