Files
coinhunter-cli/src/coinhunter/services/trade_service.py
Tacit Lab cf26a3dd3a feat: split audit logs into live/dryrun subdirs, add catlog --dry-run, list all kline intervals
- 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>
2026-04-20 10:27:22 +08:00

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}