Files
AlphaPulse/backend/tests/test_plan.py
T
eezd 1fcf41952c Add demo trading executor and analysis scheduling (plan 3 C4)
- Executor reconciles each symbol to its desired orders: journaled ap{id}
  client ids, reduce-only quantity stop placed before entries, make-before-
  break stop moves, breakeven after TP1, orphan cancel, -2021 market close
- Plan lifecycle: proposed plans are adopted only if the position is
  unchanged; holding-mode plans may only tighten the stop
- Program rules: time stop, short-term invalidation close, long-term 4h
  review, reevaluate conditions, takeover of unmanaged positions
- Trigger and routine analysis scheduling with a 10 min gap, daily cap and
  bounded retries
- Plan endpoint and plan status, fills and countdowns on the trading page
2026-10-15 11:07:00 +08:00

240 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import UTC, datetime, timedelta, timezone
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
import pytest
from alphapulse.trading.plan import (
OrderSpec, PlanContext, PlanError, SymbolRules, TradePlan, desired_orders, diff_orders, next_routine_at, round_to_step,
size_plan, split_take_profits,
)
BTC = SymbolRules(tick_size=Decimal("0.1"), step_size=Decimal("0.0001"), min_notional=Decimal("50"))
def context(**overrides) -> PlanContext:
values = {
"angle": "long", "mark_price": Decimal("82000"), "capital": Decimal("1000"), "other_notional": Decimal("0"),
"rules": BTC, "has_plan": False, "position_side": None,
}
return PlanContext(**{**values, **overrides})
def long_plan(**overrides) -> TradePlan:
values = {
"action": "plan", "summary": "长线做多", "reasons": "支撑区", "risks": "无",
"direction": "long", "confidence": "medium",
"entries": [{"price": "81000", "ratio": "0.4", "basis": "区1"}, {"price": "80000", "ratio": "0.6", "basis": "区2"}],
"stop": "79500", "stop_basis": "区下沿外",
"take_profits": [{"price": "83000", "basis": "压1"}, {"price": "84000", "basis": "压2"}, {"price": "85000", "basis": "压3"}],
"entry_valid_hours": "24", "max_hold_hours": "48",
"invalidation": {"direction": "below", "price": "79800", "timeframe": "4h", "basis": "跌破区"},
}
return TradePlan.model_validate({**values, **overrides})
def short_term_plan(**overrides) -> TradePlan:
values = {
"action": "plan", "summary": "短线做空", "reasons": "压力区", "risks": "无",
"direction": "short", "confidence": "medium",
"entries": [{"price": "82500", "ratio": "1", "basis": "压力区"}],
"stop": "82900", "take_profits": [{"price": "81800", "basis": "支1"}, {"price": "81300", "basis": "支2"}],
"entry_valid_hours": "4", "max_hold_hours": "24",
"invalidation": {"direction": "above", "price": "82800", "timeframe": "15m", "basis": "站上区"},
}
return TradePlan.model_validate({**values, **overrides})
def errors_of(plan: TradePlan, ctx: PlanContext) -> str:
with pytest.raises(PlanError) as e:
size_plan(plan, ctx)
return ";".join(e.value.errors)
def test_round_to_step():
assert round_to_step(Decimal("81000.05"), Decimal("0.1"), ROUND_FLOOR) == Decimal("81000.0")
# 整数输入不能变成指数形式
assert str(round_to_step(Decimal("80500"), Decimal("0.1"), ROUND_FLOOR)) == "80500.0"
assert round_to_step(Decimal("82500.01"), Decimal("0.1"), ROUND_CEILING) == Decimal("82500.1")
assert round_to_step(Decimal("0.00829908"), Decimal("0.0001"), ROUND_FLOOR) == Decimal("0.0082")
def test_split_take_profits_keeps_total():
parts = split_take_profits(Decimal("0.0206"), (Decimal("0.5"), Decimal("0.3"), Decimal("0.2")), Decimal("0.0001"))
assert parts == [Decimal("0.0103"), Decimal("0.0061"), Decimal("0.0042")]
assert sum(parts) == Decimal("0.0206")
assert split_take_profits(Decimal("0.001"), (Decimal("0.5"), Decimal("0.3"), Decimal("0.2")), Decimal("0.001")) == [0, 0, Decimal("0.001")]
def test_long_plan_sizing_matches_hand_calculation():
sizing = size_plan(long_plan(), context())
# 均价 0.4×81000 + 0.6×80000 = 80400;单位亏损 900 + (80400+79500)×0.0004 = 963.96;20 ÷ 963.96 = 0.020747
assert sizing["average_price"] == "80400.0"
assert [e["quantity"] for e in sizing["entries"]] == ["0.0082", "0.0124"]
assert sizing["quantity"] == "0.0206"
assert sizing["notional"] == "1656.20"
assert sizing["risk_amount"] == "20.00"
assert sizing["risk_at_stop"] == "19.82"
# (0.5×2600 + 0.3×3600 + 0.2×4600) ÷ 900;只成交首档 (0.5×2000 + 0.3×3000 + 0.2×4000) ÷ 1500
assert sizing["reward_risk"] == "3.67"
assert sizing["reward_risk_first_only"] == "1.80"
assert [t["quantity"] for t in sizing["take_profits"]] == ["0.0103", "0.0061", "0.0042"]
assert sizing["capped_by_leverage"] is False
def test_low_confidence_halves_risk():
sizing = size_plan(long_plan(confidence="low"), context())
assert sizing["risk_amount"] == "10.00"
# 10 ÷ 963.96 = 0.010374:0.4 档 0.0041,0.6 档 0.0062
assert sizing["quantity"] == "0.0103"
def test_leverage_cap_counts_other_positions():
sizing = size_plan(long_plan(), context(other_notional=Decimal("2000")))
assert sizing["capped_by_leverage"] is True
assert Decimal(sizing["notional"]) <= Decimal("1000")
def test_short_term_short_plan():
sizing = size_plan(short_term_plan(), context(angle="short"))
# 止盈 1 盈亏比 700 ÷ 400
assert sizing["reward_risk"] == "1.75"
assert sizing["risk_amount"] == "10.00"
assert len(sizing["take_profits"]) == 2
def test_short_prices_round_up():
sizing = size_plan(short_term_plan(entries=[{"price": "82500.01", "ratio": "1", "basis": "x"}]), context(angle="short"))
assert sizing["entries"][0]["price"] == "82500.1"
def test_rejects_wrong_counts_and_timeframe():
message = errors_of(short_term_plan(entries=[{"price": "82500", "ratio": "0.5", "basis": "a"}, {"price": "82600", "ratio": "0.5", "basis": "b"}]), context(angle="short"))
assert "短线只能 1 档入场" in message
assert "长线失效条件的周期只能是 4h" in errors_of(long_plan(invalidation={"direction": "below", "price": "79800", "timeframe": "1h", "basis": "x"}), context())
assert "长线须给 3 个止盈" in errors_of(long_plan(take_profits=[{"price": "83000", "basis": "a"}]), context())
def test_rejects_bad_price_order():
message = errors_of(long_plan(stop="80500"), context())
assert "做多时止损 80500.0 应在失效价 79800 之外" in message
assert "止盈 1 81000.0 已越过现价" in errors_of(long_plan(take_profits=[{"price": "81000", "basis": "a"}, {"price": "84000", "basis": "b"}, {"price": "85000", "basis": "c"}], entries=[{"price": "80500", "ratio": "1", "basis": "a"}]), context())
def test_rejects_low_reward_risk():
# (0.5×400 + 0.3×600 + 0.2×800) ÷ 500 = 1.08
plan = long_plan(take_profits=[{"price": "80400", "basis": "a"}, {"price": "80600", "basis": "b"}, {"price": "80800", "basis": "c"}],
entries=[{"price": "80000", "ratio": "1", "basis": "a"}])
assert "盈亏比 1.08" in errors_of(plan, context(mark_price=Decimal("80300")))
def test_rejects_entries_below_min_notional():
message = errors_of(long_plan(entries=[{"price": "81000", "ratio": "0.02", "basis": "a"}, {"price": "80000", "ratio": "0.98", "basis": "b"}]), context())
assert "最小下单金额 50" in message
assert "第 1 档" in message
def test_action_rules():
assert "不能用 keep" in errors_of(TradePlan(action="keep", summary="s", reasons="r", risks="x"), context())
assert "不能用 wait" in errors_of(TradePlan(action="wait", summary="s", reasons="r", risks="x"), context(position_side="long"))
assert size_plan(TradePlan(action="wait", summary="s", reasons="r", risks="x"), context()) == {}
satisfied = TradePlan(action="wait", summary="s", reasons="r", risks="x",
reevaluate=[{"direction": "above", "price": "81000", "timeframe": "1h", "basis": "x"}])
assert "已经满足" in errors_of(satisfied, context())
assert "方向必须一致" in errors_of(long_plan(), context(position_side="short"))
# ---------- 有持仓时的新计划 ----------
def managed_plan(**overrides) -> TradePlan:
values = {
"action": "plan", "summary": "调整", "reasons": "r", "risks": "x", "direction": "long", "confidence": "medium",
"stop": "80000", "take_profits": [{"price": "83000", "basis": "a"}, {"price": "84000", "basis": "b"}, {"price": "85000", "basis": "c"}],
"invalidation": {"direction": "below", "price": "80500", "timeframe": "4h", "basis": "x"},
}
return TradePlan.model_validate({**values, **overrides})
def holding(**overrides) -> PlanContext:
return context(position_side="long", position_qty=Decimal("0.0100"), position_entry=Decimal("81000"), current_stop=Decimal("79500"), has_plan=True, **overrides)
def test_managed_plan_does_not_add_and_splits_position():
sizing = size_plan(managed_plan(), holding())
assert sizing["managed"] is True
assert sizing["entries"] == []
assert [t["quantity"] for t in sizing["take_profits"]] == ["0.0050", "0.0030", "0.0020"]
assert sizing["position_qty"] == "0.0100"
assert "entries 须为空" in errors_of(managed_plan(entries=[{"price": "80000", "ratio": "1", "basis": "a"}]), holding())
def test_managed_plan_only_tightens_stop():
assert "止损只能收紧" in errors_of(managed_plan(stop="79000", invalidation={"direction": "below", "price": "79400", "timeframe": "4h", "basis": "x"}), holding())
# ---------- 应有订单 ----------
LONG_SIZING = {
"direction": "long", "managed": False, "quantity": "0.0030",
"entries": [{"price": "81000.0", "quantity": "0.0012"}, {"price": "80000.0", "quantity": "0.0018"}],
"take_profits": [{"price": "83000.0", "ratio": "0.5"}, {"price": "84000.0", "ratio": "0.3"}, {"price": "85000.0", "ratio": "0.2"}],
}
ZERO3 = [Decimal(0)] * 3
def test_desired_orders_before_fill_places_entries_and_stop_together():
orders = desired_orders(LONG_SIZING, entries_open=True, entry_filled=[Decimal(0)] * 2, tp_filled=ZERO3,
position_qty=Decimal(0), stop_price=Decimal("79500"), step_size=Decimal("0.0001"))
assert [(o.purpose, o.leg, o.side, o.quantity) for o in orders] == [
("entry", 0, "BUY", Decimal("0.0012")), ("entry", 1, "BUY", Decimal("0.0018")), ("stop", None, "SELL", Decimal("0.0030")),
]
def test_desired_orders_partial_fill_splits_take_profits_by_filled():
orders = desired_orders(LONG_SIZING, entries_open=True, entry_filled=[Decimal("0.0012"), Decimal("0.0004")], tp_filled=ZERO3,
position_qty=Decimal("0.0016"), stop_price=Decimal("79500"), step_size=Decimal("0.0001"))
assert [(o.purpose, o.leg, o.quantity) for o in orders] == [
("entry", 1, Decimal("0.0014")), ("stop", None, Decimal("0.0030")),
("tp", 0, Decimal("0.0008")), ("tp", 1, Decimal("0.0004")), ("tp", 2, Decimal("0.0004")),
]
def test_desired_orders_after_tp1_keeps_remaining_targets_only():
orders = desired_orders(LONG_SIZING, entries_open=False, entry_filled=[Decimal("0.0012"), Decimal("0.0018")],
tp_filled=[Decimal("0.0015"), Decimal(0), Decimal(0)], position_qty=Decimal("0.0015"),
stop_price=Decimal("81050"), step_size=Decimal("0.0001"))
assert [(o.purpose, o.leg, o.quantity, o.trigger_price) for o in orders] == [
("stop", None, Decimal("0.0030"), Decimal("81050")), ("tp", 1, Decimal("0.0009"), None), ("tp", 2, Decimal("0.0006"), None),
]
def test_desired_orders_nothing_when_flat_and_entries_closed():
assert desired_orders(LONG_SIZING, entries_open=False, entry_filled=[Decimal(0)] * 2, tp_filled=ZERO3,
position_qty=Decimal(0), stop_price=Decimal("79500"), step_size=Decimal("0.0001")) == []
def test_diff_orders_matches_by_value():
keep = OrderSpec("stop", None, "SELL", "STOP_MARKET", None, Decimal("79500.0"), Decimal("0.0030"))
stale = OrderSpec("entry", 0, "BUY", "LIMIT", Decimal("81000"), None, Decimal("0.0012"))
wanted = OrderSpec("entry", 0, "BUY", "LIMIT", Decimal("80900"), None, Decimal("0.0012"))
cancel, place = diff_orders([OrderSpec("stop", None, "SELL", "STOP_MARKET", None, Decimal("79500"), Decimal("0.003")), wanted],
[(1, keep), (2, stale)])
assert cancel == [2]
assert place == [wanted]
def test_next_routine_at_aligns_to_candle_close():
tz8 = timezone(timedelta(hours=8))
# 北京 10:20 = UTC 02:20;短线 +45 分钟后的第一个整点 UTC 04:00,再加 1 分钟
assert next_routine_at(datetime(2026, 10, 15, 10, 20, tzinfo=tz8), "short") == datetime(2026, 10, 15, 4, 1, tzinfo=UTC)
assert next_routine_at(datetime(2026, 10, 15, 3, 1, tzinfo=UTC), "short") == datetime(2026, 10, 15, 4, 1, tzinfo=UTC)
# 长线:UTC 04:01 + 3h = 07:01,下一个 4h 收盘 08:00
assert next_routine_at(datetime(2026, 10, 15, 4, 1, tzinfo=UTC), "long") == datetime(2026, 10, 15, 8, 1, tzinfo=UTC)
def test_step_decimal_never_uses_exponent():
from alphapulse.trading.client import step_decimal
assert str(step_decimal("0.10")) == "0.1"
assert str(step_decimal("100")) == "100"
assert str(round_to_step(Decimal("12345"), step_decimal("100"), ROUND_FLOOR)) == "12300"