refactor: rewrite to CoinHunter V2 flat architecture

Replace the V1 commands/services split with a flat, direct architecture:
- cli.py dispatches directly to service functions
- New services: account, market, trade, opportunity
- Thin Binance wrappers: spot_client, um_futures_client
- Add audit logging, runtime paths, and TOML config
- Remove legacy V1 code: commands/, precheck, review engine, smart executor
- Add ruff + mypy toolchain and fix edge cases in trade params

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 17:22:29 +08:00
parent 3819e35a7b
commit 52cd76a750
78 changed files with 2023 additions and 5407 deletions

View File

@@ -0,0 +1,263 @@
"""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"))
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})
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)))
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})
return {"trade": result}
def execute_futures_trade(
config: dict[str, Any],
*,
side: str,
symbol: str,
qty: float,
order_type: str,
price: float | None,
reduce_only: bool,
dry_run: bool | None,
futures_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 qty <= 0:
raise RuntimeError("Futures orders require a positive --qty")
if order_type == "LIMIT" and price is None:
raise RuntimeError("Futures limit orders require --price")
payload: dict[str, Any] = {
"symbol": normalized_symbol,
"side": side,
"type": order_type,
"quantity": qty,
"reduceOnly": "true" if reduce_only else "false",
}
if price is not None:
payload["price"] = price
payload["timeInForce"] = "GTC"
intent = TradeIntent(
market_type="futures",
symbol=normalized_symbol,
side=side,
order_type=order_type,
qty=qty,
quote_amount=None,
price=price,
reduce_only=reduce_only,
dry_run=is_dry_run,
)
audit_event("trade_submitted", _trade_log_payload(intent, payload, status="submitted"))
if is_dry_run:
response = {"dry_run": True, "status": "DRY_RUN", "request": payload}
result = asdict(
TradeResult(
market_type="futures",
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})
return {"trade": result}
try:
response = futures_client.new_order(**payload)
except Exception as exc:
audit_event("trade_failed", _trade_log_payload(intent, payload, status="failed", error=str(exc)))
raise RuntimeError(f"Futures order failed: {exc}") from exc
result = asdict(
TradeResult(
market_type="futures",
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})
return {"trade": result}
def close_futures_position(
config: dict[str, Any],
*,
symbol: str,
dry_run: bool | None,
futures_client: Any,
) -> dict[str, Any]:
normalized_symbol = normalize_symbol(symbol)
positions = futures_client.position_risk(normalized_symbol)
target = next((item for item in positions if normalize_symbol(item["symbol"]) == normalized_symbol), None)
if target is None:
raise RuntimeError(f"No futures position found for {normalized_symbol}")
position_amt = float(target.get("positionAmt", 0.0))
if position_amt == 0:
raise RuntimeError(f"No open futures position for {normalized_symbol}")
side = "SELL" if position_amt > 0 else "BUY"
return execute_futures_trade(
config,
side=side,
symbol=normalized_symbol,
qty=abs(position_amt),
order_type="MARKET",
price=None,
reduce_only=True,
dry_run=dry_run,
futures_client=futures_client,
)