|
|
@@ -0,0 +1,146 @@
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import json
|
|
|
+from datetime import datetime, timezone, timedelta
|
|
|
+from decimal import Decimal, ROUND_DOWN
|
|
|
+from uuid import uuid4
|
|
|
+
|
|
|
+from fastapi import HTTPException
|
|
|
+
|
|
|
+from .bitstamp import BitstampClient, BitstampError
|
|
|
+from .bitstamp_metadata import load_market_by_symbol
|
|
|
+from .storage import get_connection
|
|
|
+
|
|
|
+
|
|
|
+def _utc_now() -> str:
|
|
|
+ return datetime.now(timezone.utc).isoformat()
|
|
|
+
|
|
|
+
|
|
|
+def _get_client(account_id: str) -> BitstampClient:
|
|
|
+ from .repo import get_account, get_account_secrets
|
|
|
+ account = get_account(account_id)
|
|
|
+ secrets = get_account_secrets(account_id)
|
|
|
+ return BitstampClient(
|
|
|
+ username=account["venue_account_ref"],
|
|
|
+ api_key=secrets["api_key"],
|
|
|
+ api_secret=secrets["api_secret"],
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _format_decimal(value, decimals: int) -> str:
|
|
|
+ quant = Decimal("1").scaleb(-decimals)
|
|
|
+ return str(Decimal(str(value)).quantize(quant, rounding=ROUND_DOWN))
|
|
|
+
|
|
|
+
|
|
|
+def _validate_order_shape(market: str, side: str, order_type: str, amount, price) -> tuple[str, str | None, dict]:
|
|
|
+ meta = load_market_by_symbol(market)
|
|
|
+ if meta is None:
|
|
|
+ raise HTTPException(status_code=400, detail=f"unknown market {market}")
|
|
|
+
|
|
|
+ base_decimals = int(meta.get("base_decimals", 8))
|
|
|
+ counter_decimals = int(meta.get("counter_decimals", 2))
|
|
|
+ minimum_order_value = Decimal(str(meta.get("minimum_order_value", "0")))
|
|
|
+
|
|
|
+ amount_dec = Decimal(str(amount))
|
|
|
+ amount_fmt = _format_decimal(amount, base_decimals)
|
|
|
+
|
|
|
+ if side == "buy" and order_type == "market":
|
|
|
+ if Decimal(amount_fmt) < minimum_order_value:
|
|
|
+ raise HTTPException(status_code=400, detail=f"Minimum order size is {minimum_order_value} {meta.get('counter_currency', 'USD')}.")
|
|
|
+
|
|
|
+ if price is not None:
|
|
|
+ price_fmt = _format_decimal(price, counter_decimals)
|
|
|
+ if Decimal(price_fmt) <= 0:
|
|
|
+ raise HTTPException(status_code=400, detail="price must be positive")
|
|
|
+ else:
|
|
|
+ price_fmt = None
|
|
|
+
|
|
|
+ if amount_dec <= 0:
|
|
|
+ raise HTTPException(status_code=400, detail="amount must be positive")
|
|
|
+
|
|
|
+ return amount_fmt, price_fmt, meta
|
|
|
+
|
|
|
+
|
|
|
+def place_order(*, account_id: str, market: str, side: str, order_type: str, amount, price=None, expire_time: int | None = None, client_order_id: str | None = None) -> dict:
|
|
|
+ client = _get_client(account_id)
|
|
|
+ side = side.lower()
|
|
|
+ order_type = order_type.lower()
|
|
|
+ market = market.lower()
|
|
|
+ if len(market) < 6:
|
|
|
+ raise HTTPException(status_code=400, detail="market must look like xrpusd")
|
|
|
+ base = market[:-3]
|
|
|
+ quote = market[-3:]
|
|
|
+
|
|
|
+ expire_timestamp = None
|
|
|
+ if expire_time is not None:
|
|
|
+ expire_timestamp = int((datetime.now(timezone.utc) + timedelta(seconds=expire_time)).timestamp() * 1000)
|
|
|
+
|
|
|
+ amount, price, _meta = _validate_order_shape(market, side, order_type, amount, price)
|
|
|
+
|
|
|
+ try:
|
|
|
+ if order_type not in {"market", "limit"}:
|
|
|
+ raise HTTPException(status_code=400, detail="invalid order_type")
|
|
|
+ if side == "buy":
|
|
|
+ result = client.trading.buy_gtd_order(amount=amount, price=price or "0", base=base, quote=quote, expire_time=expire_timestamp)
|
|
|
+ elif side == "sell":
|
|
|
+ result = client.trading.sell_gtd_order(amount=amount, price=price or "0", base=base, quote=quote, expire_time=expire_timestamp)
|
|
|
+ else:
|
|
|
+ raise HTTPException(status_code=400, detail="invalid side")
|
|
|
+ except BitstampError as exc:
|
|
|
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
+
|
|
|
+ bitstamp_order_id = str(result.get("id") or result.get("order_id") or "")
|
|
|
+ if not bitstamp_order_id:
|
|
|
+ return {"ok": False, "error": "missing Bitstamp order id", "details": {"raw": result}}
|
|
|
+
|
|
|
+ record_id = str(uuid4())
|
|
|
+ now = _utc_now()
|
|
|
+ with get_connection() as conn:
|
|
|
+ conn.execute(
|
|
|
+ """
|
|
|
+ INSERT INTO order_records
|
|
|
+ (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_order_id, raw_json, created_at, updated_at)
|
|
|
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
+ """,
|
|
|
+ (record_id, account_id, market, side, order_type, amount, price, expire_time, str(result.get("status", "open")), bitstamp_order_id, client_order_id or result.get("client_order_id"), json.dumps(result), now, now),
|
|
|
+ )
|
|
|
+ conn.commit()
|
|
|
+
|
|
|
+ return {"ok": True, "bitstamp_order_id": bitstamp_order_id, "record_id": record_id, "status": str(result.get("status", "open")), "raw": result}
|
|
|
+
|
|
|
+
|
|
|
+def query_order(*, account_id: str, order_id, client_order_id: str | None = None, omit_transactions: bool | None = None) -> dict:
|
|
|
+ order_id = str(order_id)
|
|
|
+ client = _get_client(account_id)
|
|
|
+ try:
|
|
|
+ result = client.trading.order_status_v2(order_id=order_id, client_order_id=client_order_id, omit_transactions=omit_transactions)
|
|
|
+ except BitstampError as exc:
|
|
|
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
+
|
|
|
+ with get_connection() as conn:
|
|
|
+ conn.execute(
|
|
|
+ "UPDATE order_records SET status = ?, raw_json = ?, updated_at = ? WHERE bitstamp_order_id = ?",
|
|
|
+ (str(result.get("status", "unknown")), json.dumps(result), _utc_now(), order_id),
|
|
|
+ )
|
|
|
+ conn.commit()
|
|
|
+
|
|
|
+ return {"ok": True, "order_id": order_id, "raw": result}
|
|
|
+
|
|
|
+
|
|
|
+def cancel_order(*, account_id: str, order_id) -> dict:
|
|
|
+ order_id = str(order_id)
|
|
|
+ client = _get_client(account_id)
|
|
|
+ try:
|
|
|
+ result = client.trading.cancel_order(order_id=order_id, version=2)
|
|
|
+ except BitstampError as exc:
|
|
|
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
+
|
|
|
+ status = "cancelled" if result else "cancel_failed"
|
|
|
+ with get_connection() as conn:
|
|
|
+ conn.execute(
|
|
|
+ "UPDATE order_records SET status = ?, updated_at = ? WHERE bitstamp_order_id = ?",
|
|
|
+ (status, _utc_now(), order_id),
|
|
|
+ )
|
|
|
+ conn.commit()
|
|
|
+
|
|
|
+ return {"ok": bool(result), "order_id": order_id, "raw": result}
|