This commit is contained in:
aaron 2026-05-23 12:13:31 +08:00
parent ecb4076dfe
commit 3a6aeedef0
15 changed files with 879 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View 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 门槛。

View File

@ -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\">&rarr;</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\">&rarr;</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\">&rarr;</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\">&rarr;</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

View File

@ -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: {})

View 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"

View File

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

View File

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