1
This commit is contained in:
parent
567b5b7268
commit
5e863e6d2a
3
.gitignore
vendored
3
.gitignore
vendored
@ -21,6 +21,9 @@ venv/
|
|||||||
env/
|
env/
|
||||||
ENV/
|
ENV/
|
||||||
|
|
||||||
|
# Report
|
||||||
|
reports/
|
||||||
|
|
||||||
# Local environment files
|
# Local environment files
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@ -28,9 +28,7 @@ from app.config import config_loader
|
|||||||
from app.services.altcoin_screener import (
|
from app.services.altcoin_screener import (
|
||||||
STABLECOINS,
|
STABLECOINS,
|
||||||
WRAPPED,
|
WRAPPED,
|
||||||
BTC_ETH,
|
|
||||||
GOLD_METAL,
|
GOLD_METAL,
|
||||||
BNB_CHAIN,
|
|
||||||
EXCLUDED_BASES,
|
EXCLUDED_BASES,
|
||||||
EXCLUDED_BASE_SUFFIXES,
|
EXCLUDED_BASE_SUFFIXES,
|
||||||
)
|
)
|
||||||
@ -53,7 +51,7 @@ def _is_altcoin_usdt_symbol(symbol_str):
|
|||||||
if not symbol_str or not symbol_str.endswith("USDT"):
|
if not symbol_str or not symbol_str.endswith("USDT"):
|
||||||
return False
|
return False
|
||||||
base = symbol_str.replace("USDT", "").upper()
|
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
|
return False
|
||||||
if base in EXCLUDED_BASES or base.endswith(EXCLUDED_BASE_SUFFIXES):
|
if base in EXCLUDED_BASES or base.endswith(EXCLUDED_BASE_SUFFIXES):
|
||||||
return False
|
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"]}
|
return {"reason_code": "stablecoin", "reason_label": UNIVERSE_REASON_LABELS["stablecoin"]}
|
||||||
if base in {"WBTC", "WETH", "RENBTC"}:
|
if base in {"WBTC", "WETH", "RENBTC"}:
|
||||||
return {"reason_code": "wrapped", "reason_label": UNIVERSE_REASON_LABELS["wrapped"]}
|
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"]}
|
return {"reason_code": "excluded_base", "reason_label": UNIVERSE_REASON_LABELS["excluded_base"]}
|
||||||
if not symbol or "/USDT" not in symbol:
|
if not symbol or "/USDT" not in symbol:
|
||||||
return {"reason_code": "invalid_pair", "reason_label": UNIVERSE_REASON_LABELS["invalid_pair"]}
|
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
|
import re
|
||||||
from typing import Any, Dict, Iterable, Tuple
|
from typing import Any, Dict, Iterable, Tuple
|
||||||
|
|
||||||
|
from app.core.opportunity_level import OPPORTUNITY_LEVELS
|
||||||
|
|
||||||
DEFAULT_ENTRY_GATE = {
|
DEFAULT_ENTRY_GATE = {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"min_rr_buy_now": 1.2,
|
"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")
|
risk_reward_ok = live_rr1 >= _cfg_value(cfg, "min_rr_buy_now")
|
||||||
entry_plan["risk_reward_ok_live"] = risk_reward_ok
|
entry_plan["risk_reward_ok_live"] = risk_reward_ok
|
||||||
entry_action = str(entry_plan.get("entry_action") or "").strip()
|
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"):
|
if entry_plan.get("entry_quality_gate"):
|
||||||
entry_plan.pop("entry_quality_gate", None)
|
entry_plan.pop("entry_quality_gate", None)
|
||||||
breakout_distance = detect_breakout_distance_pct(signals)
|
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')},禁止现价买入")
|
reasons.append(f"rr1={rr1} < {_cfg_value(cfg, 'min_rr_buy_now')},禁止现价买入")
|
||||||
|
|
||||||
if action_status == "可即刻买入":
|
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):
|
if not has_current_entry_trigger(signals, entry_plan):
|
||||||
reasons.append("缺少当前15min触发,禁止现价买入")
|
reasons.append("缺少当前15min触发,禁止现价买入")
|
||||||
if has_bearish_flow_risk(signals):
|
if has_bearish_flow_risk(signals):
|
||||||
@ -397,6 +404,13 @@ def apply_entry_quality_gate(
|
|||||||
else:
|
else:
|
||||||
target_action = action_status
|
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:
|
if target_action != action_status:
|
||||||
entry_plan["entry_quality_gate"] = {
|
entry_plan["entry_quality_gate"] = {
|
||||||
"blocked_action": action_status,
|
"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)
|
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,
|
def create_recommendation(symbol, rec_state, rec_score, entry_price,
|
||||||
stop_loss=0, tp1=0, tp2=0, sector="",
|
stop_loss=0, tp1=0, tp2=0, sector="",
|
||||||
signals="", is_meme=0, entry_plan=None, direction="中性",
|
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 {}
|
"active", incoming_action, entry_plan or {}
|
||||||
)
|
)
|
||||||
stored_signals, signal_codes_json, signal_labels_json = _serialized_signal_payload(signals)
|
stored_signals, signal_codes_json, signal_labels_json = _serialized_signal_payload(signals)
|
||||||
|
opportunity_fields = _opportunity_fields_from_plan(entry_plan or {})
|
||||||
# 当前状态唯一:同一 symbol 同一时间只允许一条可执行/观察主记录;
|
# 当前状态唯一:同一 symbol 同一时间只允许一条可执行/观察主记录;
|
||||||
# 但兼容粗筛蓄力→加速/爆发的状态迁移测试:无 entry_plan 的旧粗筛记录仍可新建演化轨迹。
|
# 但兼容粗筛蓄力→加速/爆发的状态迁移测试:无 entry_plan 的旧粗筛记录仍可新建演化轨迹。
|
||||||
duplicate_cursor = conn.execute(
|
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),
|
sector_signal_count=GREATEST(COALESCE(sector_signal_count,0), %s),
|
||||||
entry_plan_json=CASE WHEN %s != '{}' THEN %s ELSE entry_plan_json END,
|
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,
|
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
|
action_status=CASE
|
||||||
WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈','衰减','反转') THEN action_status
|
WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈','衰减','反转') THEN action_status
|
||||||
ELSE COALESCE(NULLIF(%s, ''), 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(market_context or {}, ensure_ascii=False),
|
||||||
json.dumps(derivatives_context or {}, ensure_ascii=False),
|
json.dumps(derivatives_context or {}, ensure_ascii=False),
|
||||||
json.dumps(sector_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_action if entry_plan else "",
|
||||||
incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason,
|
incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason,
|
||||||
existing_id,
|
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,
|
current_price, max_price, min_price, last_track_time, entry_plan_json,
|
||||||
force_reason, base_state, sector_signal_count,
|
force_reason, base_state, sector_signal_count,
|
||||||
market_context_json, derivatives_context_json, sector_context_json,
|
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,
|
action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason,
|
||||||
strategy_version)
|
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
|
RETURNING id
|
||||||
""", (
|
""", (
|
||||||
symbol, now, rec_state, rec_score_pct, entry_price,
|
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(market_context or {}, ensure_ascii=False),
|
||||||
json.dumps(derivatives_context or {}, ensure_ascii=False),
|
json.dumps(derivatives_context or {}, ensure_ascii=False),
|
||||||
json.dumps(sector_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_action,
|
||||||
incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason,
|
incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason,
|
||||||
strategy_version,
|
strategy_version,
|
||||||
@ -957,6 +989,20 @@ def _derive_execution_fields(item):
|
|||||||
item["observe_tier"] = observe_tier
|
item["observe_tier"] = observe_tier
|
||||||
item["observe_reason"] = observe_reason
|
item["observe_reason"] = observe_reason
|
||||||
item["entry_plan"] = entry_plan
|
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
|
item["entry_window"] = entry_window
|
||||||
if entry_window and entry_window.get("status") != "active":
|
if entry_window and entry_window.get("status") != "active":
|
||||||
item["entry_window_alert"] = entry_window
|
item["entry_window_alert"] = entry_window
|
||||||
|
|||||||
@ -81,11 +81,18 @@ CREATE TABLE IF NOT EXISTS recommendation (
|
|||||||
entry_triggered INTEGER DEFAULT 0,
|
entry_triggered INTEGER DEFAULT 0,
|
||||||
archived_at TEXT DEFAULT '',
|
archived_at TEXT DEFAULT '',
|
||||||
signal_codes_json 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_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_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_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 (
|
CREATE TABLE IF NOT EXISTS price_tracking (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
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["margin_roi_pct"] = _margin_roi_pct(effective_pnl, margin)
|
||||||
item["account_return_pct"] = _account_return_pct(effective_pnl)
|
item["account_return_pct"] = _account_return_pct(effective_pnl)
|
||||||
item["account_equity_usdt"] = default_account_equity_usdt()
|
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
|
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)
|
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)
|
total_pnl = round(total_realized + open_unrealized, 4)
|
||||||
allocated_margin = round(sum(_safe_float(x.get("margin_usdt")) for x in open_items), 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 {
|
return {
|
||||||
"days": days,
|
"days": days,
|
||||||
"total": len(items),
|
"total": len(items),
|
||||||
@ -403,12 +410,16 @@ def get_paper_trading_summary(days: int = 30) -> dict:
|
|||||||
"avg_realized_pnl_pct": avg_realized_pct,
|
"avg_realized_pnl_pct": avg_realized_pct,
|
||||||
"open_unrealized_pnl_usdt": open_unrealized,
|
"open_unrealized_pnl_usdt": open_unrealized,
|
||||||
"total_pnl_usdt": total_pnl,
|
"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_realized_return_pct": _account_return_pct(total_realized),
|
||||||
"account_unrealized_return_pct": _account_return_pct(open_unrealized),
|
"account_unrealized_return_pct": _account_return_pct(open_unrealized),
|
||||||
"account_total_return_pct": _account_return_pct(total_pnl),
|
"account_total_return_pct": _account_return_pct(total_pnl),
|
||||||
"allocated_margin_usdt": allocated_margin,
|
"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(),
|
"margin_usdt": default_margin_usdt(),
|
||||||
"leverage": default_leverage(),
|
"leverage": default_leverage(),
|
||||||
"notional_usdt": default_notional_usdt(),
|
"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]
|
total = conn.execute(f"SELECT COUNT(*) FROM paper_trades {where}", tuple(params)).fetchone()[0]
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""
|
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}
|
{where}
|
||||||
ORDER BY opened_at DESC, id DESC
|
ORDER BY pt.opened_at DESC, pt.id DESC
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
""",
|
""",
|
||||||
tuple(params + [limit, offset]),
|
tuple(params + [limit, offset]),
|
||||||
|
|||||||
@ -42,6 +42,12 @@ from app.config.config_loader import (
|
|||||||
get_strategy_params,
|
get_strategy_params,
|
||||||
)
|
)
|
||||||
from app.core.opportunity_lifecycle import apply_entry_quality_gate
|
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.core.opportunity_funnel import build_screening_detail
|
||||||
from app.config.config_loader import _get_section as _get_cfg_section
|
from app.config.config_loader import _get_section as _get_cfg_section
|
||||||
from app.core.pa_engine import (
|
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))
|
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)
|
atr_stop_price = round(float(price * (1 - stop_pct_final)), 6)
|
||||||
|
|
||||||
# 收集所有止损候选 → 取最低价(最宽止损)
|
# 收集所有止损候选,再按机会级别选择紧/中/宽止损。
|
||||||
stop_candidates = [atr_stop_price]
|
stop_candidates = [atr_stop_price]
|
||||||
|
|
||||||
# Q≥5需求区兜底:有结构支撑优先用需求区底部
|
# Q≥5需求区兜底:有结构支撑优先用需求区底部
|
||||||
@ -1096,20 +1102,43 @@ def confirm_burst(symbol, cand):
|
|||||||
stop_candidates.append(swing_stop)
|
stop_candidates.append(swing_stop)
|
||||||
signals.append(f"结构止损(swing_low${swing_low:.4f}×{swing_buffer})")
|
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()
|
atr_multipliers = confirm_atr_multipliers()
|
||||||
tp1_atr_pct = (atr_1h * atr_multipliers.get("tp1", 3.0)) / price
|
level_tp = level_tp_parameters(opportunity_level)
|
||||||
tp1_pct = max(tp1_atr_pct, atr_multipliers.get("tp1_floor", 0.05))
|
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)]
|
tp1_candidates = [round(float(price * (1 + tp1_pct)), 6)]
|
||||||
if high_q_supply:
|
if high_q_supply:
|
||||||
tp1_candidates.append(round(high_q_supply[0]["top"], 6))
|
tp1_candidates.append(round(high_q_supply[0]["top"], 6))
|
||||||
tp1 = min(tp1_candidates)
|
tp1 = min(tp1_candidates)
|
||||||
|
|
||||||
tp2_atr_pct = (atr_1h * atr_multipliers.get("tp2", 5.0)) / price
|
tp2_atr_pct = (atr_1h * level_tp.get("tp2_atr", atr_multipliers.get("tp2", 5.0))) / price
|
||||||
tp2_pct = max(tp2_atr_pct, atr_multipliers.get("tp2_floor", 0.08))
|
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)
|
tp2 = round(float(price * (1 + tp2_pct)), 6)
|
||||||
|
|
||||||
risk = price - stop_loss
|
risk = price - stop_loss
|
||||||
@ -1133,7 +1162,10 @@ def confirm_burst(symbol, cand):
|
|||||||
"pa_15min_summary": pa_15min_result.get("reason", ""),
|
"pa_15min_summary": pa_15min_result.get("reason", ""),
|
||||||
"pa_1h_exhaustion": pa_1h_exhaustion.get("severity", "low"),
|
"pa_1h_exhaustion": pa_1h_exhaustion.get("severity", "low"),
|
||||||
"trailing_stop_level": 0.0, # v1.7.8: tracker动态填充,初始0
|
"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 买点质量闸门:确认强势 ≠ 允许现价追买。
|
# v1.7.5 买点质量闸门:确认强势 ≠ 允许现价追买。
|
||||||
gated_action, gated_plan, gate_reasons = apply_entry_quality_gate(
|
gated_action, gated_plan, gate_reasons = apply_entry_quality_gate(
|
||||||
@ -1286,9 +1318,10 @@ def main(compact: bool = False):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
ep = result["entry_plan"]
|
ep = result["entry_plan"]
|
||||||
|
rec_entry_price = ep.get("entry_price") or result["price"]
|
||||||
rec_id = create_recommendation(
|
rec_id = create_recommendation(
|
||||||
symbol=symbol, rec_state="爆发", rec_score=result["score"],
|
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),
|
stop_loss=ep.get("stop_loss", 0), tp1=ep.get("tp1", 0),
|
||||||
tp2=ep.get("tp2", 0),
|
tp2=ep.get("tp2", 0),
|
||||||
sector=cand_detail.get("sector", cand.get("sector", "")),
|
sector=cand_detail.get("sector", cand.get("sector", "")),
|
||||||
|
|||||||
@ -70,9 +70,8 @@ STABLECOINS = {
|
|||||||
"GUSD", "SUSD", "USDD", "EURS", "EUR", "GBP",
|
"GUSD", "SUSD", "USDD", "EURS", "EUR", "GBP",
|
||||||
}
|
}
|
||||||
WRAPPED = {"WBTC", "WETH", "RENBTC"}
|
WRAPPED = {"WBTC", "WETH", "RENBTC"}
|
||||||
BTC_ETH = {"BTC", "ETH"}
|
|
||||||
GOLD_METAL = {"XAUT", "PAXG"}
|
GOLD_METAL = {"XAUT", "PAXG"}
|
||||||
BNB_CHAIN = {"BNB"}
|
MAJOR_BASES = {"BTC", "ETH", "BNB"}
|
||||||
EXCLUDED_BASE_SUFFIXES = (
|
EXCLUDED_BASE_SUFFIXES = (
|
||||||
"USD", "EUR", "GBP", "TRY", "BRL", "AUD", "FDUSD", "USDC", "USDP", "DAI"
|
"USD", "EUR", "GBP", "TRY", "BRL", "AUD", "FDUSD", "USDC", "USDP", "DAI"
|
||||||
)
|
)
|
||||||
@ -95,7 +94,7 @@ def fetch_all_tickers():
|
|||||||
if "/USDT" in symbol:
|
if "/USDT" in symbol:
|
||||||
base = symbol.split("/")[0]
|
base = symbol.split("/")[0]
|
||||||
vol_usd = info.get("quoteVolume", 0) or 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": "排除基础资产"}
|
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})
|
universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, **reason})
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -35,9 +35,7 @@ from app.services.altcoin_screener import (
|
|||||||
detect_static_accumulation,
|
detect_static_accumulation,
|
||||||
STABLECOINS,
|
STABLECOINS,
|
||||||
WRAPPED,
|
WRAPPED,
|
||||||
BTC_ETH,
|
|
||||||
GOLD_METAL,
|
GOLD_METAL,
|
||||||
BNB_CHAIN,
|
|
||||||
EXCLUDED_BASES,
|
EXCLUDED_BASES,
|
||||||
EXCLUDED_BASE_SUFFIXES,
|
EXCLUDED_BASE_SUFFIXES,
|
||||||
)
|
)
|
||||||
@ -193,7 +191,7 @@ def _symbols_from_text(text, aliases=None):
|
|||||||
|
|
||||||
def _tradable_symbol(symbol):
|
def _tradable_symbol(symbol):
|
||||||
base = symbol.split("/")[0].upper()
|
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
|
return False
|
||||||
if base in EXCLUDED_BASES or base.endswith(EXCLUDED_BASE_SUFFIXES):
|
if base in EXCLUDED_BASES or base.endswith(EXCLUDED_BASE_SUFFIXES):
|
||||||
return False
|
return False
|
||||||
|
|||||||
@ -364,6 +364,7 @@ def track_prices():
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
tracked_count = 0
|
||||||
failed_symbols = []
|
failed_symbols = []
|
||||||
for rec in recs:
|
for rec in recs:
|
||||||
symbol = rec["symbol"]
|
symbol = rec["symbol"]
|
||||||
@ -460,6 +461,7 @@ def track_prices():
|
|||||||
print(f" {symbol}: 入场${rec['entry_price']} → 现在${current_price} "
|
print(f" {symbol}: 入场${rec['entry_price']} → 现在${current_price} "
|
||||||
f"盈亏{tracking_signals['pnl_pct']}% 状态={track_result['status']} "
|
f"盈亏{tracking_signals['pnl_pct']}% 状态={track_result['status']} "
|
||||||
f"操作={tracking_signals['action_status']}")
|
f"操作={tracking_signals['action_status']}")
|
||||||
|
tracked_count += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
failed_symbols.append({"symbol": symbol, "error": str(e)})
|
failed_symbols.append({"symbol": symbol, "error": str(e)})
|
||||||
@ -470,7 +472,7 @@ def track_prices():
|
|||||||
|
|
||||||
output = {
|
output = {
|
||||||
"status": "tracked",
|
"status": "tracked",
|
||||||
"tracked_count": len(results),
|
"tracked_count": tracked_count,
|
||||||
"failed_count": len(failed_symbols),
|
"failed_count": len(failed_symbols),
|
||||||
"failed_symbols": failed_symbols,
|
"failed_symbols": failed_symbols,
|
||||||
"results": results,
|
"results": results,
|
||||||
|
|||||||
@ -101,6 +101,11 @@
|
|||||||
.score-badge.tier-weak { background: var(--surface); color: var(--steel); border: 1px solid var(--hairline); }
|
.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); }
|
.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; }
|
.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 .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); }
|
.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; }
|
.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-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-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 { 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-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; }
|
.ep-sub { color: var(--muted); font-size: 10px; font-weight: 600; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
/* ===== SIGNALS ===== */
|
/* ===== SIGNALS ===== */
|
||||||
@ -624,6 +630,12 @@ function renderRecCard(r) {
|
|||||||
return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'};
|
return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'};
|
||||||
}
|
}
|
||||||
var ep = r.entry_plan || {};
|
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 sigs = Array.isArray(r.signals)?r.signals:[];
|
||||||
var entryMethod = ep.entry_method || '';
|
var entryMethod = ep.entry_method || '';
|
||||||
var signalText = sigs.join(' ');
|
var signalText = sigs.join(' ');
|
||||||
@ -723,11 +735,11 @@ function renderRecCard(r) {
|
|||||||
}
|
}
|
||||||
var entryPlanHtml = '';
|
var entryPlanHtml = '';
|
||||||
if (isTradePlan) {
|
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 {
|
} 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) {
|
} catch (e) {
|
||||||
console.error('renderRecCard hard fail', r && r.symbol, e);
|
console.error('renderRecCard hard fail', r && r.symbol, e);
|
||||||
return renderLiveFallbackCard(r);
|
return renderLiveFallbackCard(r);
|
||||||
|
|||||||
@ -191,7 +191,7 @@ a { color: inherit; text-decoration: none; }
|
|||||||
<div class="sidebar-section-label admin-link" style="display:none">系统</div>
|
<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 == '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 == '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>
|
<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>
|
</nav>
|
||||||
<div class="sidebar-user" onclick="toggleUserMenu()">
|
<div class="sidebar-user" onclick="toggleUserMenu()">
|
||||||
|
|||||||
@ -21,14 +21,14 @@
|
|||||||
<button class="btn" onclick="loadAll()">刷新</button>
|
<button class="btn" onclick="loadAll()">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div class="kpis" id="kpis"><div class="kpi"><span>状态</span><b>加载中</b></div></div>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-head"><div class="panel-title">交易账本</div><div class="panel-note" id="pageInfo">--</div></div>
|
<div class="panel-head"><div class="panel-title">交易账本</div><div class="panel-note" id="pageInfo">--</div></div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table">
|
<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>
|
<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="10" class="loading">加载中...</td></tr></tbody>
|
<tbody id="tradeRows"><tr><td colspan="12" class="loading">加载中...</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="pagination" id="pager"></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')}
|
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 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 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=[
|
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.account_equity_usdt||0,0)+'U','blue','账户收益率基准'),
|
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.notional_usdt||0,0)+'U','','名义仓位'),
|
card('当前持仓价值',fmt(d.open_position_value_usdt||0,0)+'U','blue',(d.open_count||0)+' 个持仓中'),
|
||||||
card('杠杆 / 保证金',fmt(d.leverage||1,1)+'x','',fmt(d.margin_usdt||0,0)+'U 保证金'),
|
card('持仓累计杠杆',fmt(d.cumulative_leverage||0,2)+'x','', '占账户余额的名义暴露'),
|
||||||
card('持仓 / 已平仓',(d.open_count||0)+' / '+(d.closed_count||0),''),
|
card('收益率',fmt(ret,2)+'%',ret>=0?'green':'red','按初始本金计算'),
|
||||||
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('收益额',(totalPnl>=0?'+':'')+fmt(totalPnl,2)+'U',totalPnl>=0?'green':'red','已实现 '+fmt(realized,2)+'U · 浮动 '+fmt(unrealized,2)+'U'),
|
||||||
card('胜率',(d.win_rate||0)+'%','green','已平仓样本')
|
card('胜率',(d.win_rate||0)+'%','green',(d.closed_count||0)+' 个已平仓')
|
||||||
].join('')}catch(e){$('kpis').innerHTML='<div class="kpi"><span>状态</span><b>加载失败</b></div>'}}
|
].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>'}
|
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>'}}
|
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="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>'+
|
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><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><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.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="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="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>'+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><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>'+
|
'<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["leverage"] == pytest.approx(5.0)
|
||||||
assert trade["margin_usdt"] == pytest.approx(1000.0)
|
assert trade["margin_usdt"] == pytest.approx(1000.0)
|
||||||
assert summary["account_equity_usdt"] == pytest.approx(20000.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["notional_usdt"] == pytest.approx(5000.0)
|
||||||
assert summary["margin_usdt"] == pytest.approx(1000.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)
|
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):
|
def test_disabled_paper_trading_skips_without_writing(monkeypatch, buy_now_rec):
|
||||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "0")
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "0")
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,8 @@ def test_fetch_all_tickers_filters_stable_and_fiat_suffixes(monkeypatch):
|
|||||||
"fetch_tickers",
|
"fetch_tickers",
|
||||||
lambda: {
|
lambda: {
|
||||||
"BTC/USDT": {"last": 1, "percentage": 1, "quoteVolume": 100},
|
"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},
|
"RLUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||||
"BFUSD/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},
|
"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()
|
pairs = altcoin_screener.fetch_all_tickers()
|
||||||
|
|
||||||
assert "AI/USDT" in pairs
|
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 "RLUSD/USDT" not in pairs
|
||||||
assert "BFUSD/USDT" not in pairs
|
assert "BFUSD/USDT" not in pairs
|
||||||
assert "EUR/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 "USDD/USDT" not in pairs
|
||||||
assert "EURS/USDT" not in pairs
|
assert "EURS/USDT" not in pairs
|
||||||
assert "AUD/USDT" not in pairs
|
assert "AUD/USDT" not in pairs
|
||||||
assert "BTC/USDT" not in pairs
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_weights():
|
def _mock_weights():
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user