1793 lines
75 KiB
Python
1793 lines
75 KiB
Python
import json
|
|
from datetime import datetime
|
|
|
|
import pytest
|
|
|
|
from app.db import altcoin_db
|
|
from app.db.paper_trading import (
|
|
_gate_reject_lock_id,
|
|
_paper_order_gate,
|
|
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_pending_paper_orders,
|
|
sync_recommendation,
|
|
)
|
|
from app.db.recommendation_queries import get_opportunity_detail
|
|
from app.core.strategy_registry import (
|
|
LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
|
SHORT_BREAKDOWN_RETEST_STRATEGY,
|
|
)
|
|
|
|
|
|
def _visible_card_text(card: dict) -> str:
|
|
texts = []
|
|
|
|
def walk(value):
|
|
if isinstance(value, dict):
|
|
content = value.get("content")
|
|
if isinstance(content, str):
|
|
texts.append(content)
|
|
for child in value.values():
|
|
walk(child)
|
|
elif isinstance(value, list):
|
|
for child in value:
|
|
walk(child)
|
|
|
|
walk(card.get("header"))
|
|
walk(card.get("elements"))
|
|
return "\n".join(texts)
|
|
|
|
|
|
def _assert_no_paper_trading_copy(card: dict) -> None:
|
|
text = _visible_card_text(card).lower()
|
|
assert "模拟" not in text
|
|
assert "paper trading" not in text
|
|
assert "paper-trading" not in text
|
|
assert "paper trade" not in text
|
|
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")
|
|
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "0")
|
|
|
|
|
|
@pytest.fixture
|
|
def buy_now_rec(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="PAPER/USDT",
|
|
rec_state="爆发",
|
|
rec_score=28,
|
|
entry_price=100,
|
|
stop_loss=96,
|
|
tp1=106,
|
|
tp2=112,
|
|
signals=["当前15min即刻入场信号"],
|
|
entry_plan={
|
|
"entry_action": "可即刻买入",
|
|
"entry_price": 100,
|
|
"stop_loss": 96,
|
|
"tp1": 106,
|
|
"tp2": 112,
|
|
"risk_reward_ok": True,
|
|
"rr1": 1.5,
|
|
"entry_trigger_confirmed": True,
|
|
},
|
|
)
|
|
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
|
return next(r for r in rows if r["id"] == rec_id)
|
|
|
|
|
|
def test_buy_now_opens_paper_trade_once(buy_now_rec):
|
|
first = sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
second = sync_recommendation(buy_now_rec, 101, event_time="2026-05-16T10:01:00")
|
|
|
|
assert first["opened"] is True
|
|
assert second["updated"] is True
|
|
|
|
trades = list_paper_trades()["items"]
|
|
assert len(trades) == 1
|
|
assert trades[0]["symbol"] == "PAPER/USDT"
|
|
assert trades[0]["status"] == "open"
|
|
assert trades[0]["pnl_pct"] == pytest.approx(1.0)
|
|
|
|
|
|
def test_opportunity_detail_exposes_strategy_trade_markers(buy_now_rec):
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
sync_recommendation(buy_now_rec, 106, event_time="2026-05-16T10:05:00")
|
|
|
|
detail = get_opportunity_detail(symbol="PAPER/USDT", rec_id=buy_now_rec["id"])
|
|
markers = detail["trade_markers"]
|
|
|
|
labels = [item["label"] for item in markers]
|
|
assert "开仓" in labels
|
|
assert "平仓" in labels
|
|
assert all(item["time"] for item in markers)
|
|
assert all(item["source"] in {"paper_event", "paper_trade", "paper_order"} for item in markers)
|
|
|
|
|
|
def test_default_paper_trade_uses_5000u_notional_5x_and_1000u_margin(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.delenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", raising=False)
|
|
monkeypatch.delenv("ALPHAX_PAPER_TRADE_MARGIN_USDT", raising=False)
|
|
monkeypatch.delenv("ALPHAX_PAPER_TRADE_LEVERAGE", raising=False)
|
|
monkeypatch.delenv("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", raising=False)
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="DEFAULT/USDT",
|
|
rec_state="爆发",
|
|
rec_score=28,
|
|
entry_price=100,
|
|
stop_loss=95,
|
|
tp1=106,
|
|
tp2=112,
|
|
signals=["当前15min即刻入场信号"],
|
|
entry_plan={"entry_action": "可即刻买入", "entry_trigger_confirmed": True, "risk_reward_ok": True},
|
|
)
|
|
rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id)
|
|
rec = {**rec, "action_status": "可即刻买入", "execution_status": "buy_now"}
|
|
|
|
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
trade = list_paper_trades()["items"][0]
|
|
summary = get_paper_trading_summary(days=30)
|
|
|
|
assert trade["notional_usdt"] == pytest.approx(5000.0)
|
|
assert trade["leverage"] == pytest.approx(5.0)
|
|
assert trade["margin_usdt"] == pytest.approx(1000.0)
|
|
assert summary["account_equity_usdt"] == pytest.approx(20000.0)
|
|
assert summary["initial_equity_usdt"] == pytest.approx(20000.0)
|
|
assert summary["current_balance_usdt"] == pytest.approx(20000.0)
|
|
assert summary["open_position_value_usdt"] == pytest.approx(5000.0)
|
|
assert summary["cumulative_leverage"] == pytest.approx(0.25)
|
|
assert summary["notional_usdt"] == pytest.approx(5000.0)
|
|
assert summary["margin_usdt"] == pytest.approx(1000.0)
|
|
|
|
|
|
def test_paper_margin_is_derived_from_notional_and_leverage(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "5000")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_LEVERAGE", "5")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_MARGIN_USDT", "5000")
|
|
|
|
summary = get_paper_trading_summary(days=30)
|
|
|
|
assert summary["notional_usdt"] == pytest.approx(5000.0)
|
|
assert summary["leverage"] == pytest.approx(5.0)
|
|
assert summary["margin_usdt"] == pytest.approx(1000.0)
|
|
|
|
|
|
def test_paper_performance_returns_daily_equity_curve(monkeypatch, buy_now_rec):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
|
|
now = datetime.now().replace(microsecond=0).isoformat()
|
|
|
|
sync_recommendation(buy_now_rec, 100, event_time=now)
|
|
sync_recommendation(buy_now_rec, 106, event_time=now)
|
|
|
|
curve = get_paper_trading_performance(days=7)
|
|
|
|
assert curve["points"]
|
|
assert curve["current_equity_usdt"] == pytest.approx(20006.0)
|
|
assert curve["total_pnl_usdt"] == pytest.approx(6.0)
|
|
assert curve["total_return_pct"] == pytest.approx(0.03)
|
|
assert curve["max_drawdown_pct"] >= 0
|
|
assert curve["points"][-1]["daily_pnl_usdt"] == pytest.approx(6.0)
|
|
assert curve["points"][-1]["equity_usdt"] == pytest.approx(20006.0)
|
|
|
|
|
|
def test_observation_does_not_open_paper_trade(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="OBS/USDT",
|
|
rec_state="加速",
|
|
rec_score=18,
|
|
entry_price=100,
|
|
stop_loss=95,
|
|
tp1=106,
|
|
signals=["历史放量"],
|
|
entry_plan={"entry_action": "观察", "entry_price": 100, "stop_loss": 95, "tp1": 106},
|
|
)
|
|
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
|
rec = next(r for r in rows if r["id"] == rec_id)
|
|
|
|
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
|
|
assert result["skipped"] is True
|
|
assert result["reason"] == "not_buy_now"
|
|
assert list_paper_trades()["total"] == 0
|
|
|
|
|
|
def test_wait_pullback_creates_pending_paper_order(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="WAIT/USDT",
|
|
rec_state="蓄力",
|
|
rec_score=22,
|
|
entry_price=95,
|
|
stop_loss=90,
|
|
tp1=105,
|
|
tp2=112,
|
|
signals=["等待回踩"],
|
|
entry_plan={"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
|
|
)
|
|
with altcoin_db.get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE recommendation SET execution_status='wait_pullback', action_status='等回踩', display_bucket='watch_pool' WHERE id=%s",
|
|
(rec_id,),
|
|
)
|
|
conn.commit()
|
|
rec = {"id": rec_id, "symbol": "WAIT/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "tp2": 112, "entry_plan": {"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0}}
|
|
|
|
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
|
|
assert result["skipped"] is True
|
|
assert result["reason"] == "paper_order_created"
|
|
assert result["target_price"] == pytest.approx(95)
|
|
assert list_paper_trades()["total"] == 0
|
|
orders = list_paper_orders()["items"]
|
|
assert len(orders) == 1
|
|
assert orders[0]["status"] == "pending"
|
|
assert orders[0]["symbol"] == "WAIT/USDT"
|
|
|
|
|
|
def test_wait_pullback_paper_order_pushes_created_card(monkeypatch):
|
|
pushed = []
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: pushed.append(card) or (True, {"StatusCode": 0}))
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="PUSHORD/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": "PUSHORD/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")
|
|
|
|
assert pushed
|
|
card = pushed[0]
|
|
assert card["metadata"]["event_type"] == "paper_order_create"
|
|
assert "挂单创建" in card["header"]["title"]["content"]
|
|
_assert_no_paper_trading_copy(card)
|
|
assert card["elements"][0]["tag"] == "column_set"
|
|
|
|
|
|
def test_wait_pullback_without_tradeable_plan_does_not_create_order(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="BADPLAN/USDT",
|
|
rec_state="蓄力",
|
|
rec_score=20,
|
|
entry_price=95,
|
|
signals=["等待回踩"],
|
|
entry_plan={"entry_action": "等回踩", "entry_price": 95},
|
|
)
|
|
rec = {"id": rec_id, "symbol": "BADPLAN/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "entry_plan": {"entry_action": "等回踩", "entry_price": 95}}
|
|
|
|
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
|
|
assert result["reason"] == "paper_order_gate_rejected"
|
|
assert "missing_stop_loss" in result["gate_reasons"]
|
|
assert "missing_tp1" in result["gate_reasons"]
|
|
assert list_paper_orders()["total"] == 0
|
|
events = list_paper_trade_events(event_type="paper_gate_reject")["items"]
|
|
assert events
|
|
assert events[0]["symbol"] == "BADPLAN/USDT"
|
|
assert events[0]["detail"]["reason"] == "paper_order_gate_rejected"
|
|
assert "missing_stop_loss" in events[0]["detail"]["gate_reasons"]
|
|
|
|
|
|
def test_repeated_paper_gate_reject_is_deduped(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_GATE_REJECT_DEDUP_MINUTES", "30")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="DEDUP/USDT",
|
|
rec_state="蓄力",
|
|
rec_score=20,
|
|
entry_price=95,
|
|
signals=["等待回踩"],
|
|
entry_plan={"entry_action": "等回踩", "entry_price": 95},
|
|
)
|
|
rec = {
|
|
"id": rec_id,
|
|
"symbol": "DEDUP/USDT",
|
|
"execution_status": "wait_pullback",
|
|
"action_status": "等回踩",
|
|
"entry_price": 95,
|
|
"entry_plan": {"entry_action": "等回踩", "entry_price": 95},
|
|
}
|
|
|
|
first = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
second = sync_recommendation(rec, 100, event_time="2026-05-16T10:05:00")
|
|
|
|
assert first["reason"] == "paper_order_gate_rejected"
|
|
assert second["reason"] == "paper_order_gate_rejected"
|
|
events = list_paper_trade_events(event_type="paper_gate_reject")["items"]
|
|
assert len(events) == 1
|
|
assert events[0]["symbol"] == "DEDUP/USDT"
|
|
|
|
|
|
def test_strategy_order_gate_threshold_wins_over_global_env(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", "25")
|
|
rec = {
|
|
"id": 0,
|
|
"symbol": "SHORTOK/USDT",
|
|
"strategy_code": SHORT_BREAKDOWN_RETEST_STRATEGY,
|
|
"rec_score": 18,
|
|
"execution_status": "wait_pullback",
|
|
"entry_plan": {
|
|
"strategy_code": SHORT_BREAKDOWN_RETEST_STRATEGY,
|
|
"entry_action": "等回踩",
|
|
"side": "short",
|
|
"entry_price": 100,
|
|
"stop_loss": 110,
|
|
"tp1": 85,
|
|
"risk_reward_ok": True,
|
|
"rr1": 1.5,
|
|
},
|
|
}
|
|
|
|
ok, reasons, detail = _paper_order_gate(rec, 100)
|
|
|
|
assert ok is True
|
|
assert "rec_score_below_min" not in reasons
|
|
assert detail["min_rec_score"] == 18
|
|
|
|
|
|
def test_gate_reject_lock_id_uses_stable_reject_identity():
|
|
base = {
|
|
"reason": "paper_order_gate_rejected",
|
|
"gate_reasons": ["rec_score_below_min"],
|
|
"action_status": "等回踩",
|
|
"execution_status": "wait_pullback",
|
|
}
|
|
changed_runtime = {**base, "action_status": "等待回踩", "execution_status": "observe"}
|
|
|
|
assert _gate_reject_lock_id(1, "paper_gate_reject", SHORT_BREAKDOWN_RETEST_STRATEGY, base) == _gate_reject_lock_id(
|
|
1,
|
|
"paper_gate_reject",
|
|
SHORT_BREAKDOWN_RETEST_STRATEGY,
|
|
changed_runtime,
|
|
)
|
|
|
|
|
|
def test_wait_pullback_requires_confirmed_risk_reward(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="NORR/USDT",
|
|
rec_state="蓄力",
|
|
rec_score=24,
|
|
entry_price=95,
|
|
stop_loss=90,
|
|
tp1=105,
|
|
signals=["等待回踩"],
|
|
entry_plan={"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "rr1": 2.0},
|
|
)
|
|
rec = {"id": rec_id, "symbol": "NORR/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, "rr1": 2.0}}
|
|
|
|
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
|
|
assert result["reason"] == "paper_order_gate_rejected"
|
|
assert "risk_reward_not_confirmed" in result["gate_reasons"]
|
|
assert list_paper_orders()["total"] == 0
|
|
|
|
|
|
def test_wait_pullback_recalculates_rr_at_target_price(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="TARGETRR/USDT",
|
|
rec_state="蓄力",
|
|
rec_score=24,
|
|
entry_price=95,
|
|
stop_loss=90,
|
|
tp1=105,
|
|
signals=["等待回踩"],
|
|
entry_plan={
|
|
"entry_action": "等回踩",
|
|
"entry_price": 95,
|
|
"stop_loss": 90,
|
|
"tp1": 105,
|
|
"risk_reward_ok": False,
|
|
"rr1": 0.6,
|
|
},
|
|
)
|
|
rec = {
|
|
"id": rec_id,
|
|
"symbol": "TARGETRR/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": False,
|
|
"rr1": 0.6,
|
|
},
|
|
}
|
|
|
|
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
|
|
assert result["reason"] == "paper_order_created"
|
|
assert result["gate_detail"]["rr1"] == pytest.approx(2.0)
|
|
assert result["gate_detail"]["calc_rr1"] == pytest.approx(2.0)
|
|
assert list_paper_orders()["total"] == 1
|
|
|
|
|
|
def test_wait_pullback_too_far_from_entry_does_not_create_order(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="FARGATE/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": "FARGATE/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}}
|
|
|
|
result = sync_recommendation(rec, 110, event_time="2026-05-16T10:00:00")
|
|
|
|
assert result["reason"] == "paper_order_gate_rejected"
|
|
assert "too_far_from_entry" in result["gate_reasons"]
|
|
assert result["gate_detail"]["distance_to_entry_pct"] > 8
|
|
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_lowers_leverage_for_large_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")
|
|
monkeypatch.setenv("ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN", "1")
|
|
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["opened"] is True, result
|
|
trade = list_paper_trades()["items"][0]
|
|
assert trade["leverage"] < 5
|
|
assert trade["leverage"] >= 1
|
|
assert trade["leverage"] * 10.05 <= 20.01
|
|
|
|
|
|
def test_buy_now_rejects_large_leveraged_stop_loss_when_dynamic_leverage_disabled(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")
|
|
monkeypatch.setenv("ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED", "0")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="WIDESTOP2/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)
|
|
rec = {**rec, "action_status": "可即刻买入", "execution_status": "buy_now"}
|
|
|
|
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_entry_gate_uses_latest_entry_plan_rr(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", "50")
|
|
monkeypatch.setenv("ALPHAX_PAPER_ENTRY_MIN_RR", "1.5")
|
|
monkeypatch.setenv("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", "20")
|
|
monkeypatch.setenv("ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN", "3")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="PLANRR/USDT",
|
|
rec_state="爆发",
|
|
rec_score=67,
|
|
entry_price=0.0913,
|
|
stop_loss=0.085064,
|
|
tp1=0.0993,
|
|
signals=["当前15min即刻入场信号"],
|
|
entry_plan={
|
|
"entry_action": "可即刻买入",
|
|
"entry_price": 0.0899,
|
|
"current_price": 0.0899,
|
|
"stop_loss": 0.085064,
|
|
"tp1": 0.098243,
|
|
"risk_reward_ok": True,
|
|
"rr1": 1.73,
|
|
"entry_trigger_confirmed": True,
|
|
},
|
|
)
|
|
rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id)
|
|
rec = {**rec, "action_status": "可即刻买入", "execution_status": "buy_now"}
|
|
|
|
result = sync_recommendation(rec, 0.0899, event_time="2026-05-16T10:00:00")
|
|
|
|
assert result["opened"] is True, result
|
|
trade = list_paper_trades()["items"][0]
|
|
assert trade["stop_loss"] == pytest.approx(0.085064)
|
|
assert trade["tp1"] == pytest.approx(0.098243)
|
|
assert trade["leverage"] < 5
|
|
|
|
|
|
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")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "500")
|
|
monkeypatch.setenv("ALPHAX_PAPER_MAX_CUMULATIVE_LEVERAGE", "5")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
|
|
altcoin_db.init_db()
|
|
first_id = altcoin_db.create_recommendation(
|
|
symbol="LEV1/USDT",
|
|
rec_state="爆发",
|
|
rec_score=28,
|
|
entry_price=100,
|
|
stop_loss=95,
|
|
tp1=106,
|
|
signals=["当前15min即刻入场信号"],
|
|
entry_plan={"entry_action": "可即刻买入", "entry_trigger_confirmed": True, "risk_reward_ok": True},
|
|
)
|
|
second_id = altcoin_db.create_recommendation(
|
|
symbol="LEV2/USDT",
|
|
rec_state="爆发",
|
|
rec_score=28,
|
|
entry_price=100,
|
|
stop_loss=95,
|
|
tp1=106,
|
|
signals=["当前15min即刻入场信号"],
|
|
entry_plan={"entry_action": "可即刻买入", "entry_trigger_confirmed": True, "risk_reward_ok": True},
|
|
)
|
|
rows = {r["id"]: r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False)}
|
|
|
|
first = sync_recommendation(rows[first_id], 100, event_time="2026-05-16T10:00:00")
|
|
second = sync_recommendation(rows[second_id], 100, event_time="2026-05-16T10:01:00")
|
|
|
|
assert first["opened"] is True
|
|
assert second["reason"] == "cumulative_leverage_exceeded"
|
|
assert second["risk_detail"]["projected_cumulative_leverage"] > 5
|
|
assert list_paper_trades(status="open")["total"] == 1
|
|
|
|
|
|
def test_buy_now_uses_reduced_size_when_global_market_risk_is_critical(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", "0")
|
|
monkeypatch.setattr(
|
|
"app.core.global_risk.get_crypto_market_overview",
|
|
lambda allow_live_fallback=False: {
|
|
"sample_count": 200,
|
|
"benchmarks": {"BTC/USDT": {"change_24h": -3.5}, "ETH/USDT": {"change_24h": -4.1}},
|
|
"advance_decline_ratio": 0.45,
|
|
"avg_change_24h": -2.2,
|
|
"hot_count_5pct": 2,
|
|
"crash_count_5pct": 36,
|
|
"funding": {"avg_funding_rate": -0.0001, "extreme_positive_count": 0},
|
|
},
|
|
)
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="RISKOFF/USDT",
|
|
rec_state="爆发",
|
|
rec_score=95,
|
|
entry_price=100,
|
|
stop_loss=95,
|
|
tp1=112,
|
|
signals=["当前15min即刻入场信号"],
|
|
entry_plan={"entry_action": "可即刻买入", "entry_trigger_confirmed": True, "risk_reward_ok": True, "rr1": 2.4},
|
|
)
|
|
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["opened"] is True
|
|
assert result["global_risk"]["risk_level"] == "critical"
|
|
assert result["global_risk"]["decision"] == "allow_reduced_size"
|
|
assert result["notional_usdt"] == pytest.approx(1250.0)
|
|
assert list_paper_trades()["total"] == 1
|
|
|
|
|
|
def test_short_buy_now_not_reduced_by_risk_off_market(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100")
|
|
monkeypatch.setattr(
|
|
"app.core.global_risk.get_crypto_market_overview",
|
|
lambda allow_live_fallback=False: {
|
|
"sample_count": 200,
|
|
"benchmarks": {"BTC/USDT": {"change_24h": -3.5}, "ETH/USDT": {"change_24h": -4.1}},
|
|
"advance_decline_ratio": 0.45,
|
|
"avg_change_24h": -2.2,
|
|
"hot_count_5pct": 2,
|
|
"crash_count_5pct": 36,
|
|
"funding": {"avg_funding_rate": -0.0001, "extreme_positive_count": 0},
|
|
},
|
|
)
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="SHORTOFF/USDT",
|
|
rec_state="爆发",
|
|
rec_score=60,
|
|
entry_price=100,
|
|
stop_loss=105,
|
|
tp1=90,
|
|
signals=["1H破位反抽做空"],
|
|
direction="空头启动",
|
|
entry_plan={"side": "short", "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:00:00")
|
|
|
|
assert result["opened"] is True
|
|
assert result["side"] == "short"
|
|
assert result["global_risk"]["directional_market_bias"]["market_bias"] == "favorable"
|
|
assert result["notional_usdt"] == pytest.approx(100.0)
|
|
|
|
|
|
def test_open_event_records_market_regime_and_score_components(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "0")
|
|
monkeypatch.setattr(
|
|
"app.core.global_risk.get_crypto_market_overview",
|
|
lambda allow_live_fallback=False: {
|
|
"sample_count": 200,
|
|
"benchmarks": {"BTC/USDT": {"change_24h": 0.8}, "ETH/USDT": {"change_24h": 1.2}},
|
|
"advance_decline_ratio": 1.5,
|
|
"avg_change_24h": 1.4,
|
|
"hot_count_5pct": 25,
|
|
"crash_count_5pct": 2,
|
|
"funding": {"avg_funding_rate": 0.0001, "extreme_positive_count": 3},
|
|
},
|
|
)
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="REGIME/USDT",
|
|
rec_state="爆发",
|
|
rec_score=95,
|
|
entry_price=100,
|
|
stop_loss=95,
|
|
tp1=112,
|
|
signals=["当前15min即刻入场信号"],
|
|
entry_plan={
|
|
"entry_action": "可即刻买入",
|
|
"entry_trigger_confirmed": True,
|
|
"risk_reward_ok": True,
|
|
"rr1": 2.4,
|
|
"score_components": {"opportunity_score": 14, "entry_score": 4, "risk_score": 1},
|
|
},
|
|
)
|
|
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")
|
|
events = list_paper_trade_events(symbol="REGIME/USDT")["items"]
|
|
|
|
assert result["opened"] is True
|
|
assert events[0]["event_type"] == "open"
|
|
assert events[0]["detail"]["market_regime"]["regime"] == "altcoin_rotation"
|
|
assert events[0]["detail"]["score_components"]["opportunity_score"] == 14
|
|
|
|
|
|
def test_observe_only_wait_pullback_does_not_create_order(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="OBSWAIT/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,
|
|
"opportunity_level": "theme_trend",
|
|
},
|
|
)
|
|
rec = {"id": rec_id, "symbol": "OBSWAIT/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, "opportunity_level": "theme_trend"}}
|
|
|
|
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
|
|
assert result["reason"] == "paper_order_gate_rejected"
|
|
assert "observe_only_opportunity" in result["gate_reasons"]
|
|
assert list_paper_orders()["total"] == 0
|
|
|
|
|
|
def test_wait_pullback_paper_order_fills_when_price_touches(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="FILL/USDT",
|
|
rec_state="蓄力",
|
|
rec_score=22,
|
|
entry_price=95,
|
|
stop_loss=90,
|
|
tp1=105,
|
|
tp2=112,
|
|
signals=["等待回踩"],
|
|
entry_plan={"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
|
|
)
|
|
with altcoin_db.get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE recommendation SET execution_status='wait_pullback', action_status='等回踩', display_bucket='watch_pool' WHERE id=%s",
|
|
(rec_id,),
|
|
)
|
|
conn.commit()
|
|
rec = {"id": rec_id, "symbol": "FILL/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "tp2": 112, "entry_plan": {"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0}}
|
|
|
|
created = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
filled = sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00")
|
|
|
|
assert created["reason"] == "paper_order_created"
|
|
assert filled["opened"] is True
|
|
assert filled["paper_order"]["filled"] is True
|
|
trade = list_paper_trades()["items"][0]
|
|
assert trade["entry_price"] == pytest.approx(95)
|
|
order = list_paper_orders(status="filled")["items"][0]
|
|
assert order["fill_price"] == pytest.approx(95)
|
|
|
|
|
|
def test_pending_paper_order_reconciles_from_latest_price_cache(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="CACHEFILL/USDT",
|
|
rec_state="蓄力",
|
|
rec_score=22,
|
|
entry_price=95,
|
|
stop_loss=90,
|
|
tp1=105,
|
|
tp2=112,
|
|
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": "CACHEFILL/USDT",
|
|
"execution_status": "wait_pullback",
|
|
"action_status": "等回踩",
|
|
"entry_price": 95,
|
|
"stop_loss": 90,
|
|
"tp1": 105,
|
|
"tp2": 112,
|
|
"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")
|
|
altcoin_db.update_latest_price_cache("CACHEFILL/USDT", 94.9, updated_at="2026-05-16T10:05:00", source="test")
|
|
|
|
result = sync_pending_paper_orders(event_time="2026-05-16T10:05:00")
|
|
|
|
assert result["filled_count"] == 1
|
|
assert result["results"][0]["paper_order"]["filled"] is True
|
|
trade = list_paper_trades()["items"][0]
|
|
assert trade["symbol"] == "CACHEFILL/USDT"
|
|
order = list_paper_orders(status="filled")["items"][0]
|
|
assert order["fill_price"] == pytest.approx(95)
|
|
|
|
|
|
def test_touched_wait_pullback_order_ignores_market_only_critical_pause(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")
|
|
monkeypatch.setattr(
|
|
"app.db.paper_trading.evaluate_global_risk",
|
|
lambda **kwargs: {
|
|
"allow_new_entries": False,
|
|
"decision": "block_critical",
|
|
"risk_level": "critical",
|
|
"position_multiplier": 0,
|
|
"reasons": ["critical 市场环境下推荐分不足"],
|
|
"portfolio": {"unrealized_drawdown_pct": 0.0},
|
|
"critical_drawdown_pct": 6.0,
|
|
},
|
|
)
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="RISKPAUSE/USDT",
|
|
rec_state="蓄力",
|
|
rec_score=22,
|
|
entry_price=95,
|
|
stop_loss=90,
|
|
tp1=105,
|
|
tp2=112,
|
|
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": "RISKPAUSE/USDT",
|
|
"execution_status": "wait_pullback",
|
|
"action_status": "等回踩",
|
|
"entry_price": 95,
|
|
"stop_loss": 90,
|
|
"tp1": 105,
|
|
"tp2": 112,
|
|
"entry_plan": {"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
|
|
}
|
|
|
|
created = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
filled = sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00")
|
|
|
|
assert created["reason"] == "paper_order_created"
|
|
assert filled.get("opened") is True
|
|
assert filled["global_risk"]["touch_soft_gate_overridden"] is True
|
|
assert filled["global_risk"]["touch_soft_gate_reason"] == "block_critical"
|
|
assert list_paper_trades()["total"] == 1
|
|
assert list_paper_orders(status="filled")["total"] == 1
|
|
assert list_paper_orders(status="canceled")["total"] == 0
|
|
|
|
|
|
def test_touched_wait_pullback_order_still_cancels_on_account_hard_limit(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")
|
|
monkeypatch.setattr(
|
|
"app.db.paper_trading.evaluate_global_risk",
|
|
lambda **kwargs: {
|
|
"allow_new_entries": False,
|
|
"decision": "block_max_open_positions",
|
|
"risk_level": "high",
|
|
"position_multiplier": 1,
|
|
"reasons": ["持仓数量已达到上限"],
|
|
"portfolio": {"unrealized_drawdown_pct": 0.0},
|
|
"critical_drawdown_pct": 6.0,
|
|
},
|
|
)
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="HARDLIMIT/USDT",
|
|
rec_state="蓄力",
|
|
rec_score=80,
|
|
entry_price=95,
|
|
stop_loss=90,
|
|
tp1=105,
|
|
tp2=112,
|
|
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": "HARDLIMIT/USDT",
|
|
"rec_score": 80,
|
|
"execution_status": "wait_pullback",
|
|
"action_status": "等回踩",
|
|
"entry_price": 95,
|
|
"stop_loss": 90,
|
|
"tp1": 105,
|
|
"tp2": 112,
|
|
"entry_plan": {"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
|
|
}
|
|
|
|
created = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
canceled = sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00")
|
|
|
|
assert created["reason"] == "paper_order_created"
|
|
assert canceled["reason"] == "paper_order_touch_max_open_positions"
|
|
assert list_paper_trades()["total"] == 0
|
|
assert list_paper_orders(status="pending")["total"] == 0
|
|
canceled = list_paper_orders(status="canceled")["items"][0]
|
|
assert canceled["symbol"] == "HARDLIMIT/USDT"
|
|
assert canceled["cancel_reason"] == "touch_max_open_positions"
|
|
|
|
|
|
def test_touched_order_soft_global_risk_gate_fills_instead_of_canceling(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")
|
|
monkeypatch.setattr(
|
|
"app.db.paper_trading.evaluate_global_risk",
|
|
lambda **kwargs: {
|
|
"allow_new_entries": False,
|
|
"decision": "block_high_weak_score",
|
|
"risk_level": "high",
|
|
"position_multiplier": 0.5,
|
|
"reasons": ["高风险环境下推荐分不足"],
|
|
},
|
|
)
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="SOFTOUCH/USDT",
|
|
rec_state="蓄力",
|
|
rec_score=60,
|
|
entry_price=95,
|
|
stop_loss=90,
|
|
tp1=105,
|
|
tp2=112,
|
|
signals=["等待回踩"],
|
|
entry_plan={"side": "long", "entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
|
|
)
|
|
rec = {
|
|
"id": rec_id,
|
|
"symbol": "SOFTOUCH/USDT",
|
|
"rec_score": 60,
|
|
"execution_status": "wait_pullback",
|
|
"action_status": "等回踩",
|
|
"entry_price": 95,
|
|
"stop_loss": 90,
|
|
"tp1": 105,
|
|
"tp2": 112,
|
|
"entry_plan": {"side": "long", "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 = sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00")
|
|
|
|
assert result.get("opened") is True
|
|
assert result["global_risk"]["touch_soft_gate_overridden"] is True
|
|
assert list_paper_orders(status="canceled")["total"] == 0
|
|
assert list_paper_trades()["total"] == 1
|
|
|
|
|
|
def test_touched_order_uses_order_snapshot_side_for_global_risk(monkeypatch, pg_conn):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")
|
|
captured = []
|
|
|
|
def fake_global_risk(**kwargs):
|
|
captured.append(dict(kwargs.get("rec") or {}))
|
|
return {
|
|
"allow_new_entries": True,
|
|
"decision": "allow",
|
|
"risk_level": "medium",
|
|
"position_multiplier": 1,
|
|
"reasons": ["测试允许成交"],
|
|
}
|
|
|
|
monkeypatch.setattr("app.db.paper_trading.evaluate_global_risk", fake_global_risk)
|
|
altcoin_db.init_db()
|
|
pg_conn.execute(
|
|
"""
|
|
INSERT INTO recommendation (
|
|
id, symbol, rec_time, rec_state, rec_score, entry_price,
|
|
status, execution_status, action_status, display_bucket, entry_triggered,
|
|
stop_loss, tp1, tp2, strategy_code, entry_plan_json
|
|
) VALUES (
|
|
301, 'SNAPSHORT/USDT', '2026-05-16T10:00:00', '蓄力', 88, 105,
|
|
'active', 'wait_pullback', '等回踩', 'watch_pool', 0,
|
|
95, 115, 122, 'long_momentum_breakout_15m_1h_v1', %s
|
|
)
|
|
""",
|
|
(json.dumps({"side": "long", "entry_action": "等回踩", "entry_price": 105, "stop_loss": 95, "tp1": 115, "rr1": 2.0}, ensure_ascii=False),),
|
|
)
|
|
pg_conn.execute(
|
|
"""
|
|
INSERT INTO paper_orders (
|
|
recommendation_id, symbol, side, order_type, status,
|
|
source_status, source_action, target_price, current_price_at_create,
|
|
notional_usdt, stop_loss, tp1, tp2, strategy_version, strategy_code,
|
|
strategy_signal_id, strategy_snapshot_json, factor_roles_json,
|
|
entry_plan_snapshot_json, created_at, updated_at, expires_at
|
|
) VALUES (
|
|
301, 'SNAPSHORT/USDT', 'short', 'limit', 'pending',
|
|
'wait_pullback', '等反抽', 105, 100,
|
|
5000, 110, 95, 90, 'v-test', 'short_breakdown_retest_1h_v1',
|
|
0, %s, '{}', %s, '2026-05-16T10:00:00', '2026-05-16T10:00:00', '2026-05-17T10:00:00'
|
|
)
|
|
""",
|
|
(
|
|
json.dumps({"strategy_code": "short_breakdown_retest_1h_v1"}, ensure_ascii=False),
|
|
json.dumps({"side": "short", "entry_action": "等反抽", "entry_price": 105, "stop_loss": 110, "tp1": 95, "rr1": 2.0}, ensure_ascii=False),
|
|
),
|
|
)
|
|
pg_conn.commit()
|
|
altcoin_db.update_latest_price_cache("SNAPSHORT/USDT", 106, updated_at="2026-05-16T10:05:00", source="test")
|
|
|
|
result = sync_pending_paper_orders(event_time="2026-05-16T10:05:00")
|
|
|
|
assert result["filled_count"] == 1
|
|
assert captured
|
|
assert all(x.get("side") == "short" for x in captured)
|
|
assert all(x.get("strategy_code") == "short_breakdown_retest_1h_v1" for x in captured)
|
|
trade = list_paper_trades()["items"][0]
|
|
assert trade["side"] == "short"
|
|
assert trade["strategy_code"] == "short_breakdown_retest_1h_v1"
|
|
|
|
|
|
def test_wait_pullback_order_cancels_when_recommendation_invalid(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="CANCEL/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": "CANCEL/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")
|
|
with altcoin_db.get_conn() as conn:
|
|
conn.execute("UPDATE recommendation SET status='invalid', execution_status='invalid' WHERE id=%s", (rec_id,))
|
|
conn.commit()
|
|
|
|
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:05:00")
|
|
|
|
assert result["reason"] == "paper_order_recommendation_invalid"
|
|
order = list_paper_orders(status="canceled")["items"][0]
|
|
assert order["cancel_reason"] == "recommendation_invalid"
|
|
|
|
|
|
def test_wait_pullback_order_cancels_when_price_runs_too_far(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="FAR/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": "FAR/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 = sync_recommendation(rec, 108, event_time="2026-05-16T10:05:00")
|
|
|
|
assert result["reason"] == "paper_order_too_far_from_entry"
|
|
order = list_paper_orders(status="canceled")["items"][0]
|
|
assert order["cancel_reason"] == "too_far_from_entry"
|
|
assert list_paper_trades()["total"] == 0
|
|
|
|
|
|
def test_wait_pullback_order_fills_before_same_tick_stop_loss(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="GAP/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": "GAP/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 = sync_recommendation(rec, 89, event_time="2026-05-16T10:05:00")
|
|
|
|
assert result["opened"] is True
|
|
assert result["closed"] is True
|
|
assert result["same_tick_stop_loss"] is True
|
|
assert result["exit_reason"] == "stop_loss_same_tick"
|
|
order = list_paper_orders(status="filled")["items"][0]
|
|
assert order["fill_price"] == pytest.approx(95)
|
|
trade = list_paper_trades()["items"][0]
|
|
assert trade["status"] == "closed"
|
|
assert trade["entry_price"] == pytest.approx(95)
|
|
assert trade["exit_price"] == pytest.approx(89)
|
|
|
|
|
|
def test_wait_pullback_paper_order_fill_pushes_single_combined_card(monkeypatch):
|
|
pushed = []
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
|
|
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: pushed.append(card) or (True, {"StatusCode": 0}))
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="FILLPUSH/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": "FILLPUSH/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")
|
|
sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00")
|
|
|
|
event_types = [card["metadata"]["event_type"] for card in pushed]
|
|
assert event_types == ["paper_order_create", "paper_order_fill"]
|
|
assert "挂单成交并开仓" in pushed[1]["header"]["title"]["content"]
|
|
_assert_no_paper_trading_copy(pushed[1])
|
|
assert "open" not in event_types
|
|
|
|
|
|
def test_paper_trade_open_push_card_is_structured(monkeypatch, buy_now_rec):
|
|
pushed = []
|
|
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: pushed.append(card) or (True, {"StatusCode": 0}))
|
|
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
|
|
assert pushed
|
|
card = pushed[0]
|
|
assert card["metadata"]["event_type"] == "open"
|
|
assert "交易开仓" in card["header"]["title"]["content"]
|
|
_assert_no_paper_trading_copy(card)
|
|
assert card["elements"][0]["tag"] == "column_set"
|
|
|
|
|
|
def test_trailing_move_push_is_throttled_but_stop_still_updates(monkeypatch, buy_now_rec):
|
|
pushed = []
|
|
rec = dict(buy_now_rec)
|
|
rec["tp1"] = 200
|
|
rec["tp2"] = 220
|
|
rec["entry_plan"] = {
|
|
"entry_action": "可即刻买入",
|
|
"entry_price": 100,
|
|
"stop_loss": 95,
|
|
"tp1": 200,
|
|
"tp2": 220,
|
|
"entry_trigger_confirmed": True,
|
|
"risk_reward_ok": True,
|
|
}
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS", "300")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT", "2")
|
|
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: pushed.append(card) or (True, {"StatusCode": 0}))
|
|
|
|
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
activated = sync_recommendation(rec, 105, event_time="2026-05-16T10:01:00")
|
|
small_move = sync_recommendation(rec, 105.5, event_time="2026-05-16T10:01:05")
|
|
large_move = sync_recommendation(rec, 108, event_time="2026-05-16T10:01:10")
|
|
|
|
assert small_move["moved"] is True
|
|
assert small_move["trailing_stop"] > activated["trailing_stop"]
|
|
assert small_move["notification_emitted"] is False
|
|
assert large_move["notification_emitted"] is True
|
|
assert [card["metadata"]["event_type"] for card in pushed] == ["open", "trailing_activate", "trailing_move"]
|
|
_assert_no_paper_trading_copy(pushed[-1])
|
|
|
|
|
|
def test_send_paper_trading_report_pushes_performance_summary(monkeypatch, buy_now_rec):
|
|
pushed = []
|
|
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: pushed.append(card) or (True, {"StatusCode": 0}))
|
|
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
sync_recommendation(buy_now_rec, 106, event_time="2026-05-16T10:05:00")
|
|
result = send_paper_trading_report(days=30)
|
|
|
|
assert result["ok"] is True
|
|
assert result["running_days"] >= 1
|
|
assert result["monthly_return_pct"] is None
|
|
assert result["annualized_return_pct"] is None
|
|
assert result["monthly_return_label"] == "样本不足"
|
|
assert result["annualized_return_label"] == "样本不足"
|
|
assert pushed[-1]["metadata"]["event_type"] == "trade_report"
|
|
text = _visible_card_text(pushed[-1])
|
|
assert "交易报告" in text
|
|
assert "初始资金" in text
|
|
assert "当前资金" in text
|
|
assert "运行天数" in text
|
|
assert "账户收益率" in text
|
|
assert "月化收益率" in text
|
|
assert "年化收益率" in text
|
|
assert "成功率" in text
|
|
assert "战绩概览" in text
|
|
_assert_no_paper_trading_copy(pushed[-1])
|
|
|
|
|
|
def test_summary_counts_pending_paper_orders(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
altcoin_db.init_db()
|
|
rec_id = altcoin_db.create_recommendation(
|
|
symbol="COUNT/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},
|
|
)
|
|
with altcoin_db.get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE recommendation SET execution_status='wait_pullback', action_status='等回踩', display_bucket='watch_pool' WHERE id=%s",
|
|
(rec_id,),
|
|
)
|
|
conn.commit()
|
|
rec = {"id": rec_id, "symbol": "COUNT/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")
|
|
|
|
assert get_paper_trading_summary(days=30)["pending_order_count"] == 1
|
|
|
|
|
|
def test_open_paper_trade_closes_on_tp1_and_summary_counts_win(buy_now_rec):
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
result = sync_recommendation(buy_now_rec, 106, event_time="2026-05-16T10:05:00")
|
|
|
|
assert result["closed"] is True
|
|
assert result["exit_reason"] == "tp1"
|
|
assert result["pnl_pct"] == pytest.approx(6.0)
|
|
|
|
summary = get_paper_trading_summary(days=30)
|
|
assert summary["closed_count"] == 1
|
|
assert summary["win_count"] == 1
|
|
assert summary["win_rate"] == pytest.approx(100.0)
|
|
|
|
|
|
def test_short_paper_trade_opens_and_closes_on_take_profit(monkeypatch, buy_now_rec):
|
|
rec = dict(buy_now_rec)
|
|
rec["side"] = "short"
|
|
rec["stop_loss"] = 105
|
|
rec["tp1"] = 94
|
|
rec["tp2"] = 90
|
|
rec["entry_plan"] = {
|
|
"side": "short",
|
|
"entry_action": "可即刻买入",
|
|
"entry_price": 100,
|
|
"stop_loss": 105,
|
|
"tp1": 94,
|
|
"tp2": 90,
|
|
"risk_reward_ok": True,
|
|
"rr1": 1.2,
|
|
"entry_trigger_confirmed": True,
|
|
}
|
|
|
|
opened = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
closed = sync_recommendation(rec, 94, event_time="2026-05-16T10:10:00")
|
|
|
|
assert opened["opened"] is True
|
|
assert closed["closed"] is True
|
|
assert closed["exit_reason"] == "tp1"
|
|
assert closed["pnl_pct"] > 0
|
|
trade = list_paper_trades()["items"][0]
|
|
assert trade["side"] == "short"
|
|
assert trade["status"] == "closed"
|
|
assert list_paper_trades(side="short")["total"] == 1
|
|
assert list_paper_trades(side="long")["total"] == 0
|
|
|
|
|
|
def test_short_paper_trade_closes_on_stop_loss(monkeypatch, buy_now_rec):
|
|
rec = dict(buy_now_rec)
|
|
rec["side"] = "short"
|
|
rec["stop_loss"] = 105
|
|
rec["tp1"] = 94
|
|
rec["entry_plan"] = {
|
|
"side": "short",
|
|
"entry_action": "可即刻买入",
|
|
"entry_price": 100,
|
|
"stop_loss": 105,
|
|
"tp1": 94,
|
|
"risk_reward_ok": True,
|
|
"rr1": 1.2,
|
|
"entry_trigger_confirmed": True,
|
|
}
|
|
|
|
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
result = sync_recommendation(rec, 106, event_time="2026-05-16T10:10:00")
|
|
|
|
assert result["closed"] is True
|
|
assert result["exit_reason"] == "stop_loss"
|
|
assert result["pnl_pct"] < 0
|
|
|
|
|
|
def test_paper_trading_trailing_stop_activates_moves_and_closes(monkeypatch, buy_now_rec):
|
|
pushed = []
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
|
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: pushed.append(card) or (True, {"StatusCode": 0}))
|
|
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
activated = sync_recommendation(buy_now_rec, 105, event_time="2026-05-16T10:01:00")
|
|
moved = sync_recommendation(buy_now_rec, 105.5, event_time="2026-05-16T10:02:00")
|
|
closed = sync_recommendation(buy_now_rec, moved["trailing_stop"] * 0.999, event_time="2026-05-16T10:03:00")
|
|
|
|
assert activated["activated"] is True
|
|
assert activated["trailing_stop"] >= 100.5
|
|
assert moved["moved"] is True
|
|
assert moved["trailing_stop"] > activated["trailing_stop"]
|
|
assert closed["closed"] is True
|
|
assert closed["exit_reason"] == "trailing_stop"
|
|
|
|
trade = list_paper_trades()["items"][0]
|
|
assert trade["status"] == "closed"
|
|
assert trade["exit_reason"] == "trailing_stop"
|
|
assert trade["exit_price"] > trade["entry_price"]
|
|
assert pushed[-1]["metadata"]["event_type"] == "close"
|
|
assert "移动止盈成交平仓" in pushed[-1]["header"]["title"]["content"]
|
|
_assert_no_paper_trading_copy(pushed[-1])
|
|
|
|
|
|
def test_volatility_trailing_uses_wider_distance_for_choppy_coin(monkeypatch, buy_now_rec):
|
|
rec = dict(buy_now_rec)
|
|
rec["tp1"] = 200
|
|
rec["tp2"] = 220
|
|
rec["entry_plan"] = {
|
|
"entry_action": "可即刻买入",
|
|
"entry_price": 100,
|
|
"stop_loss": 95,
|
|
"tp1": 200,
|
|
"tp2": 220,
|
|
"entry_trigger_confirmed": True,
|
|
"risk_reward_ok": True,
|
|
}
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "volatility")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_ACTIVATE_MULT", "0.6")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_DISTANCE_MULT", "0.7")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_MAX_DISTANCE_PCT", "8")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_MAX_ACTIVATE_PCT", "8")
|
|
|
|
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
|
sync_recommendation(rec, 98, event_time="2026-05-16T10:01:00")
|
|
result = sync_recommendation(rec, 110, event_time="2026-05-16T10:02:00")
|
|
|
|
assert result["activated"] is True
|
|
assert result["trailing_mode"] == "volatility"
|
|
assert result["volatility_pct"] == pytest.approx(12.0)
|
|
assert result["activate_pnl_pct"] == pytest.approx(7.2)
|
|
assert result["distance_pct"] == pytest.approx(8.0)
|
|
assert result["trailing_stop"] == pytest.approx(101.2)
|
|
|
|
|
|
def test_volatility_trailing_stays_tighter_for_smooth_coin(monkeypatch, buy_now_rec):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "volatility")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_DISTANCE_MULT", "0.7")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_MIN_DISTANCE_PCT", "1.2")
|
|
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
result = sync_recommendation(buy_now_rec, 104, event_time="2026-05-16T10:01:00")
|
|
|
|
assert result["activated"] is True
|
|
assert result["trailing_mode"] == "volatility"
|
|
assert result["volatility_pct"] == pytest.approx(4.0)
|
|
assert result["distance_pct"] == pytest.approx(2.8)
|
|
assert result["trailing_stop"] == pytest.approx(101.088)
|
|
|
|
|
|
def test_paper_push_failure_is_recorded(monkeypatch, buy_now_rec):
|
|
errors = []
|
|
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: (False, "webhook failed"))
|
|
monkeypatch.setattr("app.db.paper_trading.record_system_error", lambda **kwargs: errors.append(kwargs) or 1)
|
|
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
|
|
assert errors
|
|
assert errors[0]["error_type"] == "FeishuPushFailed"
|
|
assert errors[0]["context"]["event_type"] == "open"
|
|
|
|
|
|
def test_paper_trading_trailing_stop_never_moves_down(monkeypatch, buy_now_rec):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
|
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
high = sync_recommendation(buy_now_rec, 105, event_time="2026-05-16T10:01:00")
|
|
pullback = sync_recommendation(buy_now_rec, 104, event_time="2026-05-16T10:02:00")
|
|
|
|
assert high["activated"] is True
|
|
assert pullback["updated"] is True
|
|
assert pullback.get("trailing_stop", high["trailing_stop"]) == pytest.approx(high["trailing_stop"])
|
|
assert pullback.get("moved") is False
|
|
|
|
|
|
def test_position_guard_tightens_when_trade_does_not_launch(monkeypatch, buy_now_rec):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_SOFT_HOURS", "6")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_SOFT_MIN_MAX_PNL_PCT", "1.5")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_HARD_HOURS", "24")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_TIGHTEN_LOCK_PROFIT_PCT", "0.15")
|
|
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
result = sync_recommendation(buy_now_rec, 100.4, event_time="2026-05-16T16:01:00")
|
|
|
|
assert result["updated"] is True
|
|
assert result["tightened"] is True
|
|
assert result["trailing_stop"] == pytest.approx(100.15)
|
|
assert result["position_health"]["reason"] == "position_timeout_soft"
|
|
trade = list_paper_trades(status="open")["items"][0]
|
|
assert trade["trailing_stop"] == pytest.approx(100.15)
|
|
events = list_paper_trade_events(symbol="PAPER/USDT", limit=20)["items"]
|
|
assert "position_guard_tighten" in [e["event_type"] for e in events]
|
|
|
|
|
|
def test_position_guard_closes_stale_unlaunched_trade(monkeypatch, buy_now_rec):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_SOFT_HOURS", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_HARD_HOURS", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_HARD_MIN_MAX_PNL_PCT", "2.5")
|
|
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
result = sync_recommendation(buy_now_rec, 100.4, event_time="2026-05-16T11:10:00")
|
|
|
|
assert result["closed"] is True
|
|
assert result["exit_reason"] == "position_timeout_weak"
|
|
trade = list_paper_trades()["items"][0]
|
|
assert trade["status"] == "closed"
|
|
assert trade["exit_reason"] == "position_timeout_weak"
|
|
|
|
|
|
def test_position_guard_closes_profit_giveback_before_trailing(monkeypatch, buy_now_rec):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_SOFT_HOURS", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_HARD_HOURS", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_MIN_MAX_PNL_PCT", "2")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_EXIT_PCT", "70")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_EXIT_CURRENT_PNL_PCT", "0.6")
|
|
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
sync_recommendation(buy_now_rec, 102.5, event_time="2026-05-16T10:20:00")
|
|
result = sync_recommendation(buy_now_rec, 100.5, event_time="2026-05-16T10:40:00")
|
|
|
|
assert result["closed"] is True
|
|
assert result["exit_reason"] == "profit_giveback_before_trailing"
|
|
assert result["pnl_pct"] == pytest.approx(0.5)
|
|
|
|
|
|
def test_position_guard_closes_unprotected_trade_when_market_turns_critical(monkeypatch, buy_now_rec):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "0")
|
|
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MIN_AGE_HOURS", "0.5")
|
|
monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MAX_PNL_PCT", "1")
|
|
monkeypatch.setattr(
|
|
"app.db.paper_trading.evaluate_global_risk",
|
|
lambda **kwargs: {
|
|
"enabled": True,
|
|
"allow_new_entries": True,
|
|
"risk_level": "critical",
|
|
"decision": "allow_reduced_size",
|
|
"reasons": ["测试环境进入 critical"],
|
|
},
|
|
)
|
|
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
result = sync_recommendation(buy_now_rec, 100.5, event_time="2026-05-16T10:40:00")
|
|
|
|
assert result["closed"] is True
|
|
assert result["exit_reason"] == "market_risk_unprotected"
|
|
|
|
|
|
def test_paper_trading_events_capture_open_close_and_trailing(monkeypatch, buy_now_rec):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
|
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
sync_recommendation(buy_now_rec, 105, event_time="2026-05-16T10:01:00")
|
|
sync_recommendation(buy_now_rec, 104.9, event_time="2026-05-16T10:02:00")
|
|
|
|
events = list_paper_trade_events(limit=20)["items"]
|
|
types = [e["event_type"] for e in events]
|
|
|
|
assert "open" in types
|
|
assert "trailing_activate" in types
|
|
assert "trailing_move" not in types or isinstance(types, list)
|
|
|
|
|
|
def test_closed_paper_trade_keeps_exit_price_and_shows_latest_market_price(buy_now_rec):
|
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
sync_recommendation(buy_now_rec, 106, event_time="2026-05-16T10:05:00")
|
|
altcoin_db.update_latest_price_cache("PAPER/USDT", 103.25, updated_at="2026-05-16T10:10:00", source="unit")
|
|
|
|
trade = list_paper_trades()["items"][0]
|
|
|
|
assert trade["status"] == "closed"
|
|
assert trade["exit_price"] == pytest.approx(106.0)
|
|
assert trade["current_price"] == pytest.approx(106.0)
|
|
assert trade["latest_price"] == pytest.approx(103.25)
|
|
assert trade["latest_price_updated_at"] == "2026-05-16T10:10:00"
|
|
|
|
|
|
def test_disabled_paper_trading_skips_without_writing(monkeypatch, buy_now_rec):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "0")
|
|
|
|
result = sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
|
|
|
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_pending_order_distance_never_negative_for_touched_long_or_short(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
|
altcoin_db.init_db()
|
|
long_rec_id = altcoin_db.create_recommendation(
|
|
symbol="LONGTOUCH/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},
|
|
)
|
|
short_rec_id = altcoin_db.create_recommendation(
|
|
symbol="SHORTTOUCH/USDT",
|
|
rec_state="蓄力",
|
|
rec_score=22,
|
|
entry_price=105,
|
|
stop_loss=110,
|
|
tp1=95,
|
|
signals=["等待反抽"],
|
|
entry_plan={"entry_action": "等反抽", "side": "short", "entry_price": 105, "stop_loss": 110, "tp1": 95, "risk_reward_ok": True, "rr1": 2.0},
|
|
)
|
|
conn = altcoin_db.get_conn()
|
|
try:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO latest_price_cache (symbol, price, updated_at, source)
|
|
VALUES (%s, %s, %s, %s), (%s, %s, %s, %s)
|
|
""",
|
|
(
|
|
"LONGTOUCH/USDT", 94, "2026-05-16T10:00:00", "unit",
|
|
"SHORTTOUCH/USDT", 106, "2026-05-16T10:00:00", "unit",
|
|
),
|
|
)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO paper_orders (
|
|
recommendation_id, symbol, side, order_type, status, source_status,
|
|
source_action, target_price, current_price_at_create, notional_usdt,
|
|
stop_loss, tp1, tp2, created_at, updated_at, expires_at
|
|
) VALUES
|
|
(%s, %s, %s, 'limit', 'pending', 'wait_pullback', '等回踩', %s, %s, %s, %s, %s, %s, %s, %s, %s),
|
|
(%s, %s, %s, 'limit', 'pending', 'wait_pullback', '等反抽', %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
long_rec_id, "LONGTOUCH/USDT", "long", 95, 100, 5000, 90, 105, 110, "2026-05-16T09:00:00", "2026-05-16T09:00:00", "2026-05-17T09:00:00",
|
|
short_rec_id, "SHORTTOUCH/USDT", "short", 105, 100, 5000, 110, 95, 90, "2026-05-16T09:00:00", "2026-05-16T09:00:00", "2026-05-17T09:00:00",
|
|
),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
rows = list_paper_orders(status="pending", limit=10)["items"]
|
|
by_symbol = {item["symbol"]: item for item in rows}
|
|
|
|
assert by_symbol["LONGTOUCH/USDT"]["distance_to_target_pct"] == pytest.approx(0)
|
|
assert by_symbol["SHORTTOUCH/USDT"]["distance_to_target_pct"] == pytest.approx(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
|