1
This commit is contained in:
parent
d227ab126c
commit
9ba8340050
14
AGENTS.md
14
AGENTS.md
@ -102,6 +102,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
|
||||
### 4.1.1 因子评分与复盘进化
|
||||
|
||||
- 核心认知:因子不等于策略。一个因子可以是先决条件、触发、确认、入场、风控或归因,但不能因为单个因子表现好就直接升级成完整策略。
|
||||
- 完整策略必须至少包含:适用市场环境、交易宇宙、先决条件、核心触发、辅助确认、入场规则、止盈止损、失效条件、仓位/杠杆约束和独立复盘口径。
|
||||
- `app/core/factor_scoring.py` 是确认层因子评分中心。新增确认加减分不要继续散落写死 `score += N`,应优先通过 `FactorScorer.delta(factor_code, base_delta, evidence=...)` 计算。
|
||||
- 稳定因子代码来自 `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()` 会让下一轮筛选/确认读取生效权重。
|
||||
@ -113,8 +115,18 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
- `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`、默认基准权重、证据字段和复盘归因口径,避免只做展示标签而不参与策略进化。
|
||||
- `box_breakout_pullback_4h` 是 4H 箱体突破回踩强结构因子,不是完整策略;它可以作为 `box_retest_4h_v1` 这类策略的核心触发,但仍必须经过市场环境、交易宇宙、确认、入场、风控和失效条件。
|
||||
|
||||
### 4.1.2 链上数据源
|
||||
### 4.1.2 多策略架构方向
|
||||
|
||||
- 多策略改造计划记录在 `docs/MULTI_STRATEGY_ARCHITECTURE.md`。后续做策略级改造前必须先阅读并更新该文档。
|
||||
- 目标架构是:统一交易宇宙 -> 多个独立策略并行扫描 -> 标准策略信号 -> 冲突/重复仲裁 -> 推荐/观察/挂单 -> paper trading 保留策略血缘 -> 按策略独立复盘。
|
||||
- `strategy_version` 只表示版本,不应替代策略身份;后续推荐、挂单和交易账本都应补充 `strategy_code`、`strategy_signal_id`、`strategy_snapshot_json` 和 `factor_roles_json`。
|
||||
- 现有综合确认链路在迁移期应标记为 `main_composite_v1`,避免无策略来源的推荐继续进入 paper trading。
|
||||
- 第一个建议拆出的独立策略是 `box_retest_4h_v1`:核心触发来自 `box_breakout_pullback_4h`,但策略成立还需要市场环境、交易量、回踩距离、15m/1H 承接、盈亏比、失效条件和账户风控。
|
||||
- 新增策略必须先 observe-only 或 paper-only 积累样本,再进入灰度/发布;不能因为某个因子短期表现好就直接同步真实交易。
|
||||
|
||||
### 4.1.3 链上数据源
|
||||
|
||||
- 当前链上主数据源支持 NodeReal 与 Alchemy,入口分别是 `app/services/nodereal_client.py`、`app/services/alchemy_client.py` 和 `app/services/onchain_monitor.py`。
|
||||
- 默认仍可跑 `ALPHAX_ONCHAIN_PROVIDER=nodereal`;如果 NodeReal 额度受限,可切到 `ALPHAX_ONCHAIN_PROVIDER=alchemy` 并设置 `ALPHAX_ALCHEMY_API_KEY`;并行模式可用 `ALPHAX_ONCHAIN_PROVIDER=nodereal,alchemy`。
|
||||
|
||||
100
app/core/factor_roles.py
Normal file
100
app/core/factor_roles.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""Explicit factor roles for multi-strategy attribution.
|
||||
|
||||
Factors are evidence. They only become strategy behavior after a strategy
|
||||
contract assigns them a role in a complete trading playbook.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
PREREQUISITE = "prerequisite"
|
||||
TRIGGER = "trigger"
|
||||
CONFIRMATION = "confirmation"
|
||||
ENTRY = "entry"
|
||||
RISK = "risk"
|
||||
ATTRIBUTION = "attribution"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
VALID_FACTOR_ROLES = {
|
||||
PREREQUISITE,
|
||||
TRIGGER,
|
||||
CONFIRMATION,
|
||||
ENTRY,
|
||||
RISK,
|
||||
ATTRIBUTION,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_FACTOR_ROLES: dict[str, str] = {
|
||||
"box_breakout_pullback_4h": TRIGGER,
|
||||
"vp_fly_1h_current": TRIGGER,
|
||||
"volume_consecutive_1h": CONFIRMATION,
|
||||
"volume_divergence_1h": RISK,
|
||||
"short_tf_15m_ignition": TRIGGER,
|
||||
"short_tf_5m_ignition": PREREQUISITE,
|
||||
"short_tf_resonance": CONFIRMATION,
|
||||
"static_accum_4h": CONFIRMATION,
|
||||
"higher_lows_4h": CONFIRMATION,
|
||||
"compression_surge_4h": CONFIRMATION,
|
||||
"ignition_1h_current": TRIGGER,
|
||||
"ignition_4h_current": CONFIRMATION,
|
||||
"ignition_d1_current": CONFIRMATION,
|
||||
"ignition_stale": ATTRIBUTION,
|
||||
"dynamic_k_1h_bull": CONFIRMATION,
|
||||
"dynamic_k_d1_bull": CONFIRMATION,
|
||||
"breakout_pullback_d1": CONFIRMATION,
|
||||
"breakout_pullback_w1": CONFIRMATION,
|
||||
"breakout_15m_current": ENTRY,
|
||||
"pullback_15m_confirm": ENTRY,
|
||||
"strong_resonance_bypass": CONFIRMATION,
|
||||
"entry_quality_gate": RISK,
|
||||
"top_trader_long": CONFIRMATION,
|
||||
"sector_rotation": CONFIRMATION,
|
||||
"sentiment_resonance": CONFIRMATION,
|
||||
"dex_volume_spike": CONFIRMATION,
|
||||
"liquidity_add": CONFIRMATION,
|
||||
"liquidity_remove_risk": RISK,
|
||||
"exchange_outflow": CONFIRMATION,
|
||||
"exchange_inflow_risk": RISK,
|
||||
"whale_accumulation": CONFIRMATION,
|
||||
"holder_concentration_risk": RISK,
|
||||
"smart_money_buying": CONFIRMATION,
|
||||
"funding_extreme": RISK,
|
||||
"trend_exhaustion": RISK,
|
||||
"false_breakout": RISK,
|
||||
"high_position_reject": RISK,
|
||||
"risk_reward_bad": RISK,
|
||||
"cex_top_gainer_24h": PREREQUISITE,
|
||||
"unknown": UNKNOWN,
|
||||
}
|
||||
|
||||
|
||||
def factor_role(factor_code: str | None, overrides: dict[str, str] | None = None) -> str:
|
||||
code = str(factor_code or "").strip() or "unknown"
|
||||
mapping = {**DEFAULT_FACTOR_ROLES, **(overrides or {})}
|
||||
role = str(mapping.get(code) or UNKNOWN).strip()
|
||||
return role if role in VALID_FACTOR_ROLES else UNKNOWN
|
||||
|
||||
|
||||
def factor_roles_for_codes(codes: Iterable[str] | None, overrides: dict[str, str] | None = None) -> dict[str, str]:
|
||||
roles = {}
|
||||
for code in codes or []:
|
||||
text = str(code or "").strip()
|
||||
if text and text not in roles:
|
||||
roles[text] = factor_role(text, overrides=overrides)
|
||||
return roles
|
||||
|
||||
|
||||
def validate_factor_roles(roles: dict[str, str] | None) -> dict[str, str]:
|
||||
"""Normalize role payloads without promoting unknown factors to triggers."""
|
||||
normalized = {}
|
||||
for code, role in (roles or {}).items():
|
||||
key = str(code or "").strip()
|
||||
value = str(role or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
normalized[key] = value if value in VALID_FACTOR_ROLES else UNKNOWN
|
||||
return normalized
|
||||
140
app/core/strategy_contract.py
Normal file
140
app/core/strategy_contract.py
Normal file
@ -0,0 +1,140 @@
|
||||
"""Stable strategy signal contract shared by scanners, recommendations and ledgers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.config.config_loader import get_meta
|
||||
from app.core.factor_roles import factor_roles_for_codes, validate_factor_roles
|
||||
from app.core.strategy_registry import MAIN_COMPOSITE_STRATEGY, normalize_strategy_code, strategy_label
|
||||
|
||||
|
||||
def _safe_dict(value: Any) -> dict:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return float(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategySignal:
|
||||
strategy_code: str
|
||||
symbol: str
|
||||
strategy_version: str = ""
|
||||
direction: str = "long"
|
||||
status: str = "candidate"
|
||||
confidence: float = 0.0
|
||||
score: float = 0.0
|
||||
run_id: str = ""
|
||||
trigger: dict[str, Any] = field(default_factory=dict)
|
||||
factor_roles: dict[str, str] = field(default_factory=dict)
|
||||
entry_plan: dict[str, Any] = field(default_factory=dict)
|
||||
risk_plan: dict[str, Any] = field(default_factory=dict)
|
||||
decision_log: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
def __post_init__(self):
|
||||
self.strategy_code = normalize_strategy_code(self.strategy_code)
|
||||
self.factor_roles = validate_factor_roles(self.factor_roles)
|
||||
|
||||
def to_json_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"strategy_code": self.strategy_code,
|
||||
"strategy_name": strategy_label(self.strategy_code),
|
||||
"strategy_version": self.strategy_version or "",
|
||||
"symbol": self.symbol,
|
||||
"direction": self.direction or "long",
|
||||
"status": self.status or "candidate",
|
||||
"confidence": round(_safe_float(self.confidence), 6),
|
||||
"score": round(_safe_float(self.score), 6),
|
||||
"run_id": self.run_id or "",
|
||||
"trigger": _safe_dict(self.trigger),
|
||||
"factor_roles": validate_factor_roles(self.factor_roles),
|
||||
"entry_plan": _safe_dict(self.entry_plan),
|
||||
"risk_plan": _safe_dict(self.risk_plan),
|
||||
"decision_log": _safe_dict(self.decision_log),
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
def current_strategy_version() -> str:
|
||||
try:
|
||||
return str(get_meta().get("strategy_version") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def default_main_composite_signal(
|
||||
*,
|
||||
symbol: str,
|
||||
score: float = 0,
|
||||
signal_codes: list[str] | None = None,
|
||||
entry_plan: dict | None = None,
|
||||
market_context: dict | None = None,
|
||||
decision_log: dict | None = None,
|
||||
) -> StrategySignal:
|
||||
return StrategySignal(
|
||||
strategy_code=MAIN_COMPOSITE_STRATEGY,
|
||||
strategy_version=current_strategy_version(),
|
||||
symbol=symbol,
|
||||
status="candidate",
|
||||
confidence=max(0.0, min(100.0, _safe_float(score))),
|
||||
score=_safe_float(score),
|
||||
trigger={
|
||||
"source": MAIN_COMPOSITE_STRATEGY,
|
||||
"signal_codes": signal_codes or [],
|
||||
"market_context": _safe_dict(market_context),
|
||||
},
|
||||
factor_roles=factor_roles_for_codes(signal_codes or []),
|
||||
entry_plan=_safe_dict(entry_plan),
|
||||
risk_plan={"source": "entry_quality_and_global_risk"},
|
||||
decision_log=_safe_dict(decision_log),
|
||||
)
|
||||
|
||||
|
||||
def strategy_context_payload(signal: StrategySignal | dict | None, *, fallback_symbol: str = "", fallback_score: float = 0, signal_codes: list[str] | None = None, entry_plan: dict | None = None, market_context: dict | None = None) -> dict[str, Any]:
|
||||
if isinstance(signal, StrategySignal):
|
||||
payload = signal.to_json_dict()
|
||||
elif isinstance(signal, dict):
|
||||
payload = dict(signal)
|
||||
else:
|
||||
payload = default_main_composite_signal(
|
||||
symbol=fallback_symbol,
|
||||
score=fallback_score,
|
||||
signal_codes=signal_codes or [],
|
||||
entry_plan=entry_plan,
|
||||
market_context=market_context,
|
||||
).to_json_dict()
|
||||
|
||||
payload["strategy_code"] = normalize_strategy_code(payload.get("strategy_code"))
|
||||
payload.setdefault("strategy_name", strategy_label(payload["strategy_code"]))
|
||||
payload.setdefault("strategy_version", current_strategy_version())
|
||||
payload.setdefault("symbol", fallback_symbol or payload.get("symbol") or "")
|
||||
payload.setdefault("score", fallback_score)
|
||||
payload.setdefault("confidence", payload.get("score") or fallback_score)
|
||||
payload["factor_roles"] = validate_factor_roles(payload.get("factor_roles") or factor_roles_for_codes(signal_codes or []))
|
||||
payload.setdefault("entry_plan", _safe_dict(entry_plan))
|
||||
payload.setdefault("trigger", {})
|
||||
payload.setdefault("risk_plan", {})
|
||||
payload.setdefault("decision_log", {})
|
||||
payload.setdefault("created_at", datetime.now().isoformat())
|
||||
return payload
|
||||
|
||||
|
||||
def signal_to_recommendation_context(signal: StrategySignal | dict | None, **fallback) -> dict[str, Any]:
|
||||
payload = strategy_context_payload(signal, **fallback)
|
||||
return {
|
||||
"strategy_code": payload["strategy_code"],
|
||||
"strategy_version": payload.get("strategy_version") or current_strategy_version(),
|
||||
"strategy_signal_id": int(payload.get("strategy_signal_id") or payload.get("id") or 0),
|
||||
"strategy_snapshot": payload,
|
||||
"factor_roles": payload.get("factor_roles") or {},
|
||||
}
|
||||
76
app/core/strategy_registry.py
Normal file
76
app/core/strategy_registry.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""Central registry for strategy identity and display metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
MAIN_COMPOSITE_STRATEGY = "main_composite_v1"
|
||||
BOX_RETEST_4H_STRATEGY = "box_retest_4h_v1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StrategyDefinition:
|
||||
strategy_code: str
|
||||
strategy_name: str
|
||||
description: str = ""
|
||||
mode: str = "paper_only"
|
||||
status: str = "active"
|
||||
|
||||
|
||||
STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
|
||||
MAIN_COMPOSITE_STRATEGY: StrategyDefinition(
|
||||
strategy_code=MAIN_COMPOSITE_STRATEGY,
|
||||
strategy_name="综合确认主链路",
|
||||
description="迁移期兼容主链路,承载现有综合筛选与确认逻辑。",
|
||||
mode="paper_enabled",
|
||||
),
|
||||
BOX_RETEST_4H_STRATEGY: StrategyDefinition(
|
||||
strategy_code=BOX_RETEST_4H_STRATEGY,
|
||||
strategy_name="4H箱体突破回踩",
|
||||
description="底部箱体突破后回踩箱体上沿或EMA承接的结构策略。",
|
||||
mode="paper_only",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def normalize_strategy_code(strategy_code: str | None) -> str:
|
||||
code = str(strategy_code or "").strip()
|
||||
return code or MAIN_COMPOSITE_STRATEGY
|
||||
|
||||
|
||||
def strategy_definition(strategy_code: str | None) -> StrategyDefinition:
|
||||
code = normalize_strategy_code(strategy_code)
|
||||
return STRATEGY_DEFINITIONS.get(
|
||||
code,
|
||||
StrategyDefinition(
|
||||
strategy_code=code,
|
||||
strategy_name=code,
|
||||
description="未注册策略,请补充 strategy_registry。",
|
||||
status="unknown",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def strategy_label(strategy_code: str | None) -> str:
|
||||
return strategy_definition(strategy_code).strategy_name
|
||||
|
||||
|
||||
def registered_strategy_codes() -> list[str]:
|
||||
return list(STRATEGY_DEFINITIONS.keys())
|
||||
|
||||
|
||||
def strategy_catalog_seed_rows(strategy_version: str = "") -> list[dict]:
|
||||
rows = []
|
||||
for item in STRATEGY_DEFINITIONS.values():
|
||||
rows.append(
|
||||
{
|
||||
"strategy_code": item.strategy_code,
|
||||
"strategy_name": item.strategy_name,
|
||||
"strategy_version": strategy_version or "",
|
||||
"status": item.status,
|
||||
"mode": item.mode,
|
||||
"description": item.description,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
@ -57,6 +57,7 @@ def init_db():
|
||||
def _sync_command_compat_hooks():
|
||||
"""Keep legacy altcoin_db monkeypatch hooks effective after command extraction."""
|
||||
_recommendation_commands.get_meta = get_meta
|
||||
_recommendation_commands.get_conn = get_conn
|
||||
_recommendation_commands.datetime = datetime
|
||||
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ from app.db.altcoin_db import (
|
||||
_is_executed_trade,
|
||||
)
|
||||
from app.core.opportunity_funnel import screening_stage_meta, stage_label
|
||||
from app.core.strategy_registry import normalize_strategy_code, strategy_label
|
||||
from app.db.schema import get_conn
|
||||
|
||||
|
||||
@ -1059,6 +1060,10 @@ def _recommendation_item(row):
|
||||
rec_result, rec_result_label = _classify_recommendation_result(item)
|
||||
item["recommendation_result"] = rec_result
|
||||
item["recommendation_result_label"] = rec_result_label
|
||||
item["strategy_code"] = normalize_strategy_code(item.get("strategy_code"))
|
||||
item["strategy_name"] = strategy_label(item["strategy_code"])
|
||||
item["strategy_snapshot"] = _loads_json(item.get("strategy_snapshot_json"), {})
|
||||
item["factor_roles"] = _loads_json(item.get("factor_roles_json"), {})
|
||||
_derive_execution_fields(item)
|
||||
return item
|
||||
|
||||
|
||||
128
app/db/migrations/0015_multi_strategy.sql
Normal file
128
app/db/migrations/0015_multi_strategy.sql
Normal file
@ -0,0 +1,128 @@
|
||||
ALTER TABLE recommendation
|
||||
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS strategy_snapshot_json TEXT DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS factor_roles_json TEXT DEFAULT '{}';
|
||||
|
||||
ALTER TABLE paper_trades
|
||||
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS strategy_snapshot_json TEXT DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS factor_roles_json TEXT DEFAULT '{}';
|
||||
|
||||
ALTER TABLE paper_orders
|
||||
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS strategy_snapshot_json TEXT DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS factor_roles_json TEXT DEFAULT '{}';
|
||||
|
||||
ALTER TABLE paper_trade_events
|
||||
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS strategy_catalog (
|
||||
strategy_code TEXT PRIMARY KEY,
|
||||
strategy_name TEXT NOT NULL,
|
||||
strategy_version TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'active',
|
||||
mode TEXT DEFAULT 'paper_only',
|
||||
description TEXT DEFAULT '',
|
||||
config_json TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS strategy_signals (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
run_id TEXT DEFAULT '',
|
||||
strategy_code TEXT NOT NULL,
|
||||
strategy_version TEXT DEFAULT '',
|
||||
symbol TEXT NOT NULL,
|
||||
direction TEXT DEFAULT 'long',
|
||||
signal_status TEXT DEFAULT 'candidate',
|
||||
confidence DOUBLE PRECISION DEFAULT 0,
|
||||
score DOUBLE PRECISION DEFAULT 0,
|
||||
market_regime TEXT DEFAULT '',
|
||||
trigger_json TEXT DEFAULT '{}',
|
||||
factor_roles_json TEXT DEFAULT '{}',
|
||||
entry_plan_json TEXT DEFAULT '{}',
|
||||
risk_plan_json TEXT DEFAULT '{}',
|
||||
decision_log_json TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_strategy_signals_code_time
|
||||
ON strategy_signals(strategy_code, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_strategy_signals_symbol_time
|
||||
ON strategy_signals(symbol, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_strategy_code_time
|
||||
ON recommendation(strategy_code, rec_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_paper_trades_strategy_code
|
||||
ON paper_trades(strategy_code, opened_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_paper_orders_strategy_code
|
||||
ON paper_orders(strategy_code, created_at DESC);
|
||||
|
||||
INSERT INTO strategy_catalog (
|
||||
strategy_code, strategy_name, strategy_version, status, mode, description, config_json, created_at, updated_at
|
||||
) VALUES
|
||||
('main_composite_v1', '综合确认主链路', '', 'active', 'paper_enabled', '迁移期兼容主链路:统一承载旧的综合筛选与确认逻辑。', '{}', NOW()::TEXT, NOW()::TEXT),
|
||||
('box_retest_4h_v1', '4H箱体突破回踩', '', 'active', 'paper_only', '底部箱体突破后回踩箱体上沿或EMA承接的结构策略雏形。', '{}', NOW()::TEXT, NOW()::TEXT)
|
||||
ON CONFLICT(strategy_code) DO UPDATE SET
|
||||
strategy_name=EXCLUDED.strategy_name,
|
||||
status=EXCLUDED.status,
|
||||
mode=EXCLUDED.mode,
|
||||
description=EXCLUDED.description,
|
||||
updated_at=NOW()::TEXT;
|
||||
|
||||
UPDATE recommendation
|
||||
SET strategy_code='main_composite_v1'
|
||||
WHERE COALESCE(strategy_code, '') = '';
|
||||
|
||||
UPDATE paper_trades pt
|
||||
SET strategy_code=COALESCE(NULLIF(r.strategy_code, ''), 'main_composite_v1'),
|
||||
strategy_signal_id=COALESCE(NULLIF(pt.strategy_signal_id, 0), r.strategy_signal_id, 0),
|
||||
strategy_snapshot_json=CASE
|
||||
WHEN COALESCE(pt.strategy_snapshot_json, '{}') != '{}' THEN pt.strategy_snapshot_json
|
||||
ELSE COALESCE(NULLIF(r.strategy_snapshot_json, ''), '{}')
|
||||
END,
|
||||
factor_roles_json=CASE
|
||||
WHEN COALESCE(pt.factor_roles_json, '{}') != '{}' THEN pt.factor_roles_json
|
||||
ELSE COALESCE(NULLIF(r.factor_roles_json, ''), '{}')
|
||||
END
|
||||
FROM recommendation r
|
||||
WHERE pt.recommendation_id = r.id
|
||||
AND COALESCE(pt.strategy_code, '') = '';
|
||||
|
||||
UPDATE paper_trades
|
||||
SET strategy_code='main_composite_v1'
|
||||
WHERE COALESCE(strategy_code, '') = '';
|
||||
|
||||
UPDATE paper_orders po
|
||||
SET strategy_code=COALESCE(NULLIF(r.strategy_code, ''), 'main_composite_v1'),
|
||||
strategy_signal_id=COALESCE(NULLIF(po.strategy_signal_id, 0), r.strategy_signal_id, 0),
|
||||
strategy_snapshot_json=CASE
|
||||
WHEN COALESCE(po.strategy_snapshot_json, '{}') != '{}' THEN po.strategy_snapshot_json
|
||||
ELSE COALESCE(NULLIF(r.strategy_snapshot_json, ''), '{}')
|
||||
END,
|
||||
factor_roles_json=CASE
|
||||
WHEN COALESCE(po.factor_roles_json, '{}') != '{}' THEN po.factor_roles_json
|
||||
ELSE COALESCE(NULLIF(r.factor_roles_json, ''), '{}')
|
||||
END
|
||||
FROM recommendation r
|
||||
WHERE po.recommendation_id = r.id
|
||||
AND COALESCE(po.strategy_code, '') = '';
|
||||
|
||||
UPDATE paper_orders
|
||||
SET strategy_code='main_composite_v1'
|
||||
WHERE COALESCE(strategy_code, '') = '';
|
||||
|
||||
UPDATE paper_trade_events e
|
||||
SET strategy_code=COALESCE(NULLIF(pt.strategy_code, ''), 'main_composite_v1'),
|
||||
strategy_signal_id=COALESCE(pt.strategy_signal_id, 0)
|
||||
FROM paper_trades pt
|
||||
WHERE e.trade_id = pt.id
|
||||
AND COALESCE(e.strategy_code, '') = '';
|
||||
|
||||
UPDATE paper_trade_events
|
||||
SET strategy_code='main_composite_v1'
|
||||
WHERE COALESCE(strategy_code, '') = '';
|
||||
@ -8,6 +8,7 @@ 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.core.strategy_registry import normalize_strategy_code, strategy_label
|
||||
from app.db.schema import get_conn
|
||||
from app.db.system_logs import record_system_error
|
||||
from app.integrations.feishu_push import push_card
|
||||
@ -316,6 +317,45 @@ def _entry_plan(rec: dict) -> dict:
|
||||
return _loads_json(rec.get("entry_plan_json"), {})
|
||||
|
||||
|
||||
def _strategy_lineage_from_rec(rec: dict) -> dict:
|
||||
code = normalize_strategy_code(rec.get("strategy_code"))
|
||||
signal_id = _safe_int(rec.get("strategy_signal_id"))
|
||||
snapshot = _loads_json(rec.get("strategy_snapshot_json"), {})
|
||||
roles = _loads_json(rec.get("factor_roles_json"), {})
|
||||
if not snapshot:
|
||||
snapshot = {
|
||||
"strategy_code": code,
|
||||
"strategy_name": strategy_label(code),
|
||||
"strategy_version": rec.get("strategy_version") or "",
|
||||
"symbol": rec.get("symbol") or "",
|
||||
"source": "recommendation_compat",
|
||||
}
|
||||
snapshot.setdefault("strategy_code", code)
|
||||
snapshot.setdefault("strategy_name", strategy_label(code))
|
||||
return {
|
||||
"strategy_code": code,
|
||||
"strategy_name": strategy_label(code),
|
||||
"strategy_signal_id": signal_id,
|
||||
"strategy_snapshot": snapshot,
|
||||
"factor_roles": roles if isinstance(roles, dict) else {},
|
||||
"strategy_snapshot_json": json.dumps(snapshot, ensure_ascii=False, default=str),
|
||||
"factor_roles_json": json.dumps(roles if isinstance(roles, dict) else {}, ensure_ascii=False, default=str),
|
||||
}
|
||||
|
||||
|
||||
def _strategy_lineage_from_trade_or_order(item: dict) -> dict:
|
||||
code = normalize_strategy_code(item.get("strategy_code"))
|
||||
snapshot = _loads_json(item.get("strategy_snapshot_json"), {})
|
||||
roles = _loads_json(item.get("factor_roles_json"), {})
|
||||
return {
|
||||
"strategy_code": code,
|
||||
"strategy_name": strategy_label(code),
|
||||
"strategy_signal_id": _safe_int(item.get("strategy_signal_id")),
|
||||
"strategy_snapshot": snapshot if isinstance(snapshot, dict) else {},
|
||||
"factor_roles": roles if isinstance(roles, dict) else {},
|
||||
}
|
||||
|
||||
|
||||
def _parse_time(value: str):
|
||||
try:
|
||||
return datetime.fromisoformat(str(value or ""))
|
||||
@ -390,16 +430,34 @@ def _decorate_trade(trade: dict, config: dict | None = None) -> dict:
|
||||
latest_market = _safe_float(item.get("latest_market_price"))
|
||||
item["latest_price"] = latest_market if latest_market > 0 else _safe_float(item.get("current_price"))
|
||||
item["latest_price_updated_at"] = item.get("latest_market_price_updated_at") or item.get("updated_at") or ""
|
||||
item.update(_strategy_lineage_from_trade_or_order(item))
|
||||
return item
|
||||
|
||||
|
||||
def _record_event(conn, trade_id: int, rec_id: int, symbol: str, event_type: str, price: float, pnl_pct: float, message: str, detail=None, event_time: str = ""):
|
||||
detail = dict(detail or {})
|
||||
strategy_code = str(detail.get("strategy_code") or "").strip()
|
||||
strategy_signal_id = _safe_int(detail.get("strategy_signal_id"))
|
||||
if (not strategy_code or strategy_signal_id <= 0) and _safe_int(trade_id) > 0:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT strategy_code, strategy_signal_id FROM paper_trades WHERE id=%s",
|
||||
(_safe_int(trade_id),),
|
||||
).fetchone()
|
||||
if row:
|
||||
strategy_code = strategy_code or row.get("strategy_code") or ""
|
||||
strategy_signal_id = strategy_signal_id or _safe_int(row.get("strategy_signal_id"))
|
||||
except Exception:
|
||||
pass
|
||||
strategy_code = normalize_strategy_code(strategy_code)
|
||||
detail.setdefault("strategy_code", strategy_code)
|
||||
detail.setdefault("strategy_name", strategy_label(strategy_code))
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO paper_trade_events (
|
||||
trade_id, recommendation_id, symbol, event_type, event_time,
|
||||
price, pnl_pct, message, detail_json
|
||||
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
price, pnl_pct, message, detail_json, strategy_code, strategy_signal_id
|
||||
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""",
|
||||
(
|
||||
trade_id,
|
||||
@ -410,7 +468,9 @@ def _record_event(conn, trade_id: int, rec_id: int, symbol: str, event_type: str
|
||||
price,
|
||||
pnl_pct,
|
||||
message,
|
||||
json.dumps(detail or {}, ensure_ascii=False, default=str),
|
||||
json.dumps(detail, ensure_ascii=False, default=str),
|
||||
strategy_code,
|
||||
strategy_signal_id,
|
||||
),
|
||||
)
|
||||
|
||||
@ -657,14 +717,16 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
|
||||
tp2 = _safe_float(rec.get("tp2") or plan.get("tp2") or plan.get("take_profit_2"))
|
||||
fee = round(notional * default_fee_rate(cfg), 8)
|
||||
now = event_time or _now()
|
||||
lineage = _strategy_lineage_from_rec(rec)
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO paper_trades (
|
||||
recommendation_id, symbol, side, status, opened_at,
|
||||
entry_price, qty, notional_usdt, margin_usdt, leverage, stop_loss, tp1, tp2,
|
||||
max_price, min_price, current_price, pnl_pct, fee_usdt,
|
||||
source_status, source_action, strategy_version, created_at, updated_at
|
||||
) VALUES (%s,%s,'long','open',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,%s,%s,%s,%s,%s,%s)
|
||||
source_status, source_action, strategy_version, strategy_code, strategy_signal_id,
|
||||
strategy_snapshot_json, factor_roles_json, created_at, updated_at
|
||||
) VALUES (%s,%s,'long','open',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
ON CONFLICT(recommendation_id) DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
@ -687,6 +749,10 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
|
||||
rec.get("execution_status") or "",
|
||||
rec.get("action_status") or "",
|
||||
rec.get("strategy_version") or "",
|
||||
lineage["strategy_code"],
|
||||
lineage["strategy_signal_id"],
|
||||
lineage["strategy_snapshot_json"],
|
||||
lineage["factor_roles_json"],
|
||||
now,
|
||||
now,
|
||||
),
|
||||
@ -712,6 +778,11 @@ 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 "",
|
||||
"strategy_code": lineage["strategy_code"],
|
||||
"strategy_name": lineage["strategy_name"],
|
||||
"strategy_signal_id": lineage["strategy_signal_id"],
|
||||
"strategy_snapshot": lineage["strategy_snapshot"],
|
||||
"factor_roles": lineage["factor_roles"],
|
||||
"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 {},
|
||||
@ -939,6 +1010,7 @@ def _order_recommendation_cancel_reason(conn, rec: dict, order: dict) -> str:
|
||||
def _order_payload_from_rec(rec: dict, current_price: float, event_time: str, config: dict | None = None) -> dict:
|
||||
cfg = _paper_cfg(config)
|
||||
plan = _entry_plan(rec)
|
||||
lineage = _strategy_lineage_from_rec(rec)
|
||||
return {
|
||||
"recommendation_id": _safe_int(rec.get("id")),
|
||||
"symbol": str(rec.get("symbol") or "").strip().upper(),
|
||||
@ -954,6 +1026,11 @@ def _order_payload_from_rec(rec: dict, current_price: float, event_time: str, co
|
||||
"tp1": _safe_float(rec.get("tp1") or plan.get("tp1") or plan.get("take_profit_1")),
|
||||
"tp2": _safe_float(rec.get("tp2") or plan.get("tp2") or plan.get("take_profit_2")),
|
||||
"strategy_version": str(rec.get("strategy_version") or ""),
|
||||
"strategy_code": lineage["strategy_code"],
|
||||
"strategy_name": lineage["strategy_name"],
|
||||
"strategy_signal_id": lineage["strategy_signal_id"],
|
||||
"strategy_snapshot_json": lineage["strategy_snapshot_json"],
|
||||
"factor_roles_json": lineage["factor_roles_json"],
|
||||
"entry_plan_snapshot_json": json.dumps(plan, ensure_ascii=False, default=str),
|
||||
"created_at": event_time,
|
||||
"updated_at": event_time,
|
||||
@ -1078,9 +1155,10 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time:
|
||||
INSERT INTO paper_orders (
|
||||
recommendation_id, symbol, side, order_type, status,
|
||||
source_status, source_action, target_price, current_price_at_create,
|
||||
notional_usdt, stop_loss, tp1, tp2, strategy_version,
|
||||
notional_usdt, stop_loss, tp1, tp2, strategy_version, strategy_code,
|
||||
strategy_signal_id, strategy_snapshot_json, factor_roles_json,
|
||||
entry_plan_snapshot_json, created_at, updated_at, expires_at
|
||||
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
ON CONFLICT(recommendation_id) DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
@ -1099,6 +1177,10 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time:
|
||||
payload["tp1"],
|
||||
payload["tp2"],
|
||||
payload["strategy_version"],
|
||||
payload["strategy_code"],
|
||||
payload["strategy_signal_id"],
|
||||
payload["strategy_snapshot_json"],
|
||||
payload["factor_roles_json"],
|
||||
payload["entry_plan_snapshot_json"],
|
||||
payload["created_at"],
|
||||
payload["updated_at"],
|
||||
@ -1423,6 +1505,10 @@ def sync_pending_paper_orders(limit: int = 100, event_time: str = "", config: di
|
||||
r.tp1 AS rec_tp1,
|
||||
r.tp2 AS rec_tp2,
|
||||
r.strategy_version AS rec_strategy_version,
|
||||
r.strategy_code AS rec_strategy_code,
|
||||
r.strategy_signal_id AS rec_strategy_signal_id,
|
||||
r.strategy_snapshot_json AS rec_strategy_snapshot_json,
|
||||
r.factor_roles_json AS rec_factor_roles_json,
|
||||
r.entry_plan_json,
|
||||
r.market_context_json,
|
||||
r.derivatives_context_json,
|
||||
@ -1458,6 +1544,10 @@ def sync_pending_paper_orders(limit: int = 100, event_time: str = "", config: di
|
||||
"tp1",
|
||||
"tp2",
|
||||
"strategy_version",
|
||||
"strategy_code",
|
||||
"strategy_signal_id",
|
||||
"strategy_snapshot_json",
|
||||
"factor_roles_json",
|
||||
"entry_plan_snapshot_json",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
@ -1482,6 +1572,10 @@ def sync_pending_paper_orders(limit: int = 100, event_time: str = "", config: di
|
||||
"tp1": item.get("rec_tp1") or item.get("tp1"),
|
||||
"tp2": item.get("rec_tp2") or item.get("tp2"),
|
||||
"strategy_version": item.get("rec_strategy_version") or item.get("strategy_version"),
|
||||
"strategy_code": item.get("rec_strategy_code") or item.get("strategy_code"),
|
||||
"strategy_signal_id": item.get("rec_strategy_signal_id") or item.get("strategy_signal_id"),
|
||||
"strategy_snapshot_json": item.get("rec_strategy_snapshot_json") or item.get("strategy_snapshot_json"),
|
||||
"factor_roles_json": item.get("rec_factor_roles_json") or item.get("factor_roles_json"),
|
||||
"entry_plan_json": item.get("entry_plan_json") or item.get("entry_plan_snapshot_json"),
|
||||
"market_context_json": item.get("market_context_json"),
|
||||
"derivatives_context_json": item.get("derivatives_context_json"),
|
||||
@ -1676,15 +1770,21 @@ def get_paper_trading_performance(days: int = 30) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "") -> dict:
|
||||
def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "", strategy_code: str = "") -> dict:
|
||||
limit = max(1, min(_safe_int(limit, 50), 200))
|
||||
offset = max(0, _safe_int(offset, 0))
|
||||
status = str(status or "").strip()
|
||||
where = ""
|
||||
params = []
|
||||
clauses = []
|
||||
if status in {"open", "closed"}:
|
||||
where = "WHERE status=%s"
|
||||
clauses.append("status=%s")
|
||||
params.append(status)
|
||||
strategy_code = str(strategy_code or "").strip()
|
||||
if strategy_code:
|
||||
clauses.append("strategy_code=%s")
|
||||
params.append(normalize_strategy_code(strategy_code))
|
||||
where = "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
conn = get_conn()
|
||||
try:
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM paper_trades {where}", tuple(params)).fetchone()[0]
|
||||
@ -1711,15 +1811,21 @@ def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "") -> dic
|
||||
}
|
||||
|
||||
|
||||
def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "") -> dict:
|
||||
def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "", strategy_code: str = "") -> dict:
|
||||
limit = max(1, min(_safe_int(limit, 50), 200))
|
||||
offset = max(0, _safe_int(offset, 0))
|
||||
status = str(status or "").strip()
|
||||
where = ""
|
||||
params = []
|
||||
clauses = []
|
||||
if status in {"pending", "filled", "canceled", "expired", "rejected"}:
|
||||
where = "WHERE status=%s"
|
||||
clauses.append("status=%s")
|
||||
params.append(status)
|
||||
strategy_code = str(strategy_code or "").strip()
|
||||
if strategy_code:
|
||||
clauses.append("strategy_code=%s")
|
||||
params.append(normalize_strategy_code(strategy_code))
|
||||
where = "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
conn = get_conn()
|
||||
try:
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM paper_orders {where}", tuple(params)).fetchone()[0]
|
||||
@ -1743,6 +1849,7 @@ def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "") -> dic
|
||||
latest_market = _safe_float(item.get("latest_market_price"))
|
||||
item["latest_price"] = latest_market if latest_market > 0 else _safe_float(item.get("current_price_at_create"))
|
||||
item["latest_price_updated_at"] = item.get("latest_market_price_updated_at") or item.get("updated_at") or ""
|
||||
item.update(_strategy_lineage_from_trade_or_order(item))
|
||||
target = _safe_float(item.get("target_price"))
|
||||
latest = _safe_float(item.get("latest_price"))
|
||||
item["distance_to_target_pct"] = round((latest / target - 1) * 100, 4) if target and latest else 0
|
||||
@ -1788,7 +1895,9 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "",
|
||||
t.leverage,
|
||||
t.exit_reason,
|
||||
t.opened_at,
|
||||
t.closed_at
|
||||
t.closed_at,
|
||||
t.strategy_code AS trade_strategy_code,
|
||||
t.strategy_signal_id AS trade_strategy_signal_id
|
||||
FROM paper_trade_events e
|
||||
LEFT JOIN paper_trades t ON t.id = e.trade_id
|
||||
{where_sql}
|
||||
@ -1803,6 +1912,9 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "",
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item["detail"] = _loads_json(item.pop("detail_json", "{}"), {})
|
||||
item["strategy_code"] = normalize_strategy_code(item.get("strategy_code") or item.get("trade_strategy_code"))
|
||||
item["strategy_name"] = strategy_label(item["strategy_code"])
|
||||
item["strategy_signal_id"] = _safe_int(item.get("strategy_signal_id") or item.get("trade_strategy_signal_id"))
|
||||
items.append(item)
|
||||
return {
|
||||
"items": items,
|
||||
|
||||
@ -10,6 +10,8 @@ from app.core.opportunity_lifecycle import (
|
||||
normalize_json_object,
|
||||
)
|
||||
from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels
|
||||
from app.core.strategy_contract import signal_to_recommendation_context
|
||||
from app.core.strategy_registry import normalize_strategy_code
|
||||
from app.db.recommendation_state import (
|
||||
derive_minimal_state_fields,
|
||||
entry_window_policy,
|
||||
@ -70,11 +72,33 @@ def create_recommendation(
|
||||
market_context=None,
|
||||
derivatives_context=None,
|
||||
sector_context=None,
|
||||
strategy_code="",
|
||||
strategy_signal_id=0,
|
||||
strategy_snapshot=None,
|
||||
factor_roles=None,
|
||||
):
|
||||
"""Create or merge the current recommendation record for one symbol."""
|
||||
raw_pct = round(rec_score * 100.0 / 30) if rec_score else 0
|
||||
rec_score_pct = min(raw_pct, 100)
|
||||
strategy_version = str(get_meta().get("strategy_version") or "").strip()
|
||||
strategy_context = signal_to_recommendation_context(
|
||||
strategy_snapshot if strategy_snapshot else {
|
||||
"strategy_code": strategy_code,
|
||||
"strategy_signal_id": strategy_signal_id,
|
||||
"factor_roles": factor_roles or {},
|
||||
},
|
||||
fallback_symbol=symbol,
|
||||
fallback_score=rec_score_pct,
|
||||
signal_codes=build_signal_codes(build_signal_labels(signals if isinstance(signals, list) else normalize_signals(signals))),
|
||||
entry_plan=entry_plan or {},
|
||||
market_context=market_context or {},
|
||||
)
|
||||
strategy_code = normalize_strategy_code(strategy_context.get("strategy_code"))
|
||||
strategy_signal_id = int(strategy_context.get("strategy_signal_id") or 0)
|
||||
strategy_snapshot = strategy_context.get("strategy_snapshot") or {}
|
||||
factor_roles = strategy_context.get("factor_roles") or {}
|
||||
strategy_version = str(get_meta().get("strategy_version") or strategy_context.get("strategy_version") or "").strip()
|
||||
if isinstance(strategy_snapshot, dict):
|
||||
strategy_snapshot["strategy_version"] = strategy_version
|
||||
now = datetime.now().isoformat()
|
||||
conn = get_conn()
|
||||
|
||||
@ -103,6 +127,10 @@ def create_recommendation(
|
||||
UPDATE recommendation
|
||||
SET rec_state=%s, rec_score=%s, sector=COALESCE(NULLIF(%s, ''), sector),
|
||||
signals=%s, signal_codes_json=%s, signal_labels_json=%s, is_meme=%s, direction=%s, strategy_version=%s,
|
||||
strategy_code=COALESCE(NULLIF(%s, ''), NULLIF(strategy_code, ''), 'main_composite_v1'),
|
||||
strategy_signal_id=CASE WHEN %s > 0 THEN %s ELSE COALESCE(strategy_signal_id, 0) END,
|
||||
strategy_snapshot_json=CASE WHEN %s != '{}' THEN %s ELSE COALESCE(strategy_snapshot_json, '{}') END,
|
||||
factor_roles_json=CASE WHEN %s != '{}' THEN %s ELSE COALESCE(factor_roles_json, '{}') END,
|
||||
force_reason=COALESCE(NULLIF(%s, ''), force_reason),
|
||||
base_state=COALESCE(NULLIF(%s, ''), base_state),
|
||||
sector_signal_count=GREATEST(COALESCE(sector_signal_count,0), %s),
|
||||
@ -131,6 +159,13 @@ def create_recommendation(
|
||||
is_meme,
|
||||
direction,
|
||||
strategy_version,
|
||||
strategy_code,
|
||||
strategy_signal_id,
|
||||
strategy_signal_id,
|
||||
json.dumps(strategy_snapshot or {}, ensure_ascii=False),
|
||||
json.dumps(strategy_snapshot or {}, ensure_ascii=False),
|
||||
json.dumps(factor_roles or {}, ensure_ascii=False),
|
||||
json.dumps(factor_roles or {}, ensure_ascii=False),
|
||||
force_reason or "",
|
||||
base_state or "",
|
||||
int(sector_signal_count or 0),
|
||||
@ -167,8 +202,8 @@ def create_recommendation(
|
||||
market_context_json, derivatives_context_json, sector_context_json,
|
||||
opportunity_level, opportunity_level_label, holding_horizon, entry_model, stop_model, tp_model,
|
||||
action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason,
|
||||
strategy_version)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
strategy_code, strategy_signal_id, strategy_snapshot_json, factor_roles_json, strategy_version)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
@ -209,6 +244,10 @@ def create_recommendation(
|
||||
incoming_lifecycle,
|
||||
incoming_triggered,
|
||||
incoming_reason,
|
||||
strategy_code,
|
||||
strategy_signal_id,
|
||||
json.dumps(strategy_snapshot or {}, ensure_ascii=False),
|
||||
json.dumps(factor_roles or {}, ensure_ascii=False),
|
||||
strategy_version,
|
||||
),
|
||||
)
|
||||
|
||||
@ -9,6 +9,7 @@ from app.db.recommendation_state import (
|
||||
derive_execution_fields as _derive_execution_fields,
|
||||
is_actionable_execution_status as _is_actionable_execution_status,
|
||||
)
|
||||
from app.core.strategy_registry import normalize_strategy_code, strategy_label
|
||||
from app.db.push_queries import get_recommendation_for_push, log_push, should_push
|
||||
from app.db.schema import get_conn
|
||||
from app.db.tracking_queries import update_recommendation_tracking
|
||||
@ -62,6 +63,9 @@ def _attach_paper_order(item: dict) -> dict:
|
||||
"filled_at": item.get("paper_order_filled_at") or "",
|
||||
"canceled_at": item.get("paper_order_canceled_at") or "",
|
||||
"cancel_reason": item.get("paper_order_cancel_reason") or "",
|
||||
"strategy_code": normalize_strategy_code(item.get("paper_order_strategy_code") or item.get("strategy_code")),
|
||||
"strategy_name": strategy_label(item.get("paper_order_strategy_code") or item.get("strategy_code")),
|
||||
"strategy_signal_id": _safe_int(item.get("paper_order_strategy_signal_id") or item.get("strategy_signal_id")),
|
||||
}
|
||||
item["paper_order"] = order
|
||||
item["paper_order_status"] = order["status"]
|
||||
@ -77,6 +81,10 @@ def _decorate_recommendation(item: dict) -> dict:
|
||||
item["market_context"] = _loads_json(item.get("market_context_json"), {})
|
||||
item["derivatives_context"] = _loads_json(item.get("derivatives_context_json"), {})
|
||||
item["sector_context"] = _loads_json(item.get("sector_context_json"), {})
|
||||
item["strategy_code"] = normalize_strategy_code(item.get("strategy_code"))
|
||||
item["strategy_name"] = strategy_label(item["strategy_code"])
|
||||
item["strategy_snapshot"] = _loads_json(item.get("strategy_snapshot_json"), {})
|
||||
item["factor_roles"] = _loads_json(item.get("factor_roles_json"), {})
|
||||
rec_result, rec_result_label = _classify_recommendation_result(item)
|
||||
item["recommendation_result"] = rec_result
|
||||
item["recommendation_result_label"] = rec_result_label
|
||||
@ -242,7 +250,9 @@ def get_active_recommendations_deduped(
|
||||
po.expires_at AS paper_order_expires_at,
|
||||
po.filled_at AS paper_order_filled_at,
|
||||
po.canceled_at AS paper_order_canceled_at,
|
||||
po.cancel_reason AS paper_order_cancel_reason
|
||||
po.cancel_reason AS paper_order_cancel_reason,
|
||||
po.strategy_code AS paper_order_strategy_code,
|
||||
po.strategy_signal_id AS paper_order_strategy_signal_id
|
||||
FROM recommendation r
|
||||
LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol
|
||||
LEFT JOIN paper_orders po ON po.recommendation_id = r.id
|
||||
|
||||
@ -10,6 +10,7 @@ from app.core.opportunity_lifecycle import (
|
||||
is_executed_lifecycle,
|
||||
normalize_action_status,
|
||||
)
|
||||
from app.core.strategy_registry import normalize_strategy_code, strategy_label
|
||||
|
||||
|
||||
def state_fields_for_storage(status, action_status, execution_status="", reason=""):
|
||||
@ -410,6 +411,17 @@ def derive_execution_fields(item):
|
||||
item["sector_signal_count"] = sector_signal_count
|
||||
item["strategy_version"] = strategy_version
|
||||
item["strategy_version_label"] = f"策略版本 {strategy_version}" if strategy_version else ""
|
||||
strategy_code = normalize_strategy_code(item.get("strategy_code"))
|
||||
item["strategy_code"] = strategy_code
|
||||
item["strategy_name"] = strategy_label(strategy_code)
|
||||
try:
|
||||
item["strategy_snapshot"] = json.loads(item.get("strategy_snapshot_json") or "{}")
|
||||
except Exception:
|
||||
item["strategy_snapshot"] = {}
|
||||
try:
|
||||
item["factor_roles"] = json.loads(item.get("factor_roles_json") or "{}")
|
||||
except Exception:
|
||||
item["factor_roles"] = {}
|
||||
attach_discovery_trade_fields(item)
|
||||
return item
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from app.core.strategy_registry import normalize_strategy_code, strategy_label
|
||||
from app.db.schema import get_conn
|
||||
|
||||
|
||||
@ -47,12 +48,14 @@ def get_strategy_insights():
|
||||
pt.side AS paper_side,
|
||||
pt.source_status AS paper_source_status,
|
||||
pt.source_action AS paper_source_action,
|
||||
pt.strategy_code AS paper_strategy_code,
|
||||
pt.realized_pnl_pct AS paper_realized_pnl_pct,
|
||||
pt.realized_pnl_usdt AS paper_realized_pnl_usdt,
|
||||
pt.pnl_pct AS paper_pnl_pct,
|
||||
pt.exit_reason AS paper_exit_reason,
|
||||
po.id AS paper_order_id,
|
||||
po.status AS paper_order_status
|
||||
po.status AS paper_order_status,
|
||||
po.strategy_code AS paper_order_strategy_code
|
||||
FROM recommendation r
|
||||
LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id
|
||||
LEFT JOIN paper_orders po ON po.recommendation_id = r.id
|
||||
@ -122,6 +125,8 @@ def get_strategy_insights():
|
||||
trade_env_map = {}
|
||||
trade_evidence_map = {}
|
||||
trade_version_map = {}
|
||||
strategy_code_map = {}
|
||||
trade_strategy_code_map = {}
|
||||
trade_factor_group_map = {}
|
||||
trade_regime_map = {}
|
||||
trade_score_band_map = {}
|
||||
@ -153,6 +158,8 @@ def get_strategy_insights():
|
||||
add_bucket(env_map, bucket, item)
|
||||
if item.get("strategy_version"):
|
||||
add_bucket(version_map, str(item.get("strategy_version")).strip(), item)
|
||||
strategy_code = normalize_strategy_code(item.get("strategy_code") or item.get("paper_strategy_code") or item.get("paper_order_strategy_code"))
|
||||
add_bucket(strategy_code_map, strategy_code, item)
|
||||
if (item.get("execution_status") or "") in {"observe", "wait_pullback"} or (item.get("display_bucket") or "") == "watch_pool":
|
||||
add_watch_bucket(watch_map, watch_bucket(item), item)
|
||||
if item.get("paper_order_id"):
|
||||
@ -182,6 +189,7 @@ def get_strategy_insights():
|
||||
add_trade_bucket(trade_evidence_map, "链上:" + text, item)
|
||||
if item.get("strategy_version"):
|
||||
add_trade_bucket(trade_version_map, str(item.get("strategy_version")).strip(), item)
|
||||
add_trade_bucket(trade_strategy_code_map, strategy_code, item)
|
||||
|
||||
return {
|
||||
"overview": overview,
|
||||
@ -195,6 +203,7 @@ def get_strategy_insights():
|
||||
"market_environment": serialize_buckets("environment", env_map)[:20],
|
||||
"evidence_attribution": serialize_buckets("evidence", evidence_map)[:20],
|
||||
"version_performance": serialize_buckets("strategy_version", version_map, sort_by_version=True)[:20],
|
||||
"strategy_performance": add_strategy_labels(serialize_buckets("strategy_code", strategy_code_map)[:20]),
|
||||
"trade_attribution": {
|
||||
"definition": "交易级归因只统计已平仓策略交易,用 realized_pnl_usdt / realized_pnl_pct 衡量因子、入场路径、退出原因和环境的真实账本表现。",
|
||||
"factor": serialize_trade_buckets("factor", trade_factor_map)[:30],
|
||||
@ -206,6 +215,7 @@ def get_strategy_insights():
|
||||
"market_environment": serialize_trade_buckets("environment", trade_env_map)[:20],
|
||||
"evidence": serialize_trade_buckets("evidence", trade_evidence_map)[:20],
|
||||
"strategy_version": serialize_trade_buckets("strategy_version", trade_version_map, sort_by_version=True)[:20],
|
||||
"strategy_code": add_strategy_labels(serialize_trade_buckets("strategy_code", trade_strategy_code_map)[:20]),
|
||||
},
|
||||
"watch_order_attribution": {
|
||||
"definition": "观察池和挂单池只评价机会是否推进,不计入交易收益;用于判断没买/等回踩是否合理。",
|
||||
@ -215,6 +225,14 @@ def get_strategy_insights():
|
||||
}
|
||||
|
||||
|
||||
def add_strategy_labels(rows):
|
||||
for item in rows or []:
|
||||
code = item.get("strategy_code") or item.get("name") or ""
|
||||
item["strategy_code"] = normalize_strategy_code(code)
|
||||
item["strategy_name"] = strategy_label(item["strategy_code"])
|
||||
return rows
|
||||
|
||||
|
||||
def add_trade_bucket(bucket_map, key, item):
|
||||
if not key:
|
||||
return
|
||||
|
||||
102
app/db/strategy_signal_queries.py
Normal file
102
app/db/strategy_signal_queries.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""DB helpers for standard strategy signals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.strategy_contract import StrategySignal, strategy_context_payload
|
||||
from app.core.strategy_registry import normalize_strategy_code
|
||||
from app.db.schema import get_conn
|
||||
|
||||
|
||||
def insert_strategy_signal(signal: StrategySignal | dict) -> dict:
|
||||
payload = signal.to_json_dict() if isinstance(signal, StrategySignal) else strategy_context_payload(signal)
|
||||
now = payload.get("created_at") or datetime.now().isoformat()
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO strategy_signals (
|
||||
run_id, strategy_code, strategy_version, symbol, direction, signal_status,
|
||||
confidence, score, market_regime, trigger_json, factor_roles_json,
|
||||
entry_plan_json, risk_plan_json, decision_log_json, created_at
|
||||
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
payload.get("run_id") or "",
|
||||
normalize_strategy_code(payload.get("strategy_code")),
|
||||
payload.get("strategy_version") or "",
|
||||
payload.get("symbol") or "",
|
||||
payload.get("direction") or "long",
|
||||
payload.get("status") or payload.get("signal_status") or "candidate",
|
||||
float(payload.get("confidence") or 0),
|
||||
float(payload.get("score") or 0),
|
||||
(payload.get("trigger") or {}).get("market_regime") or "",
|
||||
json.dumps(payload.get("trigger") or {}, ensure_ascii=False, default=str),
|
||||
json.dumps(payload.get("factor_roles") or {}, ensure_ascii=False, default=str),
|
||||
json.dumps(payload.get("entry_plan") or {}, ensure_ascii=False, default=str),
|
||||
json.dumps(payload.get("risk_plan") or {}, ensure_ascii=False, default=str),
|
||||
json.dumps(payload.get("decision_log") or {}, ensure_ascii=False, default=str),
|
||||
now,
|
||||
),
|
||||
).fetchone()
|
||||
signal_id = int(row["id"] if row else 0)
|
||||
conn.commit()
|
||||
payload["strategy_signal_id"] = signal_id
|
||||
payload["id"] = signal_id
|
||||
return payload
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_recent_strategy_signals(strategy_code: str = "", symbol: str = "", limit: int = 50) -> list[dict]:
|
||||
limit = max(1, min(int(limit or 50), 500))
|
||||
where = []
|
||||
params = []
|
||||
if strategy_code:
|
||||
where.append("strategy_code=%s")
|
||||
params.append(normalize_strategy_code(strategy_code))
|
||||
if symbol:
|
||||
where.append("symbol=%s")
|
||||
params.append(symbol)
|
||||
where_sql = "WHERE " + " AND ".join(where) if where else ""
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM strategy_signals
|
||||
{where_sql}
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
tuple(params + [limit]),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_strategy_signal_summary(days: int = 7) -> list[dict]:
|
||||
days = max(1, min(int(days or 7), 365))
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT strategy_code,
|
||||
COUNT(*) AS signal_count,
|
||||
AVG(score) AS avg_score,
|
||||
AVG(confidence) AS avg_confidence,
|
||||
MAX(created_at) AS latest_at
|
||||
FROM strategy_signals
|
||||
WHERE created_at >= (NOW() - (%s || ' days')::interval)::TEXT
|
||||
GROUP BY strategy_code
|
||||
ORDER BY signal_count DESC, latest_at DESC
|
||||
""",
|
||||
(str(days),),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
@ -51,7 +51,9 @@ 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.db.strategy_signal_queries import insert_strategy_signal
|
||||
from app.services.market_overview import get_crypto_market_overview
|
||||
from app.strategies.box_retest_4h import build_box_retest_signal
|
||||
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,
|
||||
@ -63,6 +65,30 @@ exchange = ccxt.binance({"enableRateLimit": True})
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan: dict) -> dict:
|
||||
"""Build and persist a standard strategy signal when an independent strategy matches."""
|
||||
bp_4h = result.get("box_breakout_pullback_4h") or (result.get("market_context") or {}).get("box_breakout_pullback_4h") or {}
|
||||
if not bp_4h.get("detected"):
|
||||
return {}
|
||||
signal = build_box_retest_signal(
|
||||
symbol=symbol,
|
||||
current_price=result.get("price") or 0,
|
||||
detection=bp_4h,
|
||||
entry_plan=entry_plan or {},
|
||||
market_regime=result.get("market_regime") or (result.get("market_context") or {}).get("market_regime") or {},
|
||||
decision_log=result.get("decision_log") or {},
|
||||
)
|
||||
if not signal:
|
||||
return {}
|
||||
payload = insert_strategy_signal(signal)
|
||||
return {
|
||||
"strategy_code": payload.get("strategy_code"),
|
||||
"strategy_signal_id": payload.get("strategy_signal_id") or payload.get("id") or 0,
|
||||
"strategy_snapshot": payload,
|
||||
"factor_roles": payload.get("factor_roles") or {},
|
||||
}
|
||||
|
||||
|
||||
def fetch_klines(symbol, timeframe, limit=200):
|
||||
try:
|
||||
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
|
||||
@ -1873,6 +1899,7 @@ def main(compact: bool = False):
|
||||
if plan_entry > 0 and (plan_stop >= plan_entry or (plan_tp1 > 0 and plan_tp1 <= plan_entry)):
|
||||
rec_entry_price = result["price"]
|
||||
previous_rec_id = _active_recommendation_id(symbol)
|
||||
strategy_ctx = _strategy_context_for_recommendation(symbol, result, ep)
|
||||
rec_id = create_recommendation(
|
||||
symbol=symbol, rec_state="爆发", rec_score=result["score"],
|
||||
entry_price=rec_entry_price,
|
||||
@ -1885,6 +1912,7 @@ def main(compact: bool = False):
|
||||
market_context=result.get("market_context"),
|
||||
derivatives_context=result.get("derivatives_context"),
|
||||
sector_context=result.get("sector_context"),
|
||||
**strategy_ctx,
|
||||
)
|
||||
update_latest_price_cache(symbol, result["price"], updated_at=datetime.now().isoformat(), source="confirm")
|
||||
result["rec_id"] = rec_id
|
||||
@ -1927,6 +1955,7 @@ def main(compact: bool = False):
|
||||
)
|
||||
if _should_publish_watch_candidate(cand, result):
|
||||
watch_plan = _watch_candidate_plan(symbol, result, cand_detail)
|
||||
strategy_ctx = _strategy_context_for_recommendation(symbol, result, watch_plan)
|
||||
rec_id = create_recommendation(
|
||||
symbol=symbol,
|
||||
rec_state="观察",
|
||||
@ -1943,6 +1972,7 @@ def main(compact: bool = False):
|
||||
market_context=result.get("market_context"),
|
||||
derivatives_context=result.get("derivatives_context"),
|
||||
sector_context=result.get("sector_context"),
|
||||
**strategy_ctx,
|
||||
)
|
||||
update_latest_price_cache(symbol, result["price"], updated_at=datetime.now().isoformat(), source="confirm_watch")
|
||||
result["rec_id"] = rec_id
|
||||
|
||||
2
app/strategies/__init__.py
Normal file
2
app/strategies/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Independent strategy modules."""
|
||||
|
||||
101
app/strategies/box_retest_4h.py
Normal file
101
app/strategies/box_retest_4h.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""4H box breakout retest strategy candidate.
|
||||
|
||||
The detector factor is not the strategy. This module wraps that factor with
|
||||
market, freshness, entry-distance and risk semantics to produce a standard
|
||||
StrategySignal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.factor_roles import ENTRY, RISK, TRIGGER
|
||||
from app.core.strategy_contract import StrategySignal, current_strategy_version
|
||||
from app.core.strategy_registry import BOX_RETEST_4H_STRATEGY
|
||||
|
||||
|
||||
def _safe_float(value, default=0.0) -> float:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return float(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def build_box_retest_signal(
|
||||
*,
|
||||
symbol: str,
|
||||
current_price: float,
|
||||
detection: dict,
|
||||
entry_plan: dict | None = None,
|
||||
market_regime: dict | None = None,
|
||||
decision_log: dict | None = None,
|
||||
) -> StrategySignal | None:
|
||||
if not (detection or {}).get("detected"):
|
||||
return None
|
||||
entry_plan = entry_plan or {}
|
||||
market_regime = market_regime or {}
|
||||
risk_level = str(market_regime.get("risk_level") or "medium").lower()
|
||||
entry_zone = _safe_float(detection.get("entry_zone"))
|
||||
current_price = _safe_float(current_price)
|
||||
distance_pct = (current_price / entry_zone - 1) * 100 if entry_zone > 0 and current_price > 0 else 0
|
||||
age = int(detection.get("pullback_age_bars") or 999)
|
||||
quality = str(detection.get("quality") or "")
|
||||
status = "candidate"
|
||||
reasons = []
|
||||
if risk_level == "critical":
|
||||
status = "observe"
|
||||
reasons.append("全局风险 critical,仅观察")
|
||||
if age > 4:
|
||||
status = "observe"
|
||||
reasons.append(f"回踩已过去 {age} 根4H,时效偏旧")
|
||||
if quality not in {"良好", "优质"}:
|
||||
status = "observe"
|
||||
reasons.append(f"形态质量 {quality or '未知'},不直接交易")
|
||||
if distance_pct > 8:
|
||||
status = "observe"
|
||||
reasons.append(f"当前价离箱体上沿 {distance_pct:.1f}%,禁止追高")
|
||||
|
||||
score = _safe_float(detection.get("score"))
|
||||
confidence = min(100.0, max(0.0, score * 8))
|
||||
trigger = {
|
||||
"factor_code": "box_breakout_pullback_4h",
|
||||
"factor_label": "4H箱体突破回踩",
|
||||
"box_high": detection.get("box_high"),
|
||||
"box_low": detection.get("box_low"),
|
||||
"entry_zone": detection.get("entry_zone"),
|
||||
"stop_level": detection.get("stop_level"),
|
||||
"pullback_kind": detection.get("pullback_kind"),
|
||||
"pullback_age_bars": age,
|
||||
"quality": quality,
|
||||
"distance_to_entry_zone_pct": round(distance_pct, 4),
|
||||
"market_regime": market_regime.get("regime") or "",
|
||||
"risk_level": risk_level,
|
||||
}
|
||||
risk_plan = {
|
||||
"invalid_if": ["跌回箱体", "放量冲高回落", "回踩过久", "全局风险升为critical"],
|
||||
"stop_level": detection.get("stop_level"),
|
||||
"risk_reasons": reasons,
|
||||
}
|
||||
return StrategySignal(
|
||||
strategy_code=BOX_RETEST_4H_STRATEGY,
|
||||
strategy_version=current_strategy_version(),
|
||||
symbol=symbol,
|
||||
direction="long",
|
||||
status=status,
|
||||
confidence=confidence,
|
||||
score=score,
|
||||
trigger=trigger,
|
||||
factor_roles={
|
||||
"box_breakout_pullback_4h": TRIGGER,
|
||||
"pullback_15m_confirm": ENTRY,
|
||||
"trend_exhaustion": RISK,
|
||||
"false_breakout": RISK,
|
||||
},
|
||||
entry_plan=entry_plan,
|
||||
risk_plan=risk_plan,
|
||||
decision_log=decision_log or {
|
||||
"module": "box_retest_4h_v1",
|
||||
"decision": status,
|
||||
"reasons": reasons,
|
||||
},
|
||||
)
|
||||
20
app/strategies/orchestrator.py
Normal file
20
app/strategies/orchestrator.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""Minimal strategy orchestration helpers for first-stage multi-strategy rollout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.strategy_contract import StrategySignal
|
||||
|
||||
|
||||
def arbitrate_strategy_signals(signals: list[StrategySignal]) -> list[StrategySignal]:
|
||||
"""Deduplicate same-symbol same-direction signals by confidence.
|
||||
|
||||
First stage deliberately avoids complex voting. Opposite directions are
|
||||
left to future long/short architecture; current system is long-only.
|
||||
"""
|
||||
winners: dict[tuple[str, str], StrategySignal] = {}
|
||||
for signal in signals or []:
|
||||
key = (str(signal.symbol or "").upper(), str(signal.direction or "long").lower())
|
||||
existing = winners.get(key)
|
||||
if existing is None or float(signal.confidence or 0) > float(existing.confidence or 0):
|
||||
winners[key] = signal
|
||||
return list(winners.values())
|
||||
@ -34,10 +34,11 @@ async def api_paper_trading_trades(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
status: str = "",
|
||||
strategy_code: str = "",
|
||||
altcoin_session: str = Cookie(default=""),
|
||||
):
|
||||
require_admin(altcoin_session)
|
||||
return list_paper_trades(limit=limit, offset=offset, status=status)
|
||||
return list_paper_trades(limit=limit, offset=offset, status=status, strategy_code=strategy_code)
|
||||
|
||||
|
||||
@router.get("/api/paper-trading/orders")
|
||||
@ -45,10 +46,11 @@ async def api_paper_trading_orders(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
status: str = "",
|
||||
strategy_code: str = "",
|
||||
altcoin_session: str = Cookie(default=""),
|
||||
):
|
||||
require_admin(altcoin_session)
|
||||
return list_paper_orders(limit=limit, offset=offset, status=status)
|
||||
return list_paper_orders(limit=limit, offset=offset, status=status, strategy_code=strategy_code)
|
||||
|
||||
|
||||
@router.get("/api/paper-trading/events")
|
||||
|
||||
254
docs/MULTI_STRATEGY_ARCHITECTURE.md
Normal file
254
docs/MULTI_STRATEGY_ARCHITECTURE.md
Normal file
@ -0,0 +1,254 @@
|
||||
# AlphaX 多策略改造计划
|
||||
|
||||
本文档定义 AlphaX 从“单一综合评分链路”升级为“多策略并行研究与交易验证平台”的改造计划。
|
||||
|
||||
核心原则:**因子不等于策略**。因子只能作为证据、条件或风控输入;策略必须是一套完整的交易剧本,包含适用环境、触发、确认、入场、退出、失效、仓位和复盘口径。
|
||||
|
||||
## 1. 为什么要改
|
||||
|
||||
当前系统已经有 `strategy_version`、`FactorScorer`、`factor_score_breakdown` 和 paper trading 归因,但主链路仍更像一个“大而全评分器”:
|
||||
|
||||
- 很多因子在同一个确认函数里叠加,容易把单根行情重复加分。
|
||||
- 推荐和 paper trading 更容易知道“综合分高不高”,但不容易知道“到底是哪套策略赚了钱”。
|
||||
- 复盘能看到因子胜率,但很难独立评估一个完整交易模型的胜率、回撤、盈亏比和适用行情。
|
||||
- 新增强形态时容易误把一个因子升级为策略,例如 `box_breakout_pullback_4h`。
|
||||
|
||||
改造目标是让系统支持多个独立策略同时运行,并让每个策略从发现到交易账本都有独立记录和评价体系。
|
||||
|
||||
## 0. 架构红线
|
||||
|
||||
这是基础设施重构,不是简单加几个字段。后续实现必须遵守:
|
||||
|
||||
- `strategy_code` 表示策略身份,`strategy_version` 表示版本,两者不能混用。
|
||||
- 策略输出必须走统一契约,不能每个策略自定义一套不可比的 JSON。
|
||||
- 推荐、挂单、持仓、事件日志、复盘都必须保留策略血缘。
|
||||
- 因子角色必须显式声明,未知因子不能默认成为交易触发。
|
||||
- 旧主链路可以兼容为 `main_composite_v1`,但新数据不允许没有策略来源。
|
||||
- 策略中文名、描述、启用状态要集中维护,不能散落在页面和业务代码里。
|
||||
- 多策略架构上线后,收益评价必须按策略拆分;总收益只能作为账户层结果,不能替代策略评价。
|
||||
|
||||
## 2. 基本概念
|
||||
|
||||
### 2.1 因子角色
|
||||
|
||||
每个因子必须先归类,不允许默认都作为策略核心触发。
|
||||
|
||||
- `prerequisite`:先决条件。决定能不能看,例如交易量、交易宇宙、市场环境、是否异常币。不能单独产生交易信号。
|
||||
- `trigger`:触发条件。让策略产生候选,例如箱体突破回踩、1H 量价齐飞、短周期启动。
|
||||
- `confirmation`:确认条件。提高交易可信度,例如板块共振、链上正向、大户偏多、1H 未衰竭。
|
||||
- `entry`:入场条件。决定现在能不能买,例如 15m 承接、离箱体上沿距离、盈亏比。
|
||||
- `risk`:风控条件。一票否决或降级,例如 risk_off、假突破、止损过宽、账户回撤过大。
|
||||
- `attribution`:归因条件。用于复盘解释,不一定参与实时决策。
|
||||
|
||||
### 2.2 策略成立标准
|
||||
|
||||
一个策略至少要定义:
|
||||
|
||||
- `strategy_code`:稳定代码,例如 `box_retest_4h_v1`。
|
||||
- `strategy_version`:版本,例如 `v2026.05.26-r1`。
|
||||
- `market_regime`:适用市场环境。
|
||||
- `universe_filter`:交易宇宙要求。
|
||||
- `prerequisites`:先决条件。
|
||||
- `trigger_model`:核心触发模型。
|
||||
- `confirmation_model`:辅助确认模型。
|
||||
- `entry_model`:入场模型。
|
||||
- `exit_model`:止盈、止损、移动止盈、失效退出。
|
||||
- `risk_model`:仓位、杠杆、账户级约束。
|
||||
- `review_metrics`:胜率、收益率、最大回撤、盈亏比、持仓时长、机会转化率。
|
||||
|
||||
如果只有一个强因子,没有完整入场/退出/风控/复盘口径,只能叫“策略候选”或“因子”,不能叫正式策略。
|
||||
|
||||
## 3. 目标链路
|
||||
|
||||
```text
|
||||
统一交易宇宙
|
||||
-> 策略 A 独立扫描
|
||||
-> 策略 B 独立扫描
|
||||
-> 策略 C 独立扫描
|
||||
-> 信号标准化
|
||||
-> 冲突与重复仲裁
|
||||
-> 推荐/观察/挂单
|
||||
-> paper trading 保留策略血缘
|
||||
-> 按策略独立复盘
|
||||
-> 策略灰度/发布/淘汰
|
||||
```
|
||||
|
||||
统一交易宇宙只负责把明显不适合交易的币剔除,例如稳定币、封装币、inactive 交易对、异常交易对和长期流动性不合格的币。它不是策略,也不直接产生交易动作。
|
||||
|
||||
## 4. 初始策略池建议
|
||||
|
||||
### 4.1 `box_retest_4h_v1`
|
||||
|
||||
定位:底部箱体突破后,第一次或第二次回踩箱体上沿/EMA 承接。
|
||||
|
||||
- 先决条件:非 risk_off,交易量合格,非异常交易对,箱体宽度合理。
|
||||
- 核心触发:`box_breakout_pullback_4h`。
|
||||
- 确认条件:1H 不衰竭,15m 有承接,板块或市场环境不拖后腿。
|
||||
- 入场条件:当前价不能离箱体上沿太远,盈亏比合格。
|
||||
- 失效条件:跌回箱体、放量冲高回落、突破后过久才回踩。
|
||||
- 复盘口径:回踩入场后的 24h/72h 收益、最大回撤、止损触发率。
|
||||
|
||||
注意:`box_breakout_pullback_4h` 是该策略的核心触发因子,但不等于策略本身。
|
||||
|
||||
### 4.2 `momentum_acceleration_1h_v1`
|
||||
|
||||
定位:1H 量价齐飞后的加速机会。
|
||||
|
||||
- 核心触发:`vp_fly_1h_current`。
|
||||
- 风险重点:追高、假突破、短线衰竭。
|
||||
- 入场要求:必须有 15m 承接或回踩,不允许单纯因为放量就直接买。
|
||||
|
||||
### 4.3 `short_tf_early_watch_v1`
|
||||
|
||||
定位:5m/15m 早期启动观察,不直接交易。
|
||||
|
||||
- 核心触发:`short_tf_15m_ignition`、`short_tf_5m_ignition`。
|
||||
- 默认动作:观察或加入候选,不直接进入 paper trading。
|
||||
- 升级条件:后续 1H/4H 结构确认。
|
||||
|
||||
### 4.4 `onchain_tech_confirm_v1`
|
||||
|
||||
定位:链上重要资金行为 + 技术确认。
|
||||
|
||||
- 先决条件:链上事件必须可读、可映射、金额或置信度达到阈值。
|
||||
- 核心触发:鲸鱼增持、聪明钱买入、交易所流出等。
|
||||
- 入场要求:必须经过技术结构确认,不允许链上事件直接下单。
|
||||
|
||||
### 4.5 `top_gainer_second_wave_v1`
|
||||
|
||||
定位:强势榜币种第一波后,回踩承接走二波。
|
||||
|
||||
- 先决条件:不是纯追涨,涨幅后必须有回踩和承接。
|
||||
- 核心触发:强势榜 + 回踩结构。
|
||||
- 风险重点:高位回落、流动性退潮、meme 过热。
|
||||
|
||||
## 5. 数据模型改造
|
||||
|
||||
### 5.1 第一阶段:兼容式加字段
|
||||
|
||||
优先在现有表增加策略血缘字段,避免一次性重构过大。
|
||||
|
||||
- `recommendation.strategy_code`
|
||||
- `recommendation.strategy_signal_id`
|
||||
- `recommendation.strategy_snapshot_json`
|
||||
- `recommendation.factor_roles_json`
|
||||
- `paper_trades.strategy_code`
|
||||
- `paper_trades.strategy_signal_id`
|
||||
- `paper_trades.strategy_snapshot_json`
|
||||
- `paper_orders.strategy_code`
|
||||
- `paper_orders.strategy_signal_id`
|
||||
|
||||
当前已有 `strategy_version`,后续它表示策略版本,不再替代策略身份。
|
||||
|
||||
### 5.2 第二阶段:新增策略信号表
|
||||
|
||||
新增 `strategy_signals`,作为策略输出的标准事实表:
|
||||
|
||||
- `id`
|
||||
- `run_id`
|
||||
- `strategy_code`
|
||||
- `strategy_version`
|
||||
- `symbol`
|
||||
- `direction`
|
||||
- `signal_status`
|
||||
- `confidence`
|
||||
- `market_regime`
|
||||
- `trigger_json`
|
||||
- `factor_roles_json`
|
||||
- `entry_plan_json`
|
||||
- `risk_plan_json`
|
||||
- `decision_log_json`
|
||||
- `created_at`
|
||||
|
||||
推荐、挂单、持仓都引用 `strategy_signal_id`,形成完整链路。
|
||||
|
||||
### 5.3 第三阶段:策略运行与评价表
|
||||
|
||||
新增:
|
||||
|
||||
- `strategy_catalog`:策略注册、启用状态、适用市场、版本。
|
||||
- `strategy_run_log`:每次策略扫描的运行结果。
|
||||
- `strategy_performance_daily`:按日聚合策略表现。
|
||||
|
||||
## 6. 执行模块改造
|
||||
|
||||
### P0:先做策略规范和血缘记录
|
||||
|
||||
目标:不大改链路,先保证每个推荐和 paper trading 记录知道“来自哪套策略”。
|
||||
|
||||
- 建立 `app/core/strategy_contract.py`,定义策略输出结构。
|
||||
- 建立 `app/core/factor_roles.py`,统一因子角色分类。
|
||||
- 给 `recommendation` / `paper_trades` / `paper_orders` 补 `strategy_code` 和 `strategy_signal_id`。
|
||||
- 在确认层先把现有主链路标为 `main_composite_v1`。
|
||||
- 把 `box_breakout_pullback_4h` 标记为 `box_retest_4h_v1` 的核心触发候选,但仍通过完整策略条件判断。
|
||||
|
||||
### P1:拆出第一个独立策略
|
||||
|
||||
目标:让 `box_retest_4h_v1` 独立运行,与原主链路并行。
|
||||
|
||||
- 新增 `app/strategies/box_retest_4h.py`。
|
||||
- 让它消费统一交易宇宙和 4H K线。
|
||||
- 输出标准 `strategy_signal`。
|
||||
- 推荐层只消费标准信号,不关心策略内部细节。
|
||||
- paper trading 保留 `strategy_code`。
|
||||
- 复盘页按策略展示表现。
|
||||
|
||||
### P2:多策略编排与仲裁
|
||||
|
||||
目标:多个策略同时运行,但同一币种不要重复乱开仓。
|
||||
|
||||
- 新增 `strategy_orchestrator`。
|
||||
- 支持启用/禁用策略。
|
||||
- 支持同币种冲突仲裁:同方向合并、不同方向拒绝或降级。
|
||||
- 支持策略优先级和市场环境开关。
|
||||
- 同一币种同一方向进入 paper trading 前必须检查账户集中度和累计杠杆。
|
||||
|
||||
### P3:策略独立复盘与发布机制
|
||||
|
||||
目标:策略按独立样本晋级/淘汰,不再只看总评分。
|
||||
|
||||
- 每个策略独立统计:候选数、推荐数、成交数、胜率、平均收益、最大回撤、盈亏比、平均持仓时长。
|
||||
- 每个策略按 market regime 拆分表现。
|
||||
- 策略改动先进入灰度版本,不直接覆盖主版本。
|
||||
- 连续低表现策略自动降级为 observe-only 或暂停。
|
||||
|
||||
## 7. 页面改造计划
|
||||
|
||||
### 策略交易页
|
||||
|
||||
- 持仓、挂单、已完成、日志增加 `策略` 列。
|
||||
- 策略筛选器:全部 / 箱体突破回踩 / 量价加速 / 短周期观察 / 链上确认。
|
||||
- 交易详情展示当时的 `strategy_snapshot`。
|
||||
|
||||
### 机会总览页
|
||||
|
||||
- 列表只显示核心结论。
|
||||
- 详情页展示策略来源、触发因子、确认因子、入场因子、风险因子。
|
||||
|
||||
### 复盘中心
|
||||
|
||||
- 增加“按策略”视图。
|
||||
- 每个策略显示:样本数、转化率、胜率、收益、最大回撤、适用行情。
|
||||
- 因子归因仍保留,但作为策略内部解释,不再替代策略评价。
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
第一阶段完成后,至少满足:
|
||||
|
||||
- 任意一条 recommendation 能看到 `strategy_code`。
|
||||
- 任意一笔 paper trade 能看到 `strategy_code`。
|
||||
- 任意一笔 paper order 能看到 `strategy_code`。
|
||||
- 任意一个策略信号能追溯到 `strategy_signals` 或标准 `StrategySignal` 快照。
|
||||
- 复盘能按 `strategy_code` 聚合收益和胜率。
|
||||
- `box_breakout_pullback_4h` 不会被文档或代码误称为完整策略。
|
||||
- 新增策略必须通过策略成立标准检查。
|
||||
- 新数据不得出现空 `strategy_code`。
|
||||
|
||||
## 9. 开发纪律
|
||||
|
||||
- 不能因为一个因子表现好,就直接让它下单。
|
||||
- 不能把先决条件当作策略触发。
|
||||
- 不能把归因因子当作实时交易条件。
|
||||
- 不能让 paper trading 丢失策略来源。
|
||||
- 不能只看总收益,必须按策略看收益和回撤。
|
||||
- 新策略必须先 observe-only 或 paper-only,积累样本后再考虑真实同步。
|
||||
579
docs/MULTI_STRATEGY_IMPLEMENTATION_PLAN.md
Normal file
579
docs/MULTI_STRATEGY_IMPLEMENTATION_PLAN.md
Normal file
@ -0,0 +1,579 @@
|
||||
# AlphaX 多策略改造实施计划
|
||||
|
||||
本文档是 `MULTI_STRATEGY_ARCHITECTURE.md` 的施工版,用于把多策略架构拆成可开发、可验证、可上线观察的任务包。
|
||||
|
||||
今晚目标不是一次性完成所有长期架构,而是交付一个能真实运行的第一阶段:
|
||||
|
||||
- 每条推荐、挂单、持仓都能追溯到策略。
|
||||
- 现有主链路不被打断,统一标记为 `main_composite_v1`。
|
||||
- `box_retest_4h_v1` 进入独立策略雏形,不再只是综合确认层里的一个因子。
|
||||
- paper trading 和复盘能按策略看表现。
|
||||
- 前端至少能看到策略来源,明早可以观察跑出来的数据。
|
||||
|
||||
## 0. 基础设施重构原则
|
||||
|
||||
这次属于策略基础设施重构,不允许为了赶进度留下明显技术债。
|
||||
|
||||
必须遵守:
|
||||
|
||||
- DB schema 变化必须走 migration,不允许业务函数里偷偷补字段。
|
||||
- 策略身份必须用 `strategy_code`,版本必须用 `strategy_version`,不能继续混用。
|
||||
- 新增策略输出必须走统一 `StrategySignal` 契约,不允许每个策略各写一套 JSON。
|
||||
- 因子角色必须显式声明,未知因子默认只能作为 `attribution` 或 `unknown`,不能默认变成交易触发。
|
||||
- paper trading、paper orders、recommendation 必须完整继承策略血缘,不能只在推荐层有策略,交易账本丢失。
|
||||
- UI/API/复盘至少要有最小闭环,否则字段只是“存了但没人用”。
|
||||
- 兼容旧数据可以回填为 `main_composite_v1`,但新数据不允许空策略来源。
|
||||
- 不做全局散落的硬编码策略名,策略中文名、启用状态、描述应集中维护。
|
||||
|
||||
允许的兼容:
|
||||
|
||||
- 旧主链路继续运行,但必须标记为 `main_composite_v1`。
|
||||
- 第一版 `box_retest_4h_v1` 可以先 paper-only / observe-only。
|
||||
- 复杂仲裁可以先做最小规则,但接口要为后续多策略扩展留好。
|
||||
|
||||
不允许的临时方案:
|
||||
|
||||
- 不允许把 `box_breakout_pullback_4h` 直接当 `strategy_code`。
|
||||
- 不允许只给 paper trades 加字段,却不改写入链路。
|
||||
- 不允许只写文档不补测试。
|
||||
- 不允许把策略表现继续只按 `strategy_version` 聚合。
|
||||
- 不允许前端继续完全看不到策略来源。
|
||||
|
||||
## 1. 总体交付分层
|
||||
|
||||
### P0:策略血缘打通
|
||||
|
||||
必须完成。目标是先让系统所有后续数据都带上策略来源。
|
||||
|
||||
- 新增策略标准结构。
|
||||
- 新增因子角色定义。
|
||||
- DB 增加策略血缘字段。
|
||||
- 推荐写入时保存 `strategy_code`、`strategy_signal_id`、`strategy_snapshot_json`、`factor_roles_json`。
|
||||
- paper orders / paper trades 继承推荐里的策略字段。
|
||||
- API 和页面展示策略来源。
|
||||
- 复盘统计支持按策略聚合。
|
||||
- 测试覆盖 recommendation -> paper order -> paper trade 的策略血缘传递。
|
||||
|
||||
### P1:第一个独立策略雏形
|
||||
|
||||
尽量完成。目标是让 `box_retest_4h_v1` 从因子变成独立策略候选。
|
||||
|
||||
- 新建 `app/strategies/box_retest_4h.py`。
|
||||
- 复用统一交易宇宙和 `detect_box_breakout_pullback_4h()`。
|
||||
- 输出标准策略信号。
|
||||
- 当前先以 observe/paper-only 运行,避免直接影响真实同步。
|
||||
|
||||
### P2:最小策略编排与仲裁
|
||||
|
||||
只做最小闭环。目标是不让多策略重复乱写推荐。
|
||||
|
||||
- 新建简单策略注册表/编排函数。
|
||||
- 同一币种同方向保留最高置信度策略。
|
||||
- 不同方向先降级观察,不做自动对冲。
|
||||
- 先不做复杂策略权重投票。
|
||||
|
||||
### P3:技术债清理与文档同步
|
||||
|
||||
必须完成最小项。目标是重构后代码结构清楚、后续可扩展。
|
||||
|
||||
- `AGENTS.md`、`OPTIMIZATION_TODO.md`、`MULTI_STRATEGY_ARCHITECTURE.md` 同步最终事实。
|
||||
- 新增模块必须有单元测试。
|
||||
- 任何未完成项必须写入 TODO,不能藏在代码注释里。
|
||||
- 运行命令和验收结果写入最终交付说明。
|
||||
|
||||
## 2. 数据库改造
|
||||
|
||||
### 2.1 新增 migration
|
||||
|
||||
新增文件:
|
||||
|
||||
```text
|
||||
app/db/migrations/00xx_multi_strategy.sql
|
||||
```
|
||||
|
||||
建议字段:
|
||||
|
||||
```sql
|
||||
ALTER TABLE recommendation
|
||||
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS strategy_snapshot_json TEXT DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS factor_roles_json TEXT DEFAULT '{}';
|
||||
|
||||
ALTER TABLE paper_trades
|
||||
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS strategy_snapshot_json TEXT DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS factor_roles_json TEXT DEFAULT '{}';
|
||||
|
||||
ALTER TABLE paper_orders
|
||||
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS strategy_snapshot_json TEXT DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS factor_roles_json TEXT DEFAULT '{}';
|
||||
```
|
||||
|
||||
新增标准策略信号表:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS strategy_signals (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
run_id TEXT DEFAULT '',
|
||||
strategy_code TEXT NOT NULL,
|
||||
strategy_version TEXT DEFAULT '',
|
||||
symbol TEXT NOT NULL,
|
||||
direction TEXT DEFAULT 'long',
|
||||
signal_status TEXT DEFAULT 'candidate',
|
||||
confidence DOUBLE PRECISION DEFAULT 0,
|
||||
score DOUBLE PRECISION DEFAULT 0,
|
||||
market_regime TEXT DEFAULT '',
|
||||
trigger_json TEXT DEFAULT '{}',
|
||||
factor_roles_json TEXT DEFAULT '{}',
|
||||
entry_plan_json TEXT DEFAULT '{}',
|
||||
risk_plan_json TEXT DEFAULT '{}',
|
||||
decision_log_json TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_strategy_signals_code_time
|
||||
ON strategy_signals(strategy_code, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_strategy_signals_symbol_time
|
||||
ON strategy_signals(symbol, created_at DESC);
|
||||
```
|
||||
|
||||
可选但建议同步新增策略目录表,避免策略中文名和启用状态散落在代码里:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS strategy_catalog (
|
||||
strategy_code TEXT PRIMARY KEY,
|
||||
strategy_name TEXT NOT NULL,
|
||||
strategy_version TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'active',
|
||||
mode TEXT DEFAULT 'paper_only',
|
||||
description TEXT DEFAULT '',
|
||||
config_json TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
如果今晚来不及完整管理页面,也应先提供 DB 表和默认种子。
|
||||
|
||||
### 2.2 兼容回填
|
||||
|
||||
旧数据没有策略来源时:
|
||||
|
||||
- `recommendation.strategy_code = 'main_composite_v1'`
|
||||
- `paper_trades.strategy_code = COALESCE(recommendation.strategy_code, 'main_composite_v1')`
|
||||
- `paper_orders.strategy_code = COALESCE(recommendation.strategy_code, 'main_composite_v1')`
|
||||
|
||||
回填只做一次,不改变收益数据。
|
||||
|
||||
回填后要加非空语义检查:
|
||||
|
||||
- 新推荐写入后 `strategy_code` 不得为空。
|
||||
- 新 paper order 写入后 `strategy_code` 不得为空。
|
||||
- 新 paper trade 写入后 `strategy_code` 不得为空。
|
||||
|
||||
### 2.3 migration 验收
|
||||
|
||||
必须验证:
|
||||
|
||||
```bash
|
||||
docker compose run --rm alphax-web python scripts/postgres/run_migrations.py
|
||||
docker compose exec alphax-web python - <<'PY'
|
||||
from app.db.schema import get_conn
|
||||
conn = get_conn()
|
||||
for table in ["recommendation", "paper_trades", "paper_orders", "strategy_signals"]:
|
||||
print(table, conn.execute("SELECT COUNT(*) FROM information_schema.columns WHERE table_name=%s", (table,)).fetchone()[0])
|
||||
conn.close()
|
||||
PY
|
||||
```
|
||||
|
||||
## 3. 核心代码改造
|
||||
|
||||
### 3.1 新增 `app/core/factor_roles.py`
|
||||
|
||||
职责:
|
||||
|
||||
- 定义因子角色枚举。
|
||||
- 维护默认因子角色映射。
|
||||
- 提供 `factor_roles_for_codes(signal_codes)`。
|
||||
- 提供 `validate_factor_roles()`,防止未知因子默认变成交易触发。
|
||||
|
||||
初始角色建议:
|
||||
|
||||
```text
|
||||
box_breakout_pullback_4h -> trigger
|
||||
vp_fly_1h_current -> trigger / confirmation
|
||||
short_tf_15m_ignition -> trigger,但默认 observe-only
|
||||
short_tf_5m_ignition -> prerequisite / early_watch
|
||||
sector_rotation -> confirmation
|
||||
sentiment_resonance -> confirmation
|
||||
top_trader_long -> confirmation
|
||||
exchange_outflow -> confirmation
|
||||
false_breakout -> risk
|
||||
trend_exhaustion -> risk
|
||||
risk_reward_bad -> risk
|
||||
entry_quality_gate -> risk
|
||||
```
|
||||
|
||||
注意:同一因子在不同策略里可以有不同角色,但默认映射用于主链路兼容。
|
||||
|
||||
该模块必须保持纯函数,不依赖 DB,不读运行时配置,避免策略基础设施反向依赖业务层。
|
||||
|
||||
### 3.2 新增 `app/core/strategy_contract.py`
|
||||
|
||||
职责:
|
||||
|
||||
- 定义 `StrategySignal` 数据结构。
|
||||
- 定义 `StrategyDecision` / `StrategyRiskPlan` / `StrategyEntryPlan` 的最小字段。
|
||||
- 提供 `signal_to_recommendation_context()`,让推荐写入时能统一保存策略快照。
|
||||
|
||||
最小字段:
|
||||
|
||||
```python
|
||||
strategy_code: str
|
||||
strategy_version: str
|
||||
symbol: str
|
||||
direction: str
|
||||
status: str
|
||||
confidence: float
|
||||
score: float
|
||||
trigger: dict
|
||||
factor_roles: dict
|
||||
entry_plan: dict
|
||||
risk_plan: dict
|
||||
decision_log: dict
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 提供 `to_json_dict()`,统一序列化。
|
||||
- 提供 `from_confirm_result()`,兼容现有确认链路。
|
||||
- 提供 `default_main_composite_signal()`,给旧主链路生成标准策略快照。
|
||||
- 不在这里调用数据库,不在这里拉行情。
|
||||
|
||||
### 3.2.1 新增 `app/db/strategy_signal_queries.py`
|
||||
|
||||
职责:
|
||||
|
||||
- 插入 `strategy_signals`。
|
||||
- 查询最近策略信号。
|
||||
- 按 `strategy_code` 聚合信号数量。
|
||||
|
||||
不要塞进 `altcoin_db.py`。
|
||||
|
||||
### 3.3 修改推荐写入
|
||||
|
||||
文件:
|
||||
|
||||
```text
|
||||
app/db/recommendation_commands.py
|
||||
```
|
||||
|
||||
改造点:
|
||||
|
||||
- `create_recommendation()` 增加参数:
|
||||
- `strategy_code`
|
||||
- `strategy_signal_id`
|
||||
- `strategy_snapshot`
|
||||
- `factor_roles`
|
||||
- 未传时默认 `main_composite_v1`。
|
||||
- duplicate merge 时保留更强策略信号,或者暂时用最新策略覆盖空字段。
|
||||
- INSERT / UPDATE 同步写入新字段。
|
||||
- 写入前通过 `normalize_strategy_context()` 统一清洗,避免空字符串、坏 JSON、重复逻辑散落。
|
||||
|
||||
### 3.4 修改 paper trading
|
||||
|
||||
文件:
|
||||
|
||||
```text
|
||||
app/db/paper_trading.py
|
||||
```
|
||||
|
||||
改造点:
|
||||
|
||||
- `_open_trade()` 插入 `paper_trades` 时继承:
|
||||
- `strategy_code`
|
||||
- `strategy_signal_id`
|
||||
- `strategy_snapshot_json`
|
||||
- `factor_roles_json`
|
||||
- `_order_payload_from_rec()` 和 paper order 插入时继承同样字段。
|
||||
- `_record_event()` 的 detail_json 加入策略来源,方便操作日志直接解释。
|
||||
- list API 返回策略字段,前端不需要二次查 recommendation。
|
||||
|
||||
### 3.5 修改推荐读模型
|
||||
|
||||
文件:
|
||||
|
||||
```text
|
||||
app/db/recommendation_state.py
|
||||
app/db/recommendation_queries.py
|
||||
app/db/analytics.py
|
||||
```
|
||||
|
||||
改造点:
|
||||
|
||||
- API 输出 `strategy_code`、`strategy_signal_id`、`strategy_snapshot`、`factor_roles`。
|
||||
- 推荐详情页能展示策略来源。
|
||||
- 归档/复盘列表支持 strategy 过滤。
|
||||
|
||||
### 3.6 策略注册中心
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
app/core/strategy_registry.py
|
||||
```
|
||||
|
||||
职责:
|
||||
|
||||
- 集中维护策略代码、中文名、描述、默认模式。
|
||||
- 提供 `strategy_label(strategy_code)`。
|
||||
- 提供 `registered_strategy_codes()`。
|
||||
|
||||
初始策略:
|
||||
|
||||
```text
|
||||
main_composite_v1 -> 综合确认主链路
|
||||
box_retest_4h_v1 -> 4H箱体突破回踩
|
||||
```
|
||||
|
||||
前端展示中文名时优先用 registry/API 返回值,不硬编码。
|
||||
|
||||
## 4. 独立策略:`box_retest_4h_v1`
|
||||
|
||||
### 4.1 策略定位
|
||||
|
||||
不是“看到箱体突破回踩就买”,而是:
|
||||
|
||||
```text
|
||||
底部箱体完成
|
||||
-> 放量突破
|
||||
-> 首次/二次回踩箱体上沿或 EMA
|
||||
-> 回踩不破且承接
|
||||
-> 当前价格距离入场区不过远
|
||||
-> 市场环境允许
|
||||
-> 盈亏比合格
|
||||
-> 才进入观察/挂单/交易候选
|
||||
```
|
||||
|
||||
### 4.2 新增文件
|
||||
|
||||
```text
|
||||
app/strategies/__init__.py
|
||||
app/strategies/box_retest_4h.py
|
||||
app/strategies/orchestrator.py
|
||||
```
|
||||
|
||||
### 4.3 第一版策略规则
|
||||
|
||||
先决条件:
|
||||
|
||||
- `market_regime.risk_level != critical`
|
||||
- 交易宇宙通过。
|
||||
- 4H 数据足够。
|
||||
- 箱体宽度合理。
|
||||
|
||||
核心触发:
|
||||
|
||||
- `detect_box_breakout_pullback_4h().detected == True`
|
||||
|
||||
确认条件:
|
||||
|
||||
- `pullback_age_bars <= 4`
|
||||
- `quality in 良好/优质`
|
||||
- 没有明显 `trend_exhaustion`
|
||||
- 不是已经远离 `entry_zone` 太多。
|
||||
|
||||
入场规则:
|
||||
|
||||
- 距离 `entry_zone` 小于配置阈值时允许挂单或买点确认。
|
||||
- 如果已经大幅拉开,只进入观察,不追。
|
||||
|
||||
失效规则:
|
||||
|
||||
- 跌回箱体。
|
||||
- 距离突破/回踩时间过久。
|
||||
- 1H/15m 出现假突破或趋势衰竭。
|
||||
|
||||
输出:
|
||||
|
||||
- `StrategySignal(strategy_code='box_retest_4h_v1')`
|
||||
|
||||
### 4.4 与现有主链路关系
|
||||
|
||||
第一阶段不能直接删除现有主链路里的 `box_breakout_pullback_4h` 加分,否则会改变当前推荐行为过大。
|
||||
|
||||
处理方式:
|
||||
|
||||
- 主链路继续把它作为结构因子参与确认,策略来源仍为 `main_composite_v1`。
|
||||
- 独立策略并行生成 `box_retest_4h_v1` 信号。
|
||||
- 如果同一 symbol 同时被主链路和箱体策略命中,短期由仲裁器保留更明确的策略快照,或在推荐 `strategy_snapshot_json` 中记录 secondary strategies。
|
||||
- 明早先观察是否出现重复推荐,再决定是否让箱体策略接管该类信号。
|
||||
|
||||
## 5. API 与 UI 改造
|
||||
|
||||
### 5.1 机会总览
|
||||
|
||||
文件:
|
||||
|
||||
```text
|
||||
static/app.html
|
||||
app/web/routes_recommendations.py
|
||||
```
|
||||
|
||||
改造点:
|
||||
|
||||
- 卡片显示中文策略名,例如“4H箱体突破回踩策略”。
|
||||
- 详情页展示:
|
||||
- 策略来源
|
||||
- 核心触发
|
||||
- 确认因子
|
||||
- 入场条件
|
||||
- 风险/失效条件
|
||||
- API 增加 `strategy_code`、`strategy_name`、`strategy_snapshot`、`factor_roles`。
|
||||
|
||||
### 5.2 策略交易页
|
||||
|
||||
文件:
|
||||
|
||||
```text
|
||||
static/paper_trading.html
|
||||
app/web/routes_paper_trading.py
|
||||
```
|
||||
|
||||
改造点:
|
||||
|
||||
- 持仓中、挂单中、已完成、操作日志都显示策略列。
|
||||
- 增加策略筛选器。
|
||||
- 交易详情展示 strategy snapshot。
|
||||
- 列表接口支持 `strategy_code` 参数过滤。
|
||||
|
||||
### 5.3 复盘中心
|
||||
|
||||
文件:
|
||||
|
||||
```text
|
||||
app/db/review_center.py
|
||||
app/db/strategy_insights.py
|
||||
static/review_center.html
|
||||
```
|
||||
|
||||
改造点:
|
||||
|
||||
- 增加“按策略”聚合。
|
||||
- 指标:
|
||||
- 推荐数
|
||||
- 挂单数
|
||||
- 成交数
|
||||
- 平仓数
|
||||
- 胜率
|
||||
- 总收益
|
||||
- 平均收益
|
||||
- 最大回撤
|
||||
- 平均持仓时长
|
||||
- 适用 market regime
|
||||
|
||||
### 5.4 Pipeline / 日志中心
|
||||
|
||||
- pipeline 批次详情展示各策略产出的信号数量。
|
||||
- 系统日志中策略运行错误要带 `strategy_code`。
|
||||
- 不要求今晚完整 UI,但 API 返回结构要预留。
|
||||
|
||||
## 6. 测试计划
|
||||
|
||||
### 6.1 单元测试
|
||||
|
||||
新增/修改:
|
||||
|
||||
```text
|
||||
tests/test_factor_roles.py
|
||||
tests/test_strategy_contract.py
|
||||
tests/test_strategy_lineage.py
|
||||
tests/test_box_retest_strategy.py
|
||||
tests/test_paper_trading.py
|
||||
tests/test_strategy_insights.py
|
||||
```
|
||||
|
||||
重点断言:
|
||||
|
||||
- 因子角色不会把未知因子默认当作 trigger。
|
||||
- `box_breakout_pullback_4h` 只是 trigger,不是完整策略。
|
||||
- `create_recommendation()` 默认写入 `main_composite_v1`。
|
||||
- paper order / paper trade 继承 `strategy_code`。
|
||||
- 复盘可以按 `strategy_code` 聚合。
|
||||
- API 输出策略中文名。
|
||||
|
||||
### 6.1.1 必须新增的回归测试
|
||||
|
||||
- migration 后字段存在。
|
||||
- `StrategySignal` 序列化稳定。
|
||||
- `main_composite_v1` 默认策略上下文生成。
|
||||
- recommendation merge 不会清空已有策略来源。
|
||||
- paper order 创建继承策略来源。
|
||||
- paper trade 开仓继承策略来源。
|
||||
- strategy aggregation 不再只按 `strategy_version`。
|
||||
|
||||
### 6.2 容器验证
|
||||
|
||||
```bash
|
||||
docker compose build alphax-web alphax-scheduler alphax-price-streamer
|
||||
docker compose up -d alphax-web alphax-scheduler alphax-price-streamer
|
||||
docker compose exec alphax-web python -m pytest -q tests/test_factor_roles.py tests/test_strategy_contract.py tests/test_strategy_lineage.py tests/test_box_retest_strategy.py
|
||||
docker compose exec alphax-web python -m app.cli screener
|
||||
docker compose exec alphax-web python -m app.cli confirm
|
||||
docker compose exec alphax-web python -m app.cli paper-trader
|
||||
```
|
||||
|
||||
## 7. 明早验收标准
|
||||
|
||||
明早交付至少要能做到:
|
||||
|
||||
- 数据库 migration 正常跑过。
|
||||
- Docker 服务正常 up。
|
||||
- 新推荐至少有 `strategy_code`。
|
||||
- 新挂单/持仓至少有 `strategy_code`。
|
||||
- paper trading 页面能看到策略来源。
|
||||
- 复盘/策略归因能按策略聚合。
|
||||
- `box_retest_4h_v1` 能独立输出策略信号或至少进入标准策略信号结构。
|
||||
- `box_breakout_pullback_4h` 在代码和文档里没有被误当作完整策略。
|
||||
- 不存在新增数据 `strategy_code=''` 的情况。
|
||||
- 新增基础设施模块没有反向依赖 Web 或 DB 热路径。
|
||||
- 未完成事项已明确写入 TODO,不留口头债。
|
||||
|
||||
## 8. 风险边界
|
||||
|
||||
今晚不做:
|
||||
|
||||
- 不把多策略直接同步真实交易。
|
||||
- 不做复杂策略投票。
|
||||
- 不做多空完整重构。
|
||||
- 不做大规模 UI 重设计。
|
||||
- 不删除现有主链路。
|
||||
|
||||
今晚可以做:
|
||||
|
||||
- 保留主链路运行。
|
||||
- 补策略血缘。
|
||||
- 补第一版策略标准结构。
|
||||
- 让 `box_retest_4h_v1` 作为独立候选跑起来。
|
||||
- 给明早提供能观察的数据和复盘口径。
|
||||
|
||||
## 9. 今晚执行顺序
|
||||
|
||||
为了降低基础设施重构风险,按下面顺序施工:
|
||||
|
||||
1. 新增 migration 和回填。
|
||||
2. 新增 `strategy_registry`、`factor_roles`、`strategy_contract` 纯核心模块。
|
||||
3. 新增 `strategy_signal_queries`,不要碰业务主链路。
|
||||
4. 修改 `create_recommendation()` 写入策略血缘。
|
||||
5. 修改 paper orders / paper trades 继承策略血缘。
|
||||
6. 修改读模型/API,让前端能看到策略来源。
|
||||
7. 修改 paper trading 页面最小展示策略列。
|
||||
8. 新增 `box_retest_4h_v1` 策略模块和最小 orchestrator。
|
||||
9. 补测试。
|
||||
10. Docker build/up,跑容器内回归。
|
||||
11. 写明早交付总结和剩余 TODO。
|
||||
@ -9,6 +9,7 @@
|
||||
- 高分机会和好买点必须拆开。
|
||||
- 观察池、挂单池、交易账本不能混在一起。
|
||||
- 所有能影响策略的因子都必须可复盘、可提权、可降权、可淘汰。
|
||||
- 因子不等于策略。因子必须先归类为先决条件、触发、确认、入场、风控或归因;只有具备完整入场、退出、风控和复盘口径的交易剧本才能叫策略。
|
||||
|
||||
## 已完成
|
||||
|
||||
@ -30,9 +31,27 @@
|
||||
- NodeReal 已作为当前链上主数据源,DEX Screener / Etherscan / Helius 运行链路已移除。
|
||||
- `FactorScorer` 已建立,确认层核心技术因子、板块、舆情、大户、链上因子已接入复盘权重。
|
||||
- `factor_score_breakdown` 已进入确认上下文,复盘可追踪因子贡献。
|
||||
- `box_breakout_pullback_4h` 已作为 4H 箱体突破回踩强结构因子接入确认层,但它只是 `box_retest_4h_v1` 这类策略的核心触发候选,不应单独等同于完整策略。
|
||||
- 已新增 `docs/MULTI_STRATEGY_ARCHITECTURE.md`,定义多策略并行、策略血缘、因子角色和独立复盘口径。
|
||||
|
||||
## P0:现在优先做
|
||||
|
||||
### 0. 多策略架构第一阶段
|
||||
|
||||
目标:从“大而全评分器”过渡到“统一交易宇宙 + 多个独立策略 + 独立交易账本评价”。
|
||||
|
||||
待做:
|
||||
|
||||
- 建立 `app/core/strategy_contract.py`,定义标准策略输出结构。
|
||||
- 建立 `app/core/factor_roles.py`,统一因子角色:`prerequisite`、`trigger`、`confirmation`、`entry`、`risk`、`attribution`。
|
||||
- 建立 `app/core/strategy_registry.py`,集中维护策略代码、中文名、描述、默认模式,避免策略名散落硬编码。
|
||||
- 建立 `app/db/strategy_signal_queries.py` 和 `strategy_signals` 表,保存标准策略信号。
|
||||
- 给 `recommendation`、`paper_trades`、`paper_orders` 增加 `strategy_code`、`strategy_signal_id`、`strategy_snapshot_json`、`factor_roles_json` 等策略血缘字段。
|
||||
- 现有综合链路先标记为 `main_composite_v1`,避免无策略来源的推荐继续进入账本。
|
||||
- 先把 `box_retest_4h_v1` 作为第一个独立策略候选拆出来:`box_breakout_pullback_4h` 只能是核心触发因子,仍要经过市场环境、交易宇宙、确认、入场、风控和失效条件。
|
||||
- 复盘中心增加按 `strategy_code` 聚合的胜率、收益、最大回撤、盈亏比和持仓时长。
|
||||
- 新数据不得出现空 `strategy_code`;旧数据通过 migration 回填为 `main_composite_v1`,但不能继续产生无来源样本。
|
||||
|
||||
### 1. Global Risk Engine
|
||||
|
||||
目标:单币再好,也要先看账户和大盘能不能开新仓。
|
||||
@ -122,6 +141,8 @@
|
||||
- Paper Trade Attribution:按因子组、regime、entryAction 归因交易结果。
|
||||
- Regime-based Scoring:不同市场状态下使用不同因子权重。
|
||||
- Watchlist / Trade Ledger 进一步分离:观察样本、挂单、持仓收益完全分开统计。
|
||||
- 策略编排器:支持多个策略同时运行、启停、优先级、同币种冲突仲裁和同方向信号合并。
|
||||
- 第一个独立策略模块:`app/strategies/box_retest_4h.py`,消费统一交易宇宙并输出标准 `strategy_signal`。
|
||||
|
||||
## P2:第三阶段
|
||||
|
||||
@ -130,6 +151,7 @@
|
||||
- Onchain Flow 增强:链上事件按钱包角色、金额、方向、持续性细分。
|
||||
- 舆情质量过滤:只在有稳定数据源时做 bot ratio / smart KOL。
|
||||
- 策略分 regime 回测。
|
||||
- `strategy_catalog`、`strategy_run_log`、`strategy_performance_daily` 等长期策略运营表。
|
||||
|
||||
## 暂缓
|
||||
|
||||
@ -140,8 +162,11 @@
|
||||
|
||||
## 下一步执行建议
|
||||
|
||||
1. 部署运行一段时间,观察 `market_regime`、`score_components`、`factor_score_breakdown.groups`、观察/挂单推进率是否能解释真实回撤。
|
||||
2. 按线上样本校准 `entry_score` 门槛、group cap 和 high-risk 门槛。
|
||||
3. 做 Regime-based Scoring,让不同市场环境使用不同因子权重和分组上限。
|
||||
4. 如果 JSON 决策日志查询不方便,再新增 `strategy_decision_log` 表和页面。
|
||||
5. 后续再接入 BTC Dominance、TOTAL3、稳定币净流,增强 Market Regime Engine。
|
||||
1. 先完成多策略第一阶段的策略血缘字段和标准策略输出结构。
|
||||
2. 把现有主链路标记为 `main_composite_v1`,确保 recommendation 和 paper trading 不再丢失策略来源。
|
||||
3. 拆出 `box_retest_4h_v1` 作为第一个独立策略候选,先 observe/paper-only 跑样本。
|
||||
4. 部署运行一段时间,观察 `market_regime`、`score_components`、`factor_score_breakdown.groups`、观察/挂单推进率是否能解释真实回撤。
|
||||
5. 按线上样本校准 `entry_score` 门槛、group cap 和 high-risk 门槛。
|
||||
6. 做 Regime-based Scoring,让不同市场环境使用不同因子权重和分组上限。
|
||||
7. 如果 JSON 决策日志查询不方便,再新增 `strategy_decision_log` 表和页面。
|
||||
8. 后续再接入 BTC Dominance、TOTAL3、稳定币净流,增强 Market Regime Engine。
|
||||
|
||||
@ -338,7 +338,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<!-- compatibility markers: 实时机会 / 历史机会 / drawPin / data-entry-price / v.count / 止损 / 止盈 -->
|
||||
<!-- compatibility markers: 实时机会 / 实时推荐 / 历史机会 / 历史推荐 / drawPin / data-entry-price / v.count / 止损 / 止盈 -->
|
||||
<div class="controls-row">
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="live" onclick="switchTab('live')">实时机会<span class="count" id="liveCount"></span></button>
|
||||
@ -890,6 +890,7 @@ function renderRecCard(r) {
|
||||
return '<span class="sig '+cls+'">'+displaySignalText(s)+'</span>';
|
||||
}).join('');
|
||||
var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||'';
|
||||
var strategyLabel = r.strategy_name || ({main_composite_v1:'综合确认主链路',box_retest_4h_v1:'4H箱体突破回踩'}[r.strategy_code||''] || r.strategy_code || '');
|
||||
var hasQualityGate = ep.entry_quality_gate && Array.isArray(ep.entry_quality_gate.reasons) && ep.entry_quality_gate.reasons.length;
|
||||
var entryLabel = isWait ? '回踩参考' : (hasQualityGate ? '失效参考' : '参考价位');
|
||||
var entryRef = (isWait || hasQualityGate) ? (ep.entry_price || r.entry_price || 0) : (r.entry_price || ep.entry_price || 0);
|
||||
@ -960,7 +961,7 @@ function renderRecCard(r) {
|
||||
var compactSignals = sigs.slice(0,3).map(function(s){ return '<span class="summary-chip">'+displaySignalText(s)+'</span>'; }).join('');
|
||||
var onchainChip = oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score) ? '<span class="summary-chip">'+(ocRisk?'链上风险':'链上异动')+' '+(oc.event_count_24h||0)+'</span>' : '';
|
||||
var orderChip = r.paper_order && r.paper_order.id ? '<span class="summary-chip">挂单 '+(PAPER_ORDER_STATUS_LABELS[String(r.paper_order.status||'').toLowerCase()]||r.paper_order.status)+'</span>' : '';
|
||||
return '<a class="card summary-card '+(isWeakObserve?'weak-observe':'')+'" href="'+detailHref+'"><div class="summary-top"><div class="summary-main"><div class="summary-symbol"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span><div class="summary-meta">'+fmtTime(r.rec_time)+' · '+cleanDisplayText(levelLabel)+' · '+cleanDisplayText(horizon || levelFrameText(levelKey))+'</div></div></div></div><div class="summary-score"><span class="action-badge '+phase.cls+'">'+phase.label+'</span><span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="summary-price-row"><div class="summary-stat"><span>当前价</span><b>$'+priceFmt+'</b></div><div class="summary-stat"><span>'+changeLabel+'</span><b class="'+(changePct>=0?'green':'red')+'">'+(changePct==null?'--':changeSign+changePct.toFixed(1)+'%')+'</b></div><div class="summary-stat"><span>'+(isWait?'计划回踩':'计划入场')+'</span><b>'+fmtP(entryRef || price)+'</b></div><div class="summary-stat"><span>止损 / 目标</span><b>'+riskText+' / '+targetText+'</b></div></div><div class="summary-decision '+decisionCls+'"><h3>'+decisionTitle+' · '+decisionFocus+'</h3><p>'+decisionReason+'</p></div><div class="summary-tags"><div class="summary-chips">'+(compactSignals||'<span class="summary-chip">暂无明确信号</span>')+onchainChip+orderChip+'</div><span class="detail-link">查看详情</span></div></a>';
|
||||
return '<a class="card summary-card '+(isWeakObserve?'weak-observe':'')+'" href="'+detailHref+'"><div class="summary-top"><div class="summary-main"><div class="summary-symbol"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span><div class="summary-meta">'+fmtTime(r.rec_time)+' · '+cleanDisplayText(strategyLabel || levelLabel)+' · '+cleanDisplayText(horizon || levelFrameText(levelKey))+'</div></div></div></div><div class="summary-score"><span class="action-badge '+phase.cls+'">'+phase.label+'</span><span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="summary-price-row"><div class="summary-stat"><span>当前价</span><b>$'+priceFmt+'</b></div><div class="summary-stat"><span>'+changeLabel+'</span><b class="'+(changePct>=0?'green':'red')+'">'+(changePct==null?'--':changeSign+changePct.toFixed(1)+'%')+'</b></div><div class="summary-stat"><span>'+(isWait?'计划回踩':'计划入场')+'</span><b>'+fmtP(entryRef || price)+'</b></div><div class="summary-stat"><span>策略</span><b>'+cleanDisplayText(strategyLabel || '--')+'</b></div></div><div class="summary-decision '+decisionCls+'"><h3>'+decisionTitle+' · '+decisionFocus+'</h3><p>'+decisionReason+'</p></div><div class="summary-tags"><div class="summary-chips">'+(compactSignals||'<span class="summary-chip">暂无明确信号</span>')+onchainChip+orderChip+'</div><span class="detail-link">查看详情</span></div></a>';
|
||||
} catch (e) {
|
||||
console.error('renderRecCard hard fail', r && r.symbol, e);
|
||||
return renderLiveFallbackCard(r);
|
||||
|
||||
@ -164,10 +164,11 @@ function renderOrders(items){if(!items.length){$('orderRows').innerHTML='<tr><td
|
||||
'<td><div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span></div></td>'+
|
||||
'<td>'+time(x.created_at)+'</td>'+
|
||||
'<td>'+time(x.expires_at)+'</td>'+
|
||||
'<td><div>'+esc(x.source_status||'--')+'</div><div class="muted">'+esc(x.source_action||'')+'</div></td>'+
|
||||
'<td><div>'+esc(strategyName(x))+'</div><div class="muted">'+esc(x.source_status||'--')+' · '+esc(x.source_action||'')+'</div></td>'+
|
||||
'<td><button class="row-action" type="button" onclick="deleteOrder('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,''')+'\')">删除</button></td>'+
|
||||
'</tr>'}).join('')}
|
||||
function protectionCell(x){var trail=Number(x.trailing_stop||0);var trailHtml=trail>0?'<span class="trail-line">移动止盈 $'+fmt(trail,6)+'</span>':'<span class="trail-line off">移动止盈未启动</span>';return '<div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span>'+trailHtml+'</div>'}
|
||||
function strategyName(x){return (x&&x.strategy_name)||({main_composite_v1:'综合确认主链路',box_retest_4h_v1:'4H箱体突破回踩'}[(x&&x.strategy_code)||'']||((x&&x.strategy_code)||'--'))}
|
||||
async function loadOpenTrades(nextOffset){openOffset=Math.max(0,nextOffset||0);$('openRows').innerHTML='<tr><td colspan="14" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+openOffset+'&status=open');openTotal=d.total||0;renderTradeRows('openRows',d.items||[],'暂无持仓中的策略交易');renderOpenPager()}catch(e){$('openRows').innerHTML='<tr><td colspan="14" class="empty">'+esc(e.message)+'</td></tr>'}}
|
||||
async function loadCompleted(){await Promise.all([loadCompletedTrades(),loadCompletedOrders()])}
|
||||
async function loadCompletedTrades(){$('completedTradeRows').innerHTML='<tr><td colspan="14" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed');renderTradeRows('completedTradeRows',d.items||[],'暂无已平仓策略交易')}catch(e){$('completedTradeRows').innerHTML='<tr><td colspan="14" class="empty">'+esc(e.message)+'</td></tr>'}}
|
||||
@ -185,7 +186,7 @@ function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId)
|
||||
'<td>'+pct(x.status==='closed'?x.realized_pnl_pct:x.pnl_pct)+'</td>'+
|
||||
'<td><div>'+money(pnlUsdt)+'</div><div class="muted">账户 '+(x.account_return_pct>0?'+':'')+fmt(x.account_return_pct,2)+'% · 保证金 '+(x.margin_roi_pct>0?'+':'')+fmt(x.margin_roi_pct,2)+'%</div></td>'+
|
||||
'<td>'+esc(x.exit_reason||'--')+'</td>'+
|
||||
'<td><div>'+esc(x.source_status||'--')+'</div><div class="muted">'+esc(x.strategy_version||'')+'</div></td>'+
|
||||
'<td><div>'+esc(strategyName(x))+'</div><div class="muted">'+esc(x.source_status||'--')+' · '+esc(x.strategy_version||'')+'</div></td>'+
|
||||
'<td><button class="row-action" type="button" onclick="deleteTrade('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,''')+'\',\''+esc(String(x.status||'')).replace(/'/g,''')+'\')">删除</button></td>'+
|
||||
'</tr>'}).join('')}
|
||||
function renderCompletedOrders(items){if(!items.length){$('completedOrderRows').innerHTML='<tr><td colspan="11" class="empty">暂无已结束策略挂单</td></tr>';return}$('completedOrderRows').innerHTML=items.map(function(x){var latest=x.latest_price||x.current_price_at_create||0,dist=Number(x.distance_to_target_pct||0),ended=x.filled_at||x.canceled_at||x.updated_at;return '<tr>'+
|
||||
@ -198,7 +199,7 @@ function renderCompletedOrders(items){if(!items.length){$('completedOrderRows').
|
||||
'<td><div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span></div></td>'+
|
||||
'<td>'+time(x.created_at)+'</td>'+
|
||||
'<td>'+time(ended)+'</td>'+
|
||||
'<td><div>'+esc(x.cancel_reason||x.source_status||'--')+'</div><div class="muted">'+esc(x.source_action||'')+'</div></td>'+
|
||||
'<td><div>'+esc(strategyName(x))+'</div><div class="muted">'+esc(x.cancel_reason||x.source_status||'--')+' · '+esc(x.source_action||'')+'</div></td>'+
|
||||
'<td><button class="row-action" type="button" onclick="deleteOrder('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,''')+'\')">删除</button></td>'+
|
||||
'</tr>'}).join('')}
|
||||
function orderStatus(x){return {filled:'已成交',expired:'已过期',canceled:'已取消',rejected:'已拒绝'}[x.status]||x.status||'--'}
|
||||
|
||||
@ -56,7 +56,7 @@ function rows(items,label,value,sub){items=items||[];if(!items.length)return '<d
|
||||
function digestItems(items,label,sub){items=items||[];if(!items.length)return '<div class="digest-empty">暂无动作</div>';return items.slice(0,4).map(function(x){return '<div class="digest-item"><b>'+esc(label(x))+'</b><span>'+esc(sub?sub(x):'')+'</span></div>'}).join('')}
|
||||
function renderStrategyDigest(d){var it=d.iteration||{},dig=it.digest||{},latest=dig.latest||{},m=latest.metrics||{},decision=latest.decision||'hold';var badgeCls=decision==='release'?'ok':decision==='gray'?'warn':'warn';$('strategyDigest').innerHTML='<div class="digest-head"><div><div class="digest-title">策略迭代摘要</div><div class="digest-sub">'+esc(latest.title||'暂无复盘')+' · '+time(latest.time)+' · 版本 '+esc(latest.strategy_version||'--')+'</div></div><span class="badge '+badgeCls+'">'+esc(decision)+'</span></div><div class="kpis">'+[['因子生效调整',m.factor_weight_updates||0,'blue'],['候选 / 灰度',(it.summary&&it.summary.candidate_count||0)+' / '+(it.summary&&it.summary.gray_count||0),''],['本轮有效复盘',m.effective_review_count||0,''],['发布状态',latest.reason||'继续观察','']].map(function(x){return '<div class="kpi"><span>'+esc(x[0])+'</span><b class="'+(x[2]||'')+'">'+esc(x[1])+'</b></div>'}).join('')+'</div><div class="digest-grid"><div class="digest-card"><h3>升权了什么</h3>'+digestItems(dig.upgraded,function(x){return x.signal||'--'},function(x){return '权重 '+x.old_weight+' → '+x.new_weight+' · 样本 '+(x.sample_size||0)+' · 命中 '+(x.hit_rate||0)+'%'})+'</div><div class="digest-card"><h3>降权 / 淘汰</h3>'+digestItems(dig.downgraded,function(x){return x.signal||'--'},function(x){return (x.action||'调整')+' · '+(x.old_weight!=null?'权重 '+x.old_weight+' → '+x.new_weight:x.detail||'')})+'</div><div class="digest-card"><h3>灰度观察</h3>'+digestItems(dig.gray,function(x){return x.signal||('候选 #'+x.id)},function(x){return '样本 '+(x.sample_size||0)+' · 置信 '+(x.confidence||0)+'% · '+(x.description||'')})+'</div><div class="digest-card"><h3>最近迭代</h3>'+digestItems(dig.timeline,function(x){return time(x.time)+' · '+(x.decision||'hold')},function(x){return x.reason||x.title||''})+'</div></div>'}
|
||||
function renderOpportunity(o){var s=o.summary||{};$('oppDef').textContent=o.definition||'';$('opportunityPanel').innerHTML=kpis([['机会样本',s.total_opportunities||0,'blue'],['可买/等回踩',(s.buy_now_count||0)+' / '+(s.wait_pullback_count||0),''],['策略执行',s.paper_executed_count||0,'green'],['漏选爆发',s.missed_explosion_count||0,'red'],['有效复盘',s.effective_review_count||0,''],['机会命中',num(s.opportunity_hit_rate,1)+'%','green'],['观察样本',s.observe_count||0,''],['失效样本',s.invalid_count||0,'red']])+'<div class="split"><div><div class="mini-title">状态分布</div>'+rows(o.status_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">复盘结果</div>'+rows(o.outcome_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
|
||||
function renderPaper(p){var s=p.summary||{},ta=p.trade_attribution||{},wo=p.watch_order_attribution||{},tf=ta.factor||[],te=ta.entry_path||[],tg=ta.factor_group||[],wr=wo.watch_pool||[],orows=wo.paper_orders||[];$('paperDef').textContent=p.definition||'';$('paperPanel').innerHTML=kpis([['当前余额','$'+num(s.current_balance_usdt,2),'blue'],['总收益',usd(s.total_pnl_usdt),Number(s.total_pnl_usdt||0)>=0?'green':'red'],['账户收益率',pct(s.account_total_return_pct),Number(s.account_total_return_pct||0)>=0?'green':'red'],['胜率',num(s.win_rate,1)+'%',''],['开仓/平仓',(s.open_count||0)+' / '+(s.closed_count||0),''],['已实现',usd(s.realized_pnl_usdt),Number(s.realized_pnl_usdt||0)>=0?'green':'red'],['未实现',usd(s.open_unrealized_pnl_usdt),Number(s.open_unrealized_pnl_usdt||0)>=0?'green':'red'],['累计杠杆',num(s.cumulative_leverage,2)+'x','']])+'<div class="split"><div><div class="mini-title">退出原因</div>'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">执行事件</div>'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">真实交易因子</div>'+rows(tf.slice(0,6),function(x){return x.factor||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'</div><div><div class="mini-title">因子组表现</div>'+rows(tg.slice(0,6),function(x){return x.factor_group||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'</div><div><div class="mini-title">入场路径表现</div>'+rows(te.slice(0,6),function(x){return x.entry_path||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'</div><div><div class="mini-title">观察/挂单推进</div>'+rows(wr.concat(orows).slice(0,6),function(x){return x.watch_bucket||x.order_bucket||'--'},function(x){return (x.executed_pct!=null?num(x.executed_pct,1):num(x.fill_pct,1))+'%'},function(x){return '样本 '+(x.opportunity_count||x.order_count||0)+' · 执行/成交 '+(x.executed_count||x.filled_count||0)})+'</div></div>'}
|
||||
function renderPaper(p){var s=p.summary||{},ta=p.trade_attribution||{},wo=p.watch_order_attribution||{},tf=ta.factor||[],te=ta.entry_path||[],tg=ta.factor_group||[],ts=ta.strategy_code||[],wr=wo.watch_pool||[],orows=wo.paper_orders||[];$('paperDef').textContent=p.definition||'';$('paperPanel').innerHTML=kpis([['当前余额','$'+num(s.current_balance_usdt,2),'blue'],['总收益',usd(s.total_pnl_usdt),Number(s.total_pnl_usdt||0)>=0?'green':'red'],['账户收益率',pct(s.account_total_return_pct),Number(s.account_total_return_pct||0)>=0?'green':'red'],['胜率',num(s.win_rate,1)+'%',''],['开仓/平仓',(s.open_count||0)+' / '+(s.closed_count||0),''],['已实现',usd(s.realized_pnl_usdt),Number(s.realized_pnl_usdt||0)>=0?'green':'red'],['未实现',usd(s.open_unrealized_pnl_usdt),Number(s.open_unrealized_pnl_usdt||0)>=0?'green':'red'],['累计杠杆',num(s.cumulative_leverage,2)+'x','']])+'<div class="split"><div><div class="mini-title">策略表现</div>'+rows(ts.slice(0,8),function(x){return x.strategy_name||x.strategy_code||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'</div><div><div class="mini-title">退出原因</div>'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">执行事件</div>'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">真实交易因子</div>'+rows(tf.slice(0,6),function(x){return x.factor||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'</div><div><div class="mini-title">因子组表现</div>'+rows(tg.slice(0,6),function(x){return x.factor_group||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'</div><div><div class="mini-title">入场路径表现</div>'+rows(te.slice(0,6),function(x){return x.entry_path||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'</div><div><div class="mini-title">观察/挂单推进</div>'+rows(wr.concat(orows).slice(0,6),function(x){return x.watch_bucket||x.order_bucket||'--'},function(x){return (x.executed_pct!=null?num(x.executed_pct,1):num(x.fill_pct,1))+'%'},function(x){return '样本 '+(x.opportunity_count||x.order_count||0)+' · 执行/成交 '+(x.executed_count||x.filled_count||0)})+'</div></div>'}
|
||||
function renderEvidence(e){var s=e.summary||{};$('evidenceDef').textContent=e.definition||'';$('evidencePanel').innerHTML=kpis([['新闻事件',s.news_count||0,'blue'],['有效舆情',s.actionable_news_count||0,''],['链上信号',s.onchain_signal_count||0,'blue'],['高置信链上',s.high_confidence_onchain_count||0,'green'],['原始链上',s.raw_onchain_count||0,''],['已映射原始',s.mapped_raw_onchain_count||0,'green'],['LLM 调用',s.llm_runs||0,''],['LLM 成功',s.llm_success_count||0,'green']])+'<div class="split"><div><div class="mini-title">链上信号</div>'+rows(e.onchain_signals,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">舆情决策</div>'+rows(e.news_decisions,function(x){return x.name||'未处理'},function(x){return x.count})+'</div></div>'}
|
||||
function renderIteration(i){var s=i.summary||{};$('iterationDef').textContent=i.definition||'';$('iterationPanel').innerHTML=kpis([['迭代记录',s.iteration_count||0,'blue'],['候选规则',s.candidate_count||0,''],['灰度规则',s.gray_count||0,'green'],['生效规则',s.active_count||0,'green']])+'<div class="row" style="margin-bottom:10px"><div class="row-main"><div class="row-title">最新发布结论:'+esc(s.latest_release_decision||'hold')+'</div><div class="row-sub">'+esc(s.latest_release_reason||'暂无发布说明')+'</div></div><div class="value">闸门</div></div><div class="split"><div><div class="mini-title">发布决策</div>'+rows(i.release_decisions,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">候选状态</div>'+rows(i.candidate_status,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
|
||||
function renderRecent(d){var opp=(d.opportunity&&d.opportunity.missed_explosions)||[], trades=(d.paper_trading&&d.paper_trading.recent_trades)||[], news=(d.evidence&&d.evidence.recent_news)||[], chain=(d.evidence&&d.evidence.recent_onchain)||[];$('recentPanel').innerHTML='<div class="split"><div><div class="mini-title">最近策略交易</div>'+rows(trades.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.status||'--')},function(x){return x.status==='closed'?pct(x.realized_pnl_pct):pct(x.pnl_pct)},function(x){return time(x.opened_at)+' · '+(x.exit_reason||x.source_status||'')})+'</div><div><div class="mini-title">漏选爆发</div>'+rows(opp.slice(0,8),function(x){return x.symbol||'--'},function(x){return pct(x.gain_pct)},function(x){return time(x.detect_time)+' · '+(x.reason_missed||'')})+'</div><div><div class="mini-title">舆情事件</div>'+rows(news.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.title||'--')},function(x){return x.importance||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.decision||'未处理')})+'</div><div><div class="mini-title">链上信号</div>'+rows(chain.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.signal_label||x.signal_code||'--')},function(x){return x.severity||x.confidence||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.direction||'')})+'</div></div>'}
|
||||
|
||||
@ -84,6 +84,7 @@ _ID_TABLES = {
|
||||
"review_log",
|
||||
"missed_explosions",
|
||||
"strategy_iteration_log",
|
||||
"strategy_signals",
|
||||
"strategy_rule_candidate",
|
||||
"strategy_failure_pattern",
|
||||
"push_log",
|
||||
|
||||
116
tests/test_multi_strategy_infra.py
Normal file
116
tests/test_multi_strategy_infra.py
Normal file
@ -0,0 +1,116 @@
|
||||
from app.core.factor_roles import RISK, TRIGGER, factor_role, factor_roles_for_codes
|
||||
from app.core.strategy_contract import StrategySignal, default_main_composite_signal
|
||||
from app.core.strategy_registry import BOX_RETEST_4H_STRATEGY, MAIN_COMPOSITE_STRATEGY, strategy_label
|
||||
from app.db.recommendation_commands import create_recommendation
|
||||
from app.db.strategy_signal_queries import insert_strategy_signal
|
||||
from app.db.paper_trading import _open_trade, _order_payload_from_rec
|
||||
|
||||
|
||||
def test_factor_roles_never_promote_unknown_to_trigger():
|
||||
assert factor_role("box_breakout_pullback_4h") == TRIGGER
|
||||
assert factor_role("false_breakout") == RISK
|
||||
assert factor_role("new_unknown_factor") == "unknown"
|
||||
assert factor_roles_for_codes(["box_breakout_pullback_4h", "new_unknown_factor"]) == {
|
||||
"box_breakout_pullback_4h": "trigger",
|
||||
"new_unknown_factor": "unknown",
|
||||
}
|
||||
|
||||
|
||||
def test_default_main_composite_strategy_signal_is_stable():
|
||||
signal = default_main_composite_signal(
|
||||
symbol="AAA/USDT",
|
||||
score=70,
|
||||
signal_codes=["vp_fly_1h_current"],
|
||||
entry_plan={"entry_action": "观察"},
|
||||
).to_json_dict()
|
||||
|
||||
assert signal["strategy_code"] == MAIN_COMPOSITE_STRATEGY
|
||||
assert signal["strategy_name"] == "综合确认主链路"
|
||||
assert signal["factor_roles"]["vp_fly_1h_current"] == "trigger"
|
||||
|
||||
|
||||
def test_strategy_signal_insert_and_recommendation_lineage(pg_conn):
|
||||
signal = insert_strategy_signal(
|
||||
StrategySignal(
|
||||
strategy_code=BOX_RETEST_4H_STRATEGY,
|
||||
symbol="BOX/USDT",
|
||||
score=10,
|
||||
confidence=80,
|
||||
trigger={"factor_code": "box_breakout_pullback_4h"},
|
||||
factor_roles={"box_breakout_pullback_4h": "trigger"},
|
||||
entry_plan={"entry_action": "等回踩", "entry_price": 1.0},
|
||||
)
|
||||
)
|
||||
|
||||
rec_id = create_recommendation(
|
||||
symbol="BOX/USDT",
|
||||
rec_state="爆发",
|
||||
rec_score=30,
|
||||
entry_price=1.0,
|
||||
stop_loss=0.94,
|
||||
tp1=1.12,
|
||||
signals=["4H箱体突破回踩(箱体上沿 $1, 量2x)"],
|
||||
entry_plan={"entry_action": "等回踩", "entry_price": 1.0, "stop_loss": 0.94, "tp1": 1.12},
|
||||
strategy_code=signal["strategy_code"],
|
||||
strategy_signal_id=signal["strategy_signal_id"],
|
||||
strategy_snapshot=signal,
|
||||
factor_roles=signal["factor_roles"],
|
||||
)
|
||||
row = pg_conn.execute("SELECT * FROM recommendation WHERE id=%s", (rec_id,)).fetchone()
|
||||
|
||||
assert row["strategy_code"] == BOX_RETEST_4H_STRATEGY
|
||||
assert row["strategy_signal_id"] == signal["strategy_signal_id"]
|
||||
assert "box_breakout_pullback_4h" in row["factor_roles_json"]
|
||||
|
||||
|
||||
def test_paper_order_and_trade_inherit_strategy_lineage(pg_conn):
|
||||
rec = {
|
||||
"id": 1,
|
||||
"symbol": "BOX/USDT",
|
||||
"rec_score": 100,
|
||||
"entry_price": 1.0,
|
||||
"stop_loss": 0.94,
|
||||
"tp1": 1.12,
|
||||
"tp2": 1.2,
|
||||
"execution_status": "buy_now",
|
||||
"action_status": "可即刻买入",
|
||||
"strategy_version": "v-test",
|
||||
"strategy_code": BOX_RETEST_4H_STRATEGY,
|
||||
"strategy_signal_id": 42,
|
||||
"strategy_snapshot_json": '{"strategy_code":"box_retest_4h_v1"}',
|
||||
"factor_roles_json": '{"box_breakout_pullback_4h":"trigger"}',
|
||||
"entry_plan": {"entry_action": "可即刻买入", "entry_price": 1.0, "stop_loss": 0.94, "tp1": 1.12},
|
||||
}
|
||||
|
||||
payload = _order_payload_from_rec(rec, 1.01, "2026-05-27T00:00:00", {"trade_notional_usdt": 100})
|
||||
assert payload["strategy_code"] == BOX_RETEST_4H_STRATEGY
|
||||
assert payload["strategy_signal_id"] == 42
|
||||
|
||||
result = _open_trade(
|
||||
pg_conn,
|
||||
rec,
|
||||
1.0,
|
||||
"2026-05-27T00:00:00",
|
||||
config={
|
||||
"enabled": True,
|
||||
"trade_notional_usdt": 100,
|
||||
"trade_leverage": 1,
|
||||
"account_equity_usdt": 20000,
|
||||
"fee_rate": 0,
|
||||
"min_rec_score": 0,
|
||||
"min_rr": 0,
|
||||
"max_stop_loss_leverage_risk_pct": 999,
|
||||
"max_cumulative_leverage": 999,
|
||||
"max_account_drawdown_pause_pct": 0,
|
||||
"weak_entry_pause": {"enabled": False},
|
||||
},
|
||||
push_open_card=False,
|
||||
)
|
||||
assert result["opened"] is True
|
||||
row = pg_conn.execute("SELECT * FROM paper_trades WHERE id=%s", (result["trade_id"],)).fetchone()
|
||||
event = pg_conn.execute("SELECT * FROM paper_trade_events WHERE trade_id=%s", (result["trade_id"],)).fetchone()
|
||||
|
||||
assert row["strategy_code"] == BOX_RETEST_4H_STRATEGY
|
||||
assert row["strategy_signal_id"] == 42
|
||||
assert event["strategy_code"] == BOX_RETEST_4H_STRATEGY
|
||||
assert strategy_label(row["strategy_code"]) == "4H箱体突破回踩"
|
||||
Loading…
Reference in New Issue
Block a user