1
This commit is contained in:
parent
346c35d664
commit
ea342161fc
13
.env.example
13
.env.example
@ -66,9 +66,18 @@ ALPHAX_ONCHAIN_WHALE_TX_USD=250000
|
||||
# 策略交易挂单门控。wait_pullback 只是候选,必须通过这些条件才会创建挂单。
|
||||
ALPHAX_PAPER_ORDER_GATE_ENABLED=1
|
||||
ALPHAX_PAPER_MAX_CUMULATIVE_LEVERAGE=5
|
||||
ALPHAX_PAPER_ORDER_MIN_REC_SCORE=20
|
||||
ALPHAX_PAPER_ORDER_MIN_RR=1.2
|
||||
ALPHAX_PAPER_ENTRY_GATE_ENABLED=1
|
||||
ALPHAX_PAPER_ENTRY_MIN_REC_SCORE=50
|
||||
ALPHAX_PAPER_ENTRY_MIN_RR=1.8
|
||||
ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT=20
|
||||
ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT=3
|
||||
ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES=3
|
||||
ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS=6
|
||||
ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT=1
|
||||
ALPHAX_PAPER_ORDER_MIN_REC_SCORE=50
|
||||
ALPHAX_PAPER_ORDER_MIN_RR=1.8
|
||||
ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK=1
|
||||
ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT=1.5
|
||||
ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT=8
|
||||
ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER=0
|
||||
ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT=12
|
||||
|
||||
@ -118,9 +118,18 @@ def default_paper_trading_config():
|
||||
"trailing_move_push_min_interval_seconds": _env_int("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS", 300),
|
||||
"trailing_move_push_min_step_pct": _env_float("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT", 2.0),
|
||||
"order_gate_enabled": _env_bool("ALPHAX_PAPER_ORDER_GATE_ENABLED", True),
|
||||
"order_min_rec_score": _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 20.0),
|
||||
"order_min_rr": _env_float("ALPHAX_PAPER_ORDER_MIN_RR", 1.2),
|
||||
"entry_gate_enabled": _env_bool("ALPHAX_PAPER_ENTRY_GATE_ENABLED", True),
|
||||
"entry_min_rec_score": _env_float("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", 50.0),
|
||||
"entry_min_rr": _env_float("ALPHAX_PAPER_ENTRY_MIN_RR", 1.8),
|
||||
"max_stop_loss_leverage_risk_pct": _env_float("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", 20.0),
|
||||
"max_account_drawdown_pause_pct": _env_float("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", 3.0),
|
||||
"pause_after_weak_entries": _env_int("ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES", 3),
|
||||
"weak_entry_window_hours": _env_float("ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS", 6.0),
|
||||
"weak_entry_min_max_pnl_pct": _env_float("ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT", 1.0),
|
||||
"order_min_rec_score": _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 50.0),
|
||||
"order_min_rr": _env_float("ALPHAX_PAPER_ORDER_MIN_RR", 1.8),
|
||||
"order_require_risk_reward_ok": _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK", True),
|
||||
"order_min_distance_to_entry_pct": _env_float("ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT", 1.5),
|
||||
"order_max_distance_to_entry_pct": _env_float("ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT", 8.0),
|
||||
"order_require_current_trigger": _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER", False),
|
||||
"order_cancel_far_from_entry_pct": _env_float("ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT", 12.0),
|
||||
|
||||
@ -102,6 +102,70 @@ def _cumulative_leverage_check(conn, additional_notional: float, config: dict |
|
||||
return projected_leverage <= cap + 1e-12, detail
|
||||
|
||||
|
||||
def _portfolio_drawdown_check(conn, additional_notional: float, config: dict | None = None) -> tuple[bool, dict]:
|
||||
cfg = _paper_cfg(config)
|
||||
cap = max(0.0, _safe_float(cfg.get("max_account_drawdown_pause_pct"), 0))
|
||||
if cap <= 0:
|
||||
return True, {"disabled": True, "max_account_drawdown_pause_pct": cap}
|
||||
equity = default_account_equity_usdt(cfg)
|
||||
open_rows = conn.execute("SELECT notional_usdt, pnl_pct FROM paper_trades WHERE status='open'").fetchall()
|
||||
unrealized = sum(_safe_float(r["notional_usdt"]) * _safe_float(r["pnl_pct"]) / 100 for r in open_rows)
|
||||
drawdown_pct = abs(min(0.0, unrealized)) / equity * 100 if equity > 0 else 0
|
||||
detail = {
|
||||
"account_equity_usdt": equity,
|
||||
"open_unrealized_pnl_usdt": round(unrealized, 8),
|
||||
"open_drawdown_pct": round(drawdown_pct, 6),
|
||||
"max_account_drawdown_pause_pct": cap,
|
||||
"additional_notional_usdt": round(max(0.0, _safe_float(additional_notional)), 8),
|
||||
}
|
||||
return drawdown_pct <= cap + 1e-12, detail
|
||||
|
||||
|
||||
def _weak_entries_check(conn, event_time: str, config: dict | None = None) -> tuple[bool, dict]:
|
||||
cfg = _paper_cfg(config)
|
||||
limit = max(0, _safe_int(cfg.get("pause_after_weak_entries"), 0))
|
||||
if limit <= 0:
|
||||
return True, {"disabled": True, "pause_after_weak_entries": limit}
|
||||
window_hours = max(0.1, _safe_float(cfg.get("weak_entry_window_hours"), 6.0))
|
||||
threshold = max(0.0, _safe_float(cfg.get("weak_entry_min_max_pnl_pct"), 1.0))
|
||||
now = _parse_time(event_time or _now()) or datetime.now()
|
||||
since = (now - timedelta(hours=window_hours)).isoformat()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT symbol, opened_at, entry_price, max_price
|
||||
FROM paper_trades
|
||||
WHERE opened_at >= %s
|
||||
ORDER BY opened_at DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(since, limit),
|
||||
).fetchall()
|
||||
samples = []
|
||||
for row in rows:
|
||||
entry = _safe_float(row["entry_price"])
|
||||
max_pnl = (max(_safe_float(row["max_price"]), entry) / entry - 1) * 100 if entry > 0 else 0
|
||||
samples.append({"symbol": row["symbol"], "opened_at": row["opened_at"], "max_pnl_pct": round(max_pnl, 6)})
|
||||
enough = len(samples) >= limit
|
||||
all_weak = enough and all(x["max_pnl_pct"] < threshold for x in samples)
|
||||
detail = {
|
||||
"pause_after_weak_entries": limit,
|
||||
"weak_entry_window_hours": window_hours,
|
||||
"weak_entry_min_max_pnl_pct": threshold,
|
||||
"samples": samples,
|
||||
}
|
||||
return not all_weak, detail
|
||||
|
||||
|
||||
def _portfolio_entry_pause_check(conn, additional_notional: float, event_time: str, config: dict | None = None) -> tuple[bool, str, dict]:
|
||||
drawdown_ok, drawdown = _portfolio_drawdown_check(conn, additional_notional, config)
|
||||
if not drawdown_ok:
|
||||
return False, "portfolio_drawdown_pause", {"drawdown": drawdown}
|
||||
weak_ok, weak = _weak_entries_check(conn, event_time, config)
|
||||
if not weak_ok:
|
||||
return False, "weak_entries_pause", {"weak_entries": weak}
|
||||
return True, "", {"drawdown": drawdown, "weak_entries": weak}
|
||||
|
||||
|
||||
def _trailing_config() -> dict:
|
||||
cfg = paper_trading_config()
|
||||
return {
|
||||
@ -262,6 +326,22 @@ def _trade_pnl_pct(entry_price: float, current_price: float) -> float:
|
||||
return round((current_price / entry_price - 1) * 100, 4)
|
||||
|
||||
|
||||
def _stop_loss_distance_pct(side: str, entry_price: float, stop_loss: float) -> float:
|
||||
if entry_price <= 0 or stop_loss <= 0:
|
||||
return 0.0
|
||||
if str(side or "long").lower() == "short":
|
||||
return max(0.0, (stop_loss / entry_price - 1) * 100)
|
||||
return max(0.0, (1 - stop_loss / entry_price) * 100)
|
||||
|
||||
|
||||
def _stop_loss_leverage_risk_pct(side: str, entry_price: float, stop_loss: float, leverage: float) -> float:
|
||||
return round(_stop_loss_distance_pct(side, entry_price, stop_loss) * max(1.0, _safe_float(leverage, 1.0)), 6)
|
||||
|
||||
|
||||
def _trade_rr(side: str, entry_price: float, stop_loss: float, tp1: float) -> float:
|
||||
return _paper_order_rr(side, entry_price, stop_loss, tp1)
|
||||
|
||||
|
||||
def _account_return_pct(pnl_usdt: float, account_equity: float | None = None, config: dict | None = None) -> float:
|
||||
equity = max(1.0, _safe_float(account_equity, default_account_equity_usdt(config)))
|
||||
return round(_safe_float(pnl_usdt) / equity * 100, 4)
|
||||
@ -500,6 +580,51 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
|
||||
plan = _entry_plan(rec)
|
||||
entry_price = _open_price(current_price, cfg)
|
||||
notional = default_notional_usdt(cfg)
|
||||
side = str(plan.get("side") or rec.get("side") or "long").strip().lower() or "long"
|
||||
leverage = default_leverage(cfg)
|
||||
stop_loss = _safe_float(rec.get("stop_loss") or plan.get("stop_loss"))
|
||||
tp1 = _safe_float(rec.get("tp1") or plan.get("tp1") or plan.get("take_profit_1"))
|
||||
rec_score = _safe_float(rec.get("rec_score") or rec.get("score"))
|
||||
if rec_score <= 0 and rec_id > 0:
|
||||
row = conn.execute("SELECT rec_score FROM recommendation WHERE id=%s", (rec_id,)).fetchone()
|
||||
rec_score = _safe_float(row["rec_score"] if row else 0)
|
||||
if bool(cfg.get("entry_gate_enabled", True)):
|
||||
rr = _safe_float(plan.get("rr1") or plan.get("rr1_live")) or _trade_rr(side, entry_price, stop_loss, tp1)
|
||||
min_rr = max(0.0, _safe_float(cfg.get("entry_min_rr"), 0))
|
||||
min_score = max(0.0, _safe_float(cfg.get("entry_min_rec_score"), 0))
|
||||
sl_risk = _stop_loss_leverage_risk_pct(side, entry_price, stop_loss, leverage)
|
||||
max_sl_risk = max(0.0, _safe_float(cfg.get("max_stop_loss_leverage_risk_pct"), 0))
|
||||
entry_reasons = []
|
||||
if rec_score < min_score:
|
||||
entry_reasons.append("rec_score_below_min")
|
||||
if rr <= 0:
|
||||
entry_reasons.append("missing_rr")
|
||||
elif rr < min_rr:
|
||||
entry_reasons.append("rr_below_min")
|
||||
if max_sl_risk > 0 and sl_risk > max_sl_risk:
|
||||
entry_reasons.append("stop_loss_leverage_risk_exceeded")
|
||||
if entry_reasons:
|
||||
return {
|
||||
"opened": False,
|
||||
"skipped": True,
|
||||
"reason": "entry_gate_rejected",
|
||||
"gate_reasons": entry_reasons,
|
||||
"gate_detail": {
|
||||
"rec_score": rec_score,
|
||||
"min_rec_score": min_score,
|
||||
"rr1": round(rr, 4) if rr > 0 else 0,
|
||||
"min_rr": min_rr,
|
||||
"stop_loss_leverage_risk_pct": sl_risk,
|
||||
"max_stop_loss_leverage_risk_pct": max_sl_risk,
|
||||
"entry_price": entry_price,
|
||||
"stop_loss": stop_loss,
|
||||
"tp1": tp1,
|
||||
"leverage": leverage,
|
||||
},
|
||||
}
|
||||
pause_ok, pause_reason, pause_detail = _portfolio_entry_pause_check(conn, notional, event_time, cfg)
|
||||
if not pause_ok:
|
||||
return {"opened": False, "skipped": True, "reason": pause_reason, "risk_detail": pause_detail}
|
||||
leverage_ok, leverage_detail = _cumulative_leverage_check(conn, notional, cfg, exclude_rec_id=rec_id)
|
||||
if not leverage_ok:
|
||||
return {
|
||||
@ -508,11 +633,8 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
|
||||
"reason": "cumulative_leverage_exceeded",
|
||||
"risk_detail": leverage_detail,
|
||||
}
|
||||
leverage = default_leverage(cfg)
|
||||
margin = default_margin_usdt(cfg)
|
||||
qty = round(notional / entry_price, 12) if entry_price > 0 else 0
|
||||
stop_loss = _safe_float(rec.get("stop_loss") or plan.get("stop_loss"))
|
||||
tp1 = _safe_float(rec.get("tp1") or plan.get("tp1") or plan.get("take_profit_1"))
|
||||
tp2 = _safe_float(rec.get("tp2") or plan.get("tp2") or plan.get("take_profit_2"))
|
||||
fee = round(notional * default_fee_rate(cfg), 8)
|
||||
now = event_time or _now()
|
||||
@ -672,6 +794,7 @@ def _paper_order_gate(rec: dict, current_price: float, config: dict | None = Non
|
||||
effective_rr = rr if rr > 0 else calc_rr
|
||||
min_rr = max(0.0, _safe_float(cfg.get("order_min_rr"), 1.2))
|
||||
min_rec_score = max(0.0, _safe_float(cfg.get("order_min_rec_score"), 20.0))
|
||||
min_distance = max(0.0, _safe_float(cfg.get("order_min_distance_to_entry_pct"), 0.0))
|
||||
rec_score = _safe_float(rec.get("rec_score") or rec.get("score"))
|
||||
if rec_score <= 0 and conn is not None and _safe_int(rec.get("id")) > 0:
|
||||
row = conn.execute("SELECT rec_score FROM recommendation WHERE id=%s", (_safe_int(rec.get("id")),)).fetchone()
|
||||
@ -713,6 +836,8 @@ def _paper_order_gate(rec: dict, current_price: float, config: dict | None = Non
|
||||
reasons.append("rr_below_min")
|
||||
if effective_rr <= 0:
|
||||
reasons.append("missing_rr")
|
||||
if distance_pct < min_distance:
|
||||
reasons.append("too_close_to_entry")
|
||||
if distance_pct > max_distance:
|
||||
reasons.append("too_far_from_entry")
|
||||
if opportunity_level in {"momentum_watch", "theme_trend"} or level_max_action == "observe":
|
||||
@ -727,6 +852,7 @@ def _paper_order_gate(rec: dict, current_price: float, config: dict | None = Non
|
||||
"rr1": round(effective_rr, 4) if effective_rr > 0 else 0,
|
||||
"calc_rr1": round(calc_rr, 4) if calc_rr > 0 else 0,
|
||||
"distance_to_entry_pct": round(distance_pct, 4),
|
||||
"min_distance_to_entry_pct": min_distance,
|
||||
"max_distance_to_entry_pct": max_distance,
|
||||
"min_rr": min_rr,
|
||||
"rec_score": rec_score,
|
||||
@ -806,6 +932,17 @@ def _order_payload_from_rec(rec: dict, current_price: float, event_time: str, co
|
||||
|
||||
def _fill_paper_order(conn, order: dict, rec: dict, current_price: float, event_time: str, config: dict | None = None) -> dict:
|
||||
fill_price = _safe_float(order.get("target_price")) or current_price
|
||||
cfg = _paper_cfg(config)
|
||||
side = str(order.get("side") or "long").lower()
|
||||
leverage = default_leverage(cfg)
|
||||
stop_loss = _safe_float(order.get("stop_loss") or rec.get("stop_loss") or _entry_plan(rec).get("stop_loss"))
|
||||
sl_risk = _stop_loss_leverage_risk_pct(side, fill_price, stop_loss, leverage)
|
||||
max_sl_risk = max(0.0, _safe_float(cfg.get("max_stop_loss_leverage_risk_pct"), 0))
|
||||
if max_sl_risk > 0 and sl_risk > max_sl_risk:
|
||||
return _cancel_paper_order(conn, order, "stop_loss_leverage_risk_exceeded", event_time)
|
||||
pause_ok, pause_reason, pause_detail = _portfolio_entry_pause_check(conn, default_notional_usdt(cfg), event_time, cfg)
|
||||
if not pause_ok:
|
||||
return _cancel_paper_order(conn, order, pause_reason, event_time)
|
||||
trade_rec = dict(rec)
|
||||
plan = _entry_plan(trade_rec)
|
||||
plan.setdefault("entry_price", fill_price)
|
||||
@ -1485,6 +1622,104 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "",
|
||||
}
|
||||
|
||||
|
||||
def delete_paper_trade(trade_id: int) -> dict:
|
||||
trade_id = _safe_int(trade_id)
|
||||
if trade_id <= 0:
|
||||
return {"deleted": False, "reason": "invalid_trade_id"}
|
||||
conn = get_conn()
|
||||
try:
|
||||
trade = conn.execute("SELECT id, recommendation_id, symbol, status FROM paper_trades WHERE id=%s", (trade_id,)).fetchone()
|
||||
if not trade:
|
||||
return {"deleted": False, "reason": "not_found", "trade_id": trade_id}
|
||||
event_count = conn.execute("SELECT COUNT(*) FROM paper_trade_events WHERE trade_id=%s", (trade_id,)).fetchone()[0]
|
||||
conn.execute("DELETE FROM paper_trade_events WHERE trade_id=%s", (trade_id,))
|
||||
conn.execute("DELETE FROM paper_trades WHERE id=%s", (trade_id,))
|
||||
conn.commit()
|
||||
return {
|
||||
"deleted": True,
|
||||
"trade_id": trade_id,
|
||||
"symbol": trade["symbol"],
|
||||
"status": trade["status"],
|
||||
"deleted_events": int(event_count or 0),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_paper_order(order_id: int) -> dict:
|
||||
order_id = _safe_int(order_id)
|
||||
if order_id <= 0:
|
||||
return {"deleted": False, "reason": "invalid_order_id"}
|
||||
conn = get_conn()
|
||||
try:
|
||||
order = conn.execute("SELECT id, recommendation_id, symbol, status FROM paper_orders WHERE id=%s", (order_id,)).fetchone()
|
||||
if not order:
|
||||
return {"deleted": False, "reason": "not_found", "order_id": order_id}
|
||||
conn.execute("DELETE FROM paper_orders WHERE id=%s", (order_id,))
|
||||
conn.commit()
|
||||
return {
|
||||
"deleted": True,
|
||||
"order_id": order_id,
|
||||
"symbol": order["symbol"],
|
||||
"status": order["status"],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def reset_paper_trading_data(scope: str = "all") -> dict:
|
||||
scope = str(scope or "all").strip().lower()
|
||||
allowed = {"all", "trades", "orders", "events", "open_trades", "closed_trades", "completed"}
|
||||
if scope not in allowed:
|
||||
return {"reset": False, "reason": "invalid_scope", "scope": scope, "allowed_scopes": sorted(allowed)}
|
||||
conn = get_conn()
|
||||
try:
|
||||
deleted = {"trades": 0, "orders": 0, "events": 0}
|
||||
|
||||
def delete_trades(where_sql: str = "", params: tuple = ()) -> None:
|
||||
rows = conn.execute(f"SELECT id FROM paper_trades {where_sql}", params).fetchall()
|
||||
ids = [int(r["id"]) for r in rows]
|
||||
if not ids:
|
||||
return
|
||||
event_count = conn.execute("SELECT COUNT(*) FROM paper_trade_events WHERE trade_id = ANY(%s)", (ids,)).fetchone()[0]
|
||||
conn.execute("DELETE FROM paper_trade_events WHERE trade_id = ANY(%s)", (ids,))
|
||||
conn.execute("DELETE FROM paper_trades WHERE id = ANY(%s)", (ids,))
|
||||
deleted["events"] += int(event_count or 0)
|
||||
deleted["trades"] += len(ids)
|
||||
|
||||
def delete_orders(where_sql: str = "", params: tuple = ()) -> None:
|
||||
count = conn.execute(f"SELECT COUNT(*) FROM paper_orders {where_sql}", params).fetchone()[0]
|
||||
conn.execute(f"DELETE FROM paper_orders {where_sql}", params)
|
||||
deleted["orders"] += int(count or 0)
|
||||
|
||||
if scope == "all":
|
||||
deleted["events"] += int(conn.execute("SELECT COUNT(*) FROM paper_trade_events").fetchone()[0] or 0)
|
||||
deleted["trades"] += int(conn.execute("SELECT COUNT(*) FROM paper_trades").fetchone()[0] or 0)
|
||||
deleted["orders"] += int(conn.execute("SELECT COUNT(*) FROM paper_orders").fetchone()[0] or 0)
|
||||
conn.execute("DELETE FROM paper_trade_events")
|
||||
conn.execute("DELETE FROM paper_trades")
|
||||
conn.execute("DELETE FROM paper_orders")
|
||||
elif scope == "events":
|
||||
deleted["events"] += int(conn.execute("SELECT COUNT(*) FROM paper_trade_events").fetchone()[0] or 0)
|
||||
conn.execute("DELETE FROM paper_trade_events")
|
||||
elif scope == "orders":
|
||||
delete_orders()
|
||||
elif scope == "trades":
|
||||
delete_trades()
|
||||
elif scope == "open_trades":
|
||||
delete_trades("WHERE status='open'")
|
||||
elif scope == "closed_trades":
|
||||
delete_trades("WHERE status='closed'")
|
||||
elif scope == "completed":
|
||||
delete_trades("WHERE status='closed'")
|
||||
delete_orders("WHERE status IN ('filled','expired','canceled','rejected')")
|
||||
|
||||
conn.commit()
|
||||
return {"reset": True, "scope": scope, "deleted": deleted}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _report_trade_line(item: dict, closed: bool = False) -> str:
|
||||
symbol = item.get("symbol") or "--"
|
||||
side = "空" if str(item.get("side") or "long").lower() == "short" else "多"
|
||||
|
||||
@ -15,6 +15,7 @@ from app.db.live_trading import (
|
||||
update_live_order_intent,
|
||||
_row,
|
||||
)
|
||||
from app.config.system_config import live_trading_config
|
||||
from app.db.schema import get_conn, init_db
|
||||
from app.integrations.binance_live import LiveTradingConfigError, build_binance_client
|
||||
|
||||
@ -206,6 +207,11 @@ def sync_paper_trade_to_live(
|
||||
client_factory=None,
|
||||
) -> dict:
|
||||
init_db()
|
||||
cfg = live_trading_config()
|
||||
if not bool(cfg.get("enabled")):
|
||||
return {"ok": False, "reason": "live_trading_disabled", "items": []}
|
||||
if str(cfg.get("execution_mode") or "exchange_api").strip().lower() != "exchange_api":
|
||||
return {"ok": False, "reason": "live_trading_not_exchange_api", "items": []}
|
||||
trade = _paper_trade(paper_trade_id)
|
||||
if not trade:
|
||||
return {"ok": False, "reason": "paper_trade_not_found", "items": []}
|
||||
|
||||
@ -19,6 +19,7 @@ from app.db.paper_trading import sync_recommendation
|
||||
from app.db.recommendation_queries import get_active_recommendations_deduped
|
||||
from app.db.schema import get_conn
|
||||
from app.db.system_logs import record_exception, record_system_error
|
||||
from app.services.live_trading_sync import sync_paper_trade_to_live
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
@ -149,6 +150,8 @@ def handle_price_tick(symbol: str, price: float, targets: dict[str, dict], event
|
||||
if not rec:
|
||||
return {"updated_price": True, "paper_trading": {"skipped": True, "reason": "no_target"}}
|
||||
result = sync_recommendation(rec, price, event_time=event_time)
|
||||
if result.get("trade_id") and (result.get("opened") or result.get("paper_order", {}).get("filled")):
|
||||
result["live_sync"] = sync_paper_trade_to_live(int(result["trade_id"]), execute=True)
|
||||
return {"updated_price": True, "paper_trading": result}
|
||||
|
||||
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
from fastapi import APIRouter, Cookie
|
||||
from fastapi import APIRouter, Cookie, HTTPException
|
||||
|
||||
from app.db.paper_trading import (
|
||||
delete_paper_order,
|
||||
delete_paper_trade,
|
||||
get_paper_trading_performance,
|
||||
get_paper_trading_summary,
|
||||
list_paper_orders,
|
||||
list_paper_trade_events,
|
||||
list_paper_trades,
|
||||
reset_paper_trading_data,
|
||||
send_paper_trading_report,
|
||||
)
|
||||
from app.web.shared import require_admin
|
||||
@ -64,3 +67,30 @@ async def api_paper_trading_events(
|
||||
async def api_paper_trading_report(days: int = 30, altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
return send_paper_trading_report(days=days)
|
||||
|
||||
|
||||
@router.delete("/api/paper-trading/trades/{trade_id}")
|
||||
async def api_delete_paper_trade(trade_id: int, altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
result = delete_paper_trade(trade_id)
|
||||
if not result.get("deleted"):
|
||||
raise HTTPException(status_code=404 if result.get("reason") == "not_found" else 400, detail=result)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/api/paper-trading/orders/{order_id}")
|
||||
async def api_delete_paper_order(order_id: int, altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
result = delete_paper_order(order_id)
|
||||
if not result.get("deleted"):
|
||||
raise HTTPException(status_code=404 if result.get("reason") == "not_found" else 400, detail=result)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/paper-trading/reset")
|
||||
async def api_reset_paper_trading(scope: str = "all", altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
result = reset_paper_trading_data(scope=scope)
|
||||
if not result.get("reset"):
|
||||
raise HTTPException(status_code=400, detail=result)
|
||||
return result
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -5,11 +5,14 @@ import pytest
|
||||
|
||||
from app.db import altcoin_db
|
||||
from app.db.paper_trading import (
|
||||
delete_paper_order,
|
||||
delete_paper_trade,
|
||||
get_paper_trading_performance,
|
||||
get_paper_trading_summary,
|
||||
list_paper_orders,
|
||||
list_paper_trade_events,
|
||||
list_paper_trades,
|
||||
reset_paper_trading_data,
|
||||
send_paper_trading_report,
|
||||
sync_recommendation,
|
||||
)
|
||||
@ -43,6 +46,17 @@ def _assert_no_paper_trading_copy(card: dict) -> None:
|
||||
assert "paper_trades" not in text
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def legacy_paper_trade_thresholds(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "0")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", "20")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_ORDER_MIN_RR", "1.2")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT", "0")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", "0")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", "0")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES", "0")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def buy_now_rec(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
@ -311,6 +325,118 @@ def test_wait_pullback_too_far_from_entry_does_not_create_order(monkeypatch):
|
||||
assert list_paper_orders()["total"] == 0
|
||||
|
||||
|
||||
def test_wait_pullback_too_close_to_entry_does_not_create_order(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT", "1.5")
|
||||
altcoin_db.init_db()
|
||||
rec_id = altcoin_db.create_recommendation(
|
||||
symbol="CLOSEWAIT/USDT",
|
||||
rec_state="蓄力",
|
||||
rec_score=60,
|
||||
entry_price=99,
|
||||
stop_loss=95,
|
||||
tp1=107,
|
||||
signals=["等待回踩"],
|
||||
entry_plan={"entry_action": "等回踩", "entry_price": 99, "stop_loss": 95, "tp1": 107, "risk_reward_ok": True, "rr1": 2.0},
|
||||
)
|
||||
rec = {"id": rec_id, "symbol": "CLOSEWAIT/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 99, "stop_loss": 95, "tp1": 107, "entry_plan": {"entry_action": "等回踩", "entry_price": 99, "stop_loss": 95, "tp1": 107, "risk_reward_ok": True, "rr1": 2.0}}
|
||||
|
||||
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||
|
||||
assert result["reason"] == "paper_order_gate_rejected"
|
||||
assert "too_close_to_entry" in result["gate_reasons"]
|
||||
assert list_paper_orders()["total"] == 0
|
||||
|
||||
|
||||
def test_buy_now_rejects_large_leveraged_stop_loss(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADE_LEVERAGE", "5")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", "50")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_ENTRY_MIN_RR", "1.8")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", "20")
|
||||
altcoin_db.init_db()
|
||||
rec_id = altcoin_db.create_recommendation(
|
||||
symbol="WIDESTOP/USDT",
|
||||
rec_state="爆发",
|
||||
rec_score=60,
|
||||
entry_price=100,
|
||||
stop_loss=90,
|
||||
tp1=120,
|
||||
signals=["当前15min即刻入场信号"],
|
||||
entry_plan={"entry_action": "可即刻买入", "entry_price": 100, "stop_loss": 90, "tp1": 120, "risk_reward_ok": True, "rr1": 2.0, "entry_trigger_confirmed": True},
|
||||
)
|
||||
rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id)
|
||||
|
||||
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||
|
||||
assert result["reason"] == "entry_gate_rejected"
|
||||
assert "stop_loss_leverage_risk_exceeded" in result["gate_reasons"]
|
||||
assert list_paper_trades()["total"] == 0
|
||||
|
||||
|
||||
def test_buy_now_pauses_when_portfolio_drawdown_exceeded(monkeypatch, buy_now_rec):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", "1000")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", "3")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "0")
|
||||
altcoin_db.init_db()
|
||||
with altcoin_db.get_conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO paper_trades (
|
||||
recommendation_id, symbol, side, status, opened_at, entry_price,
|
||||
qty, notional_usdt, stop_loss, tp1, tp2, max_price, min_price,
|
||||
current_price, pnl_pct, source_status, source_action,
|
||||
strategy_version, created_at, updated_at, margin_usdt, leverage
|
||||
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""",
|
||||
(
|
||||
9001,
|
||||
"DRAWDOWN/USDT",
|
||||
"long",
|
||||
"open",
|
||||
"2026-05-16T09:00:00",
|
||||
100,
|
||||
1,
|
||||
100,
|
||||
1,
|
||||
120,
|
||||
130,
|
||||
100,
|
||||
60,
|
||||
60,
|
||||
-40,
|
||||
"test",
|
||||
"test",
|
||||
"",
|
||||
"2026-05-16T09:00:00",
|
||||
"2026-05-16T09:01:00",
|
||||
20,
|
||||
5,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
altcoin_db.init_db()
|
||||
rec_id = altcoin_db.create_recommendation(
|
||||
symbol="PAUSE/USDT",
|
||||
rec_state="爆发",
|
||||
rec_score=80,
|
||||
entry_price=100,
|
||||
stop_loss=95,
|
||||
tp1=110,
|
||||
signals=["当前15min即刻入场信号"],
|
||||
entry_plan={"entry_action": "可即刻买入", "entry_trigger_confirmed": True, "risk_reward_ok": True, "rr1": 2.0},
|
||||
)
|
||||
rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id)
|
||||
|
||||
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:02:00")
|
||||
|
||||
assert result["reason"] == "portfolio_drawdown_pause"
|
||||
assert list_paper_trades(status="open")["total"] == 1
|
||||
|
||||
|
||||
def test_buy_now_rejects_when_cumulative_leverage_exceeded(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", "100")
|
||||
@ -796,3 +922,67 @@ def test_disabled_paper_trading_skips_without_writing(monkeypatch, buy_now_rec):
|
||||
assert result["skipped"] is True
|
||||
assert result["reason"] == "disabled"
|
||||
assert list_paper_trades()["total"] == 0
|
||||
|
||||
|
||||
def test_delete_paper_trade_removes_related_events(buy_now_rec):
|
||||
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
||||
trade = list_paper_trades()["items"][0]
|
||||
assert list_paper_trade_events()["total"] == 1
|
||||
|
||||
result = delete_paper_trade(trade["id"])
|
||||
|
||||
assert result["deleted"] is True
|
||||
assert result["deleted_events"] == 1
|
||||
assert list_paper_trades()["total"] == 0
|
||||
assert list_paper_trade_events()["total"] == 0
|
||||
|
||||
|
||||
def test_delete_paper_order_removes_order_only(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
altcoin_db.init_db()
|
||||
rec_id = altcoin_db.create_recommendation(
|
||||
symbol="DELORD/USDT",
|
||||
rec_state="蓄力",
|
||||
rec_score=22,
|
||||
entry_price=95,
|
||||
stop_loss=90,
|
||||
tp1=105,
|
||||
signals=["等待回踩"],
|
||||
entry_plan={"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
|
||||
)
|
||||
rec = {"id": rec_id, "symbol": "DELORD/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "entry_plan": {"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0}}
|
||||
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||
order = list_paper_orders()["items"][0]
|
||||
|
||||
result = delete_paper_order(order["id"])
|
||||
|
||||
assert result["deleted"] is True
|
||||
assert list_paper_orders()["total"] == 0
|
||||
|
||||
|
||||
def test_reset_paper_trading_data_all_clears_ledger(monkeypatch, buy_now_rec):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
||||
altcoin_db.init_db()
|
||||
rec_id = altcoin_db.create_recommendation(
|
||||
symbol="RESETORD/USDT",
|
||||
rec_state="蓄力",
|
||||
rec_score=22,
|
||||
entry_price=95,
|
||||
stop_loss=90,
|
||||
tp1=105,
|
||||
signals=["等待回踩"],
|
||||
entry_plan={"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
|
||||
)
|
||||
rec = {"id": rec_id, "symbol": "RESETORD/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "entry_plan": {"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0}}
|
||||
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||
|
||||
result = reset_paper_trading_data("all")
|
||||
|
||||
assert result["reset"] is True
|
||||
assert result["deleted"]["trades"] == 1
|
||||
assert result["deleted"]["orders"] == 1
|
||||
assert result["deleted"]["events"] == 1
|
||||
assert list_paper_trades()["total"] == 0
|
||||
assert list_paper_orders()["total"] == 0
|
||||
assert list_paper_trade_events()["total"] == 0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user