Switch demo trading to 5m short-term swing setups (plan 3.1)

- The model now submits a pending setup (direction, zone, confirm price,
  validity); the executor enters at market once a 5m close confirms a
  dip into support or a rejection from resistance, and cancels the setup
  when the zone breaks, the trigger is missed or the stop is too far
- Stop beyond the confirmation extreme by 0.5 x ATR14(5m), skipped above
  1%; fixed take-profit ladders from the fill price (long 60/30/10 at
  +0.5/0.75/1%, short 80/20 at -0.5/0.75%); 4h time stop
- While holding, the model may only keep or exit
- Drop the long-term angle from the program, API, tables and page
This commit is contained in:
eezd committed 2026-10-15 13:09:19 +08:00
1 parent 1fcf41952c
commit 7efa2eb68b
12 files changed
+696 -746

No files matched your search

+143 -144
View File
@@ -4,49 +4,65 @@ 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,
Bar, OrderSpec, PlanContext, PlanError, SymbolRules, TradePlan, atr, check_setup, desired_orders, diff_orders, next_routine_at,
round_to_step, scan_setup, setup_stop, size_entry, split_take_profits, take_profit_targets,
)
BTC = SymbolRules(tick_size=Decimal("0.1"), step_size=Decimal("0.0001"), min_notional=Decimal("50"))
TZ8 = timezone(timedelta(hours=8))
FIVE_MINUTES_MS = 5 * 60_000
# BTCUSDT 永续 5m,2026-10-15 北京时间(开盘时间, 最高, 最低, 收盘),即用户举例的那段行情
BTC_BARS = [
("09:10", "81858.10", "81629.20", "81687.60"), ("09:15", "81771.40", "81570.20", "81716.40"),
("09:20", "81732.40", "81584.80", "81584.90"), ("09:25", "81615.70", "81451.60", "81544.00"),
("09:30", "81810.00", "81523.80", "81751.80"), ("09:35", "81827.40", "81675.00", "81731.70"),
("09:40", "81832.80", "81700.80", "81809.50"), ("09:45", "81810.00", "81637.20", "81680.20"),
("09:50", "81795.00", "81656.70", "81678.90"), ("09:55", "81730.00", "81575.10", "81602.00"),
("10:00", "81619.40", "81495.20", "81507.10"), ("10:05", "81737.00", "81500.00", "81554.70"),
("10:10", "81620.00", "81412.80", "81412.80"), ("10:15", "81775.10", "81406.20", "81769.20"),
("10:20", "81770.10", "80664.20", "80700.10"), ("10:25", "81038.10", "80188.00", "80804.20"),
("10:30", "81057.50", "80476.70", "80979.30"), ("10:35", "81293.20", "80959.00", "81081.90"),
("10:40", "81293.00", "81053.30", "81293.00"), ("10:45", "81350.00", "81111.00", "81213.70"),
("10:50", "81411.70", "81152.70", "81410.00"), ("10:55", "81646.50", "81409.90", "81500.00"),
("11:00", "81514.40", "81077.70", "81153.30"), ("11:05", "81332.10", "81116.00", "81288.10"),
]
def ms(hhmm: str) -> int:
hour, minute = map(int, hhmm.split(":"))
return int(datetime(2026, 10, 15, hour, minute, tzinfo=TZ8).timestamp() * 1000)
def bars_until(hhmm: str) -> list[Bar]:
"""到 hhmm 为止的 K 线,最后一根是 hhmm 开盘、尚未收盘的那根"""
return [Bar(ms(t), ms(t) + FIVE_MINUTES_MS - 1, Decimal(h), Decimal(lo), Decimal(c)) for t, h, lo, c in BTC_BARS if ms(t) <= ms(hhmm)]
def scan(direction, zone_low, zone_high, confirm, adopted: str, now: str):
setup = {"zone_low": zone_low, "zone_high": zone_high, "confirm_price": confirm}
return scan_setup(direction, setup, bars_until(now), ms(adopted), ms(now) + 2000)
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,
"mark_price": Decimal("81000"), "capital": Decimal("1000"), "other_notional": Decimal("0"), "rules": BTC,
"atr": Decimal("100"), "has_plan": False, "position_side": None,
}
return PlanContext(**{**values, **overrides})
def long_plan(**overrides) -> TradePlan:
def setup_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": "站上区"},
"action": "plan", "summary": "回踩支撑做多", "reasons": "支撑区", "risks": "无", "direction": "long", "confidence": "medium",
"setup": {"zone_low": "80200", "zone_high": "80700", "confirm_price": "80750", "basis": "1h 支撑区"}, "valid_hours": "2",
}
return TradePlan.model_validate({**values, **overrides})
def errors_of(plan: TradePlan, ctx: PlanContext) -> str:
with pytest.raises(PlanError) as e:
size_plan(plan, ctx)
check_setup(plan, ctx)
return ";".join(e.value.errors)
@@ -65,151 +81,137 @@ def test_split_take_profits_keeps_total():
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
# ---------- 5m 确认(用户的两个例子) ----------
def test_long_example_triggers_on_close_back_above_after_dip():
# 10:20 收在 80700 还低于确认价;10:25 下探到 80188(影线低于区间下沿但收盘没破)收回 80804 → 10:30 触发
result = scan("long", "80200", "80700", "80750", adopted="10:12", now="10:30")
assert result.state == "triggered"
assert result.bar.open_time == ms("10:25")
assert result.extreme == Decimal("80188.00")
assert scan("long", "80200", "80700", "80750", adopted="10:12", now="10:25").state == "waiting"
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_short_example_triggers_on_rejection_from_resistance():
# 10:50 只到 81411.7,未进区;10:55 冲到 81646.5 后收在 81500 → 11:00 触发
result = scan("short", "81500", "81700", "81500", adopted="10:47", now="11:00")
assert (result.state, result.bar.open_time, result.extreme) == ("triggered", ms("10:55"), Decimal("81646.50"))
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_bars_closed_before_adoption_are_ignored():
# 10:25 那根在计划生效(10:30)前已收盘,不算
assert scan("long", "80200", "80700", "80750", adopted="10:30", now="10:30").state == "waiting"
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_trigger_not_on_latest_bar_is_missed():
assert scan("long", "80200", "80700", "80750", adopted="10:12", now="10:35").state == "missed"
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_close_beyond_far_edge_breaks_setup():
result = scan("long", "80750", "81000", "81050", adopted="10:12", now="10:30")
assert (result.state, result.bar.open_time) == ("broken", ms("10:20"))
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_atr_and_stop_from_example():
closed = bars_until("10:25")
value = atr(closed)
assert value.quantize(Decimal("0.0001")) == Decimal("302.9714")
# 做多:80188 − 0.5 × 302.97 = 80036.51 → 向下取整 80036.5,距 80804.2 约 0.95%,仍在 1% 以内
stop = setup_stop("long", Decimal("80188.00"), value, BTC.tick_size)
assert stop == Decimal("80036.5")
entry = size_entry("long", Decimal("80804.2"), stop, capital=Decimal("1000"), other_notional=Decimal("0"), confidence="medium", rules=BTC)
assert entry["stop_pct"] == "0.95"
# 10 ÷ (767.7 + (80804.2 + 80036.5) × 0.0004 = 832.04) = 0.01201… → 0.0120
assert entry["quantity"] == "0.0120"
assert setup_stop("short", Decimal("81646.50"), atr(bars_until("10:55")), BTC.tick_size) == Decimal("81827.8")
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_size_entry_rejects_far_stop_and_halves_low_confidence():
with pytest.raises(PlanError, match="超过 1%"):
size_entry("long", Decimal("80000"), Decimal("79100"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="high", rules=BTC)
with pytest.raises(PlanError, match="越过现价"):
size_entry("short", Decimal("80000"), Decimal("79900"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="high", rules=BTC)
medium = size_entry("long", Decimal("80000"), Decimal("79600"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="medium", rules=BTC)
low = size_entry("long", Decimal("80000"), Decimal("79600"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="low", rules=BTC)
assert (medium["risk_amount"], low["risk_amount"]) == ("10.00", "5.00")
# 止损很近时按 3 倍杠杆封顶:3000 − 其他币 1000 = 2000 ÷ 80000 = 0.025
capped = size_entry("long", Decimal("80000"), Decimal("79990"), capital=Decimal("1000"), other_notional=Decimal("1000"), confidence="high", rules=BTC)
assert (capped["quantity"], capped["capped_by_leverage"]) == ("0.0250", True)
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_take_profit_ladders():
long = take_profit_targets("long", Decimal("80804.2"), Decimal("0.0120"), BTC)
# +0.5% = 81208.22 → 81208.2;+0.75% = 81410.23 → 81410.2;+1% = 81612.24 → 81612.2;数量 60/30/10
assert long == [(Decimal("81208.2"), Decimal("0.0072")), (Decimal("81410.2"), Decimal("0.0036")), (Decimal("81612.2"), Decimal("0.0012"))]
short = take_profit_targets("short", Decimal("81500"), Decimal("0.0123"), BTC)
# −0.5% = 81092.5;−0.75% = 80888.75 → 向上取整 80888.8;数量 80/20
assert short == [(Decimal("81092.5"), Decimal("0.0098")), (Decimal("80888.8"), Decimal("0.0025"))]
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_check_setup_accepts_and_estimates():
sizing = check_setup(setup_plan(), context())
assert sizing["setup"] == {"zone_low": "80200", "zone_high": "80700", "confirm_price": "80750"}
# 预估止损 = 80200 − 0.5 × 100 = 80150,距确认价 80750 约 0.74%
assert (sizing["estimate"]["stop"], sizing["estimate"]["stop_pct"]) == ("80150.0", "0.74")
assert sizing["position_qty"] == "0"
def test_check_setup_rejects_bad_setups():
bad_order = setup_plan(setup={"zone_low": "80700", "zone_high": "80200", "confirm_price": "80750", "basis": "x"})
assert "须低于上沿" in errors_of(bad_order, context())
inside = setup_plan(setup={"zone_low": "80200", "zone_high": "80700", "confirm_price": "80500", "basis": "x"})
assert "不低于区间上沿" in errors_of(inside, context())
short_inside = setup_plan(direction="short", setup={"zone_low": "81500", "zone_high": "81700", "confirm_price": "81600", "basis": "x"})
assert "不高于区间下沿" in errors_of(short_inside, context())
assert "支撑已破" in errors_of(setup_plan(), context(mark_price=Decimal("80100")))
wide = setup_plan(setup={"zone_low": "79800", "zone_high": "80700", "confirm_price": "80750", "basis": "x"})
assert "超过 1%" in errors_of(wide, context())
assert "valid_hours" in errors_of(setup_plan(valid_hours="3"), context())
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()) == {}
assert check_setup(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"}])
reevaluate=[{"direction": "above", "price": "80000", "timeframe": "5m", "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())
assert "只能 keep 或 exit" in errors_of(setup_plan(), context(position_side="long", has_plan=True))
# ---------- 应有订单 ----------
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
TRIGGERED = {"direction": "long", "setup": {}, "entry": {"quantity": "0.0120", "stop": "80036.5"}}
NO_TP = [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_places_stop_and_market_entry_once():
orders = desired_orders(TRIGGERED, entry_sent=False, entry_fill=None, tp_filled=NO_TP, position_qty=Decimal(0),
stop_price=Decimal("80036.5"), rules=BTC)
assert [(o.purpose, o.side, o.type, o.quantity) for o in orders] == [
("entry", "BUY", "MARKET", Decimal("0.0120")), ("stop", "SELL", "STOP_MARKET", Decimal("0.0120")),
]
# 入场单已发出、还没成交:只保留止损,不重发入场
orders = desired_orders(TRIGGERED, entry_sent=True, entry_fill=None, tp_filled=NO_TP, position_qty=Decimal(0),
stop_price=Decimal("80036.5"), rules=BTC)
assert [o.purpose for o in orders] == ["stop"]
def test_desired_orders_take_profits_follow_fill_price():
orders = desired_orders(TRIGGERED, entry_sent=True, entry_fill=(Decimal("0.0120"), Decimal("80810.0")),
tp_filled=[Decimal("0.0072"), Decimal(0), Decimal(0)], position_qty=Decimal("0.0048"),
stop_price=Decimal("80850.0"), rules=BTC)
assert [(o.purpose, o.leg, o.price, o.trigger_price, o.quantity) for o in orders] == [
("stop", None, None, Decimal("80850.0"), Decimal("0.0120")),
("tp", 1, Decimal("81416.0"), None, Decimal("0.0036")), ("tp", 2, Decimal("81618.1"), None, Decimal("0.0012")),
]
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_desired_orders_nothing_after_flat():
assert desired_orders(TRIGGERED, entry_sent=True, entry_fill=(Decimal("0.0120"), Decimal("80810.0")), tp_filled=NO_TP,
position_qty=Decimal(0), stop_price=Decimal("80036.5"), rules=BTC) == []
def test_diff_orders_matches_by_value():
@@ -223,12 +225,9 @@ def test_diff_orders_matches_by_value():
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)
# 北京 10:20 = UTC 02:20;+45 分钟后的第一个整点 UTC 04:00,再加 1 分钟
assert next_routine_at(datetime(2026, 10, 15, 10, 20, tzinfo=TZ8)) == datetime(2026, 10, 15, 4, 1, tzinfo=UTC)
assert next_routine_at(datetime(2026, 10, 15, 3, 1, tzinfo=UTC)) == datetime(2026, 10, 15, 4, 1, tzinfo=UTC)
def test_step_decimal_never_uses_exponent():