From f6c95d43803135a66f357c46d12f8e3448944cea Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sat, 6 Jun 2026 17:52:29 +0800 Subject: [PATCH] 1 --- .env.example | 20 ++- app/config/system_config.py | 81 +++++++-- app/core/global_risk.py | 15 +- app/core/strategy_registry.py | 50 ++++-- app/db/analytics.py | 2 + app/db/intraday_frequency.py | 247 ++++++++++++++++++++++++++++ app/db/paper_trading.py | 141 ++++++++++++++++ app/db/review_center.py | 3 + app/services/altcoin_confirm.py | 43 ++++- app/strategies/altcoin_breakout.py | 32 +++- app/web/routes_paper_trading.py | 7 + tests/test_market_regime.py | 33 ++++ tests/test_multi_strategy_infra.py | 58 +++++++ tests/test_opportunity_lifecycle.py | 25 ++- tests/test_paper_trading.py | 5 + 15 files changed, 711 insertions(+), 51 deletions(-) create mode 100644 app/db/intraday_frequency.py diff --git a/.env.example b/.env.example index a694a7d..5978696 100644 --- a/.env.example +++ b/.env.example @@ -76,12 +76,15 @@ ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS=6 ALPHAX_ONCHAIN_WHALE_TX_USD=250000 # 策略交易挂单门控。wait_pullback 只是候选,必须通过这些条件才会创建挂单。 +ALPHAX_PAPER_TRADING_MODE=intraday_trading +ALPHAX_PAPER_TARGET_TRADES_PER_DAY_MIN=3 +ALPHAX_PAPER_TARGET_TRADES_PER_DAY_MAX=5 ALPHAX_PAPER_ORDER_GATE_ENABLED=1 ALPHAX_PAPER_MAX_CUMULATIVE_LEVERAGE=5 ALPHAX_PAPER_ENTRY_GATE_ENABLED=1 -ALPHAX_PAPER_ENTRY_MIN_REC_SCORE=50 -ALPHAX_PAPER_MIN_RR=1.5 -ALPHAX_PAPER_ENTRY_MIN_RR=1.5 +ALPHAX_PAPER_ENTRY_MIN_REC_SCORE=25 +ALPHAX_PAPER_MIN_RR=1.25 +ALPHAX_PAPER_ENTRY_MIN_RR=1.25 ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT=20 ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED=1 ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN=3 @@ -91,22 +94,23 @@ ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS=6 ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT=1 ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED=1 ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL=0 -ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE=70 -ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_MIN_REC_SCORE=80 +ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE=25 +ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_MIN_REC_SCORE=25 +ALPHAX_PAPER_GLOBAL_RISK_SCORE_BLOCKS_INTRADAY=0 ALPHAX_PAPER_GLOBAL_RISK_MIN_POSITION_MULTIPLIER=0.2 ALPHAX_PAPER_GLOBAL_RISK_HIGH_DRAWDOWN_PCT=3 ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_DRAWDOWN_PCT=6 ALPHAX_PAPER_GLOBAL_RISK_MAX_OPEN_POSITIONS=0 ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_SECTOR_POSITIONS=3 ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_DIRECTION_POSITIONS=6 -ALPHAX_PAPER_ORDER_MIN_REC_SCORE=50 -ALPHAX_PAPER_ORDER_MIN_RR=1.5 +ALPHAX_PAPER_ORDER_MIN_REC_SCORE=25 +ALPHAX_PAPER_ORDER_MIN_RR=1.25 ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK=1 ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT=0 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_PAPER_ORDER_EXPIRE_HOURS=8 # 策略交易移动止盈。volatility 会按持仓后实际高低价波动动态调整启动阈值和保护距离。 ALPHAX_PAPER_TRAILING_STOP_ENABLED=1 diff --git a/app/config/system_config.py b/app/config/system_config.py index fd914ab..2e8bb77 100644 --- a/app/config/system_config.py +++ b/app/config/system_config.py @@ -153,9 +153,12 @@ def default_paper_trading_config(): # One shared default keeps buy-now entries and wait-pullback orders from # drifting into two unrelated RR standards. The explicit entry/order envs # remain supported for advanced overrides. - paper_min_rr = _env_float("ALPHAX_PAPER_MIN_RR", 1.5) + paper_min_rr = _env_float("ALPHAX_PAPER_MIN_RR", 1.25) return { "enabled": _env_bool("ALPHAX_PAPER_TRADING_ENABLED", True), + "trading_mode": _env_str("ALPHAX_PAPER_TRADING_MODE", "intraday_trading"), + "target_trades_per_day_min": _env_int("ALPHAX_PAPER_TARGET_TRADES_PER_DAY_MIN", 3), + "target_trades_per_day_max": _env_int("ALPHAX_PAPER_TARGET_TRADES_PER_DAY_MAX", 5), "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), @@ -164,10 +167,10 @@ def default_paper_trading_config(): "slippage_pct": _env_float("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", 0.05), "trailing_stop_enabled": _env_bool("ALPHAX_PAPER_TRAILING_STOP_ENABLED", True), "trailing_mode": _env_str("ALPHAX_PAPER_TRAILING_MODE", "volatility"), - "trailing_activate_pnl_pct": _env_float("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", 3.0), + "trailing_activate_pnl_pct": _env_float("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", 2.0), "trailing_min_lock_profit_pct": _env_float("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", 0.5), "trailing_distance_pct": _env_float("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", 1.5), - "trailing_volatility_min_activation_pct": _env_float("ALPHAX_PAPER_TRAILING_VOL_MIN_ACTIVATE_PCT", 2.5), + "trailing_volatility_min_activation_pct": _env_float("ALPHAX_PAPER_TRAILING_VOL_MIN_ACTIVATE_PCT", 1.8), "trailing_volatility_max_activation_pct": _env_float("ALPHAX_PAPER_TRAILING_VOL_MAX_ACTIVATE_PCT", 8.0), "trailing_volatility_activation_mult": _env_float("ALPHAX_PAPER_TRAILING_VOL_ACTIVATE_MULT", 0.6), "trailing_volatility_min_distance_pct": _env_float("ALPHAX_PAPER_TRAILING_VOL_MIN_DISTANCE_PCT", 1.2), @@ -177,7 +180,7 @@ def default_paper_trading_config(): "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), "entry_gate_enabled": _env_bool("ALPHAX_PAPER_ENTRY_GATE_ENABLED", True), - "entry_min_rec_score": _env_float("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", 50.0), + "entry_min_rec_score": _env_float("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", 25.0), "min_rr": paper_min_rr, "entry_min_rr": _env_float("ALPHAX_PAPER_ENTRY_MIN_RR", paper_min_rr), "max_stop_loss_leverage_risk_pct": _env_float("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", 20.0), @@ -202,22 +205,23 @@ def default_paper_trading_config(): "position_guard_critical_max_pnl_pct": _env_float("ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MAX_PNL_PCT", 1.0), "global_risk_gate_enabled": _env_bool("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", True), "global_risk_block_critical": _env_bool("ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL", False), - "global_risk_critical_min_rec_score": _env_float("ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_MIN_REC_SCORE", 80.0), + "global_risk_critical_min_rec_score": _env_float("ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_MIN_REC_SCORE", 25.0), + "global_risk_score_blocks_intraday": _env_bool("ALPHAX_PAPER_GLOBAL_RISK_SCORE_BLOCKS_INTRADAY", False), "global_risk_min_position_multiplier": _env_float("ALPHAX_PAPER_GLOBAL_RISK_MIN_POSITION_MULTIPLIER", 0.2), - "global_risk_high_min_rec_score": _env_float("ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE", 70.0), + "global_risk_high_min_rec_score": _env_float("ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE", 25.0), "global_risk_high_drawdown_pct": _env_float("ALPHAX_PAPER_GLOBAL_RISK_HIGH_DRAWDOWN_PCT", 3.0), "global_risk_critical_drawdown_pct": _env_float("ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_DRAWDOWN_PCT", 6.0), "global_risk_max_open_positions": _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_OPEN_POSITIONS", 0), "global_risk_max_same_sector_positions": _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_SECTOR_POSITIONS", 3), "global_risk_max_same_direction_positions": _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_DIRECTION_POSITIONS", 6), - "order_min_rec_score": _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 50.0), + "order_min_rec_score": _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 25.0), "order_min_rr": _env_float("ALPHAX_PAPER_ORDER_MIN_RR", paper_min_rr), "order_require_risk_reward_ok": _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK", True), - "order_min_distance_to_entry_pct": _env_float("ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT", 1.5), + "order_min_distance_to_entry_pct": _env_float("ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT", 0.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_cancel_far_from_entry_pct": _env_float("ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT", 12.0), - "order_expire_hours": _env_float("ALPHAX_PAPER_ORDER_EXPIRE_HOURS", 24.0), + "order_expire_hours": _env_float("ALPHAX_PAPER_ORDER_EXPIRE_HOURS", 8.0), "trailing_tiers": [ {"min_pnl_pct": 8.0, "distance_pct": 1.0, "label": "紧贴"}, {"min_pnl_pct": 5.0, "distance_pct": 1.2, "label": "锁利"}, @@ -238,6 +242,9 @@ def _paper_trading_env_overrides(): }) checks = { "ALPHAX_PAPER_TRADING_ENABLED": ("enabled", lambda: _env_bool("ALPHAX_PAPER_TRADING_ENABLED", True)), + "ALPHAX_PAPER_TRADING_MODE": ("trading_mode", lambda: _env_str("ALPHAX_PAPER_TRADING_MODE", "intraday_trading")), + "ALPHAX_PAPER_TARGET_TRADES_PER_DAY_MIN": ("target_trades_per_day_min", lambda: _env_int("ALPHAX_PAPER_TARGET_TRADES_PER_DAY_MIN", 3)), + "ALPHAX_PAPER_TARGET_TRADES_PER_DAY_MAX": ("target_trades_per_day_max", lambda: _env_int("ALPHAX_PAPER_TARGET_TRADES_PER_DAY_MAX", 5)), "ALPHAX_PAPER_ACCOUNT_EQUITY_USDT": ("account_equity_usdt", lambda: _env_float("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", 20000)), "ALPHAX_PAPER_TRADE_NOTIONAL_USDT": ("trade_notional_usdt", lambda: _env_float("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", 5000)), "ALPHAX_PAPER_TRADE_LEVERAGE": ("trade_leverage", lambda: _env_float("ALPHAX_PAPER_TRADE_LEVERAGE", 5)), @@ -246,8 +253,8 @@ def _paper_trading_env_overrides(): "ALPHAX_PAPER_TRADE_SLIPPAGE_PCT": ("slippage_pct", lambda: _env_float("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", 0.05)), "ALPHAX_PAPER_ORDER_GATE_ENABLED": ("order_gate_enabled", lambda: _env_bool("ALPHAX_PAPER_ORDER_GATE_ENABLED", True)), "ALPHAX_PAPER_ENTRY_GATE_ENABLED": ("entry_gate_enabled", lambda: _env_bool("ALPHAX_PAPER_ENTRY_GATE_ENABLED", True)), - "ALPHAX_PAPER_ENTRY_MIN_REC_SCORE": ("entry_min_rec_score", lambda: _env_float("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", 50.0)), - "ALPHAX_PAPER_ENTRY_MIN_RR": ("entry_min_rr", lambda: _env_float("ALPHAX_PAPER_ENTRY_MIN_RR", overrides.get("min_rr", 1.5))), + "ALPHAX_PAPER_ENTRY_MIN_REC_SCORE": ("entry_min_rec_score", lambda: _env_float("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", 25.0)), + "ALPHAX_PAPER_ENTRY_MIN_RR": ("entry_min_rr", lambda: _env_float("ALPHAX_PAPER_ENTRY_MIN_RR", overrides.get("min_rr", 1.25))), "ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT": ("max_stop_loss_leverage_risk_pct", lambda: _env_float("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", 20.0)), "ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED": ("dynamic_leverage_enabled", lambda: _env_bool("ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED", True)), "ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN": ("dynamic_leverage_min", lambda: _env_float("ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN", 3.0)), @@ -270,22 +277,23 @@ def _paper_trading_env_overrides(): "ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MAX_PNL_PCT": ("position_guard_critical_max_pnl_pct", lambda: _env_float("ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MAX_PNL_PCT", 1.0)), "ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED": ("global_risk_gate_enabled", lambda: _env_bool("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", True)), "ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL": ("global_risk_block_critical", lambda: _env_bool("ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL", False)), - "ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_MIN_REC_SCORE": ("global_risk_critical_min_rec_score", lambda: _env_float("ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_MIN_REC_SCORE", 80.0)), + "ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_MIN_REC_SCORE": ("global_risk_critical_min_rec_score", lambda: _env_float("ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_MIN_REC_SCORE", 25.0)), + "ALPHAX_PAPER_GLOBAL_RISK_SCORE_BLOCKS_INTRADAY": ("global_risk_score_blocks_intraday", lambda: _env_bool("ALPHAX_PAPER_GLOBAL_RISK_SCORE_BLOCKS_INTRADAY", False)), "ALPHAX_PAPER_GLOBAL_RISK_MIN_POSITION_MULTIPLIER": ("global_risk_min_position_multiplier", lambda: _env_float("ALPHAX_PAPER_GLOBAL_RISK_MIN_POSITION_MULTIPLIER", 0.2)), - "ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE": ("global_risk_high_min_rec_score", lambda: _env_float("ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE", 70.0)), + "ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE": ("global_risk_high_min_rec_score", lambda: _env_float("ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE", 25.0)), "ALPHAX_PAPER_GLOBAL_RISK_HIGH_DRAWDOWN_PCT": ("global_risk_high_drawdown_pct", lambda: _env_float("ALPHAX_PAPER_GLOBAL_RISK_HIGH_DRAWDOWN_PCT", 3.0)), "ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_DRAWDOWN_PCT": ("global_risk_critical_drawdown_pct", lambda: _env_float("ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_DRAWDOWN_PCT", 6.0)), "ALPHAX_PAPER_GLOBAL_RISK_MAX_OPEN_POSITIONS": ("global_risk_max_open_positions", lambda: _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_OPEN_POSITIONS", 0)), "ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_SECTOR_POSITIONS": ("global_risk_max_same_sector_positions", lambda: _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_SECTOR_POSITIONS", 3)), "ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_DIRECTION_POSITIONS": ("global_risk_max_same_direction_positions", lambda: _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_DIRECTION_POSITIONS", 6)), - "ALPHAX_PAPER_ORDER_MIN_REC_SCORE": ("order_min_rec_score", lambda: _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 50.0)), - "ALPHAX_PAPER_ORDER_MIN_RR": ("order_min_rr", lambda: _env_float("ALPHAX_PAPER_ORDER_MIN_RR", overrides.get("min_rr", 1.5))), + "ALPHAX_PAPER_ORDER_MIN_REC_SCORE": ("order_min_rec_score", lambda: _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 25.0)), + "ALPHAX_PAPER_ORDER_MIN_RR": ("order_min_rr", lambda: _env_float("ALPHAX_PAPER_ORDER_MIN_RR", overrides.get("min_rr", 1.25))), "ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK": ("order_require_risk_reward_ok", lambda: _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK", True)), "ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT": ("order_min_distance_to_entry_pct", lambda: _env_float("ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT", 0.0)), "ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT": ("order_max_distance_to_entry_pct", lambda: _env_float("ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT", 8.0)), "ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER": ("order_require_current_trigger", lambda: _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER", False)), "ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT": ("order_cancel_far_from_entry_pct", lambda: _env_float("ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT", 12.0)), - "ALPHAX_PAPER_ORDER_EXPIRE_HOURS": ("order_expire_hours", lambda: _env_float("ALPHAX_PAPER_ORDER_EXPIRE_HOURS", 24.0)), + "ALPHAX_PAPER_ORDER_EXPIRE_HOURS": ("order_expire_hours", lambda: _env_float("ALPHAX_PAPER_ORDER_EXPIRE_HOURS", 8.0)), } for env_name, (key, loader) in checks.items(): if _env_present(env_name): @@ -545,7 +553,46 @@ def paper_trading_config(): _seed_one("paper_trading", default_paper_trading_config(), "Paper trading account and execution model") cfg = get_paper_trading_config(default=None) merged = deep_merge(default_paper_trading_config(), cfg or {}) - return deep_merge(merged, _paper_trading_env_overrides()) + merged = deep_merge(merged, _paper_trading_env_overrides()) + if str(merged.get("trading_mode") or "").strip() == "intraday_trading": + intraday_defaults = { + "min_rr": 1.25, + "entry_min_rr": 1.25, + "order_min_rr": 1.25, + "entry_min_rec_score": 25.0, + "order_min_rec_score": 25.0, + "global_risk_high_min_rec_score": 25.0, + "global_risk_critical_min_rec_score": 25.0, + "global_risk_score_blocks_intraday": False, + "order_min_distance_to_entry_pct": 0.0, + "order_expire_hours": 8.0, + "trailing_activate_pnl_pct": 2.0, + "trailing_volatility_min_activation_pct": 1.8, + } + explicit = _paper_trading_env_overrides() + for key, value in intraday_defaults.items(): + if key in explicit: + continue + current = merged.get(key) + # Treat old seeded defaults as compatibility noise, while still + # honoring deliberately more active manual settings. + if key in { + "min_rr", + "entry_min_rr", + "order_min_rr", + "entry_min_rec_score", + "order_min_rec_score", + "global_risk_high_min_rec_score", + "global_risk_critical_min_rec_score", + "order_min_distance_to_entry_pct", + "order_expire_hours", + "trailing_activate_pnl_pct", + "trailing_volatility_min_activation_pct", + } and _env_float("__ALPHAX_UNUSED__", value) < _env_float("__ALPHAX_UNUSED__", current or value): + merged[key] = value + elif key == "global_risk_score_blocks_intraday" and current is None: + merged[key] = value + return merged def live_trading_config(): diff --git a/app/core/global_risk.py b/app/core/global_risk.py index 1131741..bbb89cf 100644 --- a/app/core/global_risk.py +++ b/app/core/global_risk.py @@ -191,6 +191,9 @@ def evaluate_global_risk( rec_score = _safe_float((rec or {}).get("rec_score") or (rec or {}).get("score")) min_score_high = max(0.0, _safe_float(cfg.get("global_risk_high_min_rec_score"), 70.0)) min_score_critical = max(min_score_high, _safe_float(cfg.get("global_risk_critical_min_rec_score"), 80.0)) + trading_mode = str(cfg.get("trading_mode") or "").strip() + score_blocks_intraday = bool(cfg.get("global_risk_score_blocks_intraday", False)) + intraday_soft_risk = trading_mode == "intraday_trading" and not score_blocks_intraday min_position_multiplier = max(0.0, _safe_float(cfg.get("global_risk_min_position_multiplier"), 0.2)) max_drawdown_critical = max(0.0, _safe_float(cfg.get("global_risk_critical_drawdown_pct"), 6.0)) max_drawdown_high = max(0.0, _safe_float(cfg.get("global_risk_high_drawdown_pct"), 3.0)) @@ -213,17 +216,23 @@ def evaluate_global_risk( risk_level = "high" reasons.append("账户浮亏偏高,只允许高质量机会") - if risk_level == "critical" and bool(cfg.get("global_risk_block_critical", False)): + if risk_level == "critical" and bool(cfg.get("global_risk_block_critical", False)) and not intraday_soft_risk: allow = False decision = "block_critical" elif risk_level == "critical" and drawdown < max_drawdown_critical: - if rec_score < min_score_critical: + if intraday_soft_risk: + decision = "allow_reduced_size" + reasons.append(f"日内模式下 critical 市场风险只做仓位调节,不按推荐分 {rec_score:.1f} 拒单") + elif rec_score < min_score_critical: allow = False decision = "block_critical_weak_score" reasons.append(f"critical 市场环境下推荐分 {rec_score:.1f} 低于 {min_score_critical:.1f}") else: decision = "allow_reduced_size" reasons.append(f"critical 市场环境不再一刀切,按 {position_multiplier:.0%} 仓位试运行") + elif risk_level == "high" and intraday_soft_risk: + decision = "allow_reduced_size" if position_multiplier < 1 else "allow" + reasons.append(f"日内模式下 high 市场风险只做仓位调节,不按推荐分 {rec_score:.1f} 拒单") elif risk_level == "high" and rec_score < min_score_high: allow = False decision = "block_high_weak_score" @@ -268,6 +277,8 @@ def evaluate_global_risk( "high_drawdown_pct": max_drawdown_high, "min_score_when_high_risk": min_score_high, "min_score_when_critical_risk": min_score_critical, + "trading_mode": trading_mode, + "intraday_soft_risk": intraday_soft_risk, "reasons": reasons, "market_regime": regime, "portfolio": portfolio, diff --git a/app/core/strategy_registry.py b/app/core/strategy_registry.py index f3d700e..389a6ec 100644 --- a/app/core/strategy_registry.py +++ b/app/core/strategy_registry.py @@ -26,6 +26,7 @@ class StrategyDefinition: strategy_name: str description: str = "" direction: str = "long" + frequency_profile: str = "intraday" mode: str = "paper_only" status: str = "active" entry_gate_config: dict = field(default_factory=dict) @@ -38,19 +39,28 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { strategy_name="多头动量启动", description="15m当前突破叠加1H成交量/波动增强,捕捉山寨币日内到1-3天启动段。", direction="long", + frequency_profile="intraday", mode="paper_enabled", entry_gate_config={ "min_entry_score_buy_now": 3, "min_entry_score_wait_pullback": 2, - "min_rr_buy_now": 1.5, + "min_rr_buy_now": 1.25, "breakout_distance_wait_pct": 8, "gain_24h_wait_pct": 10, }, paper_config={ - "entry_min_rr": 1.5, - "order_min_rr": 1.5, + "frequency_profile": "intraday_trading", + "target_trades_per_day_min": 3, + "target_trades_per_day_max": 5, + "entry_min_rec_score": 25, + "order_min_rec_score": 25, + "entry_min_rr": 1.25, + "order_min_rr": 1.25, "order_min_distance_to_entry_pct": 0, "order_require_current_trigger": True, + "order_expire_hours": 8, + "trailing_activate_pnl_pct": 2.0, + "trailing_volatility_min_activation_pct": 1.8, "dynamic_leverage_enabled": True, "dynamic_leverage_min": 3, }, @@ -60,20 +70,29 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { strategy_name="多头二波回踩", description="强势榜或放量币第一波后回踩EMA、箱体上沿或前高转支撑,再次承接的短线策略。", direction="long", + frequency_profile="intraday", mode="paper_enabled", entry_gate_config={ "min_entry_score_buy_now": 2, "min_entry_score_wait_pullback": 0, - "min_rr_buy_now": 1.5, + "min_rr_buy_now": 1.25, "max_wait_pullback_deviation_pct": 10, "breakout_distance_wait_pct": 12, "gain_24h_wait_pct": 18, }, paper_config={ - "entry_min_rr": 1.5, - "order_min_rr": 1.5, - "order_min_distance_to_entry_pct": 1.5, + "frequency_profile": "intraday_trading", + "target_trades_per_day_min": 3, + "target_trades_per_day_max": 5, + "entry_min_rec_score": 22, + "order_min_rec_score": 22, + "entry_min_rr": 1.25, + "order_min_rr": 1.25, + "order_min_distance_to_entry_pct": 0, "order_require_current_trigger": False, + "order_expire_hours": 10, + "trailing_activate_pnl_pct": 2.0, + "trailing_volatility_min_activation_pct": 1.8, "dynamic_leverage_enabled": True, "dynamic_leverage_min": 3, }, @@ -83,20 +102,29 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { strategy_name="空头破位反抽", description="1H支撑或箱体下沿破位后反抽失败,叠加15m弱确认和相对弱势的空头短线策略。", direction="short", + frequency_profile="intraday", mode="paper_enabled", entry_gate_config={ "direction": "short", "min_entry_score_buy_now": 2, "min_entry_score_wait_pullback": 1, - "min_rr_buy_now": 1.5, + "min_rr_buy_now": 1.3, "breakdown_distance_wait_pct": 10, "max_retest_deviation_pct": 8, }, paper_config={ - "entry_min_rr": 1.8, - "order_min_rr": 1.8, + "frequency_profile": "intraday_trading", + "target_trades_per_day_min": 3, + "target_trades_per_day_max": 5, + "entry_min_rec_score": 18, + "order_min_rec_score": 18, + "entry_min_rr": 1.3, + "order_min_rr": 1.3, "order_min_distance_to_entry_pct": 0, - "order_require_current_trigger": True, + "order_require_current_trigger": False, + "order_expire_hours": 8, + "trailing_activate_pnl_pct": 2.0, + "trailing_volatility_min_activation_pct": 1.8, "dynamic_leverage_enabled": True, "dynamic_leverage_min": 2, }, diff --git a/app/db/analytics.py b/app/db/analytics.py index 9c620ad..2d56b78 100644 --- a/app/db/analytics.py +++ b/app/db/analytics.py @@ -11,6 +11,7 @@ from app.db.altcoin_db import ( ) from app.core.opportunity_funnel import screening_stage_meta, stage_label from app.core.strategy_registry import normalize_strategy_code, strategy_label +from app.db.intraday_frequency import get_intraday_frequency_health from app.db.schema import get_conn @@ -1411,6 +1412,7 @@ def get_pipeline_runs(limit=30, hours=24, offset=0): current_page = (offset // limit) + 1 if total_count else 0 return { "kpi": kpi, + "intraday_frequency": get_intraday_frequency_health(days=max(1, min((hours + 23) // 24, 7))), "runs": runs, "pagination": { "hours": hours, diff --git a/app/db/intraday_frequency.py b/app/db/intraday_frequency.py new file mode 100644 index 0000000..3299b27 --- /dev/null +++ b/app/db/intraday_frequency.py @@ -0,0 +1,247 @@ +"""Intraday trading frequency health read model.""" + +from __future__ import annotations + +import json +from collections import Counter +from datetime import datetime, timedelta + +from app.config.system_config import paper_trading_config +from app.db.schema import get_conn + + +def _safe_int(value, default=0): + try: + return int(value or 0) + except Exception: + return default + + +def _safe_float(value, default=0.0): + try: + return float(value or 0) + except Exception: + return default + + +def _loads(value, fallback=None): + try: + if isinstance(value, (dict, list)): + return value + if isinstance(value, str) and value.strip(): + return json.loads(value) + except Exception: + pass + return fallback if fallback is not None else {} + + +def _since(days): + return (datetime.now() - timedelta(days=max(1, min(_safe_int(days, 1), 30)))).isoformat() + + +def _date_expr(column): + return f"substr({column},1,10)" + + +def _counter_items(counter: Counter, limit=10): + return [{"reason": key, "count": count} for key, count in counter.most_common(limit)] + + +def _extract_gate_reasons(rows): + counter = Counter() + samples = [] + for row in rows: + detail = _loads(row.get("detail_json"), {}) + reason = str(detail.get("reason") or "").strip() + if reason: + counter[reason] += 1 + gate_reasons = detail.get("gate_reasons") + if isinstance(gate_reasons, list): + for item in gate_reasons: + if item: + counter[str(item)] += 1 + risk_detail = detail.get("risk_detail") if isinstance(detail.get("risk_detail"), dict) else {} + decision = str(risk_detail.get("decision") or "").strip() + if decision: + counter[decision] += 1 + samples.append({ + "time": row.get("event_time") or "", + "symbol": row.get("symbol") or "", + "strategy_code": row.get("strategy_code") or detail.get("strategy_code") or "", + "reason": reason or (gate_reasons[0] if isinstance(gate_reasons, list) and gate_reasons else decision), + "detail": detail, + }) + return _counter_items(counter), samples[:12] + + +def get_intraday_frequency_health(days: int = 1) -> dict: + """Summarize whether the intraday paper-trading funnel is active enough.""" + days = max(1, min(_safe_int(days, 1), 30)) + since = _since(days) + cfg = paper_trading_config() + target_min = max(0, _safe_int(cfg.get("target_trades_per_day_min"), 3)) + target_max = max(target_min, _safe_int(cfg.get("target_trades_per_day_max"), 5)) + conn = get_conn() + try: + screening = conn.execute( + f""" + SELECT {_date_expr('scan_time')} AS d, layer, state, COUNT(*) AS c + FROM screening_log + WHERE scan_time >= %s + GROUP BY d, layer, state + ORDER BY d DESC + """, + (since,), + ).fetchall() + recommendations = conn.execute( + f""" + SELECT {_date_expr('rec_time')} AS d, execution_status, strategy_code, direction, COUNT(*) AS c + FROM recommendation + WHERE rec_time >= %s + GROUP BY d, execution_status, strategy_code, direction + ORDER BY d DESC + """, + (since,), + ).fetchall() + orders = conn.execute( + f""" + SELECT {_date_expr('created_at')} AS d, status, strategy_code, side, COUNT(*) AS c + FROM paper_orders + WHERE created_at >= %s + GROUP BY d, status, strategy_code, side + ORDER BY d DESC + """, + (since,), + ).fetchall() + trades = conn.execute( + f""" + SELECT {_date_expr('opened_at')} AS d, status, strategy_code, side, COUNT(*) AS c + FROM paper_trades + WHERE opened_at >= %s + GROUP BY d, status, strategy_code, side + ORDER BY d DESC + """, + (since,), + ).fetchall() + gate_rows = [dict(r) for r in conn.execute( + """ + SELECT event_time, symbol, strategy_code, detail_json + FROM paper_trade_events + WHERE event_time >= %s AND event_type IN ('paper_gate_reject','paper_order_cancel') + ORDER BY event_time DESC, id DESC + LIMIT 200 + """, + (since,), + ).fetchall()] + finally: + conn.close() + + by_day = {} + for row in screening: + d = row["d"] or "unknown" + item = by_day.setdefault(d, _empty_day(d)) + layer = str(row["layer"] or "") + state = str(row["state"] or "") + count = _safe_int(row["c"]) + if layer == "粗筛": + item["rough_candidates"] += count + elif layer == "细筛": + item["quality_candidates"] += count + elif layer == "确认": + item["confirmed_candidates"] += count + elif layer == "universe_gate": + item["universe_filtered"] += count + item["screening_breakdown"].append({"layer": layer, "state": state, "count": count}) + + for row in recommendations: + d = row["d"] or "unknown" + item = by_day.setdefault(d, _empty_day(d)) + status = str(row["execution_status"] or "") + count = _safe_int(row["c"]) + item["recommendations"] += count + if status in {"buy_now", "wait_pullback"}: + item["actionable_opportunities"] += count + elif status == "observe": + item["observe_opportunities"] += count + item["recommendation_breakdown"].append(dict(row)) + + for row in orders: + d = row["d"] or "unknown" + item = by_day.setdefault(d, _empty_day(d)) + status = str(row["status"] or "") + count = _safe_int(row["c"]) + item["paper_orders"] += count + if status == "pending": + item["pending_orders"] += count + elif status == "filled": + item["filled_orders"] += count + elif status in {"canceled", "expired"}: + item["canceled_or_expired_orders"] += count + item["order_breakdown"].append(dict(row)) + + for row in trades: + d = row["d"] or "unknown" + item = by_day.setdefault(d, _empty_day(d)) + count = _safe_int(row["c"]) + item["paper_trades"] += count + item["trade_breakdown"].append(dict(row)) + + gate_reasons, gate_samples = _extract_gate_reasons(gate_rows) + days_list = sorted(by_day.values(), key=lambda x: x["date"], reverse=True) + for item in days_list: + item["paper_actions"] = item["paper_orders"] + item["paper_trades"] + converted = item["paper_trades"] + item["filled_orders"] + item["pending_orders"] + item["paper_converted_count"] = converted + item["trade_conversion_pct"] = round(min(converted, item["actionable_opportunities"]) / item["actionable_opportunities"] * 100, 1) if item["actionable_opportunities"] else 0 + if item["paper_actions"] < target_min: + item["health_status"] = "low_frequency" + item["health_label"] = f"低于日内目标 {target_min}-{target_max} 单" + elif item["paper_actions"] > target_max: + item["health_status"] = "high_frequency" + item["health_label"] = f"高于日内目标 {target_min}-{target_max} 单" + else: + item["health_status"] = "healthy" + item["health_label"] = f"达到日内目标 {target_min}-{target_max} 单" + + latest = days_list[0] if days_list else _empty_day(datetime.now().date().isoformat()) + return { + "mode": cfg.get("trading_mode") or "intraday_trading", + "target_trades_per_day": {"min": target_min, "max": target_max}, + "days": days, + "generated_at": datetime.now().isoformat(timespec="seconds"), + "latest": latest, + "daily": days_list, + "gate_reasons": gate_reasons, + "gate_samples": gate_samples, + "definition": "日内频率健康度只衡量机会到策略交易账本的转化,不代表收益质量。", + } + + +def _empty_day(day): + return { + "date": day, + "universe_filtered": 0, + "rough_candidates": 0, + "quality_candidates": 0, + "confirmed_candidates": 0, + "recommendations": 0, + "actionable_opportunities": 0, + "observe_opportunities": 0, + "paper_orders": 0, + "pending_orders": 0, + "filled_orders": 0, + "canceled_or_expired_orders": 0, + "paper_trades": 0, + "paper_actions": 0, + "paper_converted_count": 0, + "trade_conversion_pct": 0, + "health_status": "unknown", + "health_label": "暂无数据", + "screening_breakdown": [], + "recommendation_breakdown": [], + "order_breakdown": [], + "trade_breakdown": [], + } + + +__all__ = ["get_intraday_frequency_health"] diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index 59df8ed..0f07bb1 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -318,6 +318,24 @@ def _strategy_code_from_rec(rec: dict) -> str: def _paper_cfg_for_rec(rec: dict, config: dict | None = None) -> dict: cfg = dict(_paper_cfg(config) or {}) cfg.update(strategy_paper_config(_strategy_code_from_rec(rec))) + # Strategy configs tune the intraday defaults, but explicit environment + # overrides remain the emergency/manual control surface for local and prod. + env_float_keys = { + "ALPHAX_PAPER_ENTRY_MIN_REC_SCORE": "entry_min_rec_score", + "ALPHAX_PAPER_ENTRY_MIN_RR": "entry_min_rr", + "ALPHAX_PAPER_ORDER_MIN_REC_SCORE": "order_min_rec_score", + "ALPHAX_PAPER_ORDER_MIN_RR": "order_min_rr", + "ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT": "order_min_distance_to_entry_pct", + "ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT": "order_max_distance_to_entry_pct", + "ALPHAX_PAPER_ORDER_EXPIRE_HOURS": "order_expire_hours", + "ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT": "trailing_activate_pnl_pct", + "ALPHAX_PAPER_TRAILING_VOL_MIN_ACTIVATE_PCT": "trailing_volatility_min_activation_pct", + } + for env_name, key in env_float_keys.items(): + if os.getenv(env_name) is not None: + cfg[key] = _safe_float(os.getenv(env_name), cfg.get(key)) + if os.getenv("ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER") is not None: + cfg["order_require_current_trigger"] = str(os.getenv("ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER") or "").strip().lower() in {"1", "true", "yes", "on"} return cfg @@ -541,6 +559,33 @@ def _record_event(conn, trade_id: int, rec_id: int, symbol: str, event_type: str ) +def _record_recommendation_event(conn, rec: dict, event_type: str, message: str, detail=None, event_time: str = "", price: float = 0.0): + rec = rec or {} + plan = _entry_plan(rec) + lineage = _strategy_lineage_from_rec(rec) + event_detail = { + **(detail or {}), + "strategy_code": lineage["strategy_code"], + "strategy_name": lineage["strategy_name"], + "strategy_signal_id": lineage["strategy_signal_id"], + "execution_status": rec.get("execution_status") or "", + "action_status": rec.get("action_status") or plan.get("entry_action") or "", + "entry_plan": plan, + } + _record_event( + conn, + 0, + _safe_int(rec.get("id")), + str(rec.get("symbol") or "").strip().upper(), + event_type, + _safe_float(price or rec.get("current_price") or rec.get("entry_price")), + 0.0, + message, + event_detail, + event_time, + ) + + def _fmt_price(value) -> str: price = _safe_float(value) if price <= 0: @@ -768,6 +813,31 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config: if max_sl_risk > 0 and sl_risk > max_sl_risk: entry_reasons.append("stop_loss_leverage_risk_exceeded") if entry_reasons: + _record_recommendation_event( + conn, + rec, + "paper_gate_reject", + "日内策略交易入场门禁拒绝", + { + "reason": "entry_gate_rejected", + "gate_reasons": entry_reasons, + "gate_detail": { + "rec_score": rec_score, + "min_rec_score": min_score, + "rr1": round(rr, 4) if rr > 0 else 0, + "min_rr": min_rr, + "stop_loss_leverage_risk_pct": sl_risk, + "max_stop_loss_leverage_risk_pct": max_sl_risk, + "entry_price": entry_price, + "stop_loss": stop_loss, + "tp1": tp1, + "leverage": leverage, + "leverage_risk": leverage_risk, + }, + }, + event_time, + entry_price, + ) return { "opened": False, "skipped": True, @@ -791,6 +861,15 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config: if global_detail is None: global_ok, global_detail = _global_risk_entry_check(conn, rec, notional, cfg) if not global_ok: + _record_recommendation_event( + conn, + rec, + "paper_gate_reject", + "日内策略交易全局风控拒绝", + {"reason": "global_risk_rejected", "risk_detail": global_detail}, + event_time, + entry_price, + ) return { "opened": False, "skipped": True, @@ -811,9 +890,27 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config: plan["leverage_sizing"] = leverage_risk pause_ok, pause_reason, pause_detail = _portfolio_entry_pause_check(conn, notional, event_time, cfg) if not pause_ok: + _record_recommendation_event( + conn, + rec, + "paper_gate_reject", + "日内策略交易账户暂停门禁拒绝", + {"reason": pause_reason, "risk_detail": pause_detail}, + event_time, + entry_price, + ) return {"opened": False, "skipped": True, "reason": pause_reason, "risk_detail": pause_detail} leverage_ok, leverage_detail = _cumulative_leverage_check(conn, notional, cfg, exclude_rec_id=rec_id) if not leverage_ok: + _record_recommendation_event( + conn, + rec, + "paper_gate_reject", + "日内策略交易累计杠杆门禁拒绝", + {"reason": "cumulative_leverage_exceeded", "risk_detail": leverage_detail}, + event_time, + entry_price, + ) return { "opened": False, "skipped": True, @@ -1046,6 +1143,28 @@ def _cancel_paper_order(conn, order: dict, reason: str, event_time: str) -> dict """, (reason, event_time, event_time, order["id"]), ) + _record_recommendation_event( + conn, + { + "id": order.get("recommendation_id"), + "symbol": order.get("symbol"), + "strategy_code": order.get("strategy_code"), + "strategy_signal_id": order.get("strategy_signal_id"), + "entry_plan_json": order.get("entry_plan_snapshot_json"), + "execution_status": order.get("source_status"), + "action_status": order.get("source_action"), + }, + "paper_order_cancel", + "日内策略挂单取消", + { + "reason": reason, + "paper_order_id": order.get("id"), + "target_price": order.get("target_price"), + "current_price_at_create": order.get("current_price_at_create"), + }, + event_time, + order.get("target_price") or 0, + ) return {"skipped": True, "reason": f"paper_order_{reason}", "paper_order_id": order["id"]} @@ -1256,6 +1375,19 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time: gate_ok, gate_reasons, gate_detail = _paper_order_gate(rec, current_price, cfg, conn=conn) if not gate_ok: + _record_recommendation_event( + conn, + rec, + "paper_gate_reject", + "日内策略挂单门禁拒绝", + { + "reason": "paper_order_gate_rejected", + "gate_reasons": gate_reasons, + "gate_detail": gate_detail, + }, + event_time, + current_price, + ) return { "skipped": True, "reason": "paper_order_gate_rejected", @@ -1267,6 +1399,15 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time: payload = _order_payload_from_rec(rec, current_price, event_time, cfg) if payload["recommendation_id"] <= 0 or not payload["symbol"] or payload["target_price"] <= 0: + _record_recommendation_event( + conn, + rec, + "paper_gate_reject", + "日内策略挂单参数无效", + {"reason": "invalid_paper_order", "payload": payload}, + event_time, + current_price, + ) return {"skipped": True, "reason": "invalid_paper_order"} row = conn.execute( """ diff --git a/app/db/review_center.py b/app/db/review_center.py index 89c69f1..ca5ae18 100644 --- a/app/db/review_center.py +++ b/app/db/review_center.py @@ -12,6 +12,7 @@ import json from datetime import datetime, timedelta from app.db.paper_trading import get_paper_trading_summary +from app.db.intraday_frequency import get_intraday_frequency_health from app.db.schema import get_conn from app.db.strategy_insights import get_strategy_evaluation, get_strategy_insights @@ -389,6 +390,7 @@ def get_review_center_dashboard(days=30): evidence = _evidence_review(conn, since) iteration = _iteration_review(conn, since) strategy_evaluation = get_strategy_evaluation(days=days) + intraday_frequency = get_intraday_frequency_health(days=min(days, 7)) finally: conn.close() @@ -403,6 +405,7 @@ def get_review_center_dashboard(days=30): ], "opportunity": opportunity, "strategy_evaluation": strategy_evaluation, + "intraday_frequency": intraday_frequency, "paper_trading": paper, "evidence": evidence, "iteration": iteration, diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index a2a1597..f2fb719 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -141,6 +141,16 @@ def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan: } +def _is_tradeable_short_setup(short_1h: dict) -> bool: + if not (short_1h or {}).get("detected"): + return False + return ( + float((short_1h or {}).get("quality_score") or (short_1h or {}).get("score") or 0) >= 5 + and bool((short_1h or {}).get("retest_rejected")) + and str((short_1h or {}).get("quality") or "") in {"良好", "优质"} + ) + + def fetch_klines(symbol, timeframe, limit=200): try: exchange.timeout = _confirm_kline_timeout_ms() @@ -1188,12 +1198,7 @@ def confirm_burst(symbol, cand): or (cand_detail.get("market_context") or {}).get("short_breakdown_retest_1h") or {} ) - if trade_side != "short" and ( - bool(cand_short_hint.get("detected")) - or "破位反抽做空" in cand_signal_text - or "等待反抽失败" in cand_signal_text - or "breakdown_retest_1h_short" in cand_signal_text - ): + if trade_side != "short" and _is_tradeable_short_setup(cand_short_hint): trade_side = "short" cand_is_top_gainer = bool(cand_detail.get("top_gainer_24h") or "24h强势榜" in cand_signal_text or cand_change_24h >= get_burst_threshold(symbol) * 1.5) @@ -1228,6 +1233,16 @@ def confirm_burst(symbol, cand): or (cand_detail.get("market_context") or {}).get("short_breakdown_retest_1h") or {"detected": False} ) + try: + if h1_df is not None and len(h1_df) >= 60: + fresh_short_1h = detect_breakdown_retest_short_1h(h1_df, change_24h=cand_change_24h) + if fresh_short_1h.get("detected"): + short_1h = fresh_short_1h + if _is_tradeable_short_setup(short_1h): + trade_side = "short" + except Exception: + if trade_side == "short": + short_1h = {"detected": False} upstream_sector_context = cand_detail.get("sector_context") or {} if upstream_sector_context.get("hot_sectors") or upstream_sector_context.get("leader_symbol"): @@ -2278,17 +2293,29 @@ def _result_brief(item: dict) -> dict: signal_text = " ".join(str(x) for x in (item.get("signals") or [])) inferred_strategy = "" side = normalize_trade_side(item.get("side") or ctx.get("side") or item.get("direction")) - if side == "short" or any(key in signal_text for key in ("破位反抽做空", "等待反抽失败", "breakdown_retest_1h_short")): + short_ctx = item.get("short_breakdown_retest_1h") or ctx.get("short_breakdown_retest_1h") or {} + if side == "short" and _is_tradeable_short_setup(short_ctx): inferred_strategy = BREAKDOWN_RETEST_SHORT_1H_STRATEGY + elif side == "short": + inferred_strategy = "" elif any(key in signal_text for key in ("24h强势榜", "回踩", "箱体突破回踩")): inferred_strategy = LONG_SECOND_WAVE_PULLBACK_STRATEGY elif any(key in signal_text for key in ("15min", "短周期", "量价齐飞", "放量")): inferred_strategy = LONG_MOMENTUM_BREAKOUT_STRATEGY + action = item.get("entry_action") or (item.get("entry_plan") or {}).get("entry_action") or "" + if side == "short": + if _is_tradeable_short_setup(short_ctx): + if action in ("可即刻买入", "即刻买入"): + action = "可开空" + elif action == "等回踩": + action = "等反抽" + else: + action = "观察" return { "symbol": item.get("symbol"), "confirmed": bool(item.get("confirmed")), "score": item.get("score"), - "action": item.get("entry_action") or (item.get("entry_plan") or {}).get("entry_action") or "", + "action": action, "strategy_code": item.get("strategy_code") or (item.get("strategy_snapshot") or {}).get("strategy_code") or inferred_strategy, "rec_id": item.get("rec_id") or 0, "published_watch": bool(item.get("published_watch")), diff --git a/app/strategies/altcoin_breakout.py b/app/strategies/altcoin_breakout.py index 446cd82..645ca25 100644 --- a/app/strategies/altcoin_breakout.py +++ b/app/strategies/altcoin_breakout.py @@ -28,6 +28,29 @@ def _signals_text(result: dict) -> str: return " ".join(str(x or "") for x in (result or {}).get("signals") or []) +def _has_short_context(result: dict) -> bool: + text = _signals_text(result) + market_context = (result or {}).get("market_context") or {} + short_ctx = (result or {}).get("short_breakdown_retest_1h") or market_context.get("short_breakdown_retest_1h") or {} + return bool(short_ctx.get("detected")) or any( + key in text + for key in ("破位反抽做空", "等待反抽失败", "反抽失败确认", "breakdown_retest_1h_short") + ) + + +def _has_second_wave_context(result: dict) -> bool: + text = _signals_text(result) + market_context = (result or {}).get("market_context") or {} + has_first_wave = ( + bool((result or {}).get("top_gainer_24h") or market_context.get("top_gainer_24h")) + or "24h强势榜" in text + or "量价齐飞" in text + or "放量" in text + ) + has_pullback_context = any(key in text for key in ("回踩", "箱体", "EMA", "前高", "底部抬高", "静K")) + return bool(has_first_wave and has_pullback_context) + + def _trigger_context(result: dict) -> dict: return (result or {}).get("trigger_context") or ((result or {}).get("market_context") or {}).get("trigger_context") or {} @@ -53,6 +76,8 @@ def _status_for_entry(result: dict, entry_plan: dict | None = None, *, require_c def build_long_momentum_breakout_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: + if _has_short_context(result) or _has_second_wave_context(result): + return None text = _signals_text(result) has_15m = any(key in text for key in ("15min", "15m", "短周期", "5m/15m")) has_1h_participation = "量价齐飞" in text or ("连续" in text and "放量" in text) or ("1H" in text and ("突破" in text or "起爆" in text)) @@ -97,11 +122,10 @@ def build_long_momentum_breakout_signal(*, symbol: str, result: dict, entry_plan def build_long_second_wave_pullback_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: + if _has_short_context(result): + return None text = _signals_text(result) - market_context = (result or {}).get("market_context") or {} - has_first_wave = bool((result or {}).get("top_gainer_24h") or market_context.get("top_gainer_24h")) or "24h强势榜" in text or "量价齐飞" in text or "放量" in text - has_pullback_context = any(key in text for key in ("回踩", "箱体", "EMA", "前高", "底部抬高", "静K")) - if not (has_first_wave and has_pullback_context): + if not _has_second_wave_context(result): return None status, reasons = _status_for_entry(result, entry_plan, require_current_trigger=False, allow_wait=True) score = _safe_float(result.get("score")) diff --git a/app/web/routes_paper_trading.py b/app/web/routes_paper_trading.py index 6ce4ad6..5177f7e 100644 --- a/app/web/routes_paper_trading.py +++ b/app/web/routes_paper_trading.py @@ -11,6 +11,7 @@ from app.db.paper_trading import ( reset_paper_trading_data, send_paper_trading_report, ) +from app.db.intraday_frequency import get_intraday_frequency_health from app.db.strategy_insights import get_strategy_evaluation from app.web.shared import require_admin @@ -24,6 +25,12 @@ async def api_paper_trading_summary(days: int = 30, altcoin_session: str = Cooki return get_paper_trading_summary(days=days) +@router.get("/api/paper-trading/frequency-health") +async def api_paper_trading_frequency_health(days: int = 7, altcoin_session: str = Cookie(default="")): + require_admin(altcoin_session) + return get_intraday_frequency_health(days=days) + + @router.get("/api/paper-trading/performance") async def api_paper_trading_performance(days: int = 30, altcoin_session: str = Cookie(default="")): require_admin(altcoin_session) diff --git a/tests/test_market_regime.py b/tests/test_market_regime.py index 59f8c26..f6ddb1e 100644 --- a/tests/test_market_regime.py +++ b/tests/test_market_regime.py @@ -109,6 +109,39 @@ def test_global_risk_off_is_favorable_for_short_entries(pg_conn, monkeypatch): assert "risk_off 对空头属于顺风环境" in " ".join(result["reasons"]) +def test_intraday_global_risk_softens_risk_off_for_low_score_long(pg_conn, monkeypatch): + monkeypatch.setattr( + "app.core.global_risk.get_crypto_market_overview", + lambda allow_live_fallback=False: _overview( + benchmarks={"BTC/USDT": {"change_24h": -3.4}, "ETH/USDT": {"change_24h": -4.2}}, + advance_decline_ratio=0.4, + crash_count_5pct=35, + ), + ) + + result = evaluate_global_risk( + conn=pg_conn, + config={ + "trading_mode": "intraday_trading", + "account_equity_usdt": 20000, + "global_risk_gate_enabled": True, + "global_risk_block_critical": False, + "global_risk_score_blocks_intraday": False, + "global_risk_critical_min_rec_score": 80, + "global_risk_high_min_rec_score": 70, + }, + rec={"symbol": "LONG/USDT", "side": "long", "rec_score": 28}, + additional_notional=1000, + ) + + assert result["allow_new_entries"] is True + assert result["decision"] == "allow_reduced_size" + assert result["risk_level"] == "critical" + assert result["intraday_soft_risk"] is True + assert result["position_multiplier"] == 0.25 + assert "日内模式下 critical 市场风险只做仓位调节" in " ".join(result["reasons"]) + + def test_altcoin_rotation_is_adverse_for_short_entries(pg_conn, monkeypatch): monkeypatch.setattr( "app.core.global_risk.get_crypto_market_overview", diff --git a/tests/test_multi_strategy_infra.py b/tests/test_multi_strategy_infra.py index 8c574c5..9d09f2b 100644 --- a/tests/test_multi_strategy_infra.py +++ b/tests/test_multi_strategy_infra.py @@ -11,6 +11,7 @@ from app.core.strategy_registry import ( registered_strategy_codes, strategy_direction, strategy_label, + strategy_paper_config, ) from app.db.recommendation_commands import create_recommendation from app.db.paper_trading import _open_trade, _order_payload_from_rec @@ -64,6 +65,21 @@ def test_active_strategy_pool_only_contains_short_term_three_strategies(): assert is_strategy_allowed_for_side(BREAKDOWN_RETEST_SHORT_1H_STRATEGY, "short") is True +def test_active_strategy_pool_uses_intraday_paper_gates(): + long_momentum = strategy_paper_config(LONG_MOMENTUM_BREAKOUT_STRATEGY) + second_wave = strategy_paper_config(LONG_SECOND_WAVE_PULLBACK_STRATEGY) + short_retest = strategy_paper_config(SHORT_BREAKDOWN_RETEST_STRATEGY) + + assert long_momentum["frequency_profile"] == "intraday_trading" + assert long_momentum["entry_min_rr"] == 1.25 + assert long_momentum["entry_min_rec_score"] == 25 + assert long_momentum["order_require_current_trigger"] is True + assert second_wave["order_min_distance_to_entry_pct"] == 0 + assert second_wave["order_require_current_trigger"] is False + assert short_retest["entry_min_rr"] == 1.3 + assert short_retest["order_require_current_trigger"] is False + + def test_long_momentum_breakout_strategy_builds_independent_signal(): signal = build_long_momentum_breakout_signal( symbol="VOL/USDT", @@ -83,6 +99,48 @@ def test_long_momentum_breakout_strategy_builds_independent_signal(): assert payload["factor_roles"]["momentum_breakout_15m_1h"] == "trigger" +def test_long_builders_reject_short_breakdown_context(): + result = { + "score": 8, + "signals": ["1H破位反抽做空(破位1.0)", "等待反抽失败确认", "15min即刻入场"], + "trigger_context": {"current_triggers": ["15m"], "trigger_status": "current"}, + "market_context": {"short_breakdown_retest_1h": {"detected": True}}, + } + + assert build_long_momentum_breakout_signal( + symbol="SHORTY/USDT", + result=result, + entry_plan={"entry_action": "可即刻买入"}, + ) is None + assert build_long_second_wave_pullback_signal( + symbol="SHORTY/USDT", + result={**result, "signals": [*result["signals"], "1H箱体突破回踩", "量价齐飞"]}, + entry_plan={"entry_action": "等回踩"}, + ) is None + + +def test_momentum_and_second_wave_are_mutually_exclusive(): + result = { + "score": 8, + "signals": ["1H量价齐飞 · 连续放量", "15min即刻入场", "1H箱体突破回踩"], + "trigger_context": {"current_triggers": ["15m突破"], "trigger_status": "current"}, + "entry_plan": {"entry_action": "可即刻买入"}, + } + + assert build_long_momentum_breakout_signal( + symbol="WAVE/USDT", + result=result, + entry_plan={"entry_action": "可即刻买入", "entry_price": 1.0}, + ) is None + signal = build_long_second_wave_pullback_signal( + symbol="WAVE/USDT", + result=result, + entry_plan={"entry_action": "可即刻买入", "entry_price": 1.0}, + ) + assert signal is not None + assert signal.to_json_dict()["strategy_code"] == LONG_SECOND_WAVE_PULLBACK_STRATEGY + + def test_legacy_strategy_builders_no_longer_emit_signals(): assert build_volume_ignition_1h_signal( symbol="OLD/USDT", diff --git a/tests/test_opportunity_lifecycle.py b/tests/test_opportunity_lifecycle.py index 51d2ec4..360af23 100644 --- a/tests/test_opportunity_lifecycle.py +++ b/tests/test_opportunity_lifecycle.py @@ -48,11 +48,34 @@ def test_buy_now_with_bad_rr_sets_real_pullback_price(): assert action == '等回踩' assert plan['entry_price'] < 0.11455 - assert round(plan['entry_price'], 6) == 0.11251 + assert round(plan['entry_price'], 6) == 0.113071 assert plan['rr_target_entry'] == plan['entry_price'] assert any('现价不买' in r for r in reasons) +def test_buy_now_with_bad_rr_can_use_stricter_rr_override(): + action, plan, reasons = apply_entry_quality_gate( + action_status='可即刻买入', + entry_plan={ + 'entry_action': '即刻买入', + 'entry_price': 0.11455, + 'current_price': 0.11455, + 'stop_loss': 0.107457, + 'tp1': 0.120089, + 'risk_reward_ok': False, + 'rr1': 0.83, + }, + signals=['🟢 15min即刻入场信号', '日线 站稳突破位+19.2%'], + current_price=0.11455, + market_context={'change_24h': 3.1}, + cfg={'min_rr_buy_now': 1.5}, + ) + + assert action == '等回踩' + assert round(plan['entry_price'], 6) == 0.11251 + assert any('现价不买' in r for r in reasons) + + def test_low_entry_score_blocks_buy_now_and_weak_pullback(): action, plan, reasons = apply_entry_quality_gate( action_status='可即刻买入', diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py index e271fc1..1cd09b0 100644 --- a/tests/test_paper_trading.py +++ b/tests/test_paper_trading.py @@ -300,6 +300,11 @@ def test_wait_pullback_without_tradeable_plan_does_not_create_order(monkeypatch) assert "missing_stop_loss" in result["gate_reasons"] assert "missing_tp1" in result["gate_reasons"] assert list_paper_orders()["total"] == 0 + events = list_paper_trade_events(event_type="paper_gate_reject")["items"] + assert events + assert events[0]["symbol"] == "BADPLAN/USDT" + assert events[0]["detail"]["reason"] == "paper_order_gate_rejected" + assert "missing_stop_loss" in events[0]["detail"]["gate_reasons"] def test_wait_pullback_requires_confirmed_risk_reward(monkeypatch):