221 lines
9.1 KiB
Python
221 lines
9.1 KiB
Python
import json
|
|
|
|
import pytest
|
|
|
|
from app.db import altcoin_db
|
|
from app.db.paper_trading import get_paper_trading_summary, list_paper_trade_events, list_paper_trades, sync_recommendation
|
|
|
|
|
|
@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=95,
|
|
tp1=106,
|
|
tp2=112,
|
|
signals=["当前15min即刻入场信号"],
|
|
entry_plan={
|
|
"entry_action": "可即刻买入",
|
|
"entry_price": 100,
|
|
"stop_loss": 95,
|
|
"tp1": 106,
|
|
"tp2": 112,
|
|
"risk_reward_ok": True,
|
|
"rr1": 1.2,
|
|
"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_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)
|
|
|
|
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_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_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_paper_trading_trailing_stop_activates_moves_and_closes(monkeypatch, buy_now_rec):
|
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
|
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")
|
|
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"]
|
|
|
|
|
|
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_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_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_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
|