From f3463e284e6cf8b1df962d052803927dcc809b1e Mon Sep 17 00:00:00 2001 From: aaron <> Date: Thu, 21 May 2026 20:27:50 +0800 Subject: [PATCH] 1 --- .env.example | 12 +++++ app/config/system_config.py | 3 ++ app/db/paper_trading.py | 88 ++++++++++++++++++++++++++++++++- app/db/system_logs.py | 33 +++++++------ app/services/price_streamer.py | 12 ++--- tests/test_paper_trading.py | 85 ++++++++++++++++++++++++++----- tests/test_price_streamer.py | 59 +++++++++++++++++++++- tests/test_system_error_logs.py | 17 +++++++ 8 files changed, 273 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index ec28ee3..39ffda1 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,18 @@ ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE=70 ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE=70 ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS=6 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_WEBHOOK= diff --git a/app/config/system_config.py b/app/config/system_config.py index 3fb9528..d56667d 100644 --- a/app/config/system_config.py +++ b/app/config/system_config.py @@ -100,6 +100,7 @@ def default_paper_trading_config(): "account_equity_usdt": _env_float("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", 20000), "trade_notional_usdt": _env_float("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", 5000), "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), "slippage_pct": _env_float("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", 0.05), "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_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_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_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_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), diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index d0fefa2..c73cb10 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -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)) +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: cfg = paper_trading_config() return { @@ -404,6 +441,14 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config: plan = _entry_plan(rec) entry_price = _open_price(current_price, 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) margin = default_margin_usdt(cfg) 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) -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) if not bool(cfg.get("order_gate_enabled", True)): 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) effective_rr = rr if rr > 0 else calc_rr 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) 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() @@ -585,6 +644,12 @@ def _paper_order_gate(rec: dict, current_price: float, config: dict | None = Non reasons.append("invalid_risk_geometry") if risk_reward_ok is False: 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: reasons.append("rr_below_min") 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), "max_distance_to_entry_pct": max_distance, "min_rr": min_rr, + "rec_score": rec_score, + "min_rec_score": min_rec_score, + "leverage": leverage_detail, "opportunity_level": opportunity_level, "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, } - 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: return { "skipped": True, @@ -1008,6 +1076,22 @@ def sync_recommendation(rec: dict, current_price: float, event_time: str = "") - if trade: trade = dict(trade) 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) conn.commit() return result diff --git a/app/db/system_logs.py b/app/db/system_logs.py index e467330..3ad399a 100644 --- a/app/db/system_logs.py +++ b/app/db/system_logs.py @@ -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] +def _should_push_alert(level: str) -> bool: + return str(level or "").strip().lower() in {"error", "critical", "fatal"} + + def record_system_error( *, source: str, @@ -92,20 +96,21 @@ def record_system_error( ) log_id = row.fetchone()["id"] conn.commit() - push_system_error_alert({ - "id": int(log_id), - "created_at": _now(), - "level": _truncate(level or "error", 20), - "source": _truncate(source or "app", 80), - "error_type": _truncate(error_type, 160), - "message": _truncate(message, 2000), - "stack_trace": _truncate(stack_trace, 60000), - "request_method": _truncate(request_method, 16), - "request_path": _truncate(request_path, 500), - "user_email": _truncate(user_email, 255), - "status_code": int(status_code or 0), - "fingerprint": fingerprint, - }) + if _should_push_alert(level or "error"): + push_system_error_alert({ + "id": int(log_id), + "created_at": _now(), + "level": _truncate(level or "error", 20), + "source": _truncate(source or "app", 80), + "error_type": _truncate(error_type, 160), + "message": _truncate(message, 2000), + "stack_trace": _truncate(stack_trace, 60000), + "request_method": _truncate(request_method, 16), + "request_path": _truncate(request_path, 500), + "user_email": _truncate(user_email, 255), + "status_code": int(status_code or 0), + "fingerprint": fingerprint, + }) return int(log_id) finally: conn.close() diff --git a/app/services/price_streamer.py b/app/services/price_streamer.py index 5ee13cf..4b954b3 100644 --- a/app/services/price_streamer.py +++ b/app/services/price_streamer.py @@ -121,15 +121,15 @@ def load_stream_targets(limit: int | None = None, cfg: dict | None = None) -> di targets[symbol] = dict(rec) 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(): symbol = str(rec.get("symbol") or "").strip().upper() 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]) diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py index fa19257..13b3b71 100644 --- a/tests/test_paper_trading.py +++ b/tests/test_paper_trading.py @@ -244,6 +244,28 @@ def test_wait_pullback_without_tradeable_plan_does_not_create_order(monkeypatch) 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): monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") 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 +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): monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") altcoin_db.init_db() @@ -312,7 +373,7 @@ def test_wait_pullback_paper_order_fills_when_price_touches(monkeypatch): tp1=105, tp2=112, 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: conn.execute( @@ -320,7 +381,7 @@ def test_wait_pullback_paper_order_fills_when_price_touches(monkeypatch): (rec_id,), ) 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") 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, tp1=105, 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") with altcoin_db.get_conn() as conn: 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, tp1=105, 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") 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, tp1=105, 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") 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, tp1=105, 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, 94.9, event_time="2026-05-16T10:05:00") @@ -531,7 +592,7 @@ def test_summary_counts_pending_paper_orders(monkeypatch): stop_loss=90, tp1=105, 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: conn.execute( @@ -539,7 +600,7 @@ def test_summary_counts_pending_paper_orders(monkeypatch): (rec_id,), ) 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") diff --git a/tests/test_price_streamer.py b/tests/test_price_streamer.py index 8ce5fb9..7f784aa 100644 --- a/tests/test_price_streamer.py +++ b/tests/test_price_streamer.py @@ -117,7 +117,7 @@ def test_price_streamer_fills_pending_paper_order(): stop_loss=90, tp1=105, 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: conn.execute( @@ -125,7 +125,7 @@ def test_price_streamer_fills_pending_paper_order(): (rec_id,), ) 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") 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) +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(): url = price_streamer._stream_url(["BTC/USDT", "ETH/USDT"], {"stream_url": "wss://example.test/stream"}) diff --git a/tests/test_system_error_logs.py b/tests/test_system_error_logs.py index 18217d7..ba47755 100644 --- a/tests/test_system_error_logs.py +++ b/tests/test_system_error_logs.py @@ -51,6 +51,23 @@ def test_record_system_error_sends_feishu_alert(monkeypatch): 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(): log_id = record_system_error( source="scheduler",