This commit is contained in:
aaron 2026-05-17 23:58:24 +08:00
parent 567b5b7268
commit 5e863e6d2a
19 changed files with 556 additions and 44 deletions

3
.gitignore vendored
View File

@ -21,6 +21,9 @@ venv/
env/
ENV/
# Report
reports/
# Local environment files
.env
.env.*

View File

@ -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

View File

@ -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"]}

View File

@ -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}

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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);

View File

@ -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]),

View File

@ -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", "")),

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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 = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">'+phase.short+'</span><span class="ep-sub">机会所处阶段</span></div><div class="ep-item"><span class="ep-label">'+entryLabel+'</span><span class="ep-val entry-ref">'+fmtP(entryRef)+'</span><span class="ep-sub">触发/计划价</span></div><div class="ep-item"><span class="ep-label">风险边界</span><span class="ep-val risk-line">'+fmtP(riskLine)+'</span><span class="ep-sub">跌破则逻辑失效</span></div><div class="ep-item"><span class="ep-label">上方空间</span><span class="ep-val space-ref">'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+'</span><span class="ep-sub">参考位 '+fmtP(spaceRef)+'</span></div></div>';
entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">机会级别</span><span class="ep-val level-ref">'+cleanDisplayText(levelLabel)+'</span><span class="ep-sub">'+cleanDisplayText(horizon || phase.short)+'</span></div><div class="ep-item"><span class="ep-label">'+entryLabel+'</span><span class="ep-val entry-ref">'+fmtP(entryRef)+'</span><span class="ep-sub">'+cleanDisplayText(entryModel || '触发/计划价')+'</span></div><div class="ep-item"><span class="ep-label">风险边界</span><span class="ep-val risk-line">'+fmtP(riskLine)+'</span><span class="ep-sub">'+cleanDisplayText(stopModel)+'</span></div><div class="ep-item"><span class="ep-label">上方空间</span><span class="ep-val space-ref">'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+'</span><span class="ep-sub">'+cleanDisplayText(tpModel)+' · '+fmtP(spaceRef)+'</span></div></div>';
} else {
entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">'+phase.short+'</span><span class="ep-sub">观察池候选</span></div><div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+fmtP(price)+'</span><span class="ep-sub">不是入场价</span></div><div class="ep-item"><span class="ep-label">确认条件</span><span class="ep-val space-ref">待触发</span><span class="ep-sub">需15m/1H当前信号</span></div><div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未成交易推荐</span></div></div>';
entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">机会级别</span><span class="ep-val level-ref">'+cleanDisplayText(levelLabel)+'</span><span class="ep-sub">'+cleanDisplayText(horizon || '观察池候选')+'</span></div><div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+fmtP(price)+'</span><span class="ep-sub">不是入场价</span></div><div class="ep-item"><span class="ep-label">确认条件</span><span class="ep-val space-ref">待触发</span><span class="ep-sub">'+cleanDisplayText(entryModel || '需15m/1H当前信号')+'</span></div><div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未成交易推荐</span></div></div>';
}
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group">'+actionBadge+'<span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">'+st.label+'</span></span></div></div><div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+decisionHtml+onchainHtml+aiInsightHtml+'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+'<div class="card-footer"><span>'+fmtTime(r.rec_time)+'</span><span class="card-ver">'+ver+'</span>'+(isTpOrSl?'<span class="pnl-block '+pnlCls+'">'+pnlSign+pnl.toFixed(1)+'%</span>':'')+'</div></div>';
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group"><span class="level-badge '+cleanDisplayText(levelKey)+'">'+cleanDisplayText(levelLabel)+'</span>'+actionBadge+'<span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">'+st.label+'</span></span></div></div><div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+decisionHtml+onchainHtml+aiInsightHtml+'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+'<div class="card-footer"><span>'+fmtTime(r.rec_time)+'</span><span class="card-ver">'+ver+'</span>'+(isTpOrSl?'<span class="pnl-block '+pnlCls+'">'+pnlSign+pnl.toFixed(1)+'%</span>':'')+'</div></div>';
} catch (e) {
console.error('renderRecCard hard fail', r && r.symbol, e);
return renderLiveFallbackCard(r);

View File

@ -191,7 +191,7 @@ a { color: inherit; text-decoration: none; }
<div class="sidebar-section-label admin-link" style="display:none">系统</div>
<a class="sidebar-link admin-link {% if active_nav == 'config' %}active{% endif %}" href="/config" style="display:none"><svg class="link-icon"><use href="#svg-config"/></svg>配置中心</a>
<a class="sidebar-link admin-link {% if active_nav == 'cron' %}active{% endif %}" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
<a class="sidebar-link admin-link {% if active_nav == 'system_logs' %}active{% endif %}" href="/system-logs" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>系统日志</a>
<a class="sidebar-link {% if active_nav == 'system_logs' %}active{% endif %} admin-link" href="/system-logs" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>系统日志</a>
<a class="sidebar-link admin-link {% if active_nav == 'admin' %}active{% endif %}" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>用户管理</a>
</nav>
<div class="sidebar-user" onclick="toggleUserMenu()">

View File

@ -21,14 +21,14 @@
<button class="btn" onclick="loadAll()">刷新</button>
</div>
</div>
<div class="note" id="paperNote">模拟成交默认使用 20,000U 本金、每笔 5,000U 名义仓位、5x 杠杆、1,000U 保证金,用来验证策略真实交易口径。它不代表真实账户持仓,也不会反写推荐收益。</div>
<div class="note" id="paperNote">模拟交易只统计已经进入 paper trading 的信号。页面用账户余额、持仓价值、累计杠杆和实际盈亏展示策略表现,不再把观察池或推荐归档当作收益。</div>
<div class="kpis" id="kpis"><div class="kpi"><span>状态</span><b>加载中</b></div></div>
<section class="panel">
<div class="panel-head"><div class="panel-title">交易账本</div><div class="panel-note" id="pageInfo">--</div></div>
<div class="table-wrap">
<table class="table">
<thead><tr><th>币种</th><th>状态</th><th>仓位</th><th>开仓</th><th>止盈 / 止损</th><th>最新 / 平仓</th><th>价格收益</th><th>账户收益</th><th>退出原因</th><th>来源</th></tr></thead>
<tbody id="tradeRows"><tr><td colspan="10" class="loading">加载中...</td></tr></tbody>
<thead><tr><th>币种</th><th>状态</th><th>仓位</th><th>开仓</th><th>止盈 / 止损</th><th>最新</th><th>平仓价</th><th>平仓时间</th><th>价格收益</th><th>账户收益</th><th>退出原因</th><th>来源</th></tr></thead>
<tbody id="tradeRows"><tr><td colspan="12" class="loading">加载中...</td></tr></tbody>
</table>
</div>
<div class="pagination" id="pager"></div>
@ -46,23 +46,25 @@ function money(v){v=Number(v||0);var cls=v>0?'pos':v<0?'neg':'';return '<span cl
function time(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime()))return String(t).slice(0,16).replace('T',' ');return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')}
async function api(url){var r=await fetch(url);var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d}
async function loadAll(){await Promise.all([loadSummary(),loadTrades(offset)])}
async function loadSummary(){try{var d=await api('/api/paper-trading/summary?days=30');$('paperNote').textContent='模拟成交使用 '+fmt(d.account_equity_usdt||20000,0)+'U 本金、每笔 '+fmt(d.notional_usdt||5000,0)+'U 名义仓位、'+fmt(d.leverage||5,1)+'x 杠杆、'+fmt(d.margin_usdt||1000,0)+'U 保证金。账户收益率按已实现/浮动盈亏除以模拟本金计算。';$('kpis').innerHTML=[
card('模拟本金',fmt(d.account_equity_usdt||0,0)+'U','blue','账户收益率基准'),
card('单笔仓位',fmt(d.notional_usdt||0,0)+'U','','名义仓位'),
card('杠杆 / 保证金',fmt(d.leverage||1,1)+'x','',fmt(d.margin_usdt||0,0)+'U 保证金'),
card('持仓 / 已平仓',(d.open_count||0)+' / '+(d.closed_count||0),''),
card('账户总收益率',fmt(d.account_total_return_pct||0,2)+'%',(d.account_total_return_pct||0)>=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='<div class="kpi"><span>状态</span><b>加载失败</b></div>'}}
function card(label,value,cls,sub){return '<div class="kpi"><span>'+esc(label)+'</span><b class="'+esc(cls||'')+'">'+esc(value)+'</b>'+(sub?'<small>'+esc(sub)+'</small>':'')+'</div>'}
async function loadTrades(nextOffset){offset=Math.max(0,nextOffset||0);$('tradeRows').innerHTML='<tr><td colspan="10" class="loading">加载中...</td></tr>';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='<tr><td colspan="10" class="empty">'+esc(e.message)+'</td></tr>'}}
function renderTrades(items){if(!items.length){$('tradeRows').innerHTML='<tr><td colspan="10" class="empty">暂无模拟交易</td></tr>';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 '<tr>'+
async function loadTrades(nextOffset){offset=Math.max(0,nextOffset||0);$('tradeRows').innerHTML='<tr><td colspan="12" class="loading">加载中...</td></tr>';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='<tr><td colspan="12" class="empty">'+esc(e.message)+'</td></tr>'}}
function renderTrades(items){if(!items.length){$('tradeRows').innerHTML='<tr><td colspan="12" class="empty">暂无模拟交易</td></tr>';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 '<tr>'+
'<td><div class="sym">'+esc(x.symbol)+'</div><div class="muted">#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'</div></td>'+
'<td><span class="badge '+esc(x.status)+'">'+st+'</span></td>'+
'<td><div class="mono">'+fmt(x.notional_usdt,0)+'U</div><div class="muted">'+fmt(x.leverage,1)+'x · 保证金 '+fmt(x.margin_usdt,0)+'U</div></td>'+
'<td><div class="mono">$'+fmt(x.entry_price,6)+'</div><div class="muted">'+time(x.opened_at)+'</div></td>'+
'<td><div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span></div></td>'+
'<td><div class="mono">$'+fmt(latest,6)+'</div><div class="muted">'+(x.closed_at?time(x.closed_at):'最新')+'</div></td>'+
'<td><div class="mono">$'+fmt(latest,6)+'</div><div class="muted">'+(x.latest_price_updated_at?time(x.latest_price_updated_at):'最新')+'</div></td>'+
'<td><div class="mono">'+(x.status==='closed'?'$'+fmt(x.exit_price,6):'--')+'</div><div class="muted">'+(x.status==='closed'?'实际退出':'未平仓')+'</div></td>'+
'<td><div class="mono">'+(x.closed_at?time(x.closed_at):'--')+'</div></td>'+
'<td>'+pct(x.status==='closed'?x.realized_pnl_pct:x.pnl_pct)+'</td>'+
'<td><div>'+money(pnlUsdt)+'</div><div class="muted">账户 '+(x.account_return_pct>0?'+':'')+fmt(x.account_return_pct,2)+'% · 保证金 '+(x.margin_roi_pct>0?'+':'')+fmt(x.margin_roi_pct,2)+'%</div></td>'+
'<td>'+esc(x.exit_reason||'--')+'</td>'+

View File

@ -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突破延续"

View File

@ -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")

View File

@ -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():