1
This commit is contained in:
parent
567b5b7268
commit
5e863e6d2a
3
.gitignore
vendored
3
.gitignore
vendored
@ -21,6 +21,9 @@ venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Report
|
||||
reports/
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]}
|
||||
|
||||
207
app/core/opportunity_level.py
Normal file
207
app/core/opportunity_level.py
Normal 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}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
10
app/db/migrations/0008_opportunity_level.sql
Normal file
10
app/db/migrations/0008_opportunity_level.sql
Normal 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);
|
||||
@ -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]),
|
||||
|
||||
@ -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", "")),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()">
|
||||
|
||||
@ -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>'+
|
||||
|
||||
146
tests/test_opportunity_level.py
Normal file
146
tests/test_opportunity_level.py
Normal 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突破延续"
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
Loading…
Reference in New Issue
Block a user