This commit is contained in:
aaron 2026-05-23 09:46:12 +08:00
parent 346c35d664
commit ea342161fc
8 changed files with 530 additions and 24 deletions

View File

@ -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

View File

@ -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),

View File

@ -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 ""

View File

@ -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": []}

View File

@ -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}

View File

@ -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

View File

@ -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