update
This commit is contained in:
parent
ecb4076dfe
commit
3a6aeedef0
@ -74,6 +74,12 @@ ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT=3
|
||||
ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES=3
|
||||
ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS=6
|
||||
ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT=1
|
||||
ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED=1
|
||||
ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL=1
|
||||
ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE=70
|
||||
ALPHAX_PAPER_GLOBAL_RISK_HIGH_DRAWDOWN_PCT=3
|
||||
ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_DRAWDOWN_PCT=6
|
||||
ALPHAX_PAPER_GLOBAL_RISK_MAX_OPEN_POSITIONS=0
|
||||
ALPHAX_PAPER_ORDER_MIN_REC_SCORE=50
|
||||
ALPHAX_PAPER_ORDER_MIN_RR=1.8
|
||||
ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK=1
|
||||
|
||||
@ -106,6 +106,10 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
- 稳定因子代码来自 `app/core/signal_taxonomy.py`,例如 `vp_fly_1h_current`、`volume_consecutive_1h`、`ignition_d1_current`、`sector_rotation`、`sentiment_resonance`、`top_trader_long`、`risk_reward_bad`。
|
||||
- `signal_performance` 是复盘后动态权重来源;`review_engine.py` 更新信号绩效后,`config_loader.get_signal_weights()` 会让下一轮筛选/确认读取生效权重。
|
||||
- 当前确认层已把核心技术因子、资金面因子、板块因子、舆情因子和买点风险因子接入 `FactorScorer`,并在 `market_context.factor_score_breakdown` / `entry_plan.factor_score_breakdown` 中保留因子明细。
|
||||
- `FactorScorer` 已加入因子组去相关,同一类 `momentum` / `structure` / `entry_quality` / `onchain_flow` / `narrative` 信号会受 group cap 限制,避免同一根行情被重复加分。
|
||||
- 扣分因子应传负数,例如 `FactorScorer.delta("false_breakout", -5, ...)`,不要再外部 `score -= delta`,否则 `factor_score_breakdown` 会把风险误记成正向贡献。
|
||||
- 确认层会输出 `score_components`:`opportunity_score` 表示机会质量,`entry_score` 表示买点质量,`risk_score` 表示扣分风险;后续策略不要再只看单一 `rec_score`。
|
||||
- `market_context.decision_log` / `entry_plan.decision_log` 是结构化决策解释;paper trading 开仓事件也会记录当时 `market_regime`、`global_risk` 和 `score_components`。
|
||||
- NodeReal 链上因子通过 `app/db/onchain_db.py#get_onchain_factor_context()` 进入确认层,正向事件如 `whale_accumulation`、`smart_money_buying`、`exchange_outflow` 会加分,风险事件如 `exchange_inflow_risk`、`liquidity_remove_risk`、`holder_concentration_risk` 会扣分;这些因子同样受 `signal_performance.weight` 复盘权重约束。
|
||||
- 后续新增链上、资金、事件、舆情等非 K 线因子时,必须给出稳定 `factor_code`、默认基准权重、证据字段和复盘归因口径,避免只做展示标签而不参与策略进化。
|
||||
|
||||
@ -154,6 +158,10 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
- 机会级别、持有周期、入场/止损/止盈模型等结构化口径。
|
||||
- `app/core/signal_taxonomy.py`
|
||||
- 信号分类与信号语义口径。
|
||||
- `app/core/market_regime.py`
|
||||
- 市场环境识别中心,第一版基于市场快照、BTC/ETH 涨跌、山寨涨跌广度、强势/大跌数量和 funding 热度识别 `risk_off`、`btc_main_uptrend`、`altcoin_rotation`、`sideways_chop`、`meme_frenzy`、`unknown`。
|
||||
- `app/core/global_risk.py`
|
||||
- paper trading 全局风控门禁。单币机会进入开仓或挂单成交前,需要先检查市场环境和账户风险;critical 禁止新开仓,high 只允许高质量机会。
|
||||
|
||||
## 5. 数据与状态中心
|
||||
|
||||
@ -266,6 +274,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
- 后端读取的 HTML 模板资源
|
||||
- `/docs`
|
||||
- 项目结构、迁移、专题审计、参考 schema 等文档
|
||||
- `OPTIMIZATION_TODO.md` 记录筛选、风控、因子、复盘进化的后续优化路线;继续做策略优化前应先阅读并更新。
|
||||
- `/data`
|
||||
- 本地挂载数据目录,主要用于历史导入源或运行产物,不是 PostgreSQL 主存储
|
||||
- `/logs`
|
||||
|
||||
@ -126,6 +126,12 @@ def default_paper_trading_config():
|
||||
"pause_after_weak_entries": _env_int("ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES", 3),
|
||||
"weak_entry_window_hours": _env_float("ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS", 6.0),
|
||||
"weak_entry_min_max_pnl_pct": _env_float("ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT", 1.0),
|
||||
"global_risk_gate_enabled": _env_bool("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", True),
|
||||
"global_risk_block_critical": _env_bool("ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL", True),
|
||||
"global_risk_high_min_rec_score": _env_float("ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE", 70.0),
|
||||
"global_risk_high_drawdown_pct": _env_float("ALPHAX_PAPER_GLOBAL_RISK_HIGH_DRAWDOWN_PCT", 3.0),
|
||||
"global_risk_critical_drawdown_pct": _env_float("ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_DRAWDOWN_PCT", 6.0),
|
||||
"global_risk_max_open_positions": _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_OPEN_POSITIONS", 0),
|
||||
"order_min_rec_score": _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 50.0),
|
||||
"order_min_rr": _env_float("ALPHAX_PAPER_ORDER_MIN_RR", 1.8),
|
||||
"order_require_risk_reward_ok": _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK", True),
|
||||
|
||||
@ -51,6 +51,54 @@ DEFAULT_FACTOR_WEIGHTS = {
|
||||
"risk_reward_bad": 2.0,
|
||||
}
|
||||
|
||||
FACTOR_GROUPS = {
|
||||
"vp_fly_1h_current": "momentum",
|
||||
"volume_consecutive_1h": "participation",
|
||||
"cex_top_gainer_24h": "momentum",
|
||||
"volume_divergence_1h": "risk",
|
||||
"static_accum_4h": "structure",
|
||||
"higher_lows_4h": "structure",
|
||||
"compression_surge_4h": "structure",
|
||||
"ignition_1h_current": "momentum",
|
||||
"ignition_4h_current": "momentum",
|
||||
"ignition_d1_current": "momentum",
|
||||
"dynamic_k_1h_bull": "momentum",
|
||||
"dynamic_k_d1_bull": "momentum",
|
||||
"breakout_pullback_d1": "structure",
|
||||
"breakout_pullback_w1": "structure",
|
||||
"breakout_15m_current": "entry_quality",
|
||||
"pullback_15m_confirm": "entry_quality",
|
||||
"strong_resonance_bypass": "structure",
|
||||
"entry_quality_gate": "entry_quality",
|
||||
"top_trader_long": "positioning",
|
||||
"sector_rotation": "narrative",
|
||||
"sentiment_resonance": "narrative",
|
||||
"dex_volume_spike": "onchain_flow",
|
||||
"liquidity_add": "onchain_flow",
|
||||
"exchange_outflow": "onchain_flow",
|
||||
"whale_accumulation": "onchain_flow",
|
||||
"smart_money_buying": "onchain_flow",
|
||||
"liquidity_remove_risk": "risk",
|
||||
"exchange_inflow_risk": "risk",
|
||||
"holder_concentration_risk": "risk",
|
||||
"funding_extreme": "risk",
|
||||
"trend_exhaustion": "risk",
|
||||
"false_breakout": "risk",
|
||||
"high_position_reject": "risk",
|
||||
"risk_reward_bad": "risk",
|
||||
}
|
||||
|
||||
GROUP_CAPS = {
|
||||
"momentum": 16.0,
|
||||
"participation": 6.0,
|
||||
"structure": 16.0,
|
||||
"positioning": 4.0,
|
||||
"narrative": 5.0,
|
||||
"onchain_flow": 6.0,
|
||||
"entry_quality": 7.0,
|
||||
"risk": 12.0,
|
||||
}
|
||||
|
||||
WEIGHT_ALIASES = {
|
||||
"vp_fly_1h_current": ("量价齐飞", "1H当前量价齐飞"),
|
||||
"volume_consecutive_1h": ("连续3x放量", "连续3x放量(≥3根)", "1H连续放量"),
|
||||
@ -99,6 +147,7 @@ class FactorScorer:
|
||||
|
||||
weights: dict[str, Any] = field(default_factory=dict)
|
||||
breakdown: list[dict[str, Any]] = field(default_factory=list)
|
||||
group_totals: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_runtime(cls) -> "FactorScorer":
|
||||
@ -118,6 +167,17 @@ class FactorScorer:
|
||||
return max(0.0, _safe_float(value))
|
||||
return None
|
||||
|
||||
def _apply_group_cap(self, code: str, adjusted: float) -> tuple[float, str, float, float]:
|
||||
group = FACTOR_GROUPS.get(code, "other")
|
||||
cap = _safe_float(GROUP_CAPS.get(group), 99.0)
|
||||
if cap <= 0:
|
||||
return adjusted, group, cap, 0.0
|
||||
used = abs(_safe_float(self.group_totals.get(group)))
|
||||
remaining = max(0.0, cap - used)
|
||||
capped = max(-remaining, min(remaining, adjusted))
|
||||
self.group_totals[group] = round(_safe_float(self.group_totals.get(group)) + capped, 6)
|
||||
return capped, group, cap, remaining
|
||||
|
||||
def delta(self, code: str, base: float, *, evidence: str = "", value: Any = None) -> float:
|
||||
"""Return the review-aware score delta for one factor.
|
||||
|
||||
@ -137,16 +197,20 @@ class FactorScorer:
|
||||
multiplier = _clamp(runtime_weight / baseline, 0.0, 1.8)
|
||||
adjusted = base * multiplier
|
||||
source = "review_weight"
|
||||
adjusted, group, group_cap, group_remaining = self._apply_group_cap(code, adjusted)
|
||||
adjusted = round(adjusted, 3)
|
||||
self.breakdown.append(
|
||||
{
|
||||
"factor_code": code,
|
||||
"factor_name": signal_label_for_code(code),
|
||||
"factor_group": group,
|
||||
"base_delta": base,
|
||||
"score_delta": adjusted,
|
||||
"runtime_weight": runtime_weight,
|
||||
"baseline_weight": baseline,
|
||||
"multiplier": round(multiplier, 4),
|
||||
"group_cap": group_cap,
|
||||
"group_remaining_before": round(group_remaining, 3),
|
||||
"source": source,
|
||||
"evidence": evidence,
|
||||
"value": value,
|
||||
@ -162,4 +226,22 @@ class FactorScorer:
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
total = round(sum(_safe_float(item.get("score_delta")) for item in self.breakdown), 3)
|
||||
return {"total_delta": total, "items": self.breakdown}
|
||||
groups = {}
|
||||
for item in self.breakdown:
|
||||
group = item.get("factor_group") or "other"
|
||||
bucket = groups.setdefault(group, {"score_delta": 0.0, "items": 0})
|
||||
bucket["score_delta"] = round(bucket["score_delta"] + _safe_float(item.get("score_delta")), 3)
|
||||
bucket["items"] += 1
|
||||
opportunity_groups = {"momentum", "participation", "structure", "positioning", "narrative", "onchain_flow"}
|
||||
opportunity_score = round(sum(_safe_float(v.get("score_delta")) for k, v in groups.items() if k in opportunity_groups), 3)
|
||||
entry_score = round(_safe_float(groups.get("entry_quality", {}).get("score_delta")), 3)
|
||||
risk_score = round(abs(min(0.0, _safe_float(groups.get("risk", {}).get("score_delta")))), 3)
|
||||
return {
|
||||
"total_delta": total,
|
||||
"opportunity_score": opportunity_score,
|
||||
"entry_score": entry_score,
|
||||
"risk_score": risk_score,
|
||||
"groups": groups,
|
||||
"group_caps": GROUP_CAPS,
|
||||
"items": self.breakdown,
|
||||
}
|
||||
|
||||
118
app/core/global_risk.py
Normal file
118
app/core/global_risk.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""Global risk gate for paper trading entries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.market_regime import classify_market_regime
|
||||
from app.services.market_overview import get_crypto_market_overview
|
||||
|
||||
|
||||
def _safe_float(value, default: float = 0.0) -> float:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return float(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _safe_int(value, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _portfolio_snapshot(conn, account_equity: float, additional_notional: float) -> dict:
|
||||
open_rows = conn.execute("SELECT notional_usdt, pnl_pct FROM paper_trades WHERE status='open'").fetchall()
|
||||
pending_notional = _safe_float(conn.execute("SELECT COALESCE(SUM(notional_usdt),0) FROM paper_orders WHERE status='pending'").fetchone()[0])
|
||||
open_notional = 0.0
|
||||
unrealized = 0.0
|
||||
for row in open_rows:
|
||||
notional = _safe_float(row["notional_usdt"])
|
||||
open_notional += notional
|
||||
unrealized += notional * _safe_float(row["pnl_pct"]) / 100
|
||||
projected_notional = open_notional + pending_notional + max(0.0, _safe_float(additional_notional))
|
||||
current_equity = account_equity + unrealized
|
||||
return {
|
||||
"open_count": len(open_rows),
|
||||
"open_notional_usdt": round(open_notional, 8),
|
||||
"pending_notional_usdt": round(pending_notional, 8),
|
||||
"additional_notional_usdt": round(max(0.0, _safe_float(additional_notional)), 8),
|
||||
"projected_notional_usdt": round(projected_notional, 8),
|
||||
"unrealized_pnl_usdt": round(unrealized, 8),
|
||||
"current_equity_usdt": round(current_equity, 8),
|
||||
"unrealized_drawdown_pct": round(abs(min(0.0, unrealized)) / account_equity * 100, 6) if account_equity > 0 else 0,
|
||||
"projected_cumulative_leverage": round(projected_notional / max(1.0, current_equity), 6),
|
||||
}
|
||||
|
||||
|
||||
def evaluate_global_risk(
|
||||
*,
|
||||
conn,
|
||||
config: dict,
|
||||
rec: dict | None = None,
|
||||
additional_notional: float = 0.0,
|
||||
overview: dict | None = None,
|
||||
) -> dict:
|
||||
"""Evaluate whether the system should allow a new paper-trading entry."""
|
||||
cfg = config if isinstance(config, dict) else {}
|
||||
if not bool(cfg.get("global_risk_gate_enabled", True)):
|
||||
return {
|
||||
"enabled": False,
|
||||
"allow_new_entries": True,
|
||||
"risk_level": "disabled",
|
||||
"reasons": ["全局风控门禁已关闭"],
|
||||
}
|
||||
|
||||
if overview is None:
|
||||
overview = get_crypto_market_overview(allow_live_fallback=False)
|
||||
regime = classify_market_regime(overview)
|
||||
account_equity = max(1.0, _safe_float(cfg.get("account_equity_usdt"), 20000.0))
|
||||
portfolio = _portfolio_snapshot(conn, account_equity, additional_notional)
|
||||
rec_score = _safe_float((rec or {}).get("rec_score") or (rec or {}).get("score"))
|
||||
min_score_high = max(0.0, _safe_float(cfg.get("global_risk_high_min_rec_score"), 70.0))
|
||||
max_drawdown_critical = max(0.0, _safe_float(cfg.get("global_risk_critical_drawdown_pct"), 6.0))
|
||||
max_drawdown_high = max(0.0, _safe_float(cfg.get("global_risk_high_drawdown_pct"), 3.0))
|
||||
reasons = list(regime.get("reasons") or [])
|
||||
risk_level = str(regime.get("risk_level") or "medium")
|
||||
allow = True
|
||||
decision = "allow"
|
||||
|
||||
drawdown = _safe_float(portfolio.get("unrealized_drawdown_pct"))
|
||||
if max_drawdown_critical > 0 and drawdown >= max_drawdown_critical:
|
||||
risk_level = "critical"
|
||||
reasons.append("账户浮亏已进入 critical 区间,暂停所有新开仓")
|
||||
elif max_drawdown_high > 0 and drawdown >= max_drawdown_high and risk_level not in {"critical"}:
|
||||
risk_level = "high"
|
||||
reasons.append("账户浮亏偏高,只允许高质量机会")
|
||||
|
||||
if risk_level == "critical" and bool(cfg.get("global_risk_block_critical", True)):
|
||||
allow = False
|
||||
decision = "block_critical"
|
||||
elif risk_level == "high" and rec_score < min_score_high:
|
||||
allow = False
|
||||
decision = "block_high_weak_score"
|
||||
reasons.append(f"高风险环境下推荐分 {rec_score:.1f} 低于 {min_score_high:.1f}")
|
||||
|
||||
max_open_positions = max(0, _safe_int(cfg.get("global_risk_max_open_positions"), 0))
|
||||
if allow and max_open_positions > 0 and int(portfolio.get("open_count") or 0) >= max_open_positions:
|
||||
allow = False
|
||||
decision = "block_max_open_positions"
|
||||
risk_level = "high" if risk_level not in {"critical"} else risk_level
|
||||
reasons.append(f"持仓数量已达到上限 {max_open_positions}")
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"allow_new_entries": allow,
|
||||
"decision": decision,
|
||||
"risk_level": risk_level,
|
||||
"position_multiplier": regime.get("position_multiplier", 1.0),
|
||||
"max_open_positions": max_open_positions,
|
||||
"min_score_when_high_risk": min_score_high,
|
||||
"reasons": reasons,
|
||||
"market_regime": regime,
|
||||
"portfolio": portfolio,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["evaluate_global_risk"]
|
||||
137
app/core/market_regime.py
Normal file
137
app/core/market_regime.py
Normal file
@ -0,0 +1,137 @@
|
||||
"""Market regime classification for strategy risk controls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _safe_float(value, default: float = 0.0) -> float:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return float(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _benchmark_change(overview: dict, symbol: str) -> float:
|
||||
benchmarks = overview.get("benchmarks") if isinstance(overview, dict) else {}
|
||||
item = benchmarks.get(symbol) if isinstance(benchmarks, dict) else {}
|
||||
return _safe_float((item or {}).get("change_24h"))
|
||||
|
||||
|
||||
def classify_market_regime(overview: dict | None) -> dict:
|
||||
"""Return a plain-language market regime from the latest market snapshot.
|
||||
|
||||
The first version intentionally uses broad market facts that already exist
|
||||
in `market_overview`: BTC/ETH 24h change, alt breadth, hot/crash counts and
|
||||
funding heat. It is meant to be a guardrail, not a prediction model.
|
||||
"""
|
||||
data = overview if isinstance(overview, dict) else {}
|
||||
if not data or data.get("snapshot_missing") or _safe_float(data.get("sample_count")) <= 0:
|
||||
return {
|
||||
"regime": "unknown",
|
||||
"label": "数据不足",
|
||||
"confidence": 0.2,
|
||||
"risk_level": "medium",
|
||||
"position_multiplier": 0.75,
|
||||
"reasons": ["市场快照不足,保守运行但不直接停止交易"],
|
||||
"metrics": {},
|
||||
}
|
||||
|
||||
btc_change = _benchmark_change(data, "BTC/USDT")
|
||||
eth_change = _benchmark_change(data, "ETH/USDT")
|
||||
adv_dec = _safe_float(data.get("advance_decline_ratio"))
|
||||
avg_change = _safe_float(data.get("avg_change_24h"))
|
||||
hot_count = int(_safe_float(data.get("hot_count_5pct")))
|
||||
crash_count = int(_safe_float(data.get("crash_count_5pct")))
|
||||
funding = data.get("funding") if isinstance(data.get("funding"), dict) else {}
|
||||
extreme_positive = int(_safe_float(funding.get("extreme_positive_count")))
|
||||
avg_funding = _safe_float(funding.get("avg_funding_rate"))
|
||||
metrics = {
|
||||
"btc_change_24h": btc_change,
|
||||
"eth_change_24h": eth_change,
|
||||
"advance_decline_ratio": adv_dec,
|
||||
"avg_change_24h": avg_change,
|
||||
"hot_count_5pct": hot_count,
|
||||
"crash_count_5pct": crash_count,
|
||||
"extreme_positive_funding_count": extreme_positive,
|
||||
"avg_funding_rate": avg_funding,
|
||||
}
|
||||
|
||||
if btc_change <= -3 or eth_change <= -4 or adv_dec < 0.55 or crash_count >= 30:
|
||||
return {
|
||||
"regime": "risk_off",
|
||||
"label": "风险释放期",
|
||||
"confidence": 0.88,
|
||||
"risk_level": "critical",
|
||||
"position_multiplier": 0.0,
|
||||
"reasons": ["主流币或山寨广度明显走弱,新开山寨仓位容易变成接飞刀"],
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
if crash_count >= 18 or adv_dec < 0.75 or (btc_change <= -1.5 and avg_change < 0):
|
||||
return {
|
||||
"regime": "risk_off",
|
||||
"label": "偏风险释放",
|
||||
"confidence": 0.75,
|
||||
"risk_level": "high",
|
||||
"position_multiplier": 0.35,
|
||||
"reasons": ["市场下跌覆盖面较大,只允许特别高质量的机会"],
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
if hot_count >= 35 and extreme_positive >= 20 and avg_funding >= 0.00045:
|
||||
return {
|
||||
"regime": "meme_frenzy",
|
||||
"label": "情绪过热期",
|
||||
"confidence": 0.72,
|
||||
"risk_level": "high",
|
||||
"position_multiplier": 0.5,
|
||||
"reasons": ["强势币和正 funding 同时过热,追高回撤风险升高"],
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
if avg_change >= 0.8 and adv_dec >= 1.2 and hot_count >= 15 and btc_change > -1:
|
||||
return {
|
||||
"regime": "altcoin_rotation",
|
||||
"label": "山寨轮动期",
|
||||
"confidence": 0.78,
|
||||
"risk_level": "medium",
|
||||
"position_multiplier": 1.0,
|
||||
"reasons": ["上涨覆盖面和强势币数量支持继续寻找精选机会"],
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
if btc_change >= 1.2 and eth_change >= 0.8 and adv_dec >= 0.85:
|
||||
return {
|
||||
"regime": "btc_main_uptrend",
|
||||
"label": "主流带动期",
|
||||
"confidence": 0.7,
|
||||
"risk_level": "medium",
|
||||
"position_multiplier": 0.8,
|
||||
"reasons": ["BTC/ETH 提供方向支撑,但山寨仍要精选"],
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
if abs(avg_change) <= 0.6 and 0.75 <= adv_dec <= 1.25:
|
||||
return {
|
||||
"regime": "sideways_chop",
|
||||
"label": "横盘震荡期",
|
||||
"confidence": 0.65,
|
||||
"risk_level": "medium",
|
||||
"position_multiplier": 0.65,
|
||||
"reasons": ["市场没有单边方向,追突破容易来回挨打,偏向等回踩"],
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
return {
|
||||
"regime": "unknown",
|
||||
"label": "结构性行情",
|
||||
"confidence": 0.45,
|
||||
"risk_level": "medium",
|
||||
"position_multiplier": 0.75,
|
||||
"reasons": ["市场环境不够清晰,保持精选和轻仓"],
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["classify_market_regime"]
|
||||
@ -7,6 +7,7 @@ import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.config.system_config import paper_trading_config
|
||||
from app.core.global_risk import evaluate_global_risk
|
||||
from app.db.schema import get_conn
|
||||
from app.db.system_logs import record_system_error
|
||||
from app.integrations.feishu_push import push_card
|
||||
@ -166,6 +167,16 @@ def _portfolio_entry_pause_check(conn, additional_notional: float, event_time: s
|
||||
return True, "", {"drawdown": drawdown, "weak_entries": weak}
|
||||
|
||||
|
||||
def _global_risk_entry_check(conn, rec: dict, additional_notional: float, config: dict | None = None) -> tuple[bool, dict]:
|
||||
detail = evaluate_global_risk(
|
||||
conn=conn,
|
||||
config=_paper_cfg(config),
|
||||
rec=rec,
|
||||
additional_notional=additional_notional,
|
||||
)
|
||||
return bool(detail.get("allow_new_entries", True)), detail
|
||||
|
||||
|
||||
def _trailing_config() -> dict:
|
||||
cfg = paper_trading_config()
|
||||
return {
|
||||
@ -625,6 +636,14 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
|
||||
pause_ok, pause_reason, pause_detail = _portfolio_entry_pause_check(conn, notional, event_time, cfg)
|
||||
if not pause_ok:
|
||||
return {"opened": False, "skipped": True, "reason": pause_reason, "risk_detail": pause_detail}
|
||||
global_ok, global_detail = _global_risk_entry_check(conn, rec, notional, cfg)
|
||||
if not global_ok:
|
||||
return {
|
||||
"opened": False,
|
||||
"skipped": True,
|
||||
"reason": "global_risk_rejected",
|
||||
"risk_detail": global_detail,
|
||||
}
|
||||
leverage_ok, leverage_detail = _cumulative_leverage_check(conn, notional, cfg, exclude_rec_id=rec_id)
|
||||
if not leverage_ok:
|
||||
return {
|
||||
@ -693,6 +712,10 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
|
||||
"slippage_pct": default_slippage_pct(cfg),
|
||||
"source_status": rec.get("execution_status") or "",
|
||||
"source_action": rec.get("action_status") or "",
|
||||
"market_regime": global_detail.get("market_regime") or _entry_plan(rec).get("market_regime") or {},
|
||||
"global_risk": global_detail,
|
||||
"score_components": _entry_plan(rec).get("score_components") or {},
|
||||
"decision_log": _entry_plan(rec).get("decision_log") or {},
|
||||
},
|
||||
now,
|
||||
)
|
||||
@ -704,6 +727,8 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
|
||||
"notional_usdt": notional,
|
||||
"margin_usdt": margin,
|
||||
"leverage": leverage,
|
||||
"global_risk": global_detail,
|
||||
"market_regime": global_detail.get("market_regime") or _entry_plan(rec).get("market_regime") or {},
|
||||
}
|
||||
if push_open_card:
|
||||
_push_event_card("open", {"symbol": symbol}, result, now)
|
||||
@ -943,6 +968,11 @@ def _fill_paper_order(conn, order: dict, rec: dict, current_price: float, event_
|
||||
pause_ok, pause_reason, pause_detail = _portfolio_entry_pause_check(conn, default_notional_usdt(cfg), event_time, cfg)
|
||||
if not pause_ok:
|
||||
return _cancel_paper_order(conn, order, pause_reason, event_time)
|
||||
global_ok, global_detail = _global_risk_entry_check(conn, rec, default_notional_usdt(cfg), cfg)
|
||||
if not global_ok:
|
||||
result = _cancel_paper_order(conn, order, "global_risk_rejected", event_time)
|
||||
result["risk_detail"] = global_detail
|
||||
return result
|
||||
trade_rec = dict(rec)
|
||||
plan = _entry_plan(trade_rec)
|
||||
plan.setdefault("entry_price", fill_price)
|
||||
|
||||
@ -49,7 +49,9 @@ from app.core.opportunity_level import (
|
||||
)
|
||||
from app.core.opportunity_funnel import build_screening_detail
|
||||
from app.core.factor_scoring import FactorScorer
|
||||
from app.core.market_regime import classify_market_regime
|
||||
from app.db.onchain_db import get_onchain_factor_context
|
||||
from app.services.market_overview import get_crypto_market_overview
|
||||
from app.config.config_loader import _get_section as _get_cfg_section
|
||||
from app.core.pa_engine import (
|
||||
classify_candles, calc_atr, find_supply_demand_zones,
|
||||
@ -450,11 +452,11 @@ def _apply_onchain_factor_score(symbol, factor_scorer):
|
||||
label = event.get("signal_label") or code
|
||||
delta = factor_scorer.delta(
|
||||
code,
|
||||
_onchain_base_delta(event),
|
||||
-_onchain_base_delta(event),
|
||||
evidence=f"NodeReal风险链上事件: {label}",
|
||||
value={"value_usd": event.get("value_usd"), "confidence": event.get("confidence")},
|
||||
)
|
||||
score_delta -= delta
|
||||
score_delta += delta
|
||||
signals.append(f"⚠️ 链上风险: {label}(置信{event.get('confidence') or 0}, ${float(event.get('value_usd') or 0):.0f})")
|
||||
|
||||
if onchain_score >= 75 and not positive_events:
|
||||
@ -469,16 +471,59 @@ def _apply_onchain_factor_score(symbol, factor_scorer):
|
||||
if risk_score >= 50 and not risk_events:
|
||||
delta = factor_scorer.delta(
|
||||
"holder_concentration_risk",
|
||||
1.5,
|
||||
-1.5,
|
||||
evidence="NodeReal综合链上风险分>=50",
|
||||
value=risk_score,
|
||||
)
|
||||
score_delta -= delta
|
||||
score_delta += delta
|
||||
signals.append(f"⚠️ 链上综合风险高({risk_score:.0f})")
|
||||
ctx["score_delta"] = round(score_delta, 3)
|
||||
return score_delta, signals, ctx
|
||||
|
||||
|
||||
def _current_market_regime_context():
|
||||
"""Read latest market snapshot and classify regime without live network fallback."""
|
||||
try:
|
||||
overview = get_crypto_market_overview(allow_live_fallback=False)
|
||||
regime = classify_market_regime(overview)
|
||||
return {
|
||||
"market_regime": regime,
|
||||
"market_snapshot": {
|
||||
"updated_at": overview.get("updated_at", ""),
|
||||
"snapshot_source": overview.get("snapshot_source", ""),
|
||||
"snapshot_missing": bool(overview.get("snapshot_missing")),
|
||||
"sample_count": overview.get("sample_count", 0),
|
||||
"advance_decline_ratio": overview.get("advance_decline_ratio", 0),
|
||||
"avg_change_24h": overview.get("avg_change_24h", 0),
|
||||
"hot_count_5pct": overview.get("hot_count_5pct", 0),
|
||||
"crash_count_5pct": overview.get("crash_count_5pct", 0),
|
||||
},
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"market_regime": {
|
||||
"regime": "unknown",
|
||||
"label": "市场环境读取失败",
|
||||
"risk_level": "medium",
|
||||
"position_multiplier": 0.75,
|
||||
"reasons": [str(exc)[:160]],
|
||||
},
|
||||
"market_snapshot": {"snapshot_missing": True},
|
||||
}
|
||||
|
||||
|
||||
def _decision_log(module: str, decision: str, *, score: float = 0.0, reasons=None, evidence=None, risk_flags=None) -> dict:
|
||||
return {
|
||||
"module": module,
|
||||
"decision": decision,
|
||||
"score": round(float(score or 0), 3),
|
||||
"reasons": reasons or [],
|
||||
"risk_flags": risk_flags or [],
|
||||
"evidence": evidence or {},
|
||||
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
|
||||
# ==================== 确认逻辑 ====================
|
||||
|
||||
def detect_volume_price_fly_1h(df_1h):
|
||||
@ -909,7 +954,7 @@ def confirm_burst(symbol, cand):
|
||||
score += factor_scorer.delta("ignition_d1_current", 6, evidence="日线当前多头起爆点", value=ig.get("strength_ratio"))
|
||||
elif ig["direction"] == -1:
|
||||
signals.append(f"日线 {ig['signal_type']}(强度{ig['strength_ratio']}×)")
|
||||
score += factor_scorer.delta("trend_exhaustion", 3, evidence="日线空头起爆风险", value=ig.get("strength_ratio"))
|
||||
score += factor_scorer.delta("trend_exhaustion", -3, evidence="日线空头起爆风险", value=ig.get("strength_ratio"))
|
||||
for ig in recent_d1_ignitions:
|
||||
if ig.get("direction") == 1:
|
||||
t = _event_time_from_age(d1_df, ig.get("age_bars"))
|
||||
@ -968,7 +1013,7 @@ def confirm_burst(symbol, cand):
|
||||
ignition_confirmed = True
|
||||
elif ig["direction"] == -1:
|
||||
signals.append(f"1H {ig['signal_type']}(强度{ig['strength_ratio']}×)")
|
||||
score += factor_scorer.delta("trend_exhaustion", 2, evidence="1H空头起爆风险", value=ig.get("strength_ratio"))
|
||||
score += factor_scorer.delta("trend_exhaustion", -2, evidence="1H空头起爆风险", value=ig.get("strength_ratio"))
|
||||
for ig in recent_1h_ignitions:
|
||||
if ig.get("direction") == 1:
|
||||
t = _event_time_from_age(h1_df, ig.get("age_bars"))
|
||||
@ -1002,9 +1047,9 @@ def confirm_burst(symbol, cand):
|
||||
for es in pa_1h_exhaustion.get("signals", []):
|
||||
signals.append(f"⚠️ {es}")
|
||||
if pa_1h_exhaustion["severity"] == "high":
|
||||
score -= factor_scorer.delta("trend_exhaustion", 3, evidence="1H高强度趋势衰竭", value=pa_1h_exhaustion.get("severity"))
|
||||
score += factor_scorer.delta("trend_exhaustion", -3, evidence="1H高强度趋势衰竭", value=pa_1h_exhaustion.get("severity"))
|
||||
elif pa_1h_exhaustion["severity"] == "medium":
|
||||
score -= factor_scorer.delta("trend_exhaustion", 1, evidence="1H中等趋势衰竭", value=pa_1h_exhaustion.get("severity"))
|
||||
score += factor_scorer.delta("trend_exhaustion", -1, evidence="1H中等趋势衰竭", value=pa_1h_exhaustion.get("severity"))
|
||||
|
||||
# ---- v1.7.7: 30min 桥接(填补 1H→15min 缺口)----
|
||||
# 30min 是中间周期:确认 1H 趋势在中等周期是否有结构支撑
|
||||
@ -1028,7 +1073,7 @@ def confirm_burst(symbol, cand):
|
||||
m30_aligned = True # 不扣分,视为中性偏多
|
||||
elif sum(1 for c in recent_30 if c["type"] == "dynamic" and c["direction"] == -1) >= 3:
|
||||
signals.append("⚠️ 30min 阴动K≥3(与1H背离)")
|
||||
score -= factor_scorer.delta("trend_exhaustion", 2, evidence="30min阴动K与1H背离", value="30m_bear_dynamic")
|
||||
score += factor_scorer.delta("trend_exhaustion", -2, evidence="30min阴动K与1H背离", value="30m_bear_dynamic")
|
||||
# else: 无明确信号,不干预
|
||||
|
||||
# ---- PA引擎:15min入场点分析 ----
|
||||
@ -1065,7 +1110,7 @@ def confirm_burst(symbol, cand):
|
||||
|
||||
if pa_15min_result.get("false_breakout"):
|
||||
signals.append("⚠️ 15min假突破!排除")
|
||||
score -= factor_scorer.delta("false_breakout", 5, evidence="15min假突破", value=True)
|
||||
score += factor_scorer.delta("false_breakout", -5, evidence="15min假突破", value=True)
|
||||
|
||||
if entry_action == "即刻买入":
|
||||
signals.append("🟢 15min即刻入场信号")
|
||||
@ -1222,7 +1267,7 @@ def confirm_burst(symbol, cand):
|
||||
pullback_cfg = confirm_cfg.get("pullback_penalty", {})
|
||||
if pullback_cfg.get("enabled", True) and entry_action == "等回踩":
|
||||
penalty = pullback_cfg.get("score_deduction", 3)
|
||||
score -= factor_scorer.delta("risk_reward_bad", penalty, evidence="等回踩历史失败率较高,降低确认分", value=entry_action)
|
||||
score += factor_scorer.delta("risk_reward_bad", -penalty, evidence="等回踩历史失败率较高,降低确认分", value=entry_action)
|
||||
signals.append(f"⚠️ 等回踩降权(-{penalty}分)")
|
||||
confirmed = confirmed and score >= confirm_min_score()
|
||||
# 假突破排除
|
||||
@ -1371,7 +1416,7 @@ def confirm_burst(symbol, cand):
|
||||
if gate_reasons:
|
||||
signals.append("⚠️ 买点质量闸门: " + ";".join(gate_reasons[:3]))
|
||||
if gated_action == "观察":
|
||||
score -= factor_scorer.delta("entry_quality_gate", 2, evidence="买点质量闸门降为观察", value=gate_reasons[:3])
|
||||
score += factor_scorer.delta("entry_quality_gate", -2, evidence="买点质量闸门降为观察", value=gate_reasons[:3])
|
||||
|
||||
# 周线突破回踩(需独立拉取)
|
||||
bp_weekly = {"detected": False}
|
||||
@ -1390,7 +1435,12 @@ def confirm_burst(symbol, cand):
|
||||
market_context = compute_market_context(h1_df, price)
|
||||
derivatives_context = fetch_derivatives_context(symbol)
|
||||
sector_context = compute_sector_context(symbol, cand_detail)
|
||||
regime_context = _current_market_regime_context()
|
||||
market_regime = regime_context.get("market_regime") or {}
|
||||
factor_score_breakdown = factor_scorer.summary()
|
||||
opportunity_score = round(float(factor_score_breakdown.get("opportunity_score") or 0), 3)
|
||||
entry_score = round(float(factor_score_breakdown.get("entry_score") or 0), 3)
|
||||
risk_score = round(float(factor_score_breakdown.get("risk_score") or 0), 3)
|
||||
trigger_context = _build_trigger_context(
|
||||
fresh_reason if 'fresh_reason' in locals() else "",
|
||||
fresh_events if 'fresh_events' in locals() else [],
|
||||
@ -1404,13 +1454,43 @@ def confirm_burst(symbol, cand):
|
||||
market_context["trigger_context"] = trigger_context
|
||||
market_context["factor_score_breakdown"] = factor_score_breakdown
|
||||
market_context["onchain_context"] = onchain_context
|
||||
market_context["market_regime"] = market_regime
|
||||
market_context["market_snapshot"] = regime_context.get("market_snapshot") or {}
|
||||
market_context["score_components"] = {
|
||||
"total_score": round(float(score), 3),
|
||||
"opportunity_score": opportunity_score,
|
||||
"entry_score": entry_score,
|
||||
"risk_score": risk_score,
|
||||
}
|
||||
market_context["decision_log"] = _decision_log(
|
||||
"confirm_burst",
|
||||
"confirmed" if confirmed else "rejected",
|
||||
score=score,
|
||||
reasons=[] if confirmed else signals[-5:],
|
||||
risk_flags=[
|
||||
f"market_regime:{market_regime.get('regime', 'unknown')}",
|
||||
f"market_risk:{market_regime.get('risk_level', 'medium')}",
|
||||
],
|
||||
evidence={
|
||||
"opportunity_score": opportunity_score,
|
||||
"entry_score": entry_score,
|
||||
"risk_score": risk_score,
|
||||
"trigger_status": trigger_context.get("trigger_status"),
|
||||
},
|
||||
)
|
||||
if entry_plan:
|
||||
entry_plan["factor_score_breakdown"] = factor_score_breakdown
|
||||
entry_plan["onchain_context"] = onchain_context
|
||||
entry_plan["market_regime"] = market_regime
|
||||
entry_plan["score_components"] = market_context["score_components"]
|
||||
entry_plan["decision_log"] = market_context["decision_log"]
|
||||
|
||||
return {
|
||||
"confirmed": confirmed,
|
||||
"score": round(float(score), 3),
|
||||
"opportunity_score": opportunity_score,
|
||||
"entry_score": entry_score,
|
||||
"risk_score": risk_score,
|
||||
"signals": signals,
|
||||
"entry_plan": entry_plan if confirmed else {},
|
||||
"price": round(float(price), 6),
|
||||
@ -1423,7 +1503,9 @@ def confirm_burst(symbol, cand):
|
||||
"derivatives_context": derivatives_context,
|
||||
"sector_context": sector_context,
|
||||
"onchain_context": onchain_context,
|
||||
"market_regime": market_regime,
|
||||
"factor_score_breakdown": factor_score_breakdown,
|
||||
"decision_log": market_context["decision_log"],
|
||||
"fresh_reason": fresh_reason if 'fresh_reason' in locals() else "",
|
||||
"fresh_events": fresh_events if 'fresh_events' in locals() else [],
|
||||
"trigger_context": trigger_context if 'trigger_context' in locals() else {},
|
||||
|
||||
145
docs/OPTIMIZATION_TODO.md
Normal file
145
docs/OPTIMIZATION_TODO.md
Normal file
@ -0,0 +1,145 @@
|
||||
# AlphaX Optimization Todo
|
||||
|
||||
本文件记录 AlphaX 筛选、确认、交易、复盘闭环的中长期优化路线,避免优化点只留在对话里。后续 Agent 接手时,应先看本文件和 `AGENTS.md`,再决定下一步开发。
|
||||
|
||||
## 当前原则
|
||||
|
||||
- 不盲目增加指标,优先降低回撤、减少无效开仓、提升可解释性。
|
||||
- 先做状态、日志、闸门,再做复杂模型。
|
||||
- 高分机会和好买点必须拆开。
|
||||
- 观察池、挂单池、交易账本不能混在一起。
|
||||
- 所有能影响策略的因子都必须可复盘、可提权、可降权、可淘汰。
|
||||
|
||||
## 已完成
|
||||
|
||||
- PostgreSQL 成为唯一运行时数据库,SQLite 已废弃。
|
||||
- 根目录和代码目录已做基础整理。
|
||||
- `altcoin_db.py` 已拆出多组 DB 查询/命令模块,剩余为兼容门面。
|
||||
- 推荐状态、展示桶、买点质量闸门已中心化到生命周期相关模块。
|
||||
- Paper trading 已拆分持仓、挂单、已完成、操作日志 tab。
|
||||
- Paper trading 已支持模拟挂单、移动止盈、策略交易报告、账本维护、删除/重置数据。
|
||||
- Paper trading 已加入更严格风控:推荐分、盈亏比、止损杠杆风险、账户回撤暂停、弱入场暂停。
|
||||
- `Market Regime Engine` 第一版已建立,可识别 `risk_off`、`btc_main_uptrend`、`altcoin_rotation`、`sideways_chop`、`meme_frenzy`、`unknown`。
|
||||
- `Global Risk Engine` 第一版已接入 paper trading 开仓和挂单成交前,critical 禁止新开仓,high 只允许高质量机会。
|
||||
- 确认层已把 `market_regime` 写入 `market_context` / `entry_plan`,paper trading 开仓事件也会记录当时市场环境和全局风控结果。
|
||||
- `FactorScorer` 已加入因子组去相关,限制同类动量/结构/链上/叙事信号重复叠加导致虚高分。
|
||||
- 确认层已输出 `opportunity_score`、`entry_score`、`risk_score` 三分制,并写入 `score_components`。
|
||||
- 确认层已写入结构化 `decision_log`,用于解释确认/拒绝、分数、风险标记和核心证据。
|
||||
- 实盘控制台已建立多账号配置、账户读取、订单/历史读取、Binance API 基础执行能力。
|
||||
- 策略交易到 live trading 的自动同步链路已具备 demo 环境验证能力。
|
||||
- NodeReal 已作为当前链上主数据源,DEX Screener / Etherscan / Helius 运行链路已移除。
|
||||
- `FactorScorer` 已建立,确认层核心技术因子、板块、舆情、大户、链上因子已接入复盘权重。
|
||||
- `factor_score_breakdown` 已进入确认上下文,复盘可追踪因子贡献。
|
||||
|
||||
## P0:现在优先做
|
||||
|
||||
### 1. Global Risk Engine
|
||||
|
||||
目标:单币再好,也要先看账户和大盘能不能开新仓。
|
||||
|
||||
已完成:
|
||||
|
||||
- 输出 `globalRiskLevel`、`allowNewEntries`、`maxOpenPositions`、`maxLeverage`、`positionMultiplier`、`reasons`。
|
||||
- 接入 paper trading 开仓/挂单前。
|
||||
- critical 时禁止新开仓;high 时只允许高质量机会。
|
||||
|
||||
待增强:
|
||||
|
||||
- 风控结果写入返回结果和操作日志,方便复盘。
|
||||
- 引入更丰富的组合风险:相关性、同板块集中度、同方向拥挤度。
|
||||
|
||||
### 2. Market Regime Engine
|
||||
|
||||
目标:先判断现在是什么市场,再决定用什么策略。
|
||||
|
||||
第一版只需要白话状态:
|
||||
|
||||
- `risk_off`:风险释放期,禁止或大幅减少新山寨开仓。
|
||||
- `btc_main_uptrend`:主流带动,山寨机会精选。
|
||||
- `altcoin_rotation`:山寨轮动,可正常寻找机会。
|
||||
- `sideways_chop`:横盘震荡,偏向等回踩,减少追突破。
|
||||
- `meme_frenzy`:MEME 情绪高涨,提高 MEME 门槛。
|
||||
- `unknown`:数据不足,保守运行。
|
||||
|
||||
已完成:
|
||||
|
||||
- 使用现有 `market_overview` 快照、BTC/ETH 涨跌、山寨涨跌广度、强势/大跌数量、funding 概览。
|
||||
|
||||
待增强:
|
||||
|
||||
- 每次确认/交易记录当时 regime。
|
||||
- 后续再接入 BTC Dominance、TOTAL3、稳定币净流。
|
||||
|
||||
### 3. Factor Group 去相关
|
||||
|
||||
目标:避免同一根大阳线被“量价、突破、动K、强势榜”重复奖励。
|
||||
|
||||
已完成:
|
||||
|
||||
- 给因子增加大类:`momentum`、`participation`、`structure`、`positioning`、`narrative`、`onchain_flow`、`risk`、`entry_quality`。
|
||||
- 每个大类内部优先取最强信号,不简单全部累加。
|
||||
- 每个大类设置分数上限。
|
||||
- `factor_score_breakdown` 增加 group 视角。
|
||||
|
||||
待增强:
|
||||
|
||||
- 让分组上限按 market regime 动态调整。
|
||||
- 做因子组级别交易归因,确认哪些组在不同市场下真正贡献收益。
|
||||
|
||||
### 4. Entry Quality Score
|
||||
|
||||
目标:机会分高不等于现在可以买。
|
||||
|
||||
已完成:
|
||||
|
||||
- 输出 `opportunityScore`、`entryScore`、`riskScore`。
|
||||
- 记录降级原因:追高、RR 不足、止损太宽、离支撑太远、24h 涨幅过热。
|
||||
|
||||
待增强:
|
||||
|
||||
- 把三分制进一步接入 `apply_entry_quality_gate`,明确用 `entryScore` 决定 `buy_now` / `wait_pullback` / `watch`。
|
||||
|
||||
### 5. 完整结构化决策日志
|
||||
|
||||
目标:任何通过、拒绝、降级都能解释清楚。
|
||||
|
||||
已完成:
|
||||
|
||||
- 每个候选/确认/开仓/拒绝都记录 module、decision、score、state、reasons、riskFlags、evidence。
|
||||
|
||||
待增强:
|
||||
|
||||
- 优先复用现有 `screening_log`、`cron_run_log`、`paper_trade_events`,必要时新增决策日志表。
|
||||
- 如果后续前端要集中展示策略决策,应考虑新增 `strategy_decision_log` 表,而不是长期只塞在 JSON 里。
|
||||
|
||||
## P1:第二阶段
|
||||
|
||||
- Continuation Quality Engine:突破后是否真的延续。
|
||||
- Fake Breakout Risk:假突破高风险时禁止 immediate buy。
|
||||
- Opportunity TTL:旧信号自动过期,不反复污染新机会。
|
||||
- Paper Trade Attribution:按因子组、regime、entryAction 归因交易结果。
|
||||
- Regime-based Scoring:不同市场状态下使用不同因子权重。
|
||||
- Watchlist / Trade Ledger 进一步分离:观察样本、挂单、持仓收益完全分开统计。
|
||||
|
||||
## P2:第三阶段
|
||||
|
||||
- Narrative Graph 简化版:板块、龙头、二线、跟风、假相关。
|
||||
- Sector Leader / Laggard 识别:热门板块不等于所有成员都加分。
|
||||
- Onchain Flow 增强:链上事件按钱包角色、金额、方向、持续性细分。
|
||||
- 舆情质量过滤:只在有稳定数据源时做 bot ratio / smart KOL。
|
||||
- 策略分 regime 回测。
|
||||
|
||||
## 暂缓
|
||||
|
||||
- 多 Agent 投票系统:当前不是优先项,先把状态、因子、风控和日志做好。
|
||||
- 复杂 Narrative Graph:数据质量不足前不要重投入。
|
||||
- 180 天表现对比:等结构化样本足够后再做。
|
||||
- 真实资金自动大规模跟单:paper trading 和 demo 稳定后再扩大。
|
||||
|
||||
## 下一步执行建议
|
||||
|
||||
1. 部署运行一段时间,观察 `market_regime`、`score_components`、`factor_score_breakdown.groups` 是否能解释真实回撤。
|
||||
2. 做 Paper Trade Attribution,把收益按因子组和 regime 归因。
|
||||
3. 把三分制进一步接入买点质量闸门,明确高机会分但低买点分时只能挂单或观察。
|
||||
4. 如果 JSON 决策日志查询不方便,再新增 `strategy_decision_log` 表和页面。
|
||||
5. 按线上样本继续调整 group cap 和 high-risk 门槛。
|
||||
@ -193,6 +193,22 @@
|
||||
.onchain-meta { color:var(--stone); font-size:11px; line-height:1.45; }
|
||||
.onchain-score { color:var(--blue); font-weight:950; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
|
||||
.onchain-brief.risk .onchain-score { color:var(--red); }
|
||||
.strategy-diagnostics { margin: 0 18px 8px; display: grid; gap: 8px; }
|
||||
.score-split { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
|
||||
.score-part { border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 8px 9px; min-width: 0; }
|
||||
.score-part span { display: block; color: var(--stone); font-size: 10px; font-weight: 900; line-height: 1.2; }
|
||||
.score-part b { display: block; margin-top: 4px; font-size: 16px; line-height: 1; font-weight: 950; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; color: var(--ink); }
|
||||
.score-part.opportunity b { color: var(--blue); }
|
||||
.score-part.entry b { color: var(--green); }
|
||||
.score-part.risk b { color: var(--red); }
|
||||
.regime-brief { border: 1px solid rgba(66,98,255,.14); border-radius: var(--radius-lg); background: rgba(66,98,255,.04); padding: 8px 10px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.regime-brief.risk_off,.regime-brief.critical { border-color: rgba(229,62,62,.18); background: var(--red-light); }
|
||||
.regime-brief.altcoin_rotation { border-color: rgba(0,180,115,.18); background: var(--green-light); }
|
||||
.regime-brief.sideways_chop,.regime-brief.meme_frenzy { border-color: rgba(252,185,0,.24); background: var(--yellow-light); }
|
||||
.regime-name { color: var(--ink); font-size: 12px; font-weight: 950; white-space: nowrap; }
|
||||
.regime-reason { color: var(--stone); font-size: 11px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
|
||||
.decision-log-brief { border: 1px dashed var(--hairline-strong); border-radius: var(--radius-lg); background: var(--canvas); padding: 8px 10px; color: var(--slate); font-size: 11px; line-height: 1.45; }
|
||||
.decision-log-brief b { color: var(--ink); font-weight: 950; }
|
||||
|
||||
/* ===== K-LINE ===== */
|
||||
.kline-wrap { padding: 0 8px 4px; }
|
||||
@ -270,6 +286,10 @@
|
||||
.entry-plan { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 8px 14px; }
|
||||
.decision-strip { margin: 0 14px 8px; grid-template-columns: 86px minmax(0,1fr); }
|
||||
.onchain-brief { margin: 0 14px 8px; }
|
||||
.strategy-diagnostics { margin: 0 14px 8px; }
|
||||
.score-split { grid-template-columns: 1fr; }
|
||||
.regime-brief { display: block; }
|
||||
.regime-reason { margin-top: 4px; text-align: left; white-space: normal; }
|
||||
}
|
||||
|
||||
@media(max-width:360px) {
|
||||
@ -426,6 +446,50 @@ function fmtCompactNumber(v) {
|
||||
if (abs >= 1e3) return (v / 1e3).toFixed(1) + 'K';
|
||||
return v.toFixed(abs >= 100 ? 0 : abs >= 10 ? 1 : 2);
|
||||
}
|
||||
function scoreComponentsFrom(r) {
|
||||
var ep = (r && r.entry_plan) || {};
|
||||
var mc = (r && r.market_context) || {};
|
||||
return ep.score_components || mc.score_components || null;
|
||||
}
|
||||
function marketRegimeFrom(r) {
|
||||
var ep = (r && r.entry_plan) || {};
|
||||
var mc = (r && r.market_context) || {};
|
||||
return ep.market_regime || mc.market_regime || null;
|
||||
}
|
||||
function decisionLogFrom(r) {
|
||||
var ep = (r && r.entry_plan) || {};
|
||||
var mc = (r && r.market_context) || {};
|
||||
return ep.decision_log || mc.decision_log || null;
|
||||
}
|
||||
function renderScoreComponents(r) {
|
||||
var sc = scoreComponentsFrom(r);
|
||||
if (!sc) return '';
|
||||
var opp = Number(sc.opportunity_score || 0);
|
||||
var entry = Number(sc.entry_score || 0);
|
||||
var risk = Number(sc.risk_score || 0);
|
||||
return '<div class="score-split" title="机会分看币本身,买点分看当前是否适合进,风险分看扣分风险">'+
|
||||
'<div class="score-part opportunity"><span>机会</span><b>'+fmtCompactNumber(opp)+'</b></div>'+
|
||||
'<div class="score-part entry"><span>买点</span><b>'+fmtCompactNumber(entry)+'</b></div>'+
|
||||
'<div class="score-part risk"><span>风险</span><b>'+fmtCompactNumber(risk)+'</b></div>'+
|
||||
'</div>';
|
||||
}
|
||||
function renderRegimeBrief(r) {
|
||||
var rg = marketRegimeFrom(r);
|
||||
if (!rg || !rg.regime) return '';
|
||||
var reasons = Array.isArray(rg.reasons) ? rg.reasons : [];
|
||||
var cls = String(rg.regime || '') + ' ' + String(rg.risk_level || '');
|
||||
return '<div class="regime-brief '+esc(cls)+'"><span class="regime-name">'+esc(rg.label || rg.regime || '市场环境')+' · '+esc(rg.risk_level || 'medium')+'</span><span class="regime-reason">'+esc(reasons[0] || '市场环境已记录到策略上下文')+'</span></div>';
|
||||
}
|
||||
function renderDecisionLogBrief(r) {
|
||||
var log = decisionLogFrom(r);
|
||||
if (!log || !log.decision) return '';
|
||||
var flags = Array.isArray(log.risk_flags) ? log.risk_flags.slice(0,2).join(' · ') : '';
|
||||
return '<div class="decision-log-brief"><b>'+esc(log.decision)+'</b> · '+esc(log.module || 'strategy')+(flags ? ' · '+esc(flags) : '')+'</div>';
|
||||
}
|
||||
function renderStrategyDiagnostics(r) {
|
||||
var html = renderScoreComponents(r) + renderRegimeBrief(r) + renderDecisionLogBrief(r);
|
||||
return html ? '<div class="strategy-diagnostics">'+html+'</div>' : '';
|
||||
}
|
||||
function normalizeTriggerCause(s) {
|
||||
return cleanDisplayText(s)
|
||||
.replace(/^15min入场窗口/, '15min 触发')
|
||||
@ -750,7 +814,7 @@ function renderRecCard(r) {
|
||||
} else {
|
||||
entryPlanHtml = '<div class="entry-plan"><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 class="ep-item"><span class="ep-label">观察阶段</span><span class="ep-val level-ref">'+cleanDisplayText(horizon || '观察池候选')+'</span><span class="ep-sub">'+cleanDisplayText(levelLabel)+'</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="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">机会分</span></span></div></div><div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+decisionHtml+signalLevelHtml+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="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+decisionHtml+renderStrategyDiagnostics(r)+signalLevelHtml+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);
|
||||
@ -1000,8 +1064,9 @@ async function loadHistoryRecommendations(reset) {
|
||||
var outcomeDetail = outcome.detail;
|
||||
return '<div class=\"card\">'+
|
||||
'<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><span class=\"hist-result-badge '+resultCls+'\">'+outcomeText+'</span></div>'+
|
||||
'<div class=\"h-pnl-row\">'+(hasPaper ? '<span class=\"price h-entry-price\">$'+fmtN(entryP)+'</span><span class=\"h-arrow neutral\">→</span><span class=\"price h-exit-price '+(paper.status === 'closed' ? resultCls : 'muted')+'\">'+(paper.status === 'closed' ? '$'+fmtN(exitP) : '持有中')+'</span>' : '<span class=\"price h-entry-price muted\">未执行</span><span class=\"h-arrow neutral\">→</span><span class=\"price h-exit-price muted\">失效/归档</span>')+'<span class=\"hist-score-pill '+scoreCls+'\">机会分 '+score+'</span><span class=\"h-duration\">'+duration+'</span></div>'+
|
||||
'<div class=\"h-pnl-row\">'+(hasPaper ? '<span class=\"price h-entry-price\">$'+fmtN(entryP)+'</span><span class=\"h-arrow neutral\">→</span><span class=\"price h-exit-price '+(paper.status === 'closed' ? resultCls : 'muted')+'\">'+(paper.status === 'closed' ? '$'+fmtN(exitP) : '持有中')+'</span>' : '<span class=\"price h-entry-price muted\">未执行</span><span class=\"h-arrow neutral\">→</span><span class=\"price h-exit-price muted\">失效/归档</span>')+'<span class=\"hist-score-pill '+scoreCls+'\">总分 '+score+'</span><span class=\"h-duration\">'+duration+'</span></div>'+
|
||||
'<div class=\"hist-metric-row\"><div class=\"hist-metric\"><span class=\"hm-label\">交易阶段</span><span class=\"hm-val '+(hasPaper ? 'win' : 'blue')+'\">'+(hasPaper ? (paper.status === 'closed' ? '已完成策略交易' : '策略交易持有中') : '未执行归档')+'</span></div><div class=\"hist-metric\"><span class=\"hm-label\">结果说明</span><span class=\"hm-val blue\">'+outcomeDetail+'</span></div><div class=\"hist-metric\"><span class=\"hm-label\">执行状态</span><span class=\"hm-val '+(Number(r.entry_triggered||0)?'win':'blue')+'\">'+execText+'</span></div></div>'+
|
||||
renderStrategyDiagnostics(r)+
|
||||
'<div class=\"kline-wrap\" id=\"wrap_'+hid+'\"><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\" id=\"'+hid+'\" data-symbol=\"'+(r.symbol||'')+'\" data-entry-price=\"'+hEntryPrice+'\" data-stop-loss=\"'+hSl+'\" data-tp1=\"'+hTp+'\" data-rec-time=\"'+hEntryTime+'\" data-tp1-time=\"'+hTpTime+'\" data-sl-time=\"'+hSlTime+'\" data-ref-price=\"'+(r.current_price||hEntryPrice||hTp||hSl||0)+'\" data-status=\"'+(r.status||'')+'\" ><div class=\"chart-loading\"><svg class=\"spin\" width=\"16\" height=\"16\" color=\"#8e91a0\"><use href=\"#svg-spinner\"/></svg></div></div></div>'+
|
||||
(sigHtml?'<div class=\"signals-row\">'+sigHtml+'</div>':'')+
|
||||
'<div class=\"card-footer hist-footer\"><span>'+fmtTime(r.rec_time)+'</span><span class=\"card-ver\">'+(r.strategy_version||'')+'</span></div></div>';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -20,6 +20,30 @@ def test_factor_scorer_promotes_reviewed_factor():
|
||||
assert scorer.summary()["items"][0]["multiplier"] == 1.5
|
||||
|
||||
|
||||
def test_factor_scorer_caps_repeated_same_group_signals():
|
||||
scorer = FactorScorer(weights={})
|
||||
|
||||
first = scorer.delta("vp_fly_1h_current", 10, evidence="first momentum")
|
||||
second = scorer.delta("ignition_1h_current", 10, evidence="second momentum")
|
||||
summary = scorer.summary()
|
||||
|
||||
assert first == 10
|
||||
assert second == 6
|
||||
assert summary["groups"]["momentum"]["score_delta"] == 16
|
||||
assert summary["opportunity_score"] == 16
|
||||
|
||||
|
||||
def test_factor_scorer_records_negative_risk_as_risk_score():
|
||||
scorer = FactorScorer(weights={})
|
||||
|
||||
delta = scorer.delta("false_breakout", -5, evidence="unit risk")
|
||||
summary = scorer.summary()
|
||||
|
||||
assert delta == -5
|
||||
assert summary["groups"]["risk"]["score_delta"] == -5
|
||||
assert summary["risk_score"] == 5
|
||||
|
||||
|
||||
def test_signal_weight_alias_keeps_legacy_chinese_keys_available(monkeypatch):
|
||||
monkeypatch.setattr("app.config.config_loader.load_rules", lambda: {"signal_weights": {"量价齐飞": 5}})
|
||||
monkeypatch.setattr("app.db.altcoin_db.get_signal_weights", lambda: {})
|
||||
|
||||
47
tests/test_market_regime.py
Normal file
47
tests/test_market_regime.py
Normal file
@ -0,0 +1,47 @@
|
||||
from app.core.market_regime import classify_market_regime
|
||||
|
||||
|
||||
def _overview(**overrides):
|
||||
data = {
|
||||
"sample_count": 200,
|
||||
"benchmarks": {
|
||||
"BTC/USDT": {"change_24h": 0.5},
|
||||
"ETH/USDT": {"change_24h": 0.4},
|
||||
},
|
||||
"advance_decline_ratio": 1.0,
|
||||
"avg_change_24h": 0.1,
|
||||
"hot_count_5pct": 8,
|
||||
"crash_count_5pct": 4,
|
||||
"funding": {"avg_funding_rate": 0.0001, "extreme_positive_count": 2},
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def test_market_regime_blocks_clear_risk_off():
|
||||
result = classify_market_regime(
|
||||
_overview(
|
||||
benchmarks={"BTC/USDT": {"change_24h": -3.4}, "ETH/USDT": {"change_24h": -4.2}},
|
||||
advance_decline_ratio=0.4,
|
||||
crash_count_5pct=35,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["regime"] == "risk_off"
|
||||
assert result["risk_level"] == "critical"
|
||||
assert result["position_multiplier"] == 0
|
||||
|
||||
|
||||
def test_market_regime_detects_altcoin_rotation():
|
||||
result = classify_market_regime(
|
||||
_overview(
|
||||
benchmarks={"BTC/USDT": {"change_24h": 0.6}, "ETH/USDT": {"change_24h": 1.1}},
|
||||
advance_decline_ratio=1.4,
|
||||
avg_change_24h=1.2,
|
||||
hot_count_5pct=22,
|
||||
crash_count_5pct=3,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["regime"] == "altcoin_rotation"
|
||||
assert result["risk_level"] == "medium"
|
||||
@ -77,3 +77,29 @@ def test_onchain_factor_score_uses_review_weights():
|
||||
assert delta == 0
|
||||
assert scorer.summary()["items"][0]["factor_code"] == "smart_money_buying"
|
||||
assert scorer.summary()["items"][0]["score_delta"] == 0
|
||||
|
||||
|
||||
def test_onchain_risk_factor_is_recorded_as_negative_score():
|
||||
now = datetime.now().isoformat()
|
||||
insert_onchain_event(
|
||||
{
|
||||
"symbol": "RISKY/USDT",
|
||||
"chain": "ethereum",
|
||||
"signal_code": "exchange_inflow_risk",
|
||||
"direction": "risk",
|
||||
"value_usd": 900_000,
|
||||
"confidence": 82,
|
||||
"detected_at": now,
|
||||
"source": "nodereal",
|
||||
}
|
||||
)
|
||||
scorer = FactorScorer(weights={})
|
||||
|
||||
delta, signals, ctx = _apply_onchain_factor_score("RISKY/USDT", scorer)
|
||||
summary = scorer.summary()
|
||||
|
||||
assert ctx["has_data"] is True
|
||||
assert signals
|
||||
assert delta < 0
|
||||
assert summary["items"][0]["score_delta"] < 0
|
||||
assert summary["risk_score"] > 0
|
||||
|
||||
@ -55,6 +55,7 @@ def legacy_paper_trade_thresholds(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", "0")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", "0")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES", "0")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "0")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -476,6 +477,88 @@ def test_buy_now_rejects_when_cumulative_leverage_exceeded(monkeypatch):
|
||||
assert list_paper_trades(status="open")["total"] == 1
|
||||
|
||||
|
||||
def test_buy_now_rejects_when_global_market_risk_is_critical(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "0")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", "0")
|
||||
monkeypatch.setattr(
|
||||
"app.core.global_risk.get_crypto_market_overview",
|
||||
lambda allow_live_fallback=False: {
|
||||
"sample_count": 200,
|
||||
"benchmarks": {"BTC/USDT": {"change_24h": -3.5}, "ETH/USDT": {"change_24h": -4.1}},
|
||||
"advance_decline_ratio": 0.45,
|
||||
"avg_change_24h": -2.2,
|
||||
"hot_count_5pct": 2,
|
||||
"crash_count_5pct": 36,
|
||||
"funding": {"avg_funding_rate": -0.0001, "extreme_positive_count": 0},
|
||||
},
|
||||
)
|
||||
altcoin_db.init_db()
|
||||
rec_id = altcoin_db.create_recommendation(
|
||||
symbol="RISKOFF/USDT",
|
||||
rec_state="爆发",
|
||||
rec_score=95,
|
||||
entry_price=100,
|
||||
stop_loss=95,
|
||||
tp1=112,
|
||||
signals=["当前15min即刻入场信号"],
|
||||
entry_plan={"entry_action": "可即刻买入", "entry_trigger_confirmed": True, "risk_reward_ok": True, "rr1": 2.4},
|
||||
)
|
||||
rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id)
|
||||
|
||||
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||
|
||||
assert result["reason"] == "global_risk_rejected"
|
||||
assert result["risk_detail"]["risk_level"] == "critical"
|
||||
assert result["risk_detail"]["market_regime"]["regime"] == "risk_off"
|
||||
assert list_paper_trades()["total"] == 0
|
||||
|
||||
|
||||
def test_open_event_records_market_regime_and_score_components(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "0")
|
||||
monkeypatch.setattr(
|
||||
"app.core.global_risk.get_crypto_market_overview",
|
||||
lambda allow_live_fallback=False: {
|
||||
"sample_count": 200,
|
||||
"benchmarks": {"BTC/USDT": {"change_24h": 0.8}, "ETH/USDT": {"change_24h": 1.2}},
|
||||
"advance_decline_ratio": 1.5,
|
||||
"avg_change_24h": 1.4,
|
||||
"hot_count_5pct": 25,
|
||||
"crash_count_5pct": 2,
|
||||
"funding": {"avg_funding_rate": 0.0001, "extreme_positive_count": 3},
|
||||
},
|
||||
)
|
||||
altcoin_db.init_db()
|
||||
rec_id = altcoin_db.create_recommendation(
|
||||
symbol="REGIME/USDT",
|
||||
rec_state="爆发",
|
||||
rec_score=95,
|
||||
entry_price=100,
|
||||
stop_loss=95,
|
||||
tp1=112,
|
||||
signals=["当前15min即刻入场信号"],
|
||||
entry_plan={
|
||||
"entry_action": "可即刻买入",
|
||||
"entry_trigger_confirmed": True,
|
||||
"risk_reward_ok": True,
|
||||
"rr1": 2.4,
|
||||
"score_components": {"opportunity_score": 14, "entry_score": 4, "risk_score": 1},
|
||||
},
|
||||
)
|
||||
rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id)
|
||||
|
||||
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||
events = list_paper_trade_events(symbol="REGIME/USDT")["items"]
|
||||
|
||||
assert result["opened"] is True
|
||||
assert events[0]["event_type"] == "open"
|
||||
assert events[0]["detail"]["market_regime"]["regime"] == "altcoin_rotation"
|
||||
assert events[0]["detail"]["score_components"]["opportunity_score"] == 14
|
||||
|
||||
|
||||
def test_observe_only_wait_pullback_does_not_create_order(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
altcoin_db.init_db()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user