1
This commit is contained in:
parent
cabd96a875
commit
f3463e284e
12
.env.example
12
.env.example
@ -62,6 +62,18 @@ ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE=70
|
|||||||
ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE=70
|
ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE=70
|
||||||
ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS=6
|
ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS=6
|
||||||
ALPHAX_ONCHAIN_WHALE_TX_USD=250000
|
ALPHAX_ONCHAIN_WHALE_TX_USD=250000
|
||||||
|
|
||||||
|
# 策略交易挂单门控。wait_pullback 只是候选,必须通过这些条件才会创建挂单。
|
||||||
|
ALPHAX_PAPER_ORDER_GATE_ENABLED=1
|
||||||
|
ALPHAX_PAPER_MAX_CUMULATIVE_LEVERAGE=5
|
||||||
|
ALPHAX_PAPER_ORDER_MIN_REC_SCORE=20
|
||||||
|
ALPHAX_PAPER_ORDER_MIN_RR=1.2
|
||||||
|
ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK=1
|
||||||
|
ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT=8
|
||||||
|
ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER=0
|
||||||
|
ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT=12
|
||||||
|
ALPHAX_PAPER_ORDER_EXPIRE_HOURS=24
|
||||||
|
|
||||||
ALPHAX_SYSTEM_ERROR_FEISHU_ENABLED=0
|
ALPHAX_SYSTEM_ERROR_FEISHU_ENABLED=0
|
||||||
ALPHAX_SYSTEM_ERROR_FEISHU_WEBHOOK=
|
ALPHAX_SYSTEM_ERROR_FEISHU_WEBHOOK=
|
||||||
|
|
||||||
|
|||||||
@ -100,6 +100,7 @@ def default_paper_trading_config():
|
|||||||
"account_equity_usdt": _env_float("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", 20000),
|
"account_equity_usdt": _env_float("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", 20000),
|
||||||
"trade_notional_usdt": _env_float("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", 5000),
|
"trade_notional_usdt": _env_float("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", 5000),
|
||||||
"trade_leverage": _env_float("ALPHAX_PAPER_TRADE_LEVERAGE", 5),
|
"trade_leverage": _env_float("ALPHAX_PAPER_TRADE_LEVERAGE", 5),
|
||||||
|
"max_cumulative_leverage": _env_float("ALPHAX_PAPER_MAX_CUMULATIVE_LEVERAGE", 5.0),
|
||||||
"fee_rate": _env_float("ALPHAX_PAPER_TRADE_FEE_RATE", 0.001),
|
"fee_rate": _env_float("ALPHAX_PAPER_TRADE_FEE_RATE", 0.001),
|
||||||
"slippage_pct": _env_float("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", 0.05),
|
"slippage_pct": _env_float("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", 0.05),
|
||||||
"trailing_stop_enabled": _env_bool("ALPHAX_PAPER_TRAILING_STOP_ENABLED", True),
|
"trailing_stop_enabled": _env_bool("ALPHAX_PAPER_TRAILING_STOP_ENABLED", True),
|
||||||
@ -109,7 +110,9 @@ def default_paper_trading_config():
|
|||||||
"trailing_move_push_min_interval_seconds": _env_int("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS", 300),
|
"trailing_move_push_min_interval_seconds": _env_int("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS", 300),
|
||||||
"trailing_move_push_min_step_pct": _env_float("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT", 2.0),
|
"trailing_move_push_min_step_pct": _env_float("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT", 2.0),
|
||||||
"order_gate_enabled": _env_bool("ALPHAX_PAPER_ORDER_GATE_ENABLED", True),
|
"order_gate_enabled": _env_bool("ALPHAX_PAPER_ORDER_GATE_ENABLED", True),
|
||||||
|
"order_min_rec_score": _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 20.0),
|
||||||
"order_min_rr": _env_float("ALPHAX_PAPER_ORDER_MIN_RR", 1.2),
|
"order_min_rr": _env_float("ALPHAX_PAPER_ORDER_MIN_RR", 1.2),
|
||||||
|
"order_require_risk_reward_ok": _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK", True),
|
||||||
"order_max_distance_to_entry_pct": _env_float("ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT", 8.0),
|
"order_max_distance_to_entry_pct": _env_float("ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT", 8.0),
|
||||||
"order_require_current_trigger": _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER", False),
|
"order_require_current_trigger": _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER", False),
|
||||||
"order_cancel_far_from_entry_pct": _env_float("ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT", 12.0),
|
"order_cancel_far_from_entry_pct": _env_float("ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT", 12.0),
|
||||||
|
|||||||
@ -65,6 +65,43 @@ def default_slippage_pct(config: dict | None = None) -> float:
|
|||||||
return max(0.0, _safe_float(_paper_cfg(config).get("slippage_pct"), 0.05))
|
return max(0.0, _safe_float(_paper_cfg(config).get("slippage_pct"), 0.05))
|
||||||
|
|
||||||
|
|
||||||
|
def max_cumulative_leverage(config: dict | None = None) -> float:
|
||||||
|
return max(0.0, _safe_float(_paper_cfg(config).get("max_cumulative_leverage"), 5.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _cumulative_leverage_check(conn, additional_notional: float, config: dict | None = None, exclude_rec_id: int = 0) -> tuple[bool, dict]:
|
||||||
|
cfg = _paper_cfg(config)
|
||||||
|
equity = default_account_equity_usdt(cfg)
|
||||||
|
cap = max_cumulative_leverage(cfg)
|
||||||
|
if cap <= 0:
|
||||||
|
return True, {"max_cumulative_leverage": cap, "disabled": True}
|
||||||
|
exclude_rec_id = _safe_int(exclude_rec_id)
|
||||||
|
open_params = []
|
||||||
|
pending_params = []
|
||||||
|
open_where = "status='open'"
|
||||||
|
pending_where = "status='pending'"
|
||||||
|
if exclude_rec_id > 0:
|
||||||
|
open_where += " AND recommendation_id<>%s"
|
||||||
|
pending_where += " AND recommendation_id<>%s"
|
||||||
|
open_params.append(exclude_rec_id)
|
||||||
|
pending_params.append(exclude_rec_id)
|
||||||
|
open_notional = _safe_float(conn.execute(f"SELECT COALESCE(SUM(notional_usdt),0) FROM paper_trades WHERE {open_where}", tuple(open_params)).fetchone()[0])
|
||||||
|
pending_notional = _safe_float(conn.execute(f"SELECT COALESCE(SUM(notional_usdt),0) FROM paper_orders WHERE {pending_where}", tuple(pending_params)).fetchone()[0])
|
||||||
|
add = max(0.0, _safe_float(additional_notional))
|
||||||
|
projected_notional = open_notional + pending_notional + add
|
||||||
|
projected_leverage = projected_notional / equity if equity > 0 else 0
|
||||||
|
detail = {
|
||||||
|
"account_equity_usdt": equity,
|
||||||
|
"open_notional_usdt": round(open_notional, 8),
|
||||||
|
"pending_notional_usdt": round(pending_notional, 8),
|
||||||
|
"additional_notional_usdt": round(add, 8),
|
||||||
|
"projected_notional_usdt": round(projected_notional, 8),
|
||||||
|
"projected_cumulative_leverage": round(projected_leverage, 6),
|
||||||
|
"max_cumulative_leverage": cap,
|
||||||
|
}
|
||||||
|
return projected_leverage <= cap + 1e-12, detail
|
||||||
|
|
||||||
|
|
||||||
def _trailing_config() -> dict:
|
def _trailing_config() -> dict:
|
||||||
cfg = paper_trading_config()
|
cfg = paper_trading_config()
|
||||||
return {
|
return {
|
||||||
@ -404,6 +441,14 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
|
|||||||
plan = _entry_plan(rec)
|
plan = _entry_plan(rec)
|
||||||
entry_price = _open_price(current_price, cfg)
|
entry_price = _open_price(current_price, cfg)
|
||||||
notional = default_notional_usdt(cfg)
|
notional = default_notional_usdt(cfg)
|
||||||
|
leverage_ok, leverage_detail = _cumulative_leverage_check(conn, notional, cfg, exclude_rec_id=rec_id)
|
||||||
|
if not leverage_ok:
|
||||||
|
return {
|
||||||
|
"opened": False,
|
||||||
|
"skipped": True,
|
||||||
|
"reason": "cumulative_leverage_exceeded",
|
||||||
|
"risk_detail": leverage_detail,
|
||||||
|
}
|
||||||
leverage = default_leverage(cfg)
|
leverage = default_leverage(cfg)
|
||||||
margin = default_margin_usdt(cfg)
|
margin = default_margin_usdt(cfg)
|
||||||
qty = round(notional / entry_price, 12) if entry_price > 0 else 0
|
qty = round(notional / entry_price, 12) if entry_price > 0 else 0
|
||||||
@ -553,7 +598,7 @@ def _paper_order_distance_pct(side: str, current_price: float, target: float) ->
|
|||||||
return max(0.0, (current_price / target - 1) * 100)
|
return max(0.0, (current_price / target - 1) * 100)
|
||||||
|
|
||||||
|
|
||||||
def _paper_order_gate(rec: dict, current_price: float, config: dict | None = None) -> tuple[bool, list[str], dict]:
|
def _paper_order_gate(rec: dict, current_price: float, config: dict | None = None, conn=None) -> tuple[bool, list[str], dict]:
|
||||||
cfg = _paper_cfg(config)
|
cfg = _paper_cfg(config)
|
||||||
if not bool(cfg.get("order_gate_enabled", True)):
|
if not bool(cfg.get("order_gate_enabled", True)):
|
||||||
return True, [], {"gate_enabled": False}
|
return True, [], {"gate_enabled": False}
|
||||||
@ -567,6 +612,20 @@ def _paper_order_gate(rec: dict, current_price: float, config: dict | None = Non
|
|||||||
calc_rr = _paper_order_rr(side, target, stop_loss, tp1)
|
calc_rr = _paper_order_rr(side, target, stop_loss, tp1)
|
||||||
effective_rr = rr if rr > 0 else calc_rr
|
effective_rr = rr if rr > 0 else calc_rr
|
||||||
min_rr = max(0.0, _safe_float(cfg.get("order_min_rr"), 1.2))
|
min_rr = max(0.0, _safe_float(cfg.get("order_min_rr"), 1.2))
|
||||||
|
min_rec_score = max(0.0, _safe_float(cfg.get("order_min_rec_score"), 20.0))
|
||||||
|
rec_score = _safe_float(rec.get("rec_score") or rec.get("score"))
|
||||||
|
if rec_score <= 0 and conn is not None and _safe_int(rec.get("id")) > 0:
|
||||||
|
row = conn.execute("SELECT rec_score FROM recommendation WHERE id=%s", (_safe_int(rec.get("id")),)).fetchone()
|
||||||
|
rec_score = _safe_float(row["rec_score"] if row else 0)
|
||||||
|
leverage_ok = True
|
||||||
|
leverage_detail = {}
|
||||||
|
if conn is not None:
|
||||||
|
leverage_ok, leverage_detail = _cumulative_leverage_check(
|
||||||
|
conn,
|
||||||
|
default_notional_usdt(cfg),
|
||||||
|
cfg,
|
||||||
|
exclude_rec_id=_safe_int(rec.get("id")),
|
||||||
|
)
|
||||||
distance_pct = _paper_order_distance_pct(side, current_price, target)
|
distance_pct = _paper_order_distance_pct(side, current_price, target)
|
||||||
max_distance = max(0.0, _safe_float(cfg.get("order_max_distance_to_entry_pct"), 8.0))
|
max_distance = max(0.0, _safe_float(cfg.get("order_max_distance_to_entry_pct"), 8.0))
|
||||||
opportunity_level = str(plan.get("opportunity_level") or rec.get("opportunity_level") or "").strip()
|
opportunity_level = str(plan.get("opportunity_level") or rec.get("opportunity_level") or "").strip()
|
||||||
@ -585,6 +644,12 @@ def _paper_order_gate(rec: dict, current_price: float, config: dict | None = Non
|
|||||||
reasons.append("invalid_risk_geometry")
|
reasons.append("invalid_risk_geometry")
|
||||||
if risk_reward_ok is False:
|
if risk_reward_ok is False:
|
||||||
reasons.append("risk_reward_rejected")
|
reasons.append("risk_reward_rejected")
|
||||||
|
if bool(cfg.get("order_require_risk_reward_ok", True)) and risk_reward_ok is not True:
|
||||||
|
reasons.append("risk_reward_not_confirmed")
|
||||||
|
if rec_score < min_rec_score:
|
||||||
|
reasons.append("rec_score_below_min")
|
||||||
|
if not leverage_ok:
|
||||||
|
reasons.append("cumulative_leverage_exceeded")
|
||||||
if effective_rr > 0 and effective_rr < min_rr:
|
if effective_rr > 0 and effective_rr < min_rr:
|
||||||
reasons.append("rr_below_min")
|
reasons.append("rr_below_min")
|
||||||
if effective_rr <= 0:
|
if effective_rr <= 0:
|
||||||
@ -605,6 +670,9 @@ def _paper_order_gate(rec: dict, current_price: float, config: dict | None = Non
|
|||||||
"distance_to_entry_pct": round(distance_pct, 4),
|
"distance_to_entry_pct": round(distance_pct, 4),
|
||||||
"max_distance_to_entry_pct": max_distance,
|
"max_distance_to_entry_pct": max_distance,
|
||||||
"min_rr": min_rr,
|
"min_rr": min_rr,
|
||||||
|
"rec_score": rec_score,
|
||||||
|
"min_rec_score": min_rec_score,
|
||||||
|
"leverage": leverage_detail,
|
||||||
"opportunity_level": opportunity_level,
|
"opportunity_level": opportunity_level,
|
||||||
"entry_trigger_confirmed": trigger_ok,
|
"entry_trigger_confirmed": trigger_ok,
|
||||||
}
|
}
|
||||||
@ -759,7 +827,7 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time:
|
|||||||
"current_price": current_price,
|
"current_price": current_price,
|
||||||
}
|
}
|
||||||
|
|
||||||
gate_ok, gate_reasons, gate_detail = _paper_order_gate(rec, current_price, cfg)
|
gate_ok, gate_reasons, gate_detail = _paper_order_gate(rec, current_price, cfg, conn=conn)
|
||||||
if not gate_ok:
|
if not gate_ok:
|
||||||
return {
|
return {
|
||||||
"skipped": True,
|
"skipped": True,
|
||||||
@ -1008,6 +1076,22 @@ def sync_recommendation(rec: dict, current_price: float, event_time: str = "") -
|
|||||||
if trade:
|
if trade:
|
||||||
trade = dict(trade)
|
trade = dict(trade)
|
||||||
if trade.get("status") == "open":
|
if trade.get("status") == "open":
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE paper_orders
|
||||||
|
SET status='filled',
|
||||||
|
fill_price=%s,
|
||||||
|
filled_at=COALESCE(NULLIF(filled_at,''), %s),
|
||||||
|
updated_at=%s
|
||||||
|
WHERE recommendation_id=%s AND status='pending'
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
_safe_float(trade.get("entry_price")),
|
||||||
|
trade.get("opened_at") or event_time,
|
||||||
|
event_time,
|
||||||
|
rec_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
result = _update_open_trade(conn, trade, current_price, event_time)
|
result = _update_open_trade(conn, trade, current_price, event_time)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return result
|
return result
|
||||||
|
|||||||
@ -39,6 +39,10 @@ def _fingerprint(error_type: str, message: str, stack_trace: str, path: str = ""
|
|||||||
return hashlib.sha256(basis.encode("utf-8", errors="ignore")).hexdigest()[:32]
|
return hashlib.sha256(basis.encode("utf-8", errors="ignore")).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def _should_push_alert(level: str) -> bool:
|
||||||
|
return str(level or "").strip().lower() in {"error", "critical", "fatal"}
|
||||||
|
|
||||||
|
|
||||||
def record_system_error(
|
def record_system_error(
|
||||||
*,
|
*,
|
||||||
source: str,
|
source: str,
|
||||||
@ -92,20 +96,21 @@ def record_system_error(
|
|||||||
)
|
)
|
||||||
log_id = row.fetchone()["id"]
|
log_id = row.fetchone()["id"]
|
||||||
conn.commit()
|
conn.commit()
|
||||||
push_system_error_alert({
|
if _should_push_alert(level or "error"):
|
||||||
"id": int(log_id),
|
push_system_error_alert({
|
||||||
"created_at": _now(),
|
"id": int(log_id),
|
||||||
"level": _truncate(level or "error", 20),
|
"created_at": _now(),
|
||||||
"source": _truncate(source or "app", 80),
|
"level": _truncate(level or "error", 20),
|
||||||
"error_type": _truncate(error_type, 160),
|
"source": _truncate(source or "app", 80),
|
||||||
"message": _truncate(message, 2000),
|
"error_type": _truncate(error_type, 160),
|
||||||
"stack_trace": _truncate(stack_trace, 60000),
|
"message": _truncate(message, 2000),
|
||||||
"request_method": _truncate(request_method, 16),
|
"stack_trace": _truncate(stack_trace, 60000),
|
||||||
"request_path": _truncate(request_path, 500),
|
"request_method": _truncate(request_method, 16),
|
||||||
"user_email": _truncate(user_email, 255),
|
"request_path": _truncate(request_path, 500),
|
||||||
"status_code": int(status_code or 0),
|
"user_email": _truncate(user_email, 255),
|
||||||
"fingerprint": fingerprint,
|
"status_code": int(status_code or 0),
|
||||||
})
|
"fingerprint": fingerprint,
|
||||||
|
})
|
||||||
return int(log_id)
|
return int(log_id)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@ -121,15 +121,15 @@ def load_stream_targets(limit: int | None = None, cfg: dict | None = None) -> di
|
|||||||
targets[symbol] = dict(rec)
|
targets[symbol] = dict(rec)
|
||||||
|
|
||||||
if cfg.get("include_open_paper_trades", True):
|
if cfg.get("include_open_paper_trades", True):
|
||||||
for rec in _load_open_paper_trade_recs():
|
|
||||||
symbol = str(rec.get("symbol") or "").strip().upper()
|
|
||||||
if symbol:
|
|
||||||
targets.setdefault(symbol, rec)
|
|
||||||
|
|
||||||
for rec in _load_pending_paper_order_recs():
|
for rec in _load_pending_paper_order_recs():
|
||||||
symbol = str(rec.get("symbol") or "").strip().upper()
|
symbol = str(rec.get("symbol") or "").strip().upper()
|
||||||
if symbol:
|
if symbol:
|
||||||
targets.setdefault(symbol, rec)
|
targets[symbol] = rec
|
||||||
|
|
||||||
|
for rec in _load_open_paper_trade_recs():
|
||||||
|
symbol = str(rec.get("symbol") or "").strip().upper()
|
||||||
|
if symbol:
|
||||||
|
targets[symbol] = rec
|
||||||
|
|
||||||
return dict(list(targets.items())[:max_symbols])
|
return dict(list(targets.items())[:max_symbols])
|
||||||
|
|
||||||
|
|||||||
@ -244,6 +244,28 @@ def test_wait_pullback_without_tradeable_plan_does_not_create_order(monkeypatch)
|
|||||||
assert list_paper_orders()["total"] == 0
|
assert list_paper_orders()["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
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_too_far_from_entry_does_not_create_order(monkeypatch):
|
def test_wait_pullback_too_far_from_entry_does_not_create_order(monkeypatch):
|
||||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||||
altcoin_db.init_db()
|
altcoin_db.init_db()
|
||||||
@ -267,6 +289,45 @@ def test_wait_pullback_too_far_from_entry_does_not_create_order(monkeypatch):
|
|||||||
assert list_paper_orders()["total"] == 0
|
assert list_paper_orders()["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
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_observe_only_wait_pullback_does_not_create_order(monkeypatch):
|
def test_observe_only_wait_pullback_does_not_create_order(monkeypatch):
|
||||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||||
altcoin_db.init_db()
|
altcoin_db.init_db()
|
||||||
@ -312,7 +373,7 @@ def test_wait_pullback_paper_order_fills_when_price_touches(monkeypatch):
|
|||||||
tp1=105,
|
tp1=105,
|
||||||
tp2=112,
|
tp2=112,
|
||||||
signals=["等待回踩"],
|
signals=["等待回踩"],
|
||||||
entry_plan={"entry_action": "等回踩", "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},
|
||||||
)
|
)
|
||||||
with altcoin_db.get_conn() as conn:
|
with altcoin_db.get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -320,7 +381,7 @@ def test_wait_pullback_paper_order_fills_when_price_touches(monkeypatch):
|
|||||||
(rec_id,),
|
(rec_id,),
|
||||||
)
|
)
|
||||||
conn.commit()
|
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}}
|
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")
|
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")
|
filled = sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00")
|
||||||
@ -345,9 +406,9 @@ def test_wait_pullback_order_cancels_when_recommendation_invalid(monkeypatch):
|
|||||||
stop_loss=90,
|
stop_loss=90,
|
||||||
tp1=105,
|
tp1=105,
|
||||||
signals=["等待回踩"],
|
signals=["等待回踩"],
|
||||||
entry_plan={"entry_action": "等回踩", "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},
|
||||||
)
|
)
|
||||||
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}}
|
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")
|
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||||
with altcoin_db.get_conn() as conn:
|
with altcoin_db.get_conn() as conn:
|
||||||
conn.execute("UPDATE recommendation SET status='invalid', execution_status='invalid' WHERE id=%s", (rec_id,))
|
conn.execute("UPDATE recommendation SET status='invalid', execution_status='invalid' WHERE id=%s", (rec_id,))
|
||||||
@ -371,9 +432,9 @@ def test_wait_pullback_order_cancels_when_price_runs_too_far(monkeypatch):
|
|||||||
stop_loss=90,
|
stop_loss=90,
|
||||||
tp1=105,
|
tp1=105,
|
||||||
signals=["等待回踩"],
|
signals=["等待回踩"],
|
||||||
entry_plan={"entry_action": "等回踩", "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},
|
||||||
)
|
)
|
||||||
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}}
|
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")
|
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||||
|
|
||||||
result = sync_recommendation(rec, 108, event_time="2026-05-16T10:05:00")
|
result = sync_recommendation(rec, 108, event_time="2026-05-16T10:05:00")
|
||||||
@ -397,9 +458,9 @@ def test_wait_pullback_order_fills_before_same_tick_stop_loss(monkeypatch):
|
|||||||
stop_loss=90,
|
stop_loss=90,
|
||||||
tp1=105,
|
tp1=105,
|
||||||
signals=["等待回踩"],
|
signals=["等待回踩"],
|
||||||
entry_plan={"entry_action": "等回踩", "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},
|
||||||
)
|
)
|
||||||
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}}
|
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")
|
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||||
|
|
||||||
result = sync_recommendation(rec, 89, event_time="2026-05-16T10:05:00")
|
result = sync_recommendation(rec, 89, event_time="2026-05-16T10:05:00")
|
||||||
@ -431,9 +492,9 @@ def test_wait_pullback_paper_order_fill_pushes_single_combined_card(monkeypatch)
|
|||||||
stop_loss=90,
|
stop_loss=90,
|
||||||
tp1=105,
|
tp1=105,
|
||||||
signals=["等待回踩"],
|
signals=["等待回踩"],
|
||||||
entry_plan={"entry_action": "等回踩", "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},
|
||||||
)
|
)
|
||||||
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}}
|
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, 100, event_time="2026-05-16T10:00:00")
|
||||||
sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00")
|
sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00")
|
||||||
@ -531,7 +592,7 @@ def test_summary_counts_pending_paper_orders(monkeypatch):
|
|||||||
stop_loss=90,
|
stop_loss=90,
|
||||||
tp1=105,
|
tp1=105,
|
||||||
signals=["等待回踩"],
|
signals=["等待回踩"],
|
||||||
entry_plan={"entry_action": "等回踩", "entry_price": 95},
|
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:
|
with altcoin_db.get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -539,7 +600,7 @@ def test_summary_counts_pending_paper_orders(monkeypatch):
|
|||||||
(rec_id,),
|
(rec_id,),
|
||||||
)
|
)
|
||||||
conn.commit()
|
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}}
|
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")
|
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||||
|
|
||||||
|
|||||||
@ -117,7 +117,7 @@ def test_price_streamer_fills_pending_paper_order():
|
|||||||
stop_loss=90,
|
stop_loss=90,
|
||||||
tp1=105,
|
tp1=105,
|
||||||
signals=["等待回踩"],
|
signals=["等待回踩"],
|
||||||
entry_plan={"entry_action": "等回踩", "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},
|
||||||
)
|
)
|
||||||
with altcoin_db.get_conn() as conn:
|
with altcoin_db.get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -125,7 +125,7 @@ def test_price_streamer_fills_pending_paper_order():
|
|||||||
(rec_id,),
|
(rec_id,),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
rec = {"id": rec_id, "symbol": "PBO/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}}
|
rec = {"id": rec_id, "symbol": "PBO/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}}
|
||||||
|
|
||||||
created = price_streamer.handle_price_tick("PBO/USDT", 100, {"PBO/USDT": rec}, event_time="2026-05-16T10:00:00")
|
created = price_streamer.handle_price_tick("PBO/USDT", 100, {"PBO/USDT": rec}, event_time="2026-05-16T10:00:00")
|
||||||
targets = price_streamer.load_stream_targets()
|
targets = price_streamer.load_stream_targets()
|
||||||
@ -138,6 +138,61 @@ def test_price_streamer_fills_pending_paper_order():
|
|||||||
assert list_paper_trades()["items"][0]["entry_price"] == pytest.approx(95)
|
assert list_paper_trades()["items"][0]["entry_price"] == pytest.approx(95)
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_streamer_prioritizes_pending_order_over_same_symbol_recommendation():
|
||||||
|
set_config("system", "paper_trading", {
|
||||||
|
"enabled": True,
|
||||||
|
"trade_notional_usdt": 5000,
|
||||||
|
"trade_leverage": 5,
|
||||||
|
"fee_rate": 0,
|
||||||
|
"slippage_pct": 0,
|
||||||
|
})
|
||||||
|
set_config("system", "price_streamer", {
|
||||||
|
"enabled": True,
|
||||||
|
"update_latest_price_cache": True,
|
||||||
|
"sync_paper_trading": True,
|
||||||
|
"include_actionable_recommendations": True,
|
||||||
|
"include_open_paper_trades": True,
|
||||||
|
})
|
||||||
|
altcoin_db.init_db()
|
||||||
|
pending_id = altcoin_db.create_recommendation(
|
||||||
|
symbol="DUP/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": True, "rr1": 2.0},
|
||||||
|
)
|
||||||
|
active_id = altcoin_db.create_recommendation(
|
||||||
|
symbol="DUP/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},
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
(pending_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
pending_rec = {"id": pending_id, "symbol": "DUP/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}}
|
||||||
|
price_streamer.handle_price_tick("DUP/USDT", 100, {"DUP/USDT": pending_rec}, event_time="2026-05-16T10:00:00")
|
||||||
|
|
||||||
|
targets = price_streamer.load_stream_targets()
|
||||||
|
filled = price_streamer.handle_price_tick("DUP/USDT", 94.9, targets, event_time="2026-05-16T10:05:00")
|
||||||
|
|
||||||
|
assert active_id > 0
|
||||||
|
assert targets["DUP/USDT"]["id"] == pending_id
|
||||||
|
assert filled["paper_trading"]["opened"] is True
|
||||||
|
assert list_paper_orders(status="filled")["total"] == 1
|
||||||
|
assert list_paper_trades()["items"][0]["recommendation_id"] == pending_id
|
||||||
|
|
||||||
|
|
||||||
def test_price_streamer_builds_binance_combined_stream_url():
|
def test_price_streamer_builds_binance_combined_stream_url():
|
||||||
url = price_streamer._stream_url(["BTC/USDT", "ETH/USDT"], {"stream_url": "wss://example.test/stream"})
|
url = price_streamer._stream_url(["BTC/USDT", "ETH/USDT"], {"stream_url": "wss://example.test/stream"})
|
||||||
|
|
||||||
|
|||||||
@ -51,6 +51,23 @@ def test_record_system_error_sends_feishu_alert(monkeypatch):
|
|||||||
assert pushed[0]["message"] == "alert me"
|
assert pushed[0]["message"] == "alert me"
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_system_error_does_not_send_feishu_alert(monkeypatch):
|
||||||
|
pushed = []
|
||||||
|
monkeypatch.setattr("app.db.system_logs.push_system_error_alert", lambda item: pushed.append(item) or (True, {"StatusCode": 0}))
|
||||||
|
|
||||||
|
log_id = record_system_error(
|
||||||
|
source="price_streamer",
|
||||||
|
level="warning",
|
||||||
|
error_type="ConnectionClosedError",
|
||||||
|
message="transient websocket disconnect",
|
||||||
|
status_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert log_id > 0
|
||||||
|
assert pushed == []
|
||||||
|
assert get_system_error(log_id)["level"] == "warning"
|
||||||
|
|
||||||
|
|
||||||
def test_admin_system_error_api_uses_local_admin():
|
def test_admin_system_error_api_uses_local_admin():
|
||||||
log_id = record_system_error(
|
log_id = record_system_error(
|
||||||
source="scheduler",
|
source="scheduler",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user