refactor(smart_executor): split monolithic executor into clean service modules
- Extract 7 focused services from smart_executor.py: - trade_common: constants, timezone, logging, dry-run state - file_utils: file locking + atomic JSON helpers - smart_executor_parser: argparse + legacy argument compatibility - execution_state: decision deduplication (executions.json) - portfolio_service: positions.json + exchange reconciliation - exchange_service: ccxt wrapper, balances, order prep - trade_execution: buy/sell/rebalance/hold actions - Turn smart_executor.py into a thin backward-compatible facade - Fix critical dry-run bug: module-level DRY_RUN copy caused real orders in dry-run mode; replace with mutable dict + is_dry_run() function - Fix dry-run polluting positions.json: skip save_positions() when dry-run - Fix rebalance dry-run budget: use sell_order cost instead of real balance - Add full legacy CLI compatibility for old --decision HOLD --dry-run style
This commit is contained in:
125
src/coinhunter/services/exchange_service.py
Normal file
125
src/coinhunter/services/exchange_service.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Exchange helpers (ccxt, markets, balances, order prep)."""
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
|
||||||
|
import ccxt
|
||||||
|
|
||||||
|
from ..runtime import get_runtime_paths, load_env_file
|
||||||
|
from .trade_common import log
|
||||||
|
|
||||||
|
PATHS = get_runtime_paths()
|
||||||
|
|
||||||
|
|
||||||
|
def load_env():
|
||||||
|
load_env_file(PATHS)
|
||||||
|
|
||||||
|
|
||||||
|
def get_exchange():
|
||||||
|
load_env()
|
||||||
|
api_key = os.getenv("BINANCE_API_KEY")
|
||||||
|
secret = os.getenv("BINANCE_API_SECRET")
|
||||||
|
if not api_key or not secret:
|
||||||
|
raise RuntimeError("缺少 BINANCE_API_KEY 或 BINANCE_API_SECRET")
|
||||||
|
ex = ccxt.binance(
|
||||||
|
{
|
||||||
|
"apiKey": api_key,
|
||||||
|
"secret": secret,
|
||||||
|
"options": {"defaultType": "spot", "createMarketBuyOrderRequiresPrice": False},
|
||||||
|
"enableRateLimit": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ex.load_markets()
|
||||||
|
return ex
|
||||||
|
|
||||||
|
|
||||||
|
def norm_symbol(symbol: str) -> str:
|
||||||
|
s = symbol.upper().replace("-", "").replace("_", "")
|
||||||
|
if "/" in s:
|
||||||
|
return s
|
||||||
|
if s.endswith("USDT"):
|
||||||
|
return s[:-4] + "/USDT"
|
||||||
|
raise ValueError(f"不支持的 symbol: {symbol}")
|
||||||
|
|
||||||
|
|
||||||
|
def storage_symbol(symbol: str) -> str:
|
||||||
|
return norm_symbol(symbol).replace("/", "")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_balances(ex):
|
||||||
|
bal = ex.fetch_balance()["free"]
|
||||||
|
return {k: float(v) for k, v in bal.items() if float(v) > 0}
|
||||||
|
|
||||||
|
|
||||||
|
def build_market_snapshot(ex):
|
||||||
|
try:
|
||||||
|
tickers = ex.fetch_tickers()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
snapshot = {}
|
||||||
|
for sym, t in tickers.items():
|
||||||
|
if not sym.endswith("/USDT"):
|
||||||
|
continue
|
||||||
|
price = t.get("last")
|
||||||
|
if price is None or float(price) <= 0:
|
||||||
|
continue
|
||||||
|
vol = float(t.get("quoteVolume") or 0)
|
||||||
|
if vol < 200_000:
|
||||||
|
continue
|
||||||
|
base = sym.replace("/", "")
|
||||||
|
snapshot[base] = {
|
||||||
|
"lastPrice": round(float(price), 8),
|
||||||
|
"price24hPcnt": round(float(t.get("percentage") or 0), 4),
|
||||||
|
"highPrice24h": round(float(t.get("high") or 0), 8) if t.get("high") else None,
|
||||||
|
"lowPrice24h": round(float(t.get("low") or 0), 8) if t.get("low") else None,
|
||||||
|
"turnover24h": round(float(vol), 2),
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def market_and_ticker(ex, symbol: str):
|
||||||
|
sym = norm_symbol(symbol)
|
||||||
|
market = ex.market(sym)
|
||||||
|
ticker = ex.fetch_ticker(sym)
|
||||||
|
return sym, market, ticker
|
||||||
|
|
||||||
|
|
||||||
|
def floor_to_step(value: float, step: float) -> float:
|
||||||
|
if not step or step <= 0:
|
||||||
|
return value
|
||||||
|
return math.floor(value / step) * step
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_buy_quantity(ex, symbol: str, amount_usdt: float):
|
||||||
|
from .trade_common import USDT_BUFFER_PCT
|
||||||
|
|
||||||
|
sym, market, ticker = market_and_ticker(ex, symbol)
|
||||||
|
ask = float(ticker.get("ask") or ticker.get("last") or 0)
|
||||||
|
if ask <= 0:
|
||||||
|
raise RuntimeError(f"{sym} 无法获取有效 ask 价格")
|
||||||
|
budget = amount_usdt * (1 - USDT_BUFFER_PCT)
|
||||||
|
raw_qty = budget / ask
|
||||||
|
qty = float(ex.amount_to_precision(sym, raw_qty))
|
||||||
|
min_amt = (market.get("limits", {}).get("amount", {}) or {}).get("min") or 0
|
||||||
|
min_cost = (market.get("limits", {}).get("cost", {}) or {}).get("min") or 0
|
||||||
|
if min_amt and qty < float(min_amt):
|
||||||
|
raise RuntimeError(f"{sym} 买入数量 {qty} 小于最小数量 {min_amt}")
|
||||||
|
est_cost = qty * ask
|
||||||
|
if min_cost and est_cost < float(min_cost):
|
||||||
|
raise RuntimeError(f"{sym} 买入金额 ${est_cost:.4f} 小于最小成交额 ${float(min_cost):.4f}")
|
||||||
|
return sym, qty, ask, est_cost
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_sell_quantity(ex, symbol: str, free_qty: float):
|
||||||
|
sym, market, ticker = market_and_ticker(ex, symbol)
|
||||||
|
bid = float(ticker.get("bid") or ticker.get("last") or 0)
|
||||||
|
if bid <= 0:
|
||||||
|
raise RuntimeError(f"{sym} 无法获取有效 bid 价格")
|
||||||
|
qty = float(ex.amount_to_precision(sym, free_qty))
|
||||||
|
min_amt = (market.get("limits", {}).get("amount", {}) or {}).get("min") or 0
|
||||||
|
min_cost = (market.get("limits", {}).get("cost", {}) or {}).get("min") or 0
|
||||||
|
if min_amt and qty < float(min_amt):
|
||||||
|
raise RuntimeError(f"{sym} 卖出数量 {qty} 小于最小数量 {min_amt}")
|
||||||
|
est_cost = qty * bid
|
||||||
|
if min_cost and est_cost < float(min_cost):
|
||||||
|
raise RuntimeError(f"{sym} 卖出金额 ${est_cost:.4f} 小于最小成交额 ${float(min_cost):.4f}")
|
||||||
|
return sym, qty, bid, est_cost
|
||||||
39
src/coinhunter/services/execution_state.py
Normal file
39
src/coinhunter/services/execution_state.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Execution state helpers (decision deduplication, executions.json)."""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from ..runtime import get_runtime_paths
|
||||||
|
from .file_utils import load_json_locked, save_json_locked
|
||||||
|
from .trade_common import bj_now_iso
|
||||||
|
|
||||||
|
PATHS = get_runtime_paths()
|
||||||
|
EXECUTIONS_FILE = PATHS.executions_file
|
||||||
|
EXECUTIONS_LOCK = PATHS.executions_lock
|
||||||
|
|
||||||
|
|
||||||
|
def default_decision_id(action: str, argv_tail: list[str]) -> str:
|
||||||
|
from datetime import datetime
|
||||||
|
from .trade_common import CST
|
||||||
|
|
||||||
|
now = datetime.now(CST)
|
||||||
|
bucket_min = (now.minute // 15) * 15
|
||||||
|
bucket = now.strftime(f"%Y%m%dT%H{bucket_min:02d}")
|
||||||
|
raw = f"{bucket}|{action}|{'|'.join(argv_tail)}"
|
||||||
|
return hashlib.sha1(raw.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def load_executions() -> dict:
|
||||||
|
return load_json_locked(EXECUTIONS_FILE, EXECUTIONS_LOCK, {"executions": {}}).get("executions", {})
|
||||||
|
|
||||||
|
|
||||||
|
def save_executions(executions: dict):
|
||||||
|
save_json_locked(EXECUTIONS_FILE, EXECUTIONS_LOCK, {"executions": executions})
|
||||||
|
|
||||||
|
|
||||||
|
def record_execution_state(decision_id: str, payload: dict):
|
||||||
|
executions = load_executions()
|
||||||
|
executions[decision_id] = payload
|
||||||
|
save_executions(executions)
|
||||||
|
|
||||||
|
|
||||||
|
def get_execution_state(decision_id: str):
|
||||||
|
return load_executions().get(decision_id)
|
||||||
40
src/coinhunter/services/file_utils.py
Normal file
40
src/coinhunter/services/file_utils.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""File locking and atomic JSON helpers."""
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def locked_file(path: Path):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(path, "a+", encoding="utf-8") as f:
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||||
|
f.seek(0)
|
||||||
|
yield f
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_json(path: Path, data: dict):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def load_json_locked(path: Path, lock_path: Path, default):
|
||||||
|
with locked_file(lock_path):
|
||||||
|
if not path.exists():
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def save_json_locked(path: Path, lock_path: Path, data: dict):
|
||||||
|
with locked_file(lock_path):
|
||||||
|
atomic_write_json(path, data)
|
||||||
57
src/coinhunter/services/portfolio_service.py
Normal file
57
src/coinhunter/services/portfolio_service.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Portfolio state helpers (positions.json, reconcile with exchange)."""
|
||||||
|
from ..runtime import get_runtime_paths
|
||||||
|
from .file_utils import load_json_locked, save_json_locked
|
||||||
|
from .trade_common import bj_now_iso
|
||||||
|
|
||||||
|
PATHS = get_runtime_paths()
|
||||||
|
POSITIONS_FILE = PATHS.positions_file
|
||||||
|
POSITIONS_LOCK = PATHS.positions_lock
|
||||||
|
|
||||||
|
|
||||||
|
def load_positions() -> list:
|
||||||
|
return load_json_locked(POSITIONS_FILE, POSITIONS_LOCK, {"positions": []}).get("positions", [])
|
||||||
|
|
||||||
|
|
||||||
|
def save_positions(positions: list):
|
||||||
|
save_json_locked(POSITIONS_FILE, POSITIONS_LOCK, {"positions": positions})
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_position(positions: list, position: dict):
|
||||||
|
sym = position["symbol"]
|
||||||
|
for i, existing in enumerate(positions):
|
||||||
|
if existing.get("symbol") == sym:
|
||||||
|
positions[i] = position
|
||||||
|
return positions
|
||||||
|
positions.append(position)
|
||||||
|
return positions
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_positions_with_exchange(ex, positions: list):
|
||||||
|
from .exchange_service import fetch_balances
|
||||||
|
|
||||||
|
balances = fetch_balances(ex)
|
||||||
|
existing_by_symbol = {p.get("symbol"): p for p in positions}
|
||||||
|
reconciled = []
|
||||||
|
for asset, qty in balances.items():
|
||||||
|
if asset == "USDT":
|
||||||
|
continue
|
||||||
|
if qty <= 0:
|
||||||
|
continue
|
||||||
|
sym = f"{asset}USDT"
|
||||||
|
old = existing_by_symbol.get(sym, {})
|
||||||
|
reconciled.append(
|
||||||
|
{
|
||||||
|
"account_id": old.get("account_id", "binance-main"),
|
||||||
|
"symbol": sym,
|
||||||
|
"base_asset": asset,
|
||||||
|
"quote_asset": "USDT",
|
||||||
|
"market_type": "spot",
|
||||||
|
"quantity": qty,
|
||||||
|
"avg_cost": old.get("avg_cost"),
|
||||||
|
"opened_at": old.get("opened_at", bj_now_iso()),
|
||||||
|
"updated_at": bj_now_iso(),
|
||||||
|
"note": old.get("note", "Reconciled from Binance balances"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
save_positions(reconciled)
|
||||||
|
return reconciled, balances
|
||||||
145
src/coinhunter/services/smart_executor_parser.py
Normal file
145
src/coinhunter/services/smart_executor_parser.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""CLI parser and legacy argument normalization for smart executor."""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Coin Hunter Smart Executor",
|
||||||
|
formatter_class=argparse.RawTextHelpFormatter,
|
||||||
|
epilog=(
|
||||||
|
"示例:\n"
|
||||||
|
" python smart_executor.py hold\n"
|
||||||
|
" python smart_executor.py sell-all ETHUSDT\n"
|
||||||
|
" python smart_executor.py buy ENJUSDT 100\n"
|
||||||
|
" python smart_executor.py rebalance PEPEUSDT ETHUSDT\n"
|
||||||
|
" python smart_executor.py balances\n\n"
|
||||||
|
"兼容旧调用:\n"
|
||||||
|
" python smart_executor.py HOLD\n"
|
||||||
|
" python smart_executor.py --decision HOLD --dry-run\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("--decision-id", help="Override decision id (otherwise derived automatically)")
|
||||||
|
parser.add_argument("--analysis", help="Decision analysis text to persist into logs")
|
||||||
|
parser.add_argument("--reasoning", help="Decision reasoning text to persist into logs")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Force dry-run mode for this invocation")
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
subparsers.add_parser("hold", help="Log a HOLD decision without trading")
|
||||||
|
subparsers.add_parser("balances", help="Print live balances as JSON")
|
||||||
|
subparsers.add_parser("balance", help="Alias of balances")
|
||||||
|
subparsers.add_parser("status", help="Print balances + positions + snapshot as JSON")
|
||||||
|
|
||||||
|
sell_all = subparsers.add_parser("sell-all", help="Sell all of one symbol")
|
||||||
|
sell_all.add_argument("symbol")
|
||||||
|
sell_all_legacy = subparsers.add_parser("sell_all", help=argparse.SUPPRESS)
|
||||||
|
sell_all_legacy.add_argument("symbol")
|
||||||
|
|
||||||
|
buy = subparsers.add_parser("buy", help="Buy symbol with USDT amount")
|
||||||
|
buy.add_argument("symbol")
|
||||||
|
buy.add_argument("amount_usdt", type=float)
|
||||||
|
|
||||||
|
rebalance = subparsers.add_parser("rebalance", help="Sell one symbol and rotate to another")
|
||||||
|
rebalance.add_argument("from_symbol")
|
||||||
|
rebalance.add_argument("to_symbol")
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_legacy_argv(argv: list[str]) -> list[str]:
|
||||||
|
if not argv:
|
||||||
|
return argv
|
||||||
|
|
||||||
|
action_aliases = {
|
||||||
|
"HOLD": ["hold"],
|
||||||
|
"hold": ["hold"],
|
||||||
|
"SELL_ALL": ["sell-all"],
|
||||||
|
"sell_all": ["sell-all"],
|
||||||
|
"sell-all": ["sell-all"],
|
||||||
|
"BUY": ["buy"],
|
||||||
|
"buy": ["buy"],
|
||||||
|
"REBALANCE": ["rebalance"],
|
||||||
|
"rebalance": ["rebalance"],
|
||||||
|
"BALANCE": ["balances"],
|
||||||
|
"balance": ["balances"],
|
||||||
|
"BALANCES": ["balances"],
|
||||||
|
"balances": ["balances"],
|
||||||
|
"STATUS": ["status"],
|
||||||
|
"status": ["status"],
|
||||||
|
}
|
||||||
|
|
||||||
|
has_legacy_flag = any(t.startswith("--decision") for t in argv)
|
||||||
|
if not has_legacy_flag:
|
||||||
|
for idx, token in enumerate(argv):
|
||||||
|
if token in action_aliases:
|
||||||
|
prefix = argv[:idx]
|
||||||
|
suffix = argv[idx + 1 :]
|
||||||
|
return prefix + action_aliases[token] + suffix
|
||||||
|
|
||||||
|
if argv[0].startswith("-"):
|
||||||
|
legacy = argparse.ArgumentParser(add_help=False)
|
||||||
|
legacy.add_argument("--decision")
|
||||||
|
legacy.add_argument("--symbol")
|
||||||
|
legacy.add_argument("--from-symbol")
|
||||||
|
legacy.add_argument("--to-symbol")
|
||||||
|
legacy.add_argument("--amount-usdt", type=float)
|
||||||
|
legacy.add_argument("--decision-id")
|
||||||
|
legacy.add_argument("--analysis")
|
||||||
|
legacy.add_argument("--reasoning")
|
||||||
|
legacy.add_argument("--dry-run", action="store_true")
|
||||||
|
ns, unknown = legacy.parse_known_args(argv)
|
||||||
|
|
||||||
|
if ns.decision:
|
||||||
|
decision = (ns.decision or "").strip().upper()
|
||||||
|
rebuilt = []
|
||||||
|
if ns.decision_id:
|
||||||
|
rebuilt += ["--decision-id", ns.decision_id]
|
||||||
|
if ns.analysis:
|
||||||
|
rebuilt += ["--analysis", ns.analysis]
|
||||||
|
if ns.reasoning:
|
||||||
|
rebuilt += ["--reasoning", ns.reasoning]
|
||||||
|
if ns.dry_run:
|
||||||
|
rebuilt += ["--dry-run"]
|
||||||
|
|
||||||
|
if decision == "HOLD":
|
||||||
|
rebuilt += ["hold"]
|
||||||
|
elif decision == "SELL_ALL":
|
||||||
|
if not ns.symbol:
|
||||||
|
raise RuntimeError("旧式 --decision SELL_ALL 需要搭配 --symbol")
|
||||||
|
rebuilt += ["sell-all", ns.symbol]
|
||||||
|
elif decision == "BUY":
|
||||||
|
if not ns.symbol or ns.amount_usdt is None:
|
||||||
|
raise RuntimeError("旧式 --decision BUY 需要 --symbol 和 --amount-usdt")
|
||||||
|
rebuilt += ["buy", ns.symbol, str(ns.amount_usdt)]
|
||||||
|
elif decision == "REBALANCE":
|
||||||
|
if not ns.from_symbol or not ns.to_symbol:
|
||||||
|
raise RuntimeError("旧式 --decision REBALANCE 需要 --from-symbol 和 --to-symbol")
|
||||||
|
rebuilt += ["rebalance", ns.from_symbol, ns.to_symbol]
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"不支持的旧式 decision: {decision}")
|
||||||
|
|
||||||
|
return rebuilt + unknown
|
||||||
|
|
||||||
|
return argv
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cli_args(argv: list[str]):
|
||||||
|
parser = build_parser()
|
||||||
|
normalized = normalize_legacy_argv(argv)
|
||||||
|
args = parser.parse_args(normalized)
|
||||||
|
if not args.command:
|
||||||
|
parser.print_help()
|
||||||
|
raise SystemExit(1)
|
||||||
|
if args.command == "sell_all":
|
||||||
|
args.command = "sell-all"
|
||||||
|
return args, normalized
|
||||||
|
|
||||||
|
|
||||||
|
def cli_action_args(args, action: str) -> list[str]:
|
||||||
|
if action == "sell_all":
|
||||||
|
return [args.symbol]
|
||||||
|
if action == "buy":
|
||||||
|
return [args.symbol, str(args.amount_usdt)]
|
||||||
|
if action == "rebalance":
|
||||||
|
return [args.from_symbol, args.to_symbol]
|
||||||
|
return []
|
||||||
@@ -5,38 +5,54 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .. import smart_executor as smart_executor_module
|
from ..logger import log_decision, log_error
|
||||||
|
from .exchange_service import fetch_balances, build_market_snapshot
|
||||||
|
from .execution_state import default_decision_id, get_execution_state, record_execution_state
|
||||||
|
from .portfolio_service import load_positions
|
||||||
|
from .smart_executor_parser import parse_cli_args, cli_action_args
|
||||||
|
from .trade_common import is_dry_run, log, set_dry_run, bj_now_iso
|
||||||
|
from .trade_execution import (
|
||||||
|
command_balances,
|
||||||
|
command_status,
|
||||||
|
build_decision_context,
|
||||||
|
action_sell_all,
|
||||||
|
action_buy,
|
||||||
|
action_rebalance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run(argv: list[str] | None = None) -> int:
|
def run(argv: list[str] | None = None) -> int:
|
||||||
argv = list(sys.argv[1:] if argv is None else argv)
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
args, normalized_argv = smart_executor_module.parse_cli_args(argv)
|
args, normalized_argv = parse_cli_args(argv)
|
||||||
action = args.command.replace("-", "_")
|
action = args.command.replace("-", "_")
|
||||||
argv_tail = smart_executor_module.cli_action_args(args, action)
|
argv_tail = cli_action_args(args, action)
|
||||||
decision_id = (
|
decision_id = (
|
||||||
args.decision_id
|
args.decision_id
|
||||||
or os.getenv("DECISION_ID")
|
or os.getenv("DECISION_ID")
|
||||||
or smart_executor_module.default_decision_id(action, normalized_argv)
|
or default_decision_id(action, normalized_argv)
|
||||||
)
|
)
|
||||||
if args.dry_run:
|
|
||||||
smart_executor_module.DRY_RUN = True
|
|
||||||
|
|
||||||
previous = smart_executor_module.get_execution_state(decision_id)
|
if args.dry_run:
|
||||||
|
set_dry_run(True)
|
||||||
|
|
||||||
|
previous = get_execution_state(decision_id)
|
||||||
read_only_action = action in {"balance", "balances", "status"}
|
read_only_action = action in {"balance", "balances", "status"}
|
||||||
if previous and previous.get("status") == "success" and not read_only_action:
|
if previous and previous.get("status") == "success" and not read_only_action:
|
||||||
smart_executor_module.log(f"⚠️ decision_id={decision_id} 已执行成功,跳过重复执行")
|
log(f"⚠️ decision_id={decision_id} 已执行成功,跳过重复执行")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ex = smart_executor_module.get_exchange()
|
from .exchange_service import get_exchange
|
||||||
|
ex = get_exchange()
|
||||||
|
|
||||||
if read_only_action:
|
if read_only_action:
|
||||||
if action in {"balance", "balances"}:
|
if action in {"balance", "balances"}:
|
||||||
smart_executor_module.command_balances(ex)
|
command_balances(ex)
|
||||||
else:
|
else:
|
||||||
smart_executor_module.command_status(ex)
|
command_status(ex)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
decision_context = smart_executor_module.build_decision_context(ex, action, argv_tail, decision_id)
|
decision_context = build_decision_context(ex, action, argv_tail, decision_id)
|
||||||
if args.analysis:
|
if args.analysis:
|
||||||
decision_context["analysis"] = args.analysis
|
decision_context["analysis"] = args.analysis
|
||||||
elif os.getenv("DECISION_ANALYSIS"):
|
elif os.getenv("DECISION_ANALYSIS"):
|
||||||
@@ -46,22 +62,22 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
elif os.getenv("DECISION_REASONING"):
|
elif os.getenv("DECISION_REASONING"):
|
||||||
decision_context["reasoning"] = os.getenv("DECISION_REASONING")
|
decision_context["reasoning"] = os.getenv("DECISION_REASONING")
|
||||||
|
|
||||||
smart_executor_module.record_execution_state(
|
record_execution_state(
|
||||||
decision_id,
|
decision_id,
|
||||||
{"status": "pending", "started_at": smart_executor_module.bj_now_iso(), "action": action, "args": argv_tail},
|
{"status": "pending", "started_at": bj_now_iso(), "action": action, "args": argv_tail},
|
||||||
)
|
)
|
||||||
|
|
||||||
if action == "sell_all":
|
if action == "sell_all":
|
||||||
result = smart_executor_module.action_sell_all(ex, args.symbol, decision_id, decision_context)
|
result = action_sell_all(ex, args.symbol, decision_id, decision_context)
|
||||||
elif action == "buy":
|
elif action == "buy":
|
||||||
result = smart_executor_module.action_buy(ex, args.symbol, float(args.amount_usdt), decision_id, decision_context)
|
result = action_buy(ex, args.symbol, float(args.amount_usdt), decision_id, decision_context)
|
||||||
elif action == "rebalance":
|
elif action == "rebalance":
|
||||||
result = smart_executor_module.action_rebalance(ex, args.from_symbol, args.to_symbol, decision_id, decision_context)
|
result = action_rebalance(ex, args.from_symbol, args.to_symbol, decision_id, decision_context)
|
||||||
elif action == "hold":
|
elif action == "hold":
|
||||||
balances = smart_executor_module.fetch_balances(ex)
|
balances = fetch_balances(ex)
|
||||||
positions = smart_executor_module.load_positions()
|
positions = load_positions()
|
||||||
market_snapshot = smart_executor_module.build_market_snapshot(ex)
|
market_snapshot = build_market_snapshot(ex)
|
||||||
smart_executor_module.log_decision(
|
log_decision(
|
||||||
{
|
{
|
||||||
**decision_context,
|
**decision_context,
|
||||||
"balances_after": balances,
|
"balances_after": balances,
|
||||||
@@ -72,40 +88,41 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
"execution_result": {"status": "hold"},
|
"execution_result": {"status": "hold"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
smart_executor_module.log("😴 决策: 持续持有,无操作")
|
log("😴 决策: 持续持有,无操作")
|
||||||
result = {"status": "hold"}
|
result = {"status": "hold"}
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"未知动作: {action};请运行 --help 查看正确 CLI 用法")
|
raise RuntimeError(f"未知动作: {action};请运行 --help 查看正确 CLI 用法")
|
||||||
|
|
||||||
smart_executor_module.record_execution_state(
|
record_execution_state(
|
||||||
decision_id,
|
decision_id,
|
||||||
{
|
{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"finished_at": smart_executor_module.bj_now_iso(),
|
"finished_at": bj_now_iso(),
|
||||||
"action": action,
|
"action": action,
|
||||||
"args": argv_tail,
|
"args": argv_tail,
|
||||||
"result": result,
|
"result": result,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
smart_executor_module.log(f"✅ 执行完成 decision_id={decision_id}")
|
log(f"✅ 执行完成 decision_id={decision_id}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
smart_executor_module.record_execution_state(
|
record_execution_state(
|
||||||
decision_id,
|
decision_id,
|
||||||
{
|
{
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"finished_at": smart_executor_module.bj_now_iso(),
|
"finished_at": bj_now_iso(),
|
||||||
"action": action,
|
"action": action,
|
||||||
"args": argv_tail,
|
"args": argv_tail,
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
smart_executor_module.log_error(
|
log_error(
|
||||||
"smart_executor",
|
"smart_executor",
|
||||||
exc,
|
exc,
|
||||||
decision_id=decision_id,
|
decision_id=decision_id,
|
||||||
action=action,
|
action=action,
|
||||||
args=argv_tail,
|
args=argv_tail,
|
||||||
)
|
)
|
||||||
smart_executor_module.log(f"❌ 执行失败: {exc}")
|
log(f"❌ 执行失败: {exc}")
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
25
src/coinhunter/services/trade_common.py
Normal file
25
src/coinhunter/services/trade_common.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Common trade utilities (time, logging, constants)."""
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
CST = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
_DRY_RUN = {"value": os.getenv("DRY_RUN", "false").lower() == "true"}
|
||||||
|
USDT_BUFFER_PCT = 0.03
|
||||||
|
MIN_REMAINING_DUST_USDT = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def is_dry_run() -> bool:
|
||||||
|
return _DRY_RUN["value"]
|
||||||
|
|
||||||
|
|
||||||
|
def set_dry_run(value: bool):
|
||||||
|
_DRY_RUN["value"] = value
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str):
|
||||||
|
print(f"[{datetime.now(CST).strftime('%Y-%m-%d %H:%M:%S')} CST] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def bj_now_iso():
|
||||||
|
return datetime.now(CST).isoformat()
|
||||||
178
src/coinhunter/services/trade_execution.py
Normal file
178
src/coinhunter/services/trade_execution.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""Trade execution actions (buy, sell, rebalance, hold, status)."""
|
||||||
|
from ..logger import log_decision, log_trade
|
||||||
|
from .exchange_service import (
|
||||||
|
fetch_balances,
|
||||||
|
norm_symbol,
|
||||||
|
storage_symbol,
|
||||||
|
build_market_snapshot,
|
||||||
|
prepare_buy_quantity,
|
||||||
|
prepare_sell_quantity,
|
||||||
|
)
|
||||||
|
from .portfolio_service import load_positions, save_positions, upsert_position, reconcile_positions_with_exchange
|
||||||
|
from .trade_common import is_dry_run, USDT_BUFFER_PCT, log, bj_now_iso
|
||||||
|
|
||||||
|
|
||||||
|
def build_decision_context(ex, action: str, argv_tail: list[str], decision_id: str):
|
||||||
|
balances = fetch_balances(ex)
|
||||||
|
positions = load_positions()
|
||||||
|
return {
|
||||||
|
"decision_id": decision_id,
|
||||||
|
"balances_before": balances,
|
||||||
|
"positions_before": positions,
|
||||||
|
"decision": action.upper(),
|
||||||
|
"action_taken": f"{action} {' '.join(argv_tail)}".strip(),
|
||||||
|
"risk_level": "high" if len(positions) <= 1 else "medium",
|
||||||
|
"data_sources": ["binance"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def market_sell(ex, symbol: str, qty: float, decision_id: str):
|
||||||
|
sym, qty, bid, est_cost = prepare_sell_quantity(ex, symbol, qty)
|
||||||
|
if is_dry_run():
|
||||||
|
log(f"[DRY RUN] 卖出 {sym} 数量 {qty}")
|
||||||
|
return {"id": f"dry-sell-{decision_id}", "symbol": sym, "amount": qty, "price": bid, "cost": est_cost, "status": "closed"}
|
||||||
|
order = ex.create_market_sell_order(sym, qty, params={"newClientOrderId": f"ch-{decision_id}-sell"})
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def market_buy(ex, symbol: str, amount_usdt: float, decision_id: str):
|
||||||
|
sym, qty, ask, est_cost = prepare_buy_quantity(ex, symbol, amount_usdt)
|
||||||
|
if is_dry_run():
|
||||||
|
log(f"[DRY RUN] 买入 {sym} 金额 ${est_cost:.4f} 数量 {qty}")
|
||||||
|
return {"id": f"dry-buy-{decision_id}", "symbol": sym, "amount": qty, "price": ask, "cost": est_cost, "status": "closed"}
|
||||||
|
order = ex.create_market_buy_order(sym, qty, params={"newClientOrderId": f"ch-{decision_id}-buy"})
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def action_sell_all(ex, symbol: str, decision_id: str, decision_context: dict):
|
||||||
|
balances_before = fetch_balances(ex)
|
||||||
|
base = norm_symbol(symbol).split("/")[0]
|
||||||
|
qty = float(balances_before.get(base, 0))
|
||||||
|
if qty <= 0:
|
||||||
|
raise RuntimeError(f"{base} 余额为0,无法卖出")
|
||||||
|
order = market_sell(ex, symbol, qty, decision_id)
|
||||||
|
positions_after, balances_after = (
|
||||||
|
reconcile_positions_with_exchange(ex, load_positions())
|
||||||
|
if not is_dry_run()
|
||||||
|
else (load_positions(), balances_before)
|
||||||
|
)
|
||||||
|
log_trade(
|
||||||
|
"SELL_ALL",
|
||||||
|
norm_symbol(symbol),
|
||||||
|
qty=order.get("amount"),
|
||||||
|
price=order.get("price"),
|
||||||
|
amount_usdt=order.get("cost"),
|
||||||
|
note="Smart executor sell_all",
|
||||||
|
decision_id=decision_id,
|
||||||
|
order_id=order.get("id"),
|
||||||
|
status=order.get("status"),
|
||||||
|
balances_before=balances_before,
|
||||||
|
balances_after=balances_after,
|
||||||
|
)
|
||||||
|
log_decision(
|
||||||
|
{
|
||||||
|
**decision_context,
|
||||||
|
"balances_after": balances_after,
|
||||||
|
"positions_after": positions_after,
|
||||||
|
"execution_result": {"order": order},
|
||||||
|
"analysis": decision_context.get("analysis", ""),
|
||||||
|
"reasoning": decision_context.get("reasoning", "sell_all execution"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def action_buy(ex, symbol: str, amount_usdt: float, decision_id: str, decision_context: dict, simulated_usdt_balance: float = None):
|
||||||
|
balances_before = fetch_balances(ex) if simulated_usdt_balance is None else {"USDT": simulated_usdt_balance}
|
||||||
|
usdt = float(balances_before.get("USDT", 0))
|
||||||
|
if usdt < amount_usdt:
|
||||||
|
raise RuntimeError(f"USDT 余额不足(${usdt:.4f} < ${amount_usdt:.4f})")
|
||||||
|
order = market_buy(ex, symbol, amount_usdt, decision_id)
|
||||||
|
positions_existing = load_positions()
|
||||||
|
sym_store = storage_symbol(symbol)
|
||||||
|
price = float(order.get("price") or 0)
|
||||||
|
qty = float(order.get("amount") or 0)
|
||||||
|
position = {
|
||||||
|
"account_id": "binance-main",
|
||||||
|
"symbol": sym_store,
|
||||||
|
"base_asset": norm_symbol(symbol).split("/")[0],
|
||||||
|
"quote_asset": "USDT",
|
||||||
|
"market_type": "spot",
|
||||||
|
"quantity": qty,
|
||||||
|
"avg_cost": price,
|
||||||
|
"opened_at": bj_now_iso(),
|
||||||
|
"updated_at": bj_now_iso(),
|
||||||
|
"note": "Smart executor entry",
|
||||||
|
}
|
||||||
|
upsert_position(positions_existing, position)
|
||||||
|
if is_dry_run():
|
||||||
|
balances_after = balances_before
|
||||||
|
positions_after = positions_existing
|
||||||
|
else:
|
||||||
|
save_positions(positions_existing)
|
||||||
|
positions_after, balances_after = reconcile_positions_with_exchange(ex, positions_existing)
|
||||||
|
for p in positions_after:
|
||||||
|
if p["symbol"] == sym_store and price:
|
||||||
|
p["avg_cost"] = price
|
||||||
|
p["updated_at"] = bj_now_iso()
|
||||||
|
save_positions(positions_after)
|
||||||
|
log_trade(
|
||||||
|
"BUY",
|
||||||
|
norm_symbol(symbol),
|
||||||
|
qty=qty,
|
||||||
|
amount_usdt=order.get("cost"),
|
||||||
|
price=price,
|
||||||
|
note="Smart executor buy",
|
||||||
|
decision_id=decision_id,
|
||||||
|
order_id=order.get("id"),
|
||||||
|
status=order.get("status"),
|
||||||
|
balances_before=balances_before,
|
||||||
|
balances_after=balances_after,
|
||||||
|
)
|
||||||
|
log_decision(
|
||||||
|
{
|
||||||
|
**decision_context,
|
||||||
|
"balances_after": balances_after,
|
||||||
|
"positions_after": positions_after,
|
||||||
|
"execution_result": {"order": order},
|
||||||
|
"analysis": decision_context.get("analysis", ""),
|
||||||
|
"reasoning": decision_context.get("reasoning", "buy execution"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def action_rebalance(ex, from_symbol: str, to_symbol: str, decision_id: str, decision_context: dict):
|
||||||
|
sell_order = action_sell_all(ex, from_symbol, decision_id + "s", decision_context)
|
||||||
|
if is_dry_run():
|
||||||
|
sell_cost = float(sell_order.get("cost") or 0)
|
||||||
|
spend = sell_cost * (1 - USDT_BUFFER_PCT)
|
||||||
|
simulated_usdt = sell_cost
|
||||||
|
else:
|
||||||
|
balances = fetch_balances(ex)
|
||||||
|
usdt = float(balances.get("USDT", 0))
|
||||||
|
spend = usdt * (1 - USDT_BUFFER_PCT)
|
||||||
|
simulated_usdt = None
|
||||||
|
if spend < 5:
|
||||||
|
raise RuntimeError(f"卖出后 USDT ${spend:.4f} 不足,无法买入新币")
|
||||||
|
buy_order = action_buy(ex, to_symbol, spend, decision_id + "b", decision_context, simulated_usdt_balance=simulated_usdt)
|
||||||
|
return {"sell": sell_order, "buy": buy_order}
|
||||||
|
|
||||||
|
|
||||||
|
def command_status(ex):
|
||||||
|
balances = fetch_balances(ex)
|
||||||
|
positions = load_positions()
|
||||||
|
market_snapshot = build_market_snapshot(ex)
|
||||||
|
payload = {
|
||||||
|
"balances": balances,
|
||||||
|
"positions": positions,
|
||||||
|
"market_snapshot": market_snapshot,
|
||||||
|
}
|
||||||
|
print(payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def command_balances(ex):
|
||||||
|
balances = fetch_balances(ex)
|
||||||
|
print({"balances": balances})
|
||||||
|
return balances
|
||||||
@@ -1,547 +1,29 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Coin Hunter robust smart executor.
|
"""Coin Hunter robust smart executor — compatibility facade."""
|
||||||
|
|
||||||
Supports both:
|
|
||||||
1. Clear argparse CLI subcommands for humans / cron prompts
|
|
||||||
2. Backward-compatible legacy flag forms previously emitted by AI agents
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import fcntl
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import ccxt
|
|
||||||
|
|
||||||
from .logger import log_decision, log_error, log_trade
|
|
||||||
from .runtime import get_runtime_paths, load_env_file
|
from .runtime import get_runtime_paths, load_env_file
|
||||||
|
from .services.trade_common import CST, is_dry_run, USDT_BUFFER_PCT, MIN_REMAINING_DUST_USDT, log, bj_now_iso, set_dry_run
|
||||||
|
from .services.file_utils import locked_file, atomic_write_json, load_json_locked, save_json_locked
|
||||||
|
from .services.smart_executor_parser import build_parser, normalize_legacy_argv, parse_cli_args, cli_action_args
|
||||||
|
from .services.execution_state import default_decision_id, record_execution_state, get_execution_state, load_executions, save_executions
|
||||||
|
from .services.portfolio_service import load_positions, save_positions, upsert_position, reconcile_positions_with_exchange
|
||||||
|
from .services.exchange_service import get_exchange, norm_symbol, storage_symbol, fetch_balances, build_market_snapshot, market_and_ticker, floor_to_step, prepare_buy_quantity, prepare_sell_quantity
|
||||||
|
from .services.trade_execution import build_decision_context, market_sell, market_buy, action_sell_all, action_buy, action_rebalance, command_status, command_balances
|
||||||
|
from .services.smart_executor_service import run as _run_service
|
||||||
|
|
||||||
PATHS = get_runtime_paths()
|
PATHS = get_runtime_paths()
|
||||||
POSITIONS_FILE = PATHS.positions_file
|
|
||||||
POSITIONS_LOCK = PATHS.positions_lock
|
|
||||||
EXECUTIONS_FILE = PATHS.executions_file
|
|
||||||
EXECUTIONS_LOCK = PATHS.executions_lock
|
|
||||||
ENV_FILE = PATHS.env_file
|
ENV_FILE = PATHS.env_file
|
||||||
DRY_RUN = os.getenv("DRY_RUN", "false").lower() == "true"
|
|
||||||
USDT_BUFFER_PCT = 0.03
|
|
||||||
MIN_REMAINING_DUST_USDT = 1.0
|
|
||||||
|
|
||||||
CST = timezone(timedelta(hours=8))
|
|
||||||
|
|
||||||
|
|
||||||
def log(msg: str):
|
|
||||||
print(f"[{datetime.now(CST).strftime('%Y-%m-%d %H:%M:%S')} CST] {msg}")
|
|
||||||
|
|
||||||
|
|
||||||
def bj_now_iso():
|
|
||||||
return datetime.now(CST).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def load_env():
|
def load_env():
|
||||||
load_env_file(PATHS)
|
load_env_file(PATHS)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
def main(argv=None):
|
||||||
def locked_file(path: Path):
|
return _run_service(argv)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(path, "a+", encoding="utf-8") as f:
|
|
||||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
||||||
f.seek(0)
|
|
||||||
yield f
|
|
||||||
f.flush()
|
|
||||||
os.fsync(f.fileno())
|
|
||||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
||||||
|
|
||||||
|
|
||||||
def atomic_write_json(path: Path, data: dict):
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
||||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
||||||
os.replace(tmp, path)
|
|
||||||
|
|
||||||
|
|
||||||
def load_json_locked(path: Path, lock_path: Path, default):
|
|
||||||
with locked_file(lock_path):
|
|
||||||
if not path.exists():
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def save_json_locked(path: Path, lock_path: Path, data: dict):
|
|
||||||
with locked_file(lock_path):
|
|
||||||
atomic_write_json(path, data)
|
|
||||||
|
|
||||||
|
|
||||||
def load_positions() -> list:
|
|
||||||
return load_json_locked(POSITIONS_FILE, POSITIONS_LOCK, {"positions": []}).get("positions", [])
|
|
||||||
|
|
||||||
|
|
||||||
def save_positions(positions: list):
|
|
||||||
save_json_locked(POSITIONS_FILE, POSITIONS_LOCK, {"positions": positions})
|
|
||||||
|
|
||||||
|
|
||||||
def load_executions() -> dict:
|
|
||||||
return load_json_locked(EXECUTIONS_FILE, EXECUTIONS_LOCK, {"executions": {}}).get("executions", {})
|
|
||||||
|
|
||||||
|
|
||||||
def save_executions(executions: dict):
|
|
||||||
save_json_locked(EXECUTIONS_FILE, EXECUTIONS_LOCK, {"executions": executions})
|
|
||||||
|
|
||||||
|
|
||||||
def default_decision_id(action: str, argv_tail: list[str]) -> str:
|
|
||||||
now = datetime.now(CST)
|
|
||||||
bucket_min = (now.minute // 15) * 15
|
|
||||||
bucket = now.strftime(f"%Y%m%dT%H{bucket_min:02d}")
|
|
||||||
raw = f"{bucket}|{action}|{'|'.join(argv_tail)}"
|
|
||||||
return hashlib.sha1(raw.encode()).hexdigest()[:16]
|
|
||||||
|
|
||||||
|
|
||||||
def get_exchange():
|
|
||||||
load_env()
|
|
||||||
api_key = os.getenv("BINANCE_API_KEY")
|
|
||||||
secret = os.getenv("BINANCE_API_SECRET")
|
|
||||||
if not api_key or not secret:
|
|
||||||
raise RuntimeError("缺少 BINANCE_API_KEY 或 BINANCE_API_SECRET")
|
|
||||||
ex = ccxt.binance({
|
|
||||||
"apiKey": api_key,
|
|
||||||
"secret": secret,
|
|
||||||
"options": {"defaultType": "spot", "createMarketBuyOrderRequiresPrice": False},
|
|
||||||
"enableRateLimit": True,
|
|
||||||
})
|
|
||||||
ex.load_markets()
|
|
||||||
return ex
|
|
||||||
|
|
||||||
|
|
||||||
def norm_symbol(symbol: str) -> str:
|
|
||||||
s = symbol.upper().replace("-", "").replace("_", "")
|
|
||||||
if "/" in s:
|
|
||||||
return s
|
|
||||||
if s.endswith("USDT"):
|
|
||||||
return s[:-4] + "/USDT"
|
|
||||||
raise ValueError(f"不支持的 symbol: {symbol}")
|
|
||||||
|
|
||||||
|
|
||||||
def storage_symbol(symbol: str) -> str:
|
|
||||||
return norm_symbol(symbol).replace("/", "")
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_balances(ex):
|
|
||||||
bal = ex.fetch_balance()["free"]
|
|
||||||
return {k: float(v) for k, v in bal.items() if float(v) > 0}
|
|
||||||
|
|
||||||
|
|
||||||
def build_market_snapshot(ex):
|
|
||||||
try:
|
|
||||||
tickers = ex.fetch_tickers()
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
snapshot = {}
|
|
||||||
for sym, t in tickers.items():
|
|
||||||
if not sym.endswith("/USDT"):
|
|
||||||
continue
|
|
||||||
price = t.get("last")
|
|
||||||
if price is None or float(price) <= 0:
|
|
||||||
continue
|
|
||||||
vol = float(t.get("quoteVolume") or 0)
|
|
||||||
if vol < 200_000:
|
|
||||||
continue
|
|
||||||
base = sym.replace("/", "")
|
|
||||||
snapshot[base] = {
|
|
||||||
"lastPrice": round(float(price), 8),
|
|
||||||
"price24hPcnt": round(float(t.get("percentage") or 0), 4),
|
|
||||||
"highPrice24h": round(float(t.get("high") or 0), 8) if t.get("high") else None,
|
|
||||||
"lowPrice24h": round(float(t.get("low") or 0), 8) if t.get("low") else None,
|
|
||||||
"turnover24h": round(float(vol), 2),
|
|
||||||
}
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
|
|
||||||
def market_and_ticker(ex, symbol: str):
|
|
||||||
sym = norm_symbol(symbol)
|
|
||||||
market = ex.market(sym)
|
|
||||||
ticker = ex.fetch_ticker(sym)
|
|
||||||
return sym, market, ticker
|
|
||||||
|
|
||||||
|
|
||||||
def floor_to_step(value: float, step: float) -> float:
|
|
||||||
if not step or step <= 0:
|
|
||||||
return value
|
|
||||||
return math.floor(value / step) * step
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_buy_quantity(ex, symbol: str, amount_usdt: float):
|
|
||||||
sym, market, ticker = market_and_ticker(ex, symbol)
|
|
||||||
ask = float(ticker.get("ask") or ticker.get("last") or 0)
|
|
||||||
if ask <= 0:
|
|
||||||
raise RuntimeError(f"{sym} 无法获取有效 ask 价格")
|
|
||||||
budget = amount_usdt * (1 - USDT_BUFFER_PCT)
|
|
||||||
raw_qty = budget / ask
|
|
||||||
qty = float(ex.amount_to_precision(sym, raw_qty))
|
|
||||||
min_amt = (market.get("limits", {}).get("amount", {}) or {}).get("min") or 0
|
|
||||||
min_cost = (market.get("limits", {}).get("cost", {}) or {}).get("min") or 0
|
|
||||||
if min_amt and qty < float(min_amt):
|
|
||||||
raise RuntimeError(f"{sym} 买入数量 {qty} 小于最小数量 {min_amt}")
|
|
||||||
est_cost = qty * ask
|
|
||||||
if min_cost and est_cost < float(min_cost):
|
|
||||||
raise RuntimeError(f"{sym} 买入金额 ${est_cost:.4f} 小于最小成交额 ${float(min_cost):.4f}")
|
|
||||||
return sym, qty, ask, est_cost
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_sell_quantity(ex, symbol: str, free_qty: float):
|
|
||||||
sym, market, ticker = market_and_ticker(ex, symbol)
|
|
||||||
bid = float(ticker.get("bid") or ticker.get("last") or 0)
|
|
||||||
if bid <= 0:
|
|
||||||
raise RuntimeError(f"{sym} 无法获取有效 bid 价格")
|
|
||||||
qty = float(ex.amount_to_precision(sym, free_qty))
|
|
||||||
min_amt = (market.get("limits", {}).get("amount", {}) or {}).get("min") or 0
|
|
||||||
min_cost = (market.get("limits", {}).get("cost", {}) or {}).get("min") or 0
|
|
||||||
if min_amt and qty < float(min_amt):
|
|
||||||
raise RuntimeError(f"{sym} 卖出数量 {qty} 小于最小数量 {min_amt}")
|
|
||||||
est_cost = qty * bid
|
|
||||||
if min_cost and est_cost < float(min_cost):
|
|
||||||
raise RuntimeError(f"{sym} 卖出金额 ${est_cost:.4f} 小于最小成交额 ${float(min_cost):.4f}")
|
|
||||||
return sym, qty, bid, est_cost
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_position(positions: list, position: dict):
|
|
||||||
sym = position["symbol"]
|
|
||||||
for i, existing in enumerate(positions):
|
|
||||||
if existing.get("symbol") == sym:
|
|
||||||
positions[i] = position
|
|
||||||
return positions
|
|
||||||
positions.append(position)
|
|
||||||
return positions
|
|
||||||
|
|
||||||
|
|
||||||
def reconcile_positions_with_exchange(ex, positions: list):
|
|
||||||
balances = fetch_balances(ex)
|
|
||||||
existing_by_symbol = {p.get("symbol"): p for p in positions}
|
|
||||||
reconciled = []
|
|
||||||
for asset, qty in balances.items():
|
|
||||||
if asset == "USDT":
|
|
||||||
continue
|
|
||||||
if qty <= 0:
|
|
||||||
continue
|
|
||||||
sym = f"{asset}USDT"
|
|
||||||
old = existing_by_symbol.get(sym, {})
|
|
||||||
reconciled.append({
|
|
||||||
"account_id": old.get("account_id", "binance-main"),
|
|
||||||
"symbol": sym,
|
|
||||||
"base_asset": asset,
|
|
||||||
"quote_asset": "USDT",
|
|
||||||
"market_type": "spot",
|
|
||||||
"quantity": qty,
|
|
||||||
"avg_cost": old.get("avg_cost"),
|
|
||||||
"opened_at": old.get("opened_at", bj_now_iso()),
|
|
||||||
"updated_at": bj_now_iso(),
|
|
||||||
"note": old.get("note", "Reconciled from Binance balances"),
|
|
||||||
})
|
|
||||||
save_positions(reconciled)
|
|
||||||
return reconciled, balances
|
|
||||||
|
|
||||||
|
|
||||||
def record_execution_state(decision_id: str, payload: dict):
|
|
||||||
executions = load_executions()
|
|
||||||
executions[decision_id] = payload
|
|
||||||
save_executions(executions)
|
|
||||||
|
|
||||||
|
|
||||||
def get_execution_state(decision_id: str):
|
|
||||||
return load_executions().get(decision_id)
|
|
||||||
|
|
||||||
|
|
||||||
def build_decision_context(ex, action: str, argv_tail: list[str], decision_id: str):
|
|
||||||
balances = fetch_balances(ex)
|
|
||||||
positions = load_positions()
|
|
||||||
return {
|
|
||||||
"decision_id": decision_id,
|
|
||||||
"balances_before": balances,
|
|
||||||
"positions_before": positions,
|
|
||||||
"decision": action.upper(),
|
|
||||||
"action_taken": f"{action} {' '.join(argv_tail)}".strip(),
|
|
||||||
"risk_level": "high" if len(positions) <= 1 else "medium",
|
|
||||||
"data_sources": ["binance"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def market_sell(ex, symbol: str, qty: float, decision_id: str):
|
|
||||||
sym, qty, bid, est_cost = prepare_sell_quantity(ex, symbol, qty)
|
|
||||||
if DRY_RUN:
|
|
||||||
log(f"[DRY RUN] 卖出 {sym} 数量 {qty}")
|
|
||||||
return {"id": f"dry-sell-{decision_id}", "symbol": sym, "amount": qty, "price": bid, "cost": est_cost, "status": "closed"}
|
|
||||||
order = ex.create_market_sell_order(sym, qty, params={"newClientOrderId": f"ch-{decision_id}-sell"})
|
|
||||||
return order
|
|
||||||
|
|
||||||
|
|
||||||
def market_buy(ex, symbol: str, amount_usdt: float, decision_id: str):
|
|
||||||
sym, qty, ask, est_cost = prepare_buy_quantity(ex, symbol, amount_usdt)
|
|
||||||
if DRY_RUN:
|
|
||||||
log(f"[DRY RUN] 买入 {sym} 金额 ${est_cost:.4f} 数量 {qty}")
|
|
||||||
return {"id": f"dry-buy-{decision_id}", "symbol": sym, "amount": qty, "price": ask, "cost": est_cost, "status": "closed"}
|
|
||||||
order = ex.create_market_buy_order(sym, qty, params={"newClientOrderId": f"ch-{decision_id}-buy"})
|
|
||||||
return order
|
|
||||||
|
|
||||||
|
|
||||||
def action_sell_all(ex, symbol: str, decision_id: str, decision_context: dict):
|
|
||||||
balances_before = fetch_balances(ex)
|
|
||||||
base = norm_symbol(symbol).split("/")[0]
|
|
||||||
qty = float(balances_before.get(base, 0))
|
|
||||||
if qty <= 0:
|
|
||||||
raise RuntimeError(f"{base} 余额为0,无法卖出")
|
|
||||||
order = market_sell(ex, symbol, qty, decision_id)
|
|
||||||
positions_after, balances_after = reconcile_positions_with_exchange(ex, load_positions()) if not DRY_RUN else (load_positions(), balances_before)
|
|
||||||
log_trade(
|
|
||||||
"SELL_ALL", norm_symbol(symbol), qty=order.get("amount"), price=order.get("price"),
|
|
||||||
amount_usdt=order.get("cost"), note="Smart executor sell_all",
|
|
||||||
decision_id=decision_id, order_id=order.get("id"), status=order.get("status"),
|
|
||||||
balances_before=balances_before, balances_after=balances_after,
|
|
||||||
)
|
|
||||||
log_decision({
|
|
||||||
**decision_context,
|
|
||||||
"balances_after": balances_after,
|
|
||||||
"positions_after": positions_after,
|
|
||||||
"execution_result": {"order": order},
|
|
||||||
"analysis": decision_context.get("analysis", ""),
|
|
||||||
"reasoning": decision_context.get("reasoning", "sell_all execution"),
|
|
||||||
})
|
|
||||||
return order
|
|
||||||
|
|
||||||
|
|
||||||
def action_buy(ex, symbol: str, amount_usdt: float, decision_id: str, decision_context: dict):
|
|
||||||
balances_before = fetch_balances(ex)
|
|
||||||
usdt = float(balances_before.get("USDT", 0))
|
|
||||||
if usdt < amount_usdt:
|
|
||||||
raise RuntimeError(f"USDT 余额不足(${usdt:.4f} < ${amount_usdt:.4f})")
|
|
||||||
order = market_buy(ex, symbol, amount_usdt, decision_id)
|
|
||||||
positions_existing = load_positions()
|
|
||||||
sym_store = storage_symbol(symbol)
|
|
||||||
price = float(order.get("price") or 0)
|
|
||||||
qty = float(order.get("amount") or 0)
|
|
||||||
position = {
|
|
||||||
"account_id": "binance-main",
|
|
||||||
"symbol": sym_store,
|
|
||||||
"base_asset": norm_symbol(symbol).split("/")[0],
|
|
||||||
"quote_asset": "USDT",
|
|
||||||
"market_type": "spot",
|
|
||||||
"quantity": qty,
|
|
||||||
"avg_cost": price,
|
|
||||||
"opened_at": bj_now_iso(),
|
|
||||||
"updated_at": bj_now_iso(),
|
|
||||||
"note": "Smart executor entry",
|
|
||||||
}
|
|
||||||
upsert_position(positions_existing, position)
|
|
||||||
if DRY_RUN:
|
|
||||||
save_positions(positions_existing)
|
|
||||||
balances_after = balances_before
|
|
||||||
positions_after = positions_existing
|
|
||||||
else:
|
|
||||||
positions_after, balances_after = reconcile_positions_with_exchange(ex, positions_existing)
|
|
||||||
for p in positions_after:
|
|
||||||
if p["symbol"] == sym_store and price:
|
|
||||||
p["avg_cost"] = price
|
|
||||||
p["updated_at"] = bj_now_iso()
|
|
||||||
save_positions(positions_after)
|
|
||||||
log_trade(
|
|
||||||
"BUY", norm_symbol(symbol), qty=qty, amount_usdt=order.get("cost"), price=price,
|
|
||||||
note="Smart executor buy", decision_id=decision_id, order_id=order.get("id"),
|
|
||||||
status=order.get("status"), balances_before=balances_before, balances_after=balances_after,
|
|
||||||
)
|
|
||||||
log_decision({
|
|
||||||
**decision_context,
|
|
||||||
"balances_after": balances_after,
|
|
||||||
"positions_after": positions_after,
|
|
||||||
"execution_result": {"order": order},
|
|
||||||
"analysis": decision_context.get("analysis", ""),
|
|
||||||
"reasoning": decision_context.get("reasoning", "buy execution"),
|
|
||||||
})
|
|
||||||
return order
|
|
||||||
|
|
||||||
|
|
||||||
def action_rebalance(ex, from_symbol: str, to_symbol: str, decision_id: str, decision_context: dict):
|
|
||||||
sell_order = action_sell_all(ex, from_symbol, decision_id + "s", decision_context)
|
|
||||||
balances = fetch_balances(ex)
|
|
||||||
usdt = float(balances.get("USDT", 0))
|
|
||||||
spend = usdt * (1 - USDT_BUFFER_PCT)
|
|
||||||
if spend < 5:
|
|
||||||
raise RuntimeError(f"卖出后 USDT ${usdt:.4f} 不足,无法买入新币")
|
|
||||||
buy_order = action_buy(ex, to_symbol, spend, decision_id + "b", decision_context)
|
|
||||||
return {"sell": sell_order, "buy": buy_order}
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Coin Hunter Smart Executor",
|
|
||||||
formatter_class=argparse.RawTextHelpFormatter,
|
|
||||||
epilog=(
|
|
||||||
"示例:\n"
|
|
||||||
" python smart_executor.py hold\n"
|
|
||||||
" python smart_executor.py sell-all ETHUSDT\n"
|
|
||||||
" python smart_executor.py buy ENJUSDT 100\n"
|
|
||||||
" python smart_executor.py rebalance PEPEUSDT ETHUSDT\n"
|
|
||||||
" python smart_executor.py balances\n\n"
|
|
||||||
"兼容旧调用:\n"
|
|
||||||
" python smart_executor.py HOLD\n"
|
|
||||||
" python smart_executor.py --decision HOLD --dry-run\n"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument("--decision-id", help="Override decision id (otherwise derived automatically)")
|
|
||||||
parser.add_argument("--analysis", help="Decision analysis text to persist into logs")
|
|
||||||
parser.add_argument("--reasoning", help="Decision reasoning text to persist into logs")
|
|
||||||
parser.add_argument("--dry-run", action="store_true", help="Force dry-run mode for this invocation")
|
|
||||||
|
|
||||||
subparsers = parser.add_subparsers(dest="command")
|
|
||||||
|
|
||||||
subparsers.add_parser("hold", help="Log a HOLD decision without trading")
|
|
||||||
subparsers.add_parser("balances", help="Print live balances as JSON")
|
|
||||||
subparsers.add_parser("balance", help="Alias of balances")
|
|
||||||
subparsers.add_parser("status", help="Print balances + positions + snapshot as JSON")
|
|
||||||
|
|
||||||
sell_all = subparsers.add_parser("sell-all", help="Sell all of one symbol")
|
|
||||||
sell_all.add_argument("symbol")
|
|
||||||
sell_all_legacy = subparsers.add_parser("sell_all", help=argparse.SUPPRESS)
|
|
||||||
sell_all_legacy.add_argument("symbol")
|
|
||||||
|
|
||||||
buy = subparsers.add_parser("buy", help="Buy symbol with USDT amount")
|
|
||||||
buy.add_argument("symbol")
|
|
||||||
buy.add_argument("amount_usdt", type=float)
|
|
||||||
|
|
||||||
rebalance = subparsers.add_parser("rebalance", help="Sell one symbol and rotate to another")
|
|
||||||
rebalance.add_argument("from_symbol")
|
|
||||||
rebalance.add_argument("to_symbol")
|
|
||||||
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_legacy_argv(argv: list[str]) -> list[str]:
|
|
||||||
if not argv:
|
|
||||||
return argv
|
|
||||||
|
|
||||||
action_aliases = {
|
|
||||||
"HOLD": ["hold"],
|
|
||||||
"hold": ["hold"],
|
|
||||||
"SELL_ALL": ["sell-all"],
|
|
||||||
"sell_all": ["sell-all"],
|
|
||||||
"sell-all": ["sell-all"],
|
|
||||||
"BUY": ["buy"],
|
|
||||||
"buy": ["buy"],
|
|
||||||
"REBALANCE": ["rebalance"],
|
|
||||||
"rebalance": ["rebalance"],
|
|
||||||
"BALANCE": ["balances"],
|
|
||||||
"balance": ["balances"],
|
|
||||||
"BALANCES": ["balances"],
|
|
||||||
"balances": ["balances"],
|
|
||||||
"STATUS": ["status"],
|
|
||||||
"status": ["status"],
|
|
||||||
}
|
|
||||||
|
|
||||||
for idx, token in enumerate(argv):
|
|
||||||
if token in action_aliases:
|
|
||||||
prefix = argv[:idx]
|
|
||||||
suffix = argv[idx + 1:]
|
|
||||||
return prefix + action_aliases[token] + suffix
|
|
||||||
|
|
||||||
if argv[0].startswith("-"):
|
|
||||||
legacy = argparse.ArgumentParser(add_help=False)
|
|
||||||
legacy.add_argument("--decision")
|
|
||||||
legacy.add_argument("--symbol")
|
|
||||||
legacy.add_argument("--from-symbol")
|
|
||||||
legacy.add_argument("--to-symbol")
|
|
||||||
legacy.add_argument("--amount-usdt", type=float)
|
|
||||||
legacy.add_argument("--decision-id")
|
|
||||||
legacy.add_argument("--analysis")
|
|
||||||
legacy.add_argument("--reasoning")
|
|
||||||
legacy.add_argument("--dry-run", action="store_true")
|
|
||||||
ns, unknown = legacy.parse_known_args(argv)
|
|
||||||
|
|
||||||
if ns.decision:
|
|
||||||
decision = (ns.decision or "").strip().upper()
|
|
||||||
rebuilt = []
|
|
||||||
if ns.decision_id:
|
|
||||||
rebuilt += ["--decision-id", ns.decision_id]
|
|
||||||
if ns.analysis:
|
|
||||||
rebuilt += ["--analysis", ns.analysis]
|
|
||||||
if ns.reasoning:
|
|
||||||
rebuilt += ["--reasoning", ns.reasoning]
|
|
||||||
if ns.dry_run:
|
|
||||||
rebuilt += ["--dry-run"]
|
|
||||||
|
|
||||||
if decision == "HOLD":
|
|
||||||
rebuilt += ["hold"]
|
|
||||||
elif decision == "SELL_ALL":
|
|
||||||
if not ns.symbol:
|
|
||||||
raise RuntimeError("旧式 --decision SELL_ALL 需要搭配 --symbol")
|
|
||||||
rebuilt += ["sell-all", ns.symbol]
|
|
||||||
elif decision == "BUY":
|
|
||||||
if not ns.symbol or ns.amount_usdt is None:
|
|
||||||
raise RuntimeError("旧式 --decision BUY 需要 --symbol 和 --amount-usdt")
|
|
||||||
rebuilt += ["buy", ns.symbol, str(ns.amount_usdt)]
|
|
||||||
elif decision == "REBALANCE":
|
|
||||||
if not ns.from_symbol or not ns.to_symbol:
|
|
||||||
raise RuntimeError("旧式 --decision REBALANCE 需要 --from-symbol 和 --to-symbol")
|
|
||||||
rebuilt += ["rebalance", ns.from_symbol, ns.to_symbol]
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"不支持的旧式 decision: {decision}")
|
|
||||||
|
|
||||||
return rebuilt + unknown
|
|
||||||
|
|
||||||
return argv
|
|
||||||
|
|
||||||
|
|
||||||
def parse_cli_args(argv: list[str]):
|
|
||||||
parser = build_parser()
|
|
||||||
normalized = normalize_legacy_argv(argv)
|
|
||||||
args = parser.parse_args(normalized)
|
|
||||||
if not args.command:
|
|
||||||
parser.print_help()
|
|
||||||
raise SystemExit(1)
|
|
||||||
if args.command == "sell_all":
|
|
||||||
args.command = "sell-all"
|
|
||||||
return args, normalized
|
|
||||||
|
|
||||||
|
|
||||||
def command_status(ex):
|
|
||||||
balances = fetch_balances(ex)
|
|
||||||
positions = load_positions()
|
|
||||||
market_snapshot = build_market_snapshot(ex)
|
|
||||||
payload = {
|
|
||||||
"balances": balances,
|
|
||||||
"positions": positions,
|
|
||||||
"market_snapshot": market_snapshot,
|
|
||||||
}
|
|
||||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def command_balances(ex):
|
|
||||||
balances = fetch_balances(ex)
|
|
||||||
print(json.dumps({"balances": balances}, ensure_ascii=False, indent=2))
|
|
||||||
return balances
|
|
||||||
|
|
||||||
|
|
||||||
def cli_action_args(args, action: str) -> list[str]:
|
|
||||||
if action == "sell_all":
|
|
||||||
return [args.symbol]
|
|
||||||
if action == "buy":
|
|
||||||
return [args.symbol, str(args.amount_usdt)]
|
|
||||||
if action == "rebalance":
|
|
||||||
return [args.from_symbol, args.to_symbol]
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
from .services.smart_executor_service import run
|
|
||||||
|
|
||||||
return run(sys.argv[1:])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
raise SystemExit(main())
|
||||||
|
|||||||
Reference in New Issue
Block a user