diff --git a/.gitignore b/.gitignore index c7cd41d..cc82eb2 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ venv/ env/ ENV/ +# Report +reports/ + # Local environment files .env .env.* diff --git a/app/analysis/reverse_analysis.py b/app/analysis/reverse_analysis.py index 4cae165..dcec666 100644 --- a/app/analysis/reverse_analysis.py +++ b/app/analysis/reverse_analysis.py @@ -28,9 +28,7 @@ from app.config import config_loader from app.services.altcoin_screener import ( STABLECOINS, WRAPPED, - BTC_ETH, GOLD_METAL, - BNB_CHAIN, EXCLUDED_BASES, EXCLUDED_BASE_SUFFIXES, ) @@ -53,7 +51,7 @@ def _is_altcoin_usdt_symbol(symbol_str): if not symbol_str or not symbol_str.endswith("USDT"): return False base = symbol_str.replace("USDT", "").upper() - if base in STABLECOINS or base in WRAPPED or base in BTC_ETH or base in GOLD_METAL or base in BNB_CHAIN: + if base in STABLECOINS or base in WRAPPED or base in GOLD_METAL: return False if base in EXCLUDED_BASES or base.endswith(EXCLUDED_BASE_SUFFIXES): return False diff --git a/app/core/opportunity_funnel.py b/app/core/opportunity_funnel.py index 0ead0f2..099ba3e 100644 --- a/app/core/opportunity_funnel.py +++ b/app/core/opportunity_funnel.py @@ -157,7 +157,7 @@ def universe_gate_reason(base: str, quote_volume: float, min_volume: float, *, s return {"reason_code": "stablecoin", "reason_label": UNIVERSE_REASON_LABELS["stablecoin"]} if base in {"WBTC", "WETH", "RENBTC"}: return {"reason_code": "wrapped", "reason_label": UNIVERSE_REASON_LABELS["wrapped"]} - if base in {"BTC", "ETH", "BNB", "XAUT", "PAXG"}: + if base in {"XAUT", "PAXG"}: return {"reason_code": "excluded_base", "reason_label": UNIVERSE_REASON_LABELS["excluded_base"]} if not symbol or "/USDT" not in symbol: return {"reason_code": "invalid_pair", "reason_label": UNIVERSE_REASON_LABELS["invalid_pair"]} diff --git a/app/core/opportunity_level.py b/app/core/opportunity_level.py new file mode 100644 index 0000000..2d89a48 --- /dev/null +++ b/app/core/opportunity_level.py @@ -0,0 +1,207 @@ +"""Opportunity level classification and level-aware trade plan helpers. + +The recommendation chain should tell users what kind of move it is trying to +capture. This module keeps that taxonomy stable and keeps level-specific +entry/SL/TP assumptions out of UI code. +""" + +from __future__ import annotations + +import statistics +from typing import Any, Dict, Iterable, List, Tuple + + +OPPORTUNITY_LEVELS: Dict[str, Dict[str, str]] = { + "intraday_breakout": { + "label": "日内启动", + "holding_horizon": "数小时-1天", + "entry_model": "15m触发 / 1H突破延续", + "stop_model": "15m摆动低点 / 1H ATR紧止损", + "tp_model": "1H压力位 / 2-3.5ATR / 移动止盈", + "max_action": "buy_now", + }, + "short_swing": { + "label": "短波段", + "holding_horizon": "1-3天", + "entry_model": "1H回踩 / 30m确认", + "stop_model": "1H摆动低点 / 4H需求区", + "tp_model": "4H压力位 / 前高 / 移动止盈", + "max_action": "buy_now", + }, + "structure_watch": { + "label": "结构观察", + "holding_horizon": "3-7天", + "entry_model": "4H结构回踩后再确认", + "stop_model": "4H结构低点 / 日线需求区", + "tp_model": "日线压力位 / 波段高点", + "max_action": "wait_pullback", + }, + "theme_trend": { + "label": "主题趋势", + "holding_horizon": "1-3周", + "entry_model": "日线/4H回踩确认", + "stop_model": "日线趋势失效位", + "tp_model": "趋势跟踪 / 分批兑现", + "max_action": "observe", + }, +} + + +LEVEL_ORDER = ("intraday_breakout", "short_swing", "structure_watch", "theme_trend") + + +def _text(signals: Iterable[Any]) -> str: + return " ".join(str(s) for s in (signals or [])) + + +def _has_any(text: str, keywords: Iterable[str]) -> bool: + return any(k in text for k in keywords) + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + if value is None or value == "": + return default + return float(value) + except Exception: + return default + + +def _current_trigger(signals: Iterable[Any], entry_plan: Dict[str, Any] | None = None) -> bool: + plan = entry_plan or {} + if plan.get("entry_trigger_confirmed") is True: + return True + text = _text(signals) + summary = str(plan.get("pa_15min_summary") or "") + return ( + "15min即刻入场" in text + or "当前15min" in text + or "当前 15min" in text + or ("即刻入场" in summary and "无动K突破" not in summary) + ) + + +def opportunity_level_meta(level: str) -> Dict[str, str]: + level = level if level in OPPORTUNITY_LEVELS else "structure_watch" + return {"opportunity_level": level, **OPPORTUNITY_LEVELS[level]} + + +def classify_opportunity_level( + *, + signals: Iterable[Any], + entry_plan: Dict[str, Any] | None = None, + market_context: Dict[str, Any] | None = None, + derivatives_context: Dict[str, Any] | None = None, + sector_context: Dict[str, Any] | None = None, + m30_aligned: bool = False, +) -> Dict[str, Any]: + """Classify the move level from structured signals and context. + + The classifier is intentionally conservative: lower timeframe current + triggers make a move tradable; higher timeframe/theme evidence without a + fresh entry trigger stays in watch-oriented levels. + """ + + plan = entry_plan or {} + text = _text(signals) + market_context = market_context or {} + sector_context = sector_context or {} + + has_15m_trigger = _current_trigger(signals, plan) + has_1h_momentum = _has_any(text, ("1H 量价齐飞", "1H放量突破", "1H极放量", "1H 起爆", "1H 动K", "1H 1根量价齐飞")) + has_30m_bridge = bool(m30_aligned) or _has_any(text, ("30min", "30m")) + has_4h_or_daily = _has_any(text, ("4H", "日线", "周线", "需求区", "突破回踩", "底部", "静K", "蓄力")) + has_theme = bool(sector_context.get("hot_sectors")) or _has_any( + text, + ("主题", "生态", "舆情", "板块", "listing", "公告", "催化"), + ) + stale_only = _has_any(text, ("历史", "已过期")) and not has_15m_trigger + + basis: List[str] = [] + if has_15m_trigger: + basis.append("当前15m触发") + if has_1h_momentum: + basis.append("1H动能确认") + if has_30m_bridge: + basis.append("30m结构桥接") + if has_4h_or_daily: + basis.append("高周期结构背景") + if has_theme: + basis.append("主题/板块线索") + if stale_only: + basis.append("旧信号仅作背景") + + if has_15m_trigger and has_1h_momentum and not stale_only: + level = "intraday_breakout" + elif (has_1h_momentum and (has_30m_bridge or has_4h_or_daily)) and not stale_only: + level = "short_swing" + elif has_theme and not has_15m_trigger and not has_1h_momentum: + level = "theme_trend" + elif has_4h_or_daily or stale_only: + level = "structure_watch" + elif has_1h_momentum: + level = "short_swing" + else: + level = "structure_watch" + + meta = opportunity_level_meta(level) + meta["plan_basis"] = basis or ["异动候选进入确认"] + return meta + + +def attach_opportunity_level(entry_plan: Dict[str, Any], level_meta: Dict[str, Any]) -> Dict[str, Any]: + plan = dict(entry_plan or {}) + level = level_meta.get("opportunity_level") or "structure_watch" + meta = opportunity_level_meta(level) + plan.update( + { + "opportunity_level": level, + "opportunity_level_label": level_meta.get("label") or meta["label"], + "holding_horizon": level_meta.get("holding_horizon") or meta["holding_horizon"], + "entry_model": level_meta.get("entry_model") or meta["entry_model"], + "stop_model": level_meta.get("stop_model") or meta["stop_model"], + "tp_model": level_meta.get("tp_model") or meta["tp_model"], + "max_action": level_meta.get("max_action") or meta["max_action"], + "plan_basis": list(level_meta.get("plan_basis") or []), + } + ) + return plan + + +def select_level_stop_loss( + *, + level: str, + price: float, + entry_price: float, + stop_candidates: Iterable[float], +) -> Tuple[float, str]: + """Pick a stop from candidates according to opportunity level.""" + + ref = _safe_float(entry_price) or _safe_float(price) + candidates = sorted( + { + round(_safe_float(c), 8) + for c in (stop_candidates or []) + if _safe_float(c) > 0 and _safe_float(c) < ref + } + ) + if not candidates: + return 0.0, OPPORTUNITY_LEVELS.get(level, OPPORTUNITY_LEVELS["structure_watch"])["stop_model"] + + if level == "intraday_breakout": + stop = candidates[-1] + elif level == "short_swing": + stop = statistics.median(candidates) + else: + stop = candidates[0] + return round(float(stop), 6), OPPORTUNITY_LEVELS.get(level, OPPORTUNITY_LEVELS["structure_watch"])["stop_model"] + + +def level_tp_parameters(level: str) -> Dict[str, float]: + if level == "intraday_breakout": + return {"tp1_atr": 2.0, "tp1_floor": 0.03, "tp2_atr": 3.5, "tp2_floor": 0.05} + if level == "short_swing": + return {"tp1_atr": 3.0, "tp1_floor": 0.05, "tp2_atr": 5.0, "tp2_floor": 0.08} + if level == "theme_trend": + return {"tp1_atr": 6.0, "tp1_floor": 0.12, "tp2_atr": 10.0, "tp2_floor": 0.20} + return {"tp1_atr": 4.0, "tp1_floor": 0.08, "tp2_atr": 7.0, "tp2_floor": 0.14} diff --git a/app/core/opportunity_lifecycle.py b/app/core/opportunity_lifecycle.py index 65f468c..b184b33 100644 --- a/app/core/opportunity_lifecycle.py +++ b/app/core/opportunity_lifecycle.py @@ -9,6 +9,8 @@ import json import re from typing import Any, Dict, Iterable, Tuple +from app.core.opportunity_level import OPPORTUNITY_LEVELS + DEFAULT_ENTRY_GATE = { "enabled": True, "min_rr_buy_now": 1.2, @@ -290,6 +292,9 @@ def apply_entry_quality_gate( risk_reward_ok = live_rr1 >= _cfg_value(cfg, "min_rr_buy_now") entry_plan["risk_reward_ok_live"] = risk_reward_ok entry_action = str(entry_plan.get("entry_action") or "").strip() + opportunity_level = str(entry_plan.get("opportunity_level") or "").strip() + level_meta = OPPORTUNITY_LEVELS.get(opportunity_level, {}) + level_max_action = level_meta.get("max_action") or str(entry_plan.get("max_action") or "").strip() if entry_plan.get("entry_quality_gate"): entry_plan.pop("entry_quality_gate", None) breakout_distance = detect_breakout_distance_pct(signals) @@ -320,6 +325,8 @@ def apply_entry_quality_gate( reasons.append(f"rr1={rr1} < {_cfg_value(cfg, 'min_rr_buy_now')},禁止现价买入") if action_status == "可即刻买入": + if level_max_action in ("observe", "wait_pullback"): + reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别最高只允许{level_meta.get('label') and ('观察/等待') or '观察/等待'},禁止现价买入") if not has_current_entry_trigger(signals, entry_plan): reasons.append("缺少当前15min触发,禁止现价买入") if has_bearish_flow_risk(signals): @@ -397,6 +404,13 @@ def apply_entry_quality_gate( else: target_action = action_status + if level_max_action == "observe" and target_action in ("可即刻买入", "等回踩"): + target_action = "观察" + reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别仅作观察,不直接给交易动作") + elif level_max_action == "wait_pullback" and target_action == "可即刻买入" and not has_current_entry_trigger(signals, entry_plan): + target_action = "等回踩" + reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别需要低周期触发后才允许买入") + if target_action != action_status: entry_plan["entry_quality_gate"] = { "blocked_action": action_status, diff --git a/app/db/altcoin_db.py b/app/db/altcoin_db.py index aa02017..22b9021 100644 --- a/app/db/altcoin_db.py +++ b/app/db/altcoin_db.py @@ -165,6 +165,18 @@ def _serialized_signal_payload(signals): return stored_signals, json.dumps(codes, ensure_ascii=False), json.dumps(labels, ensure_ascii=False) +def _opportunity_fields_from_plan(entry_plan): + plan = entry_plan if isinstance(entry_plan, dict) else {} + return { + "opportunity_level": str(plan.get("opportunity_level") or ""), + "opportunity_level_label": str(plan.get("opportunity_level_label") or ""), + "holding_horizon": str(plan.get("holding_horizon") or ""), + "entry_model": str(plan.get("entry_model") or ""), + "stop_model": str(plan.get("stop_model") or plan.get("stop_basis") or ""), + "tp_model": str(plan.get("tp_model") or plan.get("tp_basis") or ""), + } + + def create_recommendation(symbol, rec_state, rec_score, entry_price, stop_loss=0, tp1=0, tp2=0, sector="", signals="", is_meme=0, entry_plan=None, direction="中性", @@ -186,6 +198,7 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price, "active", incoming_action, entry_plan or {} ) stored_signals, signal_codes_json, signal_labels_json = _serialized_signal_payload(signals) + opportunity_fields = _opportunity_fields_from_plan(entry_plan or {}) # 当前状态唯一:同一 symbol 同一时间只允许一条可执行/观察主记录; # 但兼容粗筛蓄力→加速/爆发的状态迁移测试:无 entry_plan 的旧粗筛记录仍可新建演化轨迹。 duplicate_cursor = conn.execute( @@ -213,6 +226,12 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price, sector_signal_count=GREATEST(COALESCE(sector_signal_count,0), %s), entry_plan_json=CASE WHEN %s != '{}' THEN %s ELSE entry_plan_json END, market_context_json=%s, derivatives_context_json=%s, sector_context_json=%s, + opportunity_level=COALESCE(NULLIF(%s, ''), opportunity_level), + opportunity_level_label=COALESCE(NULLIF(%s, ''), opportunity_level_label), + holding_horizon=COALESCE(NULLIF(%s, ''), holding_horizon), + entry_model=COALESCE(NULLIF(%s, ''), entry_model), + stop_model=COALESCE(NULLIF(%s, ''), stop_model), + tp_model=COALESCE(NULLIF(%s, ''), tp_model), action_status=CASE WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈','衰减','反转') THEN action_status ELSE COALESCE(NULLIF(%s, ''), action_status) @@ -229,6 +248,12 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price, json.dumps(market_context or {}, ensure_ascii=False), json.dumps(derivatives_context or {}, ensure_ascii=False), json.dumps(sector_context or {}, ensure_ascii=False), + opportunity_fields["opportunity_level"], + opportunity_fields["opportunity_level_label"], + opportunity_fields["holding_horizon"], + opportunity_fields["entry_model"], + opportunity_fields["stop_model"], + opportunity_fields["tp_model"], incoming_action if entry_plan else "", incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason, existing_id, @@ -243,9 +268,10 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price, current_price, max_price, min_price, last_track_time, entry_plan_json, force_reason, base_state, sector_signal_count, market_context_json, derivatives_context_json, sector_context_json, + opportunity_level, opportunity_level_label, holding_horizon, entry_model, stop_model, tp_model, action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, strategy_version) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( symbol, now, rec_state, rec_score_pct, entry_price, @@ -260,6 +286,12 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price, json.dumps(market_context or {}, ensure_ascii=False), json.dumps(derivatives_context or {}, ensure_ascii=False), json.dumps(sector_context or {}, ensure_ascii=False), + opportunity_fields["opportunity_level"], + opportunity_fields["opportunity_level_label"], + opportunity_fields["holding_horizon"], + opportunity_fields["entry_model"], + opportunity_fields["stop_model"], + opportunity_fields["tp_model"], incoming_action, incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason, strategy_version, @@ -957,6 +989,20 @@ def _derive_execution_fields(item): item["observe_tier"] = observe_tier item["observe_reason"] = observe_reason item["entry_plan"] = entry_plan + opportunity_fields = _opportunity_fields_from_plan(entry_plan) + for key, value in opportunity_fields.items(): + item[key] = item.get(key) or value + if item.get("opportunity_level") and not item.get("opportunity_level_label"): + try: + from app.core.opportunity_level import opportunity_level_meta + meta = opportunity_level_meta(item["opportunity_level"]) + item["opportunity_level_label"] = meta.get("label", "") + item["holding_horizon"] = item.get("holding_horizon") or meta.get("holding_horizon", "") + item["entry_model"] = item.get("entry_model") or meta.get("entry_model", "") + item["stop_model"] = item.get("stop_model") or meta.get("stop_model", "") + item["tp_model"] = item.get("tp_model") or meta.get("tp_model", "") + except Exception: + pass item["entry_window"] = entry_window if entry_window and entry_window.get("status") != "active": item["entry_window_alert"] = entry_window diff --git a/app/db/migrations/0001_initial_postgres.sql b/app/db/migrations/0001_initial_postgres.sql index 8ab9655..ebd42a8 100644 --- a/app/db/migrations/0001_initial_postgres.sql +++ b/app/db/migrations/0001_initial_postgres.sql @@ -81,11 +81,18 @@ CREATE TABLE IF NOT EXISTS recommendation ( entry_triggered INTEGER DEFAULT 0, archived_at TEXT DEFAULT '', signal_codes_json TEXT DEFAULT '[]', - signal_labels_json TEXT DEFAULT '[]' + signal_labels_json TEXT DEFAULT '[]', + opportunity_level TEXT DEFAULT '', + opportunity_level_label TEXT DEFAULT '', + holding_horizon TEXT DEFAULT '', + entry_model TEXT DEFAULT '', + stop_model TEXT DEFAULT '', + tp_model TEXT DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_rec_active_symbol_bucket ON recommendation(symbol, status, display_bucket, id DESC); CREATE INDEX IF NOT EXISTS idx_rec_display_bucket_time ON recommendation(display_bucket, rec_time DESC); CREATE INDEX IF NOT EXISTS idx_rec_symbol_time ON recommendation(symbol, rec_time DESC); +CREATE INDEX IF NOT EXISTS idx_rec_opportunity_level_time ON recommendation(opportunity_level, rec_time DESC); CREATE TABLE IF NOT EXISTS price_tracking ( id BIGSERIAL PRIMARY KEY, diff --git a/app/db/migrations/0008_opportunity_level.sql b/app/db/migrations/0008_opportunity_level.sql new file mode 100644 index 0000000..b85fa9b --- /dev/null +++ b/app/db/migrations/0008_opportunity_level.sql @@ -0,0 +1,10 @@ +ALTER TABLE recommendation + ADD COLUMN IF NOT EXISTS opportunity_level TEXT DEFAULT '', + ADD COLUMN IF NOT EXISTS opportunity_level_label TEXT DEFAULT '', + ADD COLUMN IF NOT EXISTS holding_horizon TEXT DEFAULT '', + ADD COLUMN IF NOT EXISTS entry_model TEXT DEFAULT '', + ADD COLUMN IF NOT EXISTS stop_model TEXT DEFAULT '', + ADD COLUMN IF NOT EXISTS tp_model TEXT DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_rec_opportunity_level_time + ON recommendation(opportunity_level, rec_time DESC); diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index f709417..b0a2a7c 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -123,6 +123,9 @@ def _decorate_trade(trade: dict) -> dict: item["margin_roi_pct"] = _margin_roi_pct(effective_pnl, margin) item["account_return_pct"] = _account_return_pct(effective_pnl) item["account_equity_usdt"] = default_account_equity_usdt() + latest_market = _safe_float(item.get("latest_market_price")) + item["latest_price"] = latest_market if latest_market > 0 else _safe_float(item.get("current_price")) + item["latest_price_updated_at"] = item.get("latest_market_price_updated_at") or item.get("updated_at") or "" return item @@ -391,6 +394,10 @@ def get_paper_trading_summary(days: int = 30) -> dict: open_unrealized = round(sum(_safe_float(x.get("unrealized_pnl_usdt")) for x in open_items), 4) total_pnl = round(total_realized + open_unrealized, 4) allocated_margin = round(sum(_safe_float(x.get("margin_usdt")) for x in open_items), 4) + open_position_value = round(sum(_safe_float(x.get("notional_usdt")) for x in open_items), 4) + initial_equity = default_account_equity_usdt() + current_balance = round(initial_equity + total_pnl, 4) + cumulative_leverage = round(open_position_value / current_balance, 4) if current_balance > 0 else 0 return { "days": days, "total": len(items), @@ -403,12 +410,16 @@ def get_paper_trading_summary(days: int = 30) -> dict: "avg_realized_pnl_pct": avg_realized_pct, "open_unrealized_pnl_usdt": open_unrealized, "total_pnl_usdt": total_pnl, - "account_equity_usdt": default_account_equity_usdt(), + "initial_equity_usdt": initial_equity, + "account_equity_usdt": initial_equity, + "current_balance_usdt": current_balance, "account_realized_return_pct": _account_return_pct(total_realized), "account_unrealized_return_pct": _account_return_pct(open_unrealized), "account_total_return_pct": _account_return_pct(total_pnl), "allocated_margin_usdt": allocated_margin, - "available_equity_usdt": round(default_account_equity_usdt() - allocated_margin, 4), + "open_position_value_usdt": open_position_value, + "cumulative_leverage": cumulative_leverage, + "available_equity_usdt": round(current_balance - allocated_margin, 4), "margin_usdt": default_margin_usdt(), "leverage": default_leverage(), "notional_usdt": default_notional_usdt(), @@ -431,9 +442,11 @@ def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "") -> dic total = conn.execute(f"SELECT COUNT(*) FROM paper_trades {where}", tuple(params)).fetchone()[0] rows = conn.execute( f""" - SELECT * FROM paper_trades + SELECT pt.*, lpc.price AS latest_market_price, lpc.updated_at AS latest_market_price_updated_at + FROM paper_trades pt + LEFT JOIN latest_price_cache lpc ON lpc.symbol = pt.symbol {where} - ORDER BY opened_at DESC, id DESC + ORDER BY pt.opened_at DESC, pt.id DESC LIMIT %s OFFSET %s """, tuple(params + [limit, offset]), diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index a1f3298..2326a8a 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -42,6 +42,12 @@ from app.config.config_loader import ( get_strategy_params, ) from app.core.opportunity_lifecycle import apply_entry_quality_gate +from app.core.opportunity_level import ( + attach_opportunity_level, + classify_opportunity_level, + level_tp_parameters, + select_level_stop_loss, +) from app.core.opportunity_funnel import build_screening_detail from app.config.config_loader import _get_section as _get_cfg_section from app.core.pa_engine import ( @@ -1075,7 +1081,7 @@ def confirm_burst(symbol, cand): stop_pct_final = min(stop_pct_final, stop_cfg.get("ceiling_pct", 0.10)) atr_stop_price = round(float(price * (1 - stop_pct_final)), 6) - # 收集所有止损候选 → 取最低价(最宽止损) + # 收集所有止损候选,再按机会级别选择紧/中/宽止损。 stop_candidates = [atr_stop_price] # Q≥5需求区兜底:有结构支撑优先用需求区底部 @@ -1096,20 +1102,43 @@ def confirm_burst(symbol, cand): stop_candidates.append(swing_stop) signals.append(f"结构止损(swing_low${swing_low:.4f}×{swing_buffer})") - stop_loss = min(stop_candidates) # 最低价=最宽止损 + level_meta = classify_opportunity_level( + signals=signals, + entry_plan={ + "entry_action": entry_action, + "entry_price": entry_price, + "current_price": round(float(price), 6), + "pa_15min_summary": pa_15min_result.get("reason", ""), + }, + market_context=compute_market_context(h1_df, price), + derivatives_context=cand_detail.get("derivatives_context", {}), + sector_context=cand_detail.get("sector_context", {}), + m30_aligned=m30_aligned, + ) + opportunity_level = level_meta.get("opportunity_level", "structure_watch") + stop_loss, stop_basis = select_level_stop_loss( + level=opportunity_level, + price=price, + entry_price=entry_price, + stop_candidates=stop_candidates, + ) + if stop_loss <= 0: + stop_loss = min(stop_candidates) + stop_pct_final = max((price - stop_loss) / price, 0) if price > 0 and stop_loss > 0 else stop_pct_final - # === ATR动态止盈 (v1.6.8) === - # TP1% = max(3×ATR_1h/price, 5%地板), TP2% = max(5×ATR_1h/price, 8%地板) + # === 分级动态止盈 === + # 日内启动更重视近端兑现;结构/主题级别使用更宽目标与移动止盈。 atr_multipliers = confirm_atr_multipliers() - tp1_atr_pct = (atr_1h * atr_multipliers.get("tp1", 3.0)) / price - tp1_pct = max(tp1_atr_pct, atr_multipliers.get("tp1_floor", 0.05)) + level_tp = level_tp_parameters(opportunity_level) + tp1_atr_pct = (atr_1h * level_tp.get("tp1_atr", atr_multipliers.get("tp1", 3.0))) / price + tp1_pct = max(tp1_atr_pct, level_tp.get("tp1_floor", atr_multipliers.get("tp1_floor", 0.05))) tp1_candidates = [round(float(price * (1 + tp1_pct)), 6)] if high_q_supply: tp1_candidates.append(round(high_q_supply[0]["top"], 6)) tp1 = min(tp1_candidates) - tp2_atr_pct = (atr_1h * atr_multipliers.get("tp2", 5.0)) / price - tp2_pct = max(tp2_atr_pct, atr_multipliers.get("tp2_floor", 0.08)) + tp2_atr_pct = (atr_1h * level_tp.get("tp2_atr", atr_multipliers.get("tp2", 5.0))) / price + tp2_pct = max(tp2_atr_pct, level_tp.get("tp2_floor", atr_multipliers.get("tp2_floor", 0.08))) tp2 = round(float(price * (1 + tp2_pct)), 6) risk = price - stop_loss @@ -1133,7 +1162,10 @@ def confirm_burst(symbol, cand): "pa_15min_summary": pa_15min_result.get("reason", ""), "pa_1h_exhaustion": pa_1h_exhaustion.get("severity", "low"), "trailing_stop_level": 0.0, # v1.7.8: tracker动态填充,初始0 + "stop_basis": stop_basis, + "tp_basis": level_meta.get("tp_model", ""), } + entry_plan = attach_opportunity_level(entry_plan, level_meta) # v1.7.5 买点质量闸门:确认强势 ≠ 允许现价追买。 gated_action, gated_plan, gate_reasons = apply_entry_quality_gate( @@ -1286,9 +1318,10 @@ def main(compact: bool = False): continue ep = result["entry_plan"] + rec_entry_price = ep.get("entry_price") or result["price"] rec_id = create_recommendation( symbol=symbol, rec_state="爆发", rec_score=result["score"], - entry_price=result["price"], + entry_price=rec_entry_price, stop_loss=ep.get("stop_loss", 0), tp1=ep.get("tp1", 0), tp2=ep.get("tp2", 0), sector=cand_detail.get("sector", cand.get("sector", "")), diff --git a/app/services/altcoin_screener.py b/app/services/altcoin_screener.py index 21c300c..694e707 100644 --- a/app/services/altcoin_screener.py +++ b/app/services/altcoin_screener.py @@ -70,9 +70,8 @@ STABLECOINS = { "GUSD", "SUSD", "USDD", "EURS", "EUR", "GBP", } WRAPPED = {"WBTC", "WETH", "RENBTC"} -BTC_ETH = {"BTC", "ETH"} GOLD_METAL = {"XAUT", "PAXG"} -BNB_CHAIN = {"BNB"} +MAJOR_BASES = {"BTC", "ETH", "BNB"} EXCLUDED_BASE_SUFFIXES = ( "USD", "EUR", "GBP", "TRY", "BRL", "AUD", "FDUSD", "USDC", "USDP", "DAI" ) @@ -95,7 +94,7 @@ def fetch_all_tickers(): if "/USDT" in symbol: base = symbol.split("/")[0] vol_usd = info.get("quoteVolume", 0) or 0 - if base in STABLECOINS or base in WRAPPED or base in BTC_ETH or base in GOLD_METAL or base in BNB_CHAIN: + if base in STABLECOINS or base in WRAPPED or base in GOLD_METAL: reason = universe_gate_reason(base, vol_usd, 0, symbol=symbol) or {"reason_code": "excluded_base", "reason_label": "排除基础资产"} universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, **reason}) continue diff --git a/app/services/event_driven_screener.py b/app/services/event_driven_screener.py index 05b9c1e..ec4595a 100644 --- a/app/services/event_driven_screener.py +++ b/app/services/event_driven_screener.py @@ -35,9 +35,7 @@ from app.services.altcoin_screener import ( detect_static_accumulation, STABLECOINS, WRAPPED, - BTC_ETH, GOLD_METAL, - BNB_CHAIN, EXCLUDED_BASES, EXCLUDED_BASE_SUFFIXES, ) @@ -193,7 +191,7 @@ def _symbols_from_text(text, aliases=None): def _tradable_symbol(symbol): base = symbol.split("/")[0].upper() - if base in STABLECOINS or base in WRAPPED or base in BTC_ETH or base in GOLD_METAL or base in BNB_CHAIN: + if base in STABLECOINS or base in WRAPPED or base in GOLD_METAL: return False if base in EXCLUDED_BASES or base.endswith(EXCLUDED_BASE_SUFFIXES): return False diff --git a/app/services/price_tracker.py b/app/services/price_tracker.py index 687ea84..577b084 100644 --- a/app/services/price_tracker.py +++ b/app/services/price_tracker.py @@ -364,6 +364,7 @@ def track_prices(): return output results = [] + tracked_count = 0 failed_symbols = [] for rec in recs: symbol = rec["symbol"] @@ -460,6 +461,7 @@ def track_prices(): print(f" {symbol}: 入场${rec['entry_price']} → 现在${current_price} " f"盈亏{tracking_signals['pnl_pct']}% 状态={track_result['status']} " f"操作={tracking_signals['action_status']}") + tracked_count += 1 except Exception as e: failed_symbols.append({"symbol": symbol, "error": str(e)}) @@ -470,7 +472,7 @@ def track_prices(): output = { "status": "tracked", - "tracked_count": len(results), + "tracked_count": tracked_count, "failed_count": len(failed_symbols), "failed_symbols": failed_symbols, "results": results, diff --git a/static/app.html b/static/app.html index 3da4d4b..6cb828a 100644 --- a/static/app.html +++ b/static/app.html @@ -101,6 +101,11 @@ .score-badge.tier-weak { background: var(--surface); color: var(--steel); border: 1px solid var(--hairline); } .score-badge.tier-none { background: var(--hairline-soft); color: var(--muted); border: 1px solid var(--hairline); } .card-bar .badge-group { display: flex; align-items: center; gap: 8px; margin-left: auto; } +.level-badge { padding: 4px 10px; border-radius: var(--radius-full); font-size: 12px; font-weight: 800; white-space: nowrap; color: var(--blue); background: rgba(66,98,255,.07); border: 1px solid rgba(66,98,255,.12); } +.level-badge.intraday_breakout { color: var(--green); background: var(--green-light); border-color: rgba(0,180,115,.14); } +.level-badge.short_swing { color: #187574; background: rgba(15,188,176,.12); border-color: rgba(15,188,176,.20); } +.level-badge.structure_watch { color: var(--yellow-dark); background: var(--yellow-light); border-color: rgba(252,185,0,.22); } +.level-badge.theme_trend { color: var(--blue); background: rgba(66,98,255,.07); border-color: rgba(66,98,255,.12); } .card-bar .win-badge { padding: 4px 12px; border-radius: var(--radius-full); font-size: 12px; font-weight: 700; white-space: nowrap; color: var(--green); background: var(--green-light); } .card-bar .lose-badge { padding: 4px 12px; border-radius: var(--radius-full); font-size: 12px; font-weight: 700; white-space: nowrap; color: var(--red); background: var(--red-light); } .hist-pnl-badge { display: flex; align-items: baseline; gap: 4px; padding: 6px 14px; border-radius: var(--radius-full); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 900; white-space: nowrap; margin-left: auto; } @@ -194,6 +199,7 @@ .ep-item { display: flex; flex-direction: column; gap: 3px; min-width: 0; padding: 8px 10px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); } .ep-label { color: var(--stone); font-size: 10px; font-weight: 800; line-height: 1.2; white-space: nowrap; } .ep-val { font-weight: 900; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ep-val.level-ref { color: var(--ink); font-family: inherit; } .ep-val.entry-ref { color: var(--yellow-dark); } .ep-val.risk-line { color: var(--red); } .ep-val.space-ref { color: var(--blue); } .ep-val.phase-ref { color: var(--green); } .ep-sub { color: var(--muted); font-size: 10px; font-weight: 600; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* ===== SIGNALS ===== */ @@ -624,6 +630,12 @@ function renderRecCard(r) { return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'}; } var ep = r.entry_plan || {}; + var levelKey = r.opportunity_level || ep.opportunity_level || 'structure_watch'; + var levelLabel = r.opportunity_level_label || ep.opportunity_level_label || '结构观察'; + var horizon = r.holding_horizon || ep.holding_horizon || ''; + var entryModel = r.entry_model || ep.entry_model || ''; + var stopModel = r.stop_model || ep.stop_model || ep.stop_basis || '风险边界'; + var tpModel = r.tp_model || ep.tp_model || ep.tp_basis || '上方目标'; var sigs = Array.isArray(r.signals)?r.signals:[]; var entryMethod = ep.entry_method || ''; var signalText = sigs.join(' '); @@ -723,11 +735,11 @@ function renderRecCard(r) { } var entryPlanHtml = ''; if (isTradePlan) { - entryPlanHtml = '
阶段'+phase.short+'机会所处阶段
'+entryLabel+''+fmtP(entryRef)+'触发/计划价
风险边界'+fmtP(riskLine)+'跌破则逻辑失效
上方空间'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+'参考位 '+fmtP(spaceRef)+'
'; + entryPlanHtml = '
机会级别'+cleanDisplayText(levelLabel)+''+cleanDisplayText(horizon || phase.short)+'
'+entryLabel+''+fmtP(entryRef)+''+cleanDisplayText(entryModel || '触发/计划价')+'
风险边界'+fmtP(riskLine)+''+cleanDisplayText(stopModel)+'
上方空间'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+''+cleanDisplayText(tpModel)+' · '+fmtP(spaceRef)+'
'; } else { - entryPlanHtml = '
阶段'+phase.short+'观察池候选
当前参考'+fmtP(price)+'不是入场价
确认条件待触发需15m/1H当前信号
绩效口径不计入未成交易推荐
'; + entryPlanHtml = '
机会级别'+cleanDisplayText(levelLabel)+''+cleanDisplayText(horizon || '观察池候选')+'
当前参考'+fmtP(price)+'不是入场价
确认条件待触发'+cleanDisplayText(entryModel || '需15m/1H当前信号')+'
绩效口径不计入未成交易推荐
'; } - return '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+actionBadge+''+score+''+st.label+'
$'+priceFmt+''+changeHtml+'
'+decisionHtml+onchainHtml+aiInsightHtml+'
'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'
'+sigHtml+'
':'')+'
'; + return '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+cleanDisplayText(levelLabel)+''+actionBadge+''+score+''+st.label+'
$'+priceFmt+''+changeHtml+'
'+decisionHtml+onchainHtml+aiInsightHtml+'
'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'
'+sigHtml+'
':'')+'
'; } catch (e) { console.error('renderRecCard hard fail', r && r.symbol, e); return renderLiveFallbackCard(r); diff --git a/static/base.html b/static/base.html index 4c37328..7014900 100644 --- a/static/base.html +++ b/static/base.html @@ -191,7 +191,7 @@ a { color: inherit; text-decoration: none; } - + -
模拟成交默认使用 20,000U 本金、每笔 5,000U 名义仓位、5x 杠杆、1,000U 保证金,用来验证策略真实交易口径。它不代表真实账户持仓,也不会反写推荐收益。
+
模拟交易只统计已经进入 paper trading 的信号。页面用账户余额、持仓价值、累计杠杆和实际盈亏展示策略表现,不再把观察池或推荐归档当作收益。
状态加载中
交易账本
--
- - + +
币种状态仓位开仓止盈 / 止损最新 / 平仓价格收益账户收益退出原因来源
加载中...
币种状态仓位开仓止盈 / 止损最新价平仓价平仓时间价格收益账户收益退出原因来源
加载中...
@@ -46,23 +46,25 @@ function money(v){v=Number(v||0);var cls=v>0?'pos':v<0?'neg':'';return '=0?'green':'red',money(d.total_pnl_usdt||0).replace(/<[^>]+>/g,'')), - card('胜率',(d.win_rate||0)+'%','green','已平仓样本') +async function loadSummary(){try{var d=await api('/api/paper-trading/summary?days=30');var totalPnl=Number(d.total_pnl_usdt||0),realized=Number(d.realized_pnl_usdt||0),unrealized=Number(d.open_unrealized_pnl_usdt||0),ret=Number(d.account_total_return_pct||0);$('paperNote').textContent='当前账户余额 = 初始本金 + 已实现收益 + 持仓浮动收益。累计杠杆按当前持仓名义价值 ÷ 当前账户余额计算,用来衡量账户整体暴露。';$('kpis').innerHTML=[ + card('当前账户余额',fmt(d.current_balance_usdt||d.account_equity_usdt||0,2)+'U',totalPnl>=0?'green':'red','初始本金 '+fmt(d.initial_equity_usdt||d.account_equity_usdt||0,0)+'U'), + card('当前持仓价值',fmt(d.open_position_value_usdt||0,0)+'U','blue',(d.open_count||0)+' 个持仓中'), + card('持仓累计杠杆',fmt(d.cumulative_leverage||0,2)+'x','', '占账户余额的名义暴露'), + card('收益率',fmt(ret,2)+'%',ret>=0?'green':'red','按初始本金计算'), + card('收益额',(totalPnl>=0?'+':'')+fmt(totalPnl,2)+'U',totalPnl>=0?'green':'red','已实现 '+fmt(realized,2)+'U · 浮动 '+fmt(unrealized,2)+'U'), + card('胜率',(d.win_rate||0)+'%','green',(d.closed_count||0)+' 个已平仓') ].join('')}catch(e){$('kpis').innerHTML='
状态加载失败
'}} function card(label,value,cls,sub){return '
'+esc(label)+''+esc(value)+''+(sub?''+esc(sub)+'':'')+'
'} -async function loadTrades(nextOffset){offset=Math.max(0,nextOffset||0);$('tradeRows').innerHTML='加载中...';try{var s=$('statusFilter').value;var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+offset+'&status='+encodeURIComponent(s));total=d.total||0;renderTrades(d.items||[]);renderPager()}catch(e){$('tradeRows').innerHTML=''+esc(e.message)+''}} -function renderTrades(items){if(!items.length){$('tradeRows').innerHTML='暂无模拟交易';return}$('tradeRows').innerHTML=items.map(function(x){var st=x.status==='open'?'持仓中':'已平仓';var latest=x.status==='open'?x.current_price:x.exit_price;var pnlUsdt=x.status==='closed'?x.realized_pnl_usdt:x.unrealized_pnl_usdt;return ''+ +async function loadTrades(nextOffset){offset=Math.max(0,nextOffset||0);$('tradeRows').innerHTML='加载中...';try{var s=$('statusFilter').value;var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+offset+'&status='+encodeURIComponent(s));total=d.total||0;renderTrades(d.items||[]);renderPager()}catch(e){$('tradeRows').innerHTML=''+esc(e.message)+''}} +function renderTrades(items){if(!items.length){$('tradeRows').innerHTML='暂无模拟交易';return}$('tradeRows').innerHTML=items.map(function(x){var st=x.status==='open'?'持仓中':'已平仓';var latest=x.latest_price||x.current_price||0;var pnlUsdt=x.status==='closed'?x.realized_pnl_usdt:x.unrealized_pnl_usdt;return ''+ '
'+esc(x.symbol)+'
#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'
'+ ''+st+''+ '
'+fmt(x.notional_usdt,0)+'U
'+fmt(x.leverage,1)+'x · 保证金 '+fmt(x.margin_usdt,0)+'U
'+ '
$'+fmt(x.entry_price,6)+'
'+time(x.opened_at)+'
'+ '
TP $'+fmt(x.tp1,6)+'SL $'+fmt(x.stop_loss,6)+'
'+ - '
$'+fmt(latest,6)+'
'+(x.closed_at?time(x.closed_at):'最新')+'
'+ + '
$'+fmt(latest,6)+'
'+(x.latest_price_updated_at?time(x.latest_price_updated_at):'最新')+'
'+ + '
'+(x.status==='closed'?'$'+fmt(x.exit_price,6):'--')+'
'+(x.status==='closed'?'实际退出':'未平仓')+'
'+ + '
'+(x.closed_at?time(x.closed_at):'--')+'
'+ ''+pct(x.status==='closed'?x.realized_pnl_pct:x.pnl_pct)+''+ '
'+money(pnlUsdt)+'
账户 '+(x.account_return_pct>0?'+':'')+fmt(x.account_return_pct,2)+'% · 保证金 '+(x.margin_roi_pct>0?'+':'')+fmt(x.margin_roi_pct,2)+'%
'+ ''+esc(x.exit_reason||'--')+''+ diff --git a/tests/test_opportunity_level.py b/tests/test_opportunity_level.py new file mode 100644 index 0000000..c2c26c2 --- /dev/null +++ b/tests/test_opportunity_level.py @@ -0,0 +1,146 @@ +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from app.core.opportunity_level import ( + attach_opportunity_level, + classify_opportunity_level, + level_tp_parameters, + select_level_stop_loss, +) +from app.core.opportunity_lifecycle import apply_entry_quality_gate +from app.db.altcoin_db import create_recommendation, get_conn, init_db + + +def test_intraday_breakout_requires_current_low_timeframe_trigger(): + meta = classify_opportunity_level( + signals=["1H 量价齐飞K(量3.2x)", "🟢 15min即刻入场信号"], + entry_plan={"entry_action": "即刻买入"}, + m30_aligned=True, + ) + + assert meta["opportunity_level"] == "intraday_breakout" + assert meta["label"] == "日内启动" + assert meta["max_action"] == "buy_now" + + +def test_short_swing_uses_mid_timeframe_confirmation(): + meta = classify_opportunity_level( + signals=["1H 量价齐飞K(量3.0x)", "30min 4阳动K(与1H共振)", "4H需求区反弹"], + entry_plan={"entry_action": "等回踩"}, + m30_aligned=True, + ) + + assert meta["opportunity_level"] == "short_swing" + assert meta["holding_horizon"] == "1-3天" + + +def test_higher_timeframe_background_stays_structure_watch(): + meta = classify_opportunity_level( + signals=["日线 底部缩量(0.6x)", "日线 晨星反转", "1H历史放量阳线已过期(10小时前)"], + entry_plan={"entry_action": "等回踩"}, + ) + + assert meta["opportunity_level"] == "structure_watch" + assert meta["max_action"] == "wait_pullback" + + +def test_theme_without_price_trigger_is_research_trend(): + meta = classify_opportunity_level( + signals=["生态主题扩散", "舆情催化"], + entry_plan={"entry_action": "观察"}, + sector_context={"hot_sectors": ["AI"]}, + ) + + assert meta["opportunity_level"] == "theme_trend" + assert meta["max_action"] == "observe" + + +def test_level_stop_and_tp_models_are_different(): + stops = [90, 94, 96] + intraday_stop, _ = select_level_stop_loss(level="intraday_breakout", price=100, entry_price=100, stop_candidates=stops) + structure_stop, _ = select_level_stop_loss(level="structure_watch", price=100, entry_price=100, stop_candidates=stops) + + assert intraday_stop == 96 + assert structure_stop == 90 + assert level_tp_parameters("intraday_breakout")["tp1_floor"] < level_tp_parameters("structure_watch")["tp1_floor"] + + +def test_quality_gate_caps_structure_watch_without_current_trigger(): + meta = classify_opportunity_level( + signals=["日线 需求区反弹", "4H静K蓄力观察(4静K)"], + entry_plan={"entry_action": "即刻买入"}, + ) + plan = attach_opportunity_level( + { + "entry_action": "即刻买入", + "entry_price": 1.0, + "stop_loss": 0.92, + "tp1": 1.16, + "risk_reward_ok": True, + "rr1": 2.0, + }, + meta, + ) + + action, gated_plan, reasons = apply_entry_quality_gate( + action_status="可即刻买入", + entry_plan=plan, + signals=["日线 需求区反弹", "4H静K蓄力观察(4静K)"], + current_price=1.0, + market_context={"change_24h": 2.0}, + ) + + assert action != "可即刻买入" + assert gated_plan["opportunity_level"] == "structure_watch" + assert any("结构观察" in reason for reason in reasons) + + +def test_create_recommendation_persists_opportunity_level_fields(): + init_db() + plan = attach_opportunity_level( + { + "entry_action": "即刻买入", + "entry_price": 1.0, + "stop_loss": 0.95, + "tp1": 1.08, + "tp2": 1.12, + "risk_reward_ok": True, + "rr1": 1.6, + "entry_trigger_confirmed": True, + }, + classify_opportunity_level( + signals=["1H 量价齐飞K(量3.2x)", "🟢 15min即刻入场信号"], + entry_plan={"entry_action": "即刻买入"}, + m30_aligned=True, + ), + ) + + rec_id = create_recommendation( + symbol="TEST/USDT", + rec_state="爆发", + rec_score=18, + entry_price=plan["entry_price"], + stop_loss=plan["stop_loss"], + tp1=plan["tp1"], + tp2=plan["tp2"], + signals=["1H 量价齐飞K(量3.2x)", "🟢 15min即刻入场信号"], + entry_plan=plan, + ) + + conn = get_conn() + try: + row = conn.execute( + "SELECT opportunity_level, opportunity_level_label, holding_horizon, entry_plan_json FROM recommendation WHERE id=%s", + (rec_id,), + ).fetchone() + finally: + conn.close() + + stored_plan = json.loads(row["entry_plan_json"]) + assert row["opportunity_level"] == "intraday_breakout" + assert row["opportunity_level_label"] == "日内启动" + assert row["holding_horizon"] == "数小时-1天" + assert stored_plan["entry_model"] == "15m触发 / 1H突破延续" diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py index f8dcedd..3508b3c 100644 --- a/tests/test_paper_trading.py +++ b/tests/test_paper_trading.py @@ -81,6 +81,10 @@ def test_default_paper_trade_uses_5000u_notional_5x_and_1000u_margin(monkeypatch assert trade["leverage"] == pytest.approx(5.0) assert trade["margin_usdt"] == pytest.approx(1000.0) assert summary["account_equity_usdt"] == pytest.approx(20000.0) + assert summary["initial_equity_usdt"] == pytest.approx(20000.0) + assert summary["current_balance_usdt"] == pytest.approx(20000.0) + assert summary["open_position_value_usdt"] == pytest.approx(5000.0) + assert summary["cumulative_leverage"] == pytest.approx(0.25) assert summary["notional_usdt"] == pytest.approx(5000.0) assert summary["margin_usdt"] == pytest.approx(1000.0) @@ -134,6 +138,20 @@ def test_open_paper_trade_closes_on_tp1_and_summary_counts_win(buy_now_rec): assert summary["win_rate"] == pytest.approx(100.0) +def test_closed_paper_trade_keeps_exit_price_and_shows_latest_market_price(buy_now_rec): + sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00") + sync_recommendation(buy_now_rec, 106, event_time="2026-05-16T10:05:00") + altcoin_db.update_latest_price_cache("PAPER/USDT", 103.25, updated_at="2026-05-16T10:10:00", source="unit") + + trade = list_paper_trades()["items"][0] + + assert trade["status"] == "closed" + assert trade["exit_price"] == pytest.approx(106.0) + assert trade["current_price"] == pytest.approx(106.0) + assert trade["latest_price"] == pytest.approx(103.25) + assert trade["latest_price_updated_at"] == "2026-05-16T10:10:00" + + def test_disabled_paper_trading_skips_without_writing(monkeypatch, buy_now_rec): monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "0") diff --git a/tests/test_screener_optimizations.py b/tests/test_screener_optimizations.py index da9df1e..41cd670 100644 --- a/tests/test_screener_optimizations.py +++ b/tests/test_screener_optimizations.py @@ -16,6 +16,8 @@ def test_fetch_all_tickers_filters_stable_and_fiat_suffixes(monkeypatch): "fetch_tickers", lambda: { "BTC/USDT": {"last": 1, "percentage": 1, "quoteVolume": 100}, + "ETH/USDT": {"last": 2, "percentage": 2, "quoteVolume": 200}, + "BNB/USDT": {"last": 3, "percentage": 3, "quoteVolume": 300}, "RLUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "BFUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "EUR/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, @@ -36,6 +38,9 @@ def test_fetch_all_tickers_filters_stable_and_fiat_suffixes(monkeypatch): pairs = altcoin_screener.fetch_all_tickers() assert "AI/USDT" in pairs + assert "BTC/USDT" in pairs + assert "ETH/USDT" in pairs + assert "BNB/USDT" in pairs assert "RLUSD/USDT" not in pairs assert "BFUSD/USDT" not in pairs assert "EUR/USDT" not in pairs @@ -49,7 +54,6 @@ def test_fetch_all_tickers_filters_stable_and_fiat_suffixes(monkeypatch): assert "USDD/USDT" not in pairs assert "EURS/USDT" not in pairs assert "AUD/USDT" not in pairs - assert "BTC/USDT" not in pairs def _mock_weights():