- Write live trades to logs/live/ and dry-run trades to logs/dryrun/ - Add -d/--dry-run flag to catlog to read dry-run audit logs - List all 16 Binance kline interval options in --help and docs Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
158 lines
4.6 KiB
Python
158 lines
4.6 KiB
Python
"""Trade execution services."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass
|
|
from typing import Any
|
|
|
|
from ..audit import audit_event
|
|
from .market_service import normalize_symbol
|
|
|
|
|
|
@dataclass
|
|
class TradeIntent:
|
|
market_type: str
|
|
symbol: str
|
|
side: str
|
|
order_type: str
|
|
qty: float | None
|
|
quote_amount: float | None
|
|
price: float | None
|
|
reduce_only: bool
|
|
dry_run: bool
|
|
|
|
|
|
@dataclass
|
|
class TradeResult:
|
|
market_type: str
|
|
symbol: str
|
|
side: str
|
|
order_type: str
|
|
status: str
|
|
dry_run: bool
|
|
request_payload: dict[str, Any]
|
|
response_payload: dict[str, Any]
|
|
|
|
|
|
def _default_dry_run(config: dict[str, Any], dry_run: bool | None) -> bool:
|
|
if dry_run is not None:
|
|
return dry_run
|
|
return bool(config.get("trading", {}).get("dry_run_default", False))
|
|
|
|
|
|
def _trade_log_payload(
|
|
intent: TradeIntent, payload: dict[str, Any], *, status: str, error: str | None = None
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"market_type": intent.market_type,
|
|
"symbol": intent.symbol,
|
|
"side": intent.side,
|
|
"qty": intent.qty,
|
|
"quote_amount": intent.quote_amount,
|
|
"order_type": intent.order_type,
|
|
"dry_run": intent.dry_run,
|
|
"request_payload": payload,
|
|
"response_payload": {} if error else payload,
|
|
"status": status,
|
|
"error": error,
|
|
}
|
|
|
|
|
|
def execute_spot_trade(
|
|
config: dict[str, Any],
|
|
*,
|
|
side: str,
|
|
symbol: str,
|
|
qty: float | None,
|
|
quote: float | None,
|
|
order_type: str,
|
|
price: float | None,
|
|
dry_run: bool | None,
|
|
spot_client: Any,
|
|
) -> dict[str, Any]:
|
|
normalized_symbol = normalize_symbol(symbol)
|
|
order_type = order_type.upper()
|
|
side = side.upper()
|
|
is_dry_run = _default_dry_run(config, dry_run)
|
|
if side == "BUY" and order_type == "MARKET":
|
|
if quote is None:
|
|
raise RuntimeError("Spot market buy requires --quote")
|
|
if qty is not None:
|
|
raise RuntimeError("Spot market buy accepts --quote only; do not pass --qty")
|
|
if side == "SELL":
|
|
if qty is None:
|
|
raise RuntimeError("Spot sell requires --qty")
|
|
if quote is not None:
|
|
raise RuntimeError("Spot sell accepts --qty only; do not pass --quote")
|
|
if order_type == "LIMIT" and (qty is None or price is None):
|
|
raise RuntimeError("Limit orders require both --qty and --price")
|
|
|
|
payload: dict[str, Any] = {
|
|
"symbol": normalized_symbol,
|
|
"side": side,
|
|
"type": order_type,
|
|
}
|
|
if qty is not None:
|
|
payload["quantity"] = qty
|
|
if quote is not None:
|
|
payload["quoteOrderQty"] = quote
|
|
if price is not None:
|
|
payload["price"] = price
|
|
payload["timeInForce"] = "GTC"
|
|
|
|
intent = TradeIntent(
|
|
market_type="spot",
|
|
symbol=normalized_symbol,
|
|
side=side,
|
|
order_type=order_type,
|
|
qty=qty,
|
|
quote_amount=quote,
|
|
price=price,
|
|
reduce_only=False,
|
|
dry_run=is_dry_run,
|
|
)
|
|
|
|
audit_event("trade_submitted", _trade_log_payload(intent, payload, status="submitted"), dry_run=intent.dry_run)
|
|
if is_dry_run:
|
|
response = {"dry_run": True, "status": "DRY_RUN", "request": payload}
|
|
result = asdict(
|
|
TradeResult(
|
|
market_type="spot",
|
|
symbol=normalized_symbol,
|
|
side=side,
|
|
order_type=order_type,
|
|
status="DRY_RUN",
|
|
dry_run=True,
|
|
request_payload=payload,
|
|
response_payload=response,
|
|
)
|
|
)
|
|
audit_event(
|
|
"trade_filled", {**_trade_log_payload(intent, payload, status="DRY_RUN"), "response_payload": response}, dry_run=intent.dry_run
|
|
)
|
|
return {"trade": result}
|
|
|
|
try:
|
|
response = spot_client.new_order(**payload)
|
|
except Exception as exc:
|
|
audit_event("trade_failed", _trade_log_payload(intent, payload, status="failed", error=str(exc)), dry_run=intent.dry_run)
|
|
raise RuntimeError(f"Spot order failed: {exc}") from exc
|
|
|
|
result = asdict(
|
|
TradeResult(
|
|
market_type="spot",
|
|
symbol=normalized_symbol,
|
|
side=side,
|
|
order_type=order_type,
|
|
status=str(response.get("status", "UNKNOWN")),
|
|
dry_run=False,
|
|
request_payload=payload,
|
|
response_payload=response,
|
|
)
|
|
)
|
|
audit_event(
|
|
"trade_filled", {**_trade_log_payload(intent, payload, status=result["status"]), "response_payload": response},
|
|
dry_run=intent.dry_run,
|
|
)
|
|
return {"trade": result}
|