Validate reevaluation conditions against their declared timeframe

This commit is contained in:
eezd committed 2026-10-20 18:47:23 +08:00
1 parent 285b3952d7
commit 193af01913
4 files changed
+60 -5

No files matched your search

@@ -285,6 +285,12 @@ async def submit(prepared: Prepared, symbol: str, analysis_id: int, arguments: d
snapshot = await read_snapshot() snapshot = await read_snapshot()
if snapshot: if snapshot:
prepared.context.position_side = position_side(snapshot, symbol) prepared.context.position_side = position_side(snapshot, symbol)
prepared.context.closed_prices = {}
for interval in sorted({c.timeframe for c in plan.reevaluate if c.timeframe != "mark"}):
rows = await market_client.get("um", "klines", {"symbol": symbol, "interval": interval, "limit": 2})
now_ms = datetime.now(UTC).timestamp() * 1000
closed = rows[-1] if rows[-1][6] < now_ms else rows[-2]
prepared.context.closed_prices[interval] = Decimal(closed[4])
try: try:
sizing = check_setup(plan, prepared.context) sizing = check_setup(plan, prepared.context)
except PlanError as e: except PlanError as e:
+6 -4
View File
@@ -5,7 +5,7 @@
下方常量与 main/README 的数字重复,并写进了分析器的提示词;改 README 时要同步修改。 下方常量与 main/README 的数字重复,并写进了分析器的提示词;改 README 时要同步修改。
""" """
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from typing import Annotated, Literal from typing import Annotated, Literal
@@ -98,6 +98,8 @@ class PlanContext:
atr: Decimal atr: Decimal
has_plan: bool has_plan: bool
position_side: Direction | None position_side: Direction | None
# 非 mark 复核条件使用的最近已收盘价,由提交边界按需获取。
closed_prices: dict[str, Decimal] = field(default_factory=dict)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -204,9 +206,9 @@ def check_action(plan: TradePlan, ctx: PlanContext) -> list[str]:
if plan.action == "plan" and ctx.position_side is not None: if plan.action == "plan" and ctx.position_side is not None:
errors.append("已有持仓:持仓由程序按止损、止盈和持仓时限管理,只能 keep 或 exit") errors.append("已有持仓:持仓由程序按止损、止盈和持仓时限管理,只能 keep 或 exit")
for i, c in enumerate(plan.reevaluate, 1): for i, c in enumerate(plan.reevaluate, 1):
satisfied = ctx.mark_price > c.price if c.direction == "above" else ctx.mark_price < c.price price = ctx.mark_price if c.timeframe == "mark" else ctx.closed_prices[c.timeframe]
if satisfied: if condition_met(c.direction, c.price, price):
errors.append(f"reevaluate 第 {i} 个条件({c.direction} {c.price})按现价 {ctx.mark_price} 已经满足,提交后会立即触发") errors.append(f"reevaluate 第 {i} 个条件({c.timeframe} {c.direction} {c.price})按价格 {price} 已经满足,提交后会立即触发")
return errors return errors
+17 -1
View File
@@ -151,10 +151,26 @@ def test_action_rules():
assert check_setup(TradePlan(action="wait", summary="s", reasons="r", risks="x"), context()) == {} assert check_setup(TradePlan(action="wait", summary="s", reasons="r", risks="x"), context()) == {}
satisfied = TradePlan(action="wait", summary="s", reasons="r", risks="x", satisfied = TradePlan(action="wait", summary="s", reasons="r", risks="x",
reevaluate=[{"direction": "above", "price": "80000", "timeframe": "5m", "basis": "x"}]) reevaluate=[{"direction": "above", "price": "80000", "timeframe": "5m", "basis": "x"}])
assert "已经满足" in errors_of(satisfied, context()) assert "已经满足" in errors_of(satisfied, context(closed_prices={"5m": Decimal("81000")}))
assert "只能 keep 或 exit" in errors_of(setup_plan(), context(position_side="long", has_plan=True)) assert "只能 keep 或 exit" in errors_of(setup_plan(), context(position_side="long", has_plan=True))
@pytest.mark.parametrize("direction,mark,closed,satisfied", [
("above", "110", "100", False), ("above", "100", "110", True),
("below", "100", "110", False), ("below", "110", "100", True),
])
@pytest.mark.parametrize("timeframe", ["mark", "5m"])
def test_reevaluate_uses_its_own_price(direction, mark, closed, satisfied, timeframe):
plan = TradePlan(action="wait", summary="s", reasons="r", risks="x",
reevaluate=[{"direction": direction, "price": "105", "timeframe": timeframe, "basis": "x"}])
ctx = context(mark_price=Decimal(mark), closed_prices={"5m": Decimal(closed)})
expected = satisfied if timeframe == "5m" else not satisfied
if expected:
assert "已经满足" in errors_of(plan, ctx)
else:
assert check_setup(plan, ctx) == {}
# ---------- 应有订单 ---------- # ---------- 应有订单 ----------
LIMIT_PLAN = {"direction": "long", "setup": {}, "entry": {"price": "80500.0", "quantity": "0.0241", "stop": "80150.0"}} LIMIT_PLAN = {"direction": "long", "setup": {}, "entry": {"price": "80500.0", "quantity": "0.0241", "stop": "80150.0"}}
+31
View File
@@ -2,6 +2,7 @@ import asyncio
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest import pytest
@@ -288,3 +289,33 @@ def test_replacement_proposal_survives_old_plan_cancellation(trading):
finally: finally:
await engine.market.http.aclose() await engine.market.http.aclose()
asyncio.run(check()) asyncio.run(check())
@pytest.mark.parametrize("closed,mark,rejected", [("100", "110", False), ("110", "100", True)])
@pytest.mark.parametrize("last_bar_closed", [False, True])
def test_submit_reevaluate_uses_latest_closed_candle(trading, monkeypatch, closed, mark, rejected, last_bar_closed):
from alphapulse.trading import analyst
from alphapulse.trading.plan import PlanContext
now_ms = int(datetime.now(UTC).timestamp() * 1000)
rows = [[0, "0", "0", "0", closed, "0", now_ms - 1000],
[1, "0", "0", "0", mark, "0", now_ms + 60_000]]
if last_bar_closed:
rows[-1][4], rows[-1][6] = closed, now_ms - 1
monkeypatch.setattr(analyst.market_client, "get", AsyncMock(return_value=rows))
monkeypatch.setattr(analyst, "mark_price", AsyncMock(return_value=Decimal(mark)))
monkeypatch.setattr(analyst, "recent_atr", AsyncMock(return_value=Decimal(1)))
monkeypatch.setattr(analyst, "read_snapshot", AsyncMock(return_value=None))
monkeypatch.setattr(analyst, "add_event", AsyncMock())
ctx = PlanContext(Decimal(mark), Decimal(1000), RULES, Decimal(1), False, None)
prepared = SimpleNamespace(exchange="bybit", context=ctx)
async def check():
result, outcome = await analyst.submit(prepared, "BTCUSDT", 1, {
"action": "wait", "summary": "s", "reasons": "r", "risks": "x",
"reevaluate": [{"direction": "above", "price": "105", "timeframe": "5m", "basis": "收盘确认"}],
})
assert ctx.closed_prices == {"5m": Decimal(closed)}
assert (outcome is None) == rejected
assert ("已经满足" in result) == rejected
asyncio.run(check())