diff --git a/app/cli.py b/app/cli.py
index 95c7772..f9bcc8f 100644
--- a/app/cli.py
+++ b/app/cli.py
@@ -54,6 +54,10 @@ def build_parser():
live_smoke.add_argument("--notional-usdt", type=float, default=10.0, help="测试名义金额,默认 10U")
live_smoke.add_argument("--leverage", type=float, default=1.0, help="测试杠杆,默认 1x")
+ repair_strategy = subparsers.add_parser("repair-strategy-direction", help="修复策略方向与交易方向不一致的推荐数据")
+ repair_strategy.add_argument("--limit", type=int, default=500, help="最多扫描的 recommendation 数量")
+ repair_strategy.add_argument("--dry-run", action="store_true", help="只预览不写库")
+
return parser
@@ -122,6 +126,12 @@ def main():
notional_usdt=args.notional_usdt,
leverage=args.leverage,
)
+ if args.command == "repair-strategy-direction":
+ from app.db.strategy_direction_repair import repair_strategy_direction_mismatches
+
+ result = repair_strategy_direction_mismatches(limit=args.limit, dry_run=args.dry_run)
+ print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2))
+ return result
parser.error(f"unknown command: {args.command}")
diff --git a/app/core/signal_direction.py b/app/core/signal_direction.py
new file mode 100644
index 0000000..7354e37
--- /dev/null
+++ b/app/core/signal_direction.py
@@ -0,0 +1,179 @@
+"""Direction-aware signal hygiene for long/short opportunities."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import Any
+
+from app.core.signal_taxonomy import signal_code
+from app.core.trade_direction import normalize_trade_side
+
+
+LONG_SUPPORT_CODES = {
+ "box_breakout_pullback_1h",
+ "box_breakout_pullback_4h",
+ "breakout_pullback_d1",
+ "breakout_pullback_w1",
+ "ignition_1h_current",
+ "ignition_4h_current",
+ "ignition_d1_current",
+ "dynamic_k_1h_bull",
+ "dynamic_k_d1_bull",
+ "breakout_15m_current",
+ "pullback_15m_confirm",
+ "top_trader_long",
+ "sector_rotation",
+ "rs_strong",
+ "rs_independent_strength",
+ "funding_negative_contrarian",
+ "vcp_bull_breakout",
+ "vcp_bull_forming",
+}
+
+SHORT_SUPPORT_CODES = {
+ "breakdown_retest_1h_short",
+ "retest_reject_15m_short",
+ "market_risk_off_short",
+ "vcp_bear_breakdown",
+ "vcp_bear_forming",
+}
+
+RISK_OR_CONTEXT_CODES = {
+ "volume_divergence_1h",
+ "entry_quality_gate",
+ "liquidity_remove_risk",
+ "exchange_inflow_risk",
+ "holder_concentration_risk",
+ "funding_extreme",
+ "trend_exhaustion",
+ "false_breakout",
+ "high_position_reject",
+ "risk_reward_bad",
+ "rs_weak",
+ "oi_divergence_risk",
+ "funding_positive_risk",
+ "tf_alignment_single_penalty",
+ "tf_alignment_conflict_penalty",
+ "vp_path_blocked",
+ "breakout_quality_low",
+}
+
+LONG_SUPPORT_TEXT = (
+ "大户偏多",
+ "BTC回调中独立走强",
+ "板块联动",
+ "龙头",
+ "箱体突破回踩",
+ "底部箱体突破",
+ "日线需求区反弹",
+ "动K(阳)",
+ "阳动K",
+ "当前多头起爆",
+ "VCP多头",
+ "资金费率负值反向看多",
+ "空头拥挤",
+)
+
+
+def is_factor_supportive_for_side(code: str, side: object, score_delta: object = 0) -> bool:
+ """Whether a positive factor can support the requested trade side."""
+ trade_side = normalize_trade_side(side)
+ code = str(code or "").strip()
+ try:
+ delta = float(score_delta or 0)
+ except Exception:
+ delta = 0.0
+ if delta <= 0 or code in RISK_OR_CONTEXT_CODES:
+ return True
+ if trade_side == "short" and code in LONG_SUPPORT_CODES:
+ return False
+ if trade_side == "long" and code in SHORT_SUPPORT_CODES:
+ return False
+ return True
+
+
+def is_signal_supportive_for_side(signal: object, side: object) -> bool:
+ """Whether a display signal belongs in the main evidence list."""
+ trade_side = normalize_trade_side(side)
+ text = str(signal or "").strip()
+ if not text:
+ return False
+ code = signal_code(text)
+ if trade_side == "short":
+ if code in LONG_SUPPORT_CODES:
+ return False
+ normalized = text.replace(" ", "")
+ if any(token.replace(" ", "") in normalized for token in LONG_SUPPORT_TEXT):
+ return False
+ elif code in SHORT_SUPPORT_CODES:
+ return False
+ return True
+
+
+def sanitize_signals_for_side(signals: list[Any], side: object) -> tuple[list[Any], list[str]]:
+ clean: list[Any] = []
+ conflicts: list[str] = []
+ seen_clean: set[str] = set()
+ seen_conflict: set[str] = set()
+ for signal in signals or []:
+ text = str(signal or "").strip()
+ if not text:
+ continue
+ if is_signal_supportive_for_side(text, side):
+ if text not in seen_clean:
+ clean.append(signal)
+ seen_clean.add(text)
+ elif text not in seen_conflict:
+ conflicts.append(text)
+ seen_conflict.add(text)
+ return clean, conflicts
+
+
+def excluded_factor_delta(items: list[dict[str, Any]], side: object) -> float:
+ total = 0.0
+ for item in items or []:
+ code = str(item.get("factor_code") or "")
+ delta = item.get("score_delta") or 0
+ if not is_factor_supportive_for_side(code, side, delta):
+ try:
+ total += float(delta or 0)
+ except Exception:
+ pass
+ return round(total, 6)
+
+
+def sanitize_factor_breakdown_for_side(summary: dict[str, Any], side: object) -> tuple[dict[str, Any], list[dict[str, Any]]]:
+ """Remove side-inconsistent positive factors from an existing summary."""
+ src = deepcopy(summary or {})
+ kept: list[dict[str, Any]] = []
+ removed: list[dict[str, Any]] = []
+ for item in src.get("items") or []:
+ code = str(item.get("factor_code") or "")
+ delta = item.get("score_delta") or 0
+ if is_factor_supportive_for_side(code, side, delta):
+ kept.append(item)
+ else:
+ removed.append(item)
+
+ groups: dict[str, dict[str, Any]] = {}
+ for item in kept:
+ group = item.get("factor_group") or "other"
+ bucket = groups.setdefault(group, {"score_delta": 0.0, "items": 0})
+ try:
+ bucket["score_delta"] = round(float(bucket["score_delta"]) + float(item.get("score_delta") or 0), 3)
+ except Exception:
+ pass
+ bucket["items"] += 1
+ opportunity_groups = {"momentum", "participation", "structure", "positioning", "narrative", "onchain_flow"}
+ src["items"] = kept
+ src["groups"] = groups
+ src["total_delta"] = round(sum(float(i.get("score_delta") or 0) for i in kept), 3)
+ src["opportunity_score"] = round(sum(float(v.get("score_delta") or 0) for k, v in groups.items() if k in opportunity_groups), 3)
+ src["entry_score"] = round(float(groups.get("entry_quality", {}).get("score_delta") or 0), 3)
+ src["risk_score"] = round(abs(min(0.0, float(groups.get("risk", {}).get("score_delta") or 0))), 3)
+ if removed:
+ src["direction_filtered"] = {
+ "side": normalize_trade_side(side),
+ "removed_factor_codes": [str(x.get("factor_code") or "") for x in removed],
+ }
+ return src, removed
diff --git a/app/core/strategy_contract.py b/app/core/strategy_contract.py
index 204bdf3..476c8f0 100644
--- a/app/core/strategy_contract.py
+++ b/app/core/strategy_contract.py
@@ -8,7 +8,7 @@ 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
+from app.core.strategy_registry import MAIN_COMPOSITE_STRATEGY, is_strategy_allowed_for_side, normalize_strategy_code, strategy_label
def _safe_dict(value: Any) -> dict:
@@ -43,6 +43,9 @@ class StrategySignal:
def __post_init__(self):
self.strategy_code = normalize_strategy_code(self.strategy_code)
+ self.direction = "short" if str(self.direction or "").strip().lower() == "short" else "long"
+ if not is_strategy_allowed_for_side(self.strategy_code, self.direction):
+ raise ValueError(f"strategy {self.strategy_code} cannot emit {self.direction} signal")
self.factor_roles = validate_factor_roles(self.factor_roles)
def to_json_dict(self) -> dict[str, Any]:
diff --git a/app/core/strategy_registry.py b/app/core/strategy_registry.py
index e58fac3..f536a98 100644
--- a/app/core/strategy_registry.py
+++ b/app/core/strategy_registry.py
@@ -19,6 +19,7 @@ class StrategyDefinition:
strategy_code: str
strategy_name: str
description: str = ""
+ direction: str = "long"
mode: str = "paper_only"
status: str = "active"
entry_gate_config: dict = field(default_factory=dict)
@@ -30,6 +31,7 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
strategy_code=MAIN_COMPOSITE_STRATEGY,
strategy_name="综合确认策略",
description="迁移期兼容综合策略,承载现有综合筛选与确认逻辑;它与其他策略平等运行。",
+ direction="both",
mode="paper_enabled",
),
BOX_RETEST_1H_STRATEGY: StrategyDefinition(
@@ -144,6 +146,7 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
strategy_code=BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
strategy_name="1H破位反抽做空",
description="箱体或关键均线破位后反抽失败的空头策略;只用于独立空头样本,不与多头突破策略共享入场门槛。",
+ direction="short",
mode="paper_only",
entry_gate_config={
"direction": "short",
@@ -187,6 +190,17 @@ def strategy_label(strategy_code: str | None) -> str:
return strategy_definition(strategy_code).strategy_name
+def strategy_direction(strategy_code: str | None) -> str:
+ direction = str(strategy_definition(strategy_code).direction or "long").strip().lower()
+ return direction if direction in {"long", "short", "both"} else "long"
+
+
+def is_strategy_allowed_for_side(strategy_code: str | None, side: str | None) -> bool:
+ direction = strategy_direction(strategy_code)
+ normalized_side = "short" if str(side or "").strip().lower() == "short" else "long"
+ return direction == "both" or direction == normalized_side
+
+
def strategy_entry_gate_config(strategy_code: str | None) -> dict:
return dict(strategy_definition(strategy_code).entry_gate_config or {})
diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py
index 9f1f8d7..4c226da 100644
--- a/app/db/paper_trading.py
+++ b/app/db/paper_trading.py
@@ -1042,31 +1042,16 @@ def _fill_paper_order(conn, order: dict, rec: dict, current_price: float, event_
base_notional = _safe_float(order.get("notional_usdt"), default_notional_usdt(cfg))
global_ok, global_detail = _global_risk_entry_check(conn, rec, base_notional, cfg)
if not global_ok:
- # Market/account risk is a temporary execution gate, not proof that the
- # original limit order is invalid. Keep the order pending so it can fill
- # later if risk improves. But if price has already run far past the target
- # in the adverse direction, the pullback premise is void → cancel so we
- # don't later fill at a stale, distorted target price.
- side = str(order.get("side") or "long").lower()
- target = _safe_float(order.get("target_price"))
- threshold = max(0.0, _safe_float(cfg.get("order_cancel_far_from_entry_pct"), 12.0))
- too_far = False
- if target > 0 and current_price > 0 and threshold > 0:
- if side == "short":
- too_far = current_price > target * (1 + threshold / 100)
- else:
- too_far = current_price < target * (1 - threshold / 100)
- if too_far:
- return _cancel_paper_order(conn, order, "risk_paused_far_from_entry", event_time)
- conn.execute("UPDATE paper_orders SET updated_at=%s WHERE id=%s", (event_time, order["id"]))
- return {
- "skipped": True,
- "reason": "paper_order_risk_paused",
- "paper_order_id": order.get("id"),
+ # 触价后的限价单已经完成“等待成交”阶段。若此刻风控不允许开仓,
+ # 这张挂单必须结束,不能继续 pending 等待下一次风控放行,否则会在
+ # 页面上出现“做多价格已低于目标价仍挂单”的错误状态。
+ result = _cancel_paper_order(conn, order, "risk_paused_at_touch", event_time)
+ result.update({
"target_price": order.get("target_price"),
"current_price": current_price,
"risk_detail": global_detail,
- }
+ })
+ return result
adjusted_notional = _market_risk_adjusted_notional(base_notional, global_detail, cfg)
pause_ok, pause_reason, pause_detail = _portfolio_entry_pause_check(conn, adjusted_notional, event_time, cfg)
if not pause_ok:
@@ -1932,7 +1917,7 @@ def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "", strate
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
+ item["distance_to_target_pct"] = round(order_distance_pct(item.get("side") or "long", latest, target), 4) if target and latest else 0
items.append(item)
return {
"items": items,
diff --git a/app/db/strategy_direction_repair.py b/app/db/strategy_direction_repair.py
new file mode 100644
index 0000000..576f276
--- /dev/null
+++ b/app/db/strategy_direction_repair.py
@@ -0,0 +1,199 @@
+"""Repair recommendations whose strategy identity or evidence conflicts with trade side."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime
+
+from app.core.signal_direction import excluded_factor_delta, sanitize_factor_breakdown_for_side, sanitize_signals_for_side
+from app.core.signal_taxonomy import signal_codes, signal_labels
+from app.core.strategy_registry import BREAKDOWN_RETEST_SHORT_1H_STRATEGY, MAIN_COMPOSITE_STRATEGY, is_strategy_allowed_for_side, strategy_label
+from app.core.trade_direction import trade_side_from_payload
+from app.db.postgres_connection import connect
+
+
+def _loads(value) -> dict:
+ if isinstance(value, dict):
+ return dict(value)
+ if not value:
+ return {}
+ try:
+ parsed = json.loads(value)
+ return parsed if isinstance(parsed, dict) else {}
+ except Exception:
+ return {}
+
+
+def _loads_list(value) -> list:
+ if isinstance(value, list):
+ return list(value)
+ if not value:
+ return []
+ try:
+ parsed = json.loads(value)
+ return parsed if isinstance(parsed, list) else []
+ except Exception:
+ return [str(value)]
+
+
+def _dumps(value: dict) -> str:
+ return json.dumps(value or {}, ensure_ascii=False, default=str)
+
+
+def _has_short_breakdown_context(*payloads: dict) -> bool:
+ for payload in payloads:
+ if not isinstance(payload, dict):
+ continue
+ short_ctx = payload.get("short_breakdown_retest_1h")
+ if isinstance(short_ctx, dict) and short_ctx.get("detected"):
+ return True
+ nested = payload.get("market_context")
+ if isinstance(nested, dict):
+ short_ctx = nested.get("short_breakdown_retest_1h")
+ if isinstance(short_ctx, dict) and short_ctx.get("detected"):
+ return True
+ return False
+
+
+def repair_strategy_direction_mismatches(limit: int = 500, dry_run: bool = False) -> dict:
+ limit = max(1, min(int(limit or 500), 5000))
+ conn = connect()
+ rows = conn.execute(
+ """
+ SELECT id, symbol, direction, strategy_code, signals, rec_score,
+ entry_plan_json, market_context_json, strategy_snapshot_json, factor_roles_json
+ FROM recommendation
+ WHERE strategy_code IS NOT NULL AND strategy_code != ''
+ ORDER BY id DESC
+ LIMIT %s
+ """,
+ (limit,),
+ ).fetchall()
+
+ repaired = []
+ scanned = 0
+ now = datetime.now().isoformat()
+ try:
+ for row in rows:
+ scanned += 1
+ item = dict(row)
+ entry_plan = _loads(item.get("entry_plan_json"))
+ market_context = _loads(item.get("market_context_json"))
+ snapshot = _loads(item.get("strategy_snapshot_json"))
+ factor_roles = _loads(item.get("factor_roles_json"))
+ side = trade_side_from_payload(entry_plan, snapshot, item.get("direction"))
+ old_code = str(item.get("strategy_code") or "").strip()
+ new_code = old_code
+ reasons: list[str] = []
+
+ if not is_strategy_allowed_for_side(old_code, side):
+ if side == "short" and _has_short_breakdown_context(entry_plan, market_context, snapshot):
+ new_code = BREAKDOWN_RETEST_SHORT_1H_STRATEGY
+ reasons.append("short_breakdown_context")
+ else:
+ new_code = MAIN_COMPOSITE_STRATEGY
+ reasons.append("fallback_composite_direction_mismatch")
+
+ signals = _loads_list(item.get("signals"))
+ removed_signals: list[str] = []
+ removed_factors: list[dict] = []
+ score_delta_to_remove = 0.0
+ if side == "short":
+ clean_signals, removed_signals = sanitize_signals_for_side(signals, "short")
+ breakdown = _loads(entry_plan.get("factor_score_breakdown")) or _loads(market_context.get("factor_score_breakdown"))
+ score_delta_to_remove = excluded_factor_delta(breakdown.get("items") or [], "short")
+ clean_breakdown, removed_factors = sanitize_factor_breakdown_for_side(breakdown, "short")
+ if removed_signals or removed_factors:
+ reasons.append("short_direction_signal_cleanup")
+ signals = clean_signals or signals
+ entry_plan["factor_score_breakdown"] = clean_breakdown
+ market_context["factor_score_breakdown"] = clean_breakdown
+ market_context["direction_conflict_filter"] = {
+ "side": side,
+ "removed_signals": removed_signals,
+ "removed_factor_codes": [str(x.get("factor_code") or "") for x in removed_factors],
+ }
+ decision_log = market_context.get("decision_log") if isinstance(market_context.get("decision_log"), dict) else {}
+ risk_flags = list(decision_log.get("risk_flags") or [])
+ for sig in removed_signals[:3]:
+ flag = f"direction_conflict:{sig}"
+ if flag not in risk_flags:
+ risk_flags.append(flag)
+ decision_log["risk_flags"] = risk_flags
+ market_context["decision_log"] = decision_log
+ entry_plan["decision_log"] = decision_log
+
+ if not reasons:
+ continue
+
+ entry_plan["side"] = side
+ entry_plan["strategy_code"] = new_code
+ entry_plan["strategy_direction_repair"] = {
+ "old_strategy_code": old_code,
+ "new_strategy_code": new_code,
+ "side": side,
+ "reason": ",".join(reasons),
+ "repaired_at": now,
+ }
+ snapshot["strategy_code"] = new_code
+ snapshot["strategy_name"] = strategy_label(new_code)
+ snapshot["direction"] = side
+ snapshot["entry_plan"] = {**entry_plan, **(_loads(snapshot.get("entry_plan")) if isinstance(snapshot.get("entry_plan"), (str, dict)) else {})}
+
+ try:
+ rec_score = max(0, round(float(item.get("rec_score") or 0) - (score_delta_to_remove * 100.0 / 30.0)))
+ except Exception:
+ rec_score = item.get("rec_score") or 0
+ labels = signal_labels(signals)
+ codes = signal_codes(labels)
+ repaired.append(
+ {
+ "id": item["id"],
+ "symbol": item["symbol"],
+ "side": side,
+ "old_strategy_code": old_code,
+ "new_strategy_code": new_code,
+ "reason": ",".join(reasons),
+ "removed_signals": removed_signals,
+ "removed_factor_codes": [str(x.get("factor_code") or "") for x in removed_factors],
+ }
+ )
+ if not dry_run:
+ conn.execute(
+ """
+ UPDATE recommendation
+ SET strategy_code=%s,
+ rec_score=%s,
+ signals=%s,
+ signal_codes_json=%s,
+ signal_labels_json=%s,
+ entry_plan_json=%s,
+ market_context_json=%s,
+ strategy_snapshot_json=%s,
+ factor_roles_json=%s
+ WHERE id=%s
+ """,
+ (
+ new_code,
+ rec_score,
+ json.dumps(labels, ensure_ascii=False),
+ json.dumps(codes, ensure_ascii=False),
+ json.dumps(labels, ensure_ascii=False),
+ _dumps(entry_plan),
+ _dumps(market_context),
+ _dumps(snapshot),
+ _dumps(factor_roles),
+ item["id"],
+ ),
+ )
+ if dry_run:
+ conn.rollback()
+ else:
+ conn.commit()
+ except Exception:
+ conn.rollback()
+ raise
+ finally:
+ conn.close()
+
+ return {"scanned": scanned, "repaired_count": len(repaired), "dry_run": dry_run, "items": repaired}
diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py
index 9c6dc44..7a7835d 100644
--- a/app/services/altcoin_confirm.py
+++ b/app/services/altcoin_confirm.py
@@ -46,6 +46,7 @@ from app.core.strategy_registry import (
BOX_RETEST_4H_STRATEGY,
BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
MAIN_COMPOSITE_STRATEGY,
+ is_strategy_allowed_for_side,
)
from app.core.trade_direction import direction_label, normalize_trade_side
from app.core.opportunity_level import (
@@ -56,6 +57,7 @@ from app.core.opportunity_level import (
)
from app.core.opportunity_funnel import build_screening_detail
from app.core.factor_scoring import FactorScorer
+from app.core.signal_direction import excluded_factor_delta, sanitize_factor_breakdown_for_side, sanitize_signals_for_side
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
@@ -95,39 +97,15 @@ 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."""
+ trade_side = normalize_trade_side((entry_plan or {}).get("side") or result.get("side") or result.get("direction"))
bp_1h = result.get("box_breakout_pullback_1h") or (result.get("market_context") or {}).get("box_breakout_pullback_1h") or {}
bp_4h = result.get("box_breakout_pullback_4h") or (result.get("market_context") or {}).get("box_breakout_pullback_4h") or {}
short_1h = result.get("short_breakdown_retest_1h") or (result.get("market_context") or {}).get("short_breakdown_retest_1h") or {}
market_regime = result.get("market_regime") or (result.get("market_context") or {}).get("market_regime") or {}
signal_candidates = []
- signal_candidates.extend([
- build_volume_ignition_1h_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}),
- build_compression_breakout_4h_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}),
- build_intraday_momentum_15m_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}),
- ])
- if bp_1h.get("detected"):
- signal_candidates.append(
- build_box_retest_1h_signal(
- symbol=symbol,
- current_price=result.get("price") or 0,
- detection=bp_1h,
- entry_plan=entry_plan or {},
- market_regime=market_regime,
- decision_log=result.get("decision_log") or {},
- )
- )
- if bp_4h.get("detected"):
- signal_candidates.append(
- build_box_retest_4h_signal(
- symbol=symbol,
- current_price=result.get("price") or 0,
- detection=bp_4h,
- entry_plan=entry_plan or {},
- market_regime=market_regime,
- decision_log=result.get("decision_log") or {},
- )
- )
- if short_1h.get("detected"):
+ if trade_side == "short":
+ if not short_1h.get("detected"):
+ return {}
signal_candidates.append(
build_breakdown_retest_short_1h_signal(
symbol=symbol,
@@ -138,9 +116,40 @@ def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan:
decision_log=result.get("decision_log") or {},
)
)
+ else:
+ signal_candidates.extend([
+ build_volume_ignition_1h_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}),
+ build_compression_breakout_4h_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}),
+ build_intraday_momentum_15m_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}),
+ ])
+ if bp_1h.get("detected"):
+ signal_candidates.append(
+ build_box_retest_1h_signal(
+ symbol=symbol,
+ current_price=result.get("price") or 0,
+ detection=bp_1h,
+ entry_plan=entry_plan or {},
+ market_regime=market_regime,
+ decision_log=result.get("decision_log") or {},
+ )
+ )
+ if bp_4h.get("detected"):
+ signal_candidates.append(
+ build_box_retest_4h_signal(
+ symbol=symbol,
+ current_price=result.get("price") or 0,
+ detection=bp_4h,
+ entry_plan=entry_plan or {},
+ market_regime=market_regime,
+ decision_log=result.get("decision_log") or {},
+ )
+ )
saved_payloads = []
for signal in [item for item in signal_candidates if item]:
- saved_payloads.append(insert_strategy_signal(signal))
+ payload = signal.to_json_dict() if hasattr(signal, "to_json_dict") else dict(signal)
+ if not is_strategy_allowed_for_side(payload.get("strategy_code"), trade_side):
+ continue
+ saved_payloads.append(insert_strategy_signal(payload))
if not saved_payloads:
return {}
def _rank(payload: dict) -> tuple:
@@ -2098,6 +2107,20 @@ def confirm_burst(symbol, cand):
value=market_risk_gate_reason,
)
factor_score_breakdown = factor_scorer.summary()
+ direction_conflict_signals = []
+ direction_removed_factors = []
+ if trade_side == "short":
+ clean_signals, direction_conflict_signals = sanitize_signals_for_side(signals, "short")
+ removed_delta = excluded_factor_delta(factor_score_breakdown.get("items") or [], "short")
+ if removed_delta:
+ score -= removed_delta
+ factor_score_breakdown, direction_removed_factors = sanitize_factor_breakdown_for_side(factor_score_breakdown, "short")
+ if clean_signals:
+ signals = clean_signals
+ if direction_conflict_signals or direction_removed_factors:
+ signals.append("⚠️ 做空方向过滤:已剔除多头支持证据")
+ if confirmed and score < confirm_min_score():
+ confirmed = False
opportunity_score = round(float(factor_score_breakdown.get("opportunity_score") or 0), 3)
entry_score = round(float(factor_score_breakdown.get("entry_score") or 0), 3)
risk_score = round(float(factor_score_breakdown.get("risk_score") or 0), 3)
@@ -2122,6 +2145,12 @@ def confirm_burst(symbol, cand):
market_context["onchain_context"] = onchain_context
market_context["market_regime"] = market_regime
market_context["market_snapshot"] = regime_context.get("market_snapshot") or {}
+ if direction_conflict_signals or direction_removed_factors:
+ market_context["direction_conflict_filter"] = {
+ "side": trade_side,
+ "removed_signals": direction_conflict_signals,
+ "removed_factor_codes": [str(x.get("factor_code") or "") for x in direction_removed_factors],
+ }
market_context["score_components"] = {
"total_score": round(float(score), 3),
"opportunity_score": opportunity_score,
@@ -2136,6 +2165,7 @@ def confirm_burst(symbol, cand):
risk_flags=[
f"market_regime:{market_regime.get('regime', 'unknown')}",
f"market_risk:{market_regime.get('risk_level', 'medium')}",
+ *([f"direction_conflict:{x}" for x in direction_conflict_signals[:3]] if direction_conflict_signals else []),
],
evidence={
"opportunity_score": opportunity_score,
diff --git a/static/app.html b/static/app.html
index d65ecf2..baf8edc 100644
--- a/static/app.html
+++ b/static/app.html
@@ -55,20 +55,21 @@
@media(max-width:980px){ .overview-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.market-panels{grid-template-columns:1fr}.risk-notes{grid-template-columns:repeat(2,minmax(0,1fr));} }
@media(max-width:480px){ .overview-grid{grid-template-columns:1fr}.overview-title{font-size:20px}.overview-card .ov-value{font-size:26px}.risk-notes{grid-template-columns:1fr} }
-/* ===== STATS STRIP ===== */
-.stats-strip { display: flex; align-items: center; justify-content: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; padding: 14px 18px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); background: var(--canvas); }
-.stats-main { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
-.stat-chip { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--slate); font-weight: 700; }
-.stat-chip.filterable { cursor: pointer; padding: 8px 13px; min-height: 38px; border-radius: var(--radius-full); transition: .15s; user-select: none; border: 1px solid var(--hairline-strong); background: var(--canvas); box-shadow: 0 1px 3px rgba(5,0,56,.05); }
-.stat-chip.filterable::after { content: '筛选'; font-size: 10px; color: var(--muted); font-weight: 800; margin-left: 2px; }
-.stat-chip.filterable:hover { background: rgba(66,98,255,.05); border-color: rgba(66,98,255,.28); color: var(--blue); transform: translateY(-1px); }
-.stat-chip.filterable.active { background: var(--primary); color: var(--on-primary); border-color: var(--primary); box-shadow: 0 5px 14px rgba(5,0,56,.13); }
-.stat-chip.filterable.active::after { color: rgba(255,255,255,.72); content: '已筛选'; }
-.stat-chip.filterable.active .val { color: var(--on-primary); }
-.stat-chip .dot { width: 8px; height: 8px; border-radius: 50%; }
-.stat-chip .val { font-weight: 800; color: var(--ink); font-size: 16px; }
-.dot.all { background: var(--slate); }
-.dot.buy { background: var(--green); } .dot.wait { background: var(--yellow-deep); } .dot.obs { background: var(--blue); } .dot.weak { background: var(--muted); }
+/* ===== LIVE FILTERS ===== */
+.stats-strip { display: grid; gap: 12px; margin-bottom: 20px; padding: 14px 18px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); background: var(--canvas); }
+.stats-group { display: grid; grid-template-columns: 72px minmax(0, max-content); align-items: center; gap: 10px; min-width: 0; }
+.stats-label { color: var(--stone); font-size: 11px; font-weight: 900; letter-spacing: .02em; white-space: nowrap; }
+.stats-strip .tabs { margin: 0; }
+.stats-strip .tab-btn { gap: 7px; }
+.tab-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; background: var(--slate); }
+.tab-dot.all { background: var(--slate); }
+.tab-dot.buy { background: var(--green); }
+.tab-dot.obs { background: var(--blue); }
+.tab-dot.weak { background: var(--muted); }
+.tab-dot.short { background: var(--red); }
+.tab-count { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: var(--radius-full); background: var(--surface); color: var(--ink); font-size: 11px; font-weight: 950; }
+.tab-btn.active .tab-count { background: rgba(255,255,255,.18); color: var(--on-primary); }
+.stats-note { color: var(--stone); font-size: 12px; line-height: 1.45; }
/* ===== CARDS GRID ===== */
.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
@@ -344,8 +345,8 @@
- 实时机会
- 机会归档
+ 实时机会
+ 机会归档
@@ -688,7 +689,7 @@ async function loadContent(reset) {
liveLoading = true;
try {
var offset = (reset === false) ? liveOffset : 0;
- var url = API+'/api/recommendations/active?with_tracking=true&actionable_only=false&hours=12&limit='+liveLimit+'&offset='+offset+'&compact=true'+(currentSideFilter ? '&side='+encodeURIComponent(currentSideFilter) : '');
+ var url = API+'/api/recommendations/active?with_tracking=true&actionable_only=false&hours=12&limit='+liveLimit+'&offset='+offset+'&compact=true';
var resp = await fetch(url);
var page = await resp.json();
var items = Array.isArray(page.items) ? page.items : (Array.isArray(page) ? page : []);
@@ -728,7 +729,6 @@ async function loadContent(reset) {
renderLiveStats(cachedLiveData);
$('liveCards').innerHTML = fallbackItems.map(function(r){ return renderLiveFallbackCard(r); }).join('') + (weakCount && !currentFilter ? '另有 '+weakCount+' 个弱观察候选已收起。 查看弱观察
' : '') + (liveHasMore ? '加载更多
' : '已加载全部实时记录
');
}
- $('liveCount').textContent = '';
} catch(e) {
console.error('loadContent failed', e);
var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); });
@@ -783,7 +783,8 @@ function setFilter(status) {
function setSideFilter(side) {
currentSideFilter = side || '';
- loadContent(true);
+ applyFilterAndRender();
+ refreshVisibleKlines();
}
function renderLiveStats(data) {
@@ -793,28 +794,47 @@ function renderLiveStats(data) {
} catch (e) {
console.error('renderLiveStats failed', e);
}
- var total = visible.length;
- var buy = visible.filter(function(r){ return r.execution_status === 'buy_now' || r.display_bucket === 'realtime'; }).length;
- var observeStrong = visible.filter(function(r){ return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak'; }).length;
- var observeWeak = visible.filter(function(r){ return r.observe_tier === 'weak'; }).length;
- var longCount = visible.filter(function(r){ return recSide(r) === 'long'; }).length;
- var shortCount = visible.filter(function(r){ return recSide(r) === 'short'; }).length;
- var allCls = 'stat-chip' + (!currentFilter ? ' filterable active' : ' filterable');
- var bCls = 'stat-chip' + (currentFilter === 'buy_now' ? ' filterable active' : ' filterable');
- var oCls = 'stat-chip observe-chip' + (currentFilter === 'observe' ? ' filterable active' : ' filterable');
- var wCls = 'stat-chip weak-chip' + (currentFilter === 'weak_observe' ? ' filterable active' : ' filterable');
- var lCls = 'stat-chip' + (currentSideFilter === 'long' ? ' filterable active' : ' filterable');
- var sCls = 'stat-chip' + (currentSideFilter === 'short' ? ' filterable active' : ' filterable');
+ var statusBase = visible.filter(function(r){ return !currentSideFilter || recSide(r) === currentSideFilter; });
+ var total = statusBase.length;
+ var buy = statusBase.filter(function(r){ return r.execution_status === 'buy_now' || r.display_bucket === 'realtime'; }).length;
+ var observeStrong = statusBase.filter(function(r){ return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak'; }).length;
+ var observeWeak = statusBase.filter(function(r){ return r.observe_tier === 'weak'; }).length;
+ var directionBase = visible.filter(function(r){
+ if (!currentFilter) return !isWeakObserveRec(r);
+ if (currentFilter === 'buy_now') return (r.execution_status === 'buy_now' || r.display_bucket === 'realtime') && !isWeakObserveRec(r);
+ if (currentFilter === 'observe') return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak';
+ if (currentFilter === 'weak_observe') return r.observe_tier === 'weak';
+ return true;
+ });
+ var directionTotal = directionBase.length;
+ var longCount = directionBase.filter(function(r){ return recSide(r) === 'long'; }).length;
+ var shortCount = directionBase.filter(function(r){ return recSide(r) === 'short'; }).length;
+ var allCls = 'tab-btn' + (!currentFilter ? ' active' : '');
+ var bCls = 'tab-btn' + (currentFilter === 'buy_now' ? ' active' : '');
+ var oCls = 'tab-btn' + (currentFilter === 'observe' ? ' active' : '');
+ var wCls = 'tab-btn' + (currentFilter === 'weak_observe' ? ' active' : '');
+ var aSideCls = 'tab-btn' + (!currentSideFilter ? ' active' : '');
+ var lCls = 'tab-btn' + (currentSideFilter === 'long' ? ' active' : '');
+ var sCls = 'tab-btn' + (currentSideFilter === 'short' ? ' active' : '');
$('liveStats').innerHTML =
- '' +
- '
全部候选 '+total+'
' +
- '
入场窗口 '+buy+'
' +
- '
重点观察 '+observeStrong+'
' +
- '
弱观察 '+observeWeak+'
' +
- '
做多 '+longCount+'
' +
- '
做空 '+shortCount+'
' +
- '
全部方向
' +
- '
';
+ '' +
+ '
机会状态
' +
+ '
' +
+ '全部机会 '+total+' ' +
+ '入场窗口 '+buy+' ' +
+ '重点观察 '+observeStrong+' ' +
+ '弱观察 '+observeWeak+' ' +
+ '
' +
+ '
' +
+ '' +
+ '
交易方向
' +
+ '
' +
+ '全部方向 '+directionTotal+' ' +
+ '做多 '+longCount+' ' +
+ '做空 '+shortCount+' ' +
+ '
' +
+ '
' +
+ '状态筛选和方向筛选是两个维度;数字展示当前加载机会的分布,点击只改变下方列表,不代表另一类机会消失。
';
}
function renderLiveCards(data, weakCount) {
@@ -1150,7 +1170,6 @@ async function loadHistoryRecommendations(reset) {
var completedCount = Number(summary.completed_count || 0);
var invalidCount = Number(summary.invalid_count || 0);
var notExecutedCount = Number(summary.not_executed_count || 0);
- $('histCount').textContent = totalCount ? ' ' + totalCount : '';
$('historyStats').innerHTML =
''+totalCount+'
归档信号
机会/观察历史
'+
''+executedCount+'
进入执行
收益见策略交易
'+
diff --git a/static/base.html b/static/base.html
index 3f14215..2407f93 100644
--- a/static/base.html
+++ b/static/base.html
@@ -139,6 +139,90 @@ a { color: inherit; text-decoration: none; }
{% block extra_head_css %}{% endblock %}
{% block extra_style %}{% endblock %}
+
diff --git a/static/paper_trading.html b/static/paper_trading.html
index 1c75db5..ecb80f5 100644
--- a/static/paper_trading.html
+++ b/static/paper_trading.html
@@ -36,7 +36,7 @@
仅持仓中
仅已平仓
仅挂单
- 已完成交易和挂单
+ 已完结持仓和取消订单
仅操作日志
重置所选数据
@@ -51,7 +51,7 @@
运行策略看板
-
按策略独立看信号、机会、交易、胜率和收益;点击卡片可筛选下方持仓、挂单、已完成和日志。
+
按策略独立看信号、机会、交易、胜率和收益;点击卡片可筛选下方持仓、挂单、完结持仓、取消订单和日志。
查看全部
@@ -73,7 +73,8 @@
持仓中
挂单中
- 已完成
+ 已完结持仓
+ 取消订单
操作日志
@@ -99,19 +100,24 @@
-
+
-
+
币种 状态 持仓时间 方向 仓位 开仓 止盈 / 止损 / 移动止盈 最新价 平仓价 平仓时间 价格收益 账户收益 退出原因 来源 操作
- 加载中...
+ 加载中...
+
+
+
+
+
- 币种 状态 方向 目标价 最新价 距离目标 止盈 / 止损 创建时间 结束时间 来源 操作
- 加载中...
+ 币种 状态 方向 目标价 最新价 取消原因 止盈 / 止损 创建时间 结束时间 来源 操作
+ 加载中...
@@ -155,7 +161,7 @@ function toggleMaintenanceMenu(ev){if(ev)ev.stopPropagation();var pop=$('mainten
document.addEventListener('click',function(ev){var menu=document.querySelector('.maintenance-menu'),pop=$('maintenancePopover');if(pop&&menu&&!menu.contains(ev.target))pop.classList.remove('open')})
function sideText(v){return String(v||'long').toLowerCase()==='short'?'空':'多'}
function sideBadge(v){var s=String(v||'long').toLowerCase();return '
'+sideText(s)+' '}
-function setTradeTab(tab){['open','orders','completed','events'].forEach(function(k){var on=k===tab;$('tab-'+k).classList.toggle('active',on);$('tab-'+k).setAttribute('aria-selected',on?'true':'false');$('panel-'+k).classList.toggle('active',on)})}
+function setTradeTab(tab){['open','orders','closed','canceled','events'].forEach(function(k){var on=k===tab;$('tab-'+k).classList.toggle('active',on);$('tab-'+k).setAttribute('aria-selected',on?'true':'false');$('panel-'+k).classList.toggle('active',on)})}
async function api(url){var r=await fetch(url);var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d}
async function postApi(url){var r=await fetch(url,{method:'POST'});var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d}
async function deleteApi(url){var r=await fetch(url,{method:'DELETE'});var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error((d.detail&&d.detail.reason)||d.detail||d.error||'请求失败');return d}
@@ -164,7 +170,7 @@ function selectedSide(){return $('sideFilter')?($('sideFilter').value||''):''}
function strategyQuery(){var code=selectedStrategy();return code?'&strategy_code='+encodeURIComponent(code):''}
function sideQuery(){var side=selectedSide();return side?'&side='+encodeURIComponent(side):''}
function tradeQuery(){return strategyQuery()+sideQuery()}
-async function loadAll(){await Promise.all([loadStrategies(),loadSummary(),loadPerformance(),loadOrders(),loadOpenTrades(openOffset),loadCompleted(),loadEvents(eventOffset)])}
+async function loadAll(){await Promise.all([loadStrategies(),loadSummary(),loadPerformance(),loadOrders(),loadOpenTrades(openOffset),loadClosedTrades(),loadCanceledOrders(),loadEvents(eventOffset)])}
function onStrategyFilterChange(){openOffset=0;eventOffset=0;loadAll()}
function onSideFilterChange(){openOffset=0;eventOffset=0;loadAll()}
function clearStrategyAndSide(){if($('strategyFilter'))$('strategyFilter').value='';if($('sideFilter'))$('sideFilter').value='';openOffset=0;eventOffset=0;loadAll()}
@@ -224,9 +230,8 @@ function renderOrders(items){if(!items.length){$('orderRows').innerHTML='
0?'移动止盈 $'+fmt(trail,6)+' ':'移动止盈未启动 ';return 'TP $'+fmt(x.tp1,6)+' SL $'+fmt(x.stop_loss,6)+' '+trailHtml+'
'}
function strategyName(x){return (x&&x.strategy_name)||({main_composite_v1:'综合确认策略',box_retest_1h_v1:'1H箱体突破回踩',box_retest_4h_v1:'4H箱体突破回踩',volume_ignition_1h_v1:'1H放量突破启动',compression_breakout_4h_v1:'4H压缩蓄力突破',intraday_momentum_15m_v1:'15m日内动量延续',breakdown_retest_short_1h_v1:'1H破位反抽做空'}[(x&&x.strategy_code)||'']||((x&&x.strategy_code)||'--'))}
async function loadOpenTrades(nextOffset){openOffset=Math.max(0,nextOffset||0);$('openRows').innerHTML=' 加载中... ';try{var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+openOffset+'&status=open'+tradeQuery());openTotal=d.total||0;renderTradeRows('openRows',d.items||[],'暂无持仓中的策略交易');renderOpenPager()}catch(e){$('openRows').innerHTML='
'+esc(e.message)+' '}}
-async function loadCompleted(){await Promise.all([loadCompletedTrades(),loadCompletedOrders()])}
-async function loadCompletedTrades(){$('completedTradeRows').innerHTML='
加载中... ';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed'+tradeQuery());renderTradeRows('completedTradeRows',d.items||[],'暂无已平仓策略交易')}catch(e){$('completedTradeRows').innerHTML='
'+esc(e.message)+' '}}
-async function loadCompletedOrders(){$('completedOrderRows').innerHTML='
加载中... ';try{var sets=await Promise.all(['filled','expired','canceled','rejected'].map(function(s){return api('/api/paper-trading/orders?limit=50&offset=0&status='+s+tradeQuery())}));var items=[];sets.forEach(function(d){items=items.concat(d.items||[])});items.sort(function(a,b){return String(b.updated_at||b.created_at).localeCompare(String(a.updated_at||a.created_at))});renderCompletedOrders(items)}catch(e){$('completedOrderRows').innerHTML='
'+esc(e.message)+' '}}
+async function loadClosedTrades(){$('closedTradeRows').innerHTML='
加载中... ';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed'+tradeQuery());renderTradeRows('closedTradeRows',d.items||[],'暂无已完结持仓')}catch(e){$('closedTradeRows').innerHTML='
'+esc(e.message)+' '}}
+async function loadCanceledOrders(){$('canceledOrderRows').innerHTML='
加载中... ';try{var sets=await Promise.all(['canceled','expired','rejected'].map(function(s){return api('/api/paper-trading/orders?limit=50&offset=0&status='+s+tradeQuery())}));var items=[];sets.forEach(function(d){items=items.concat(d.items||[])});items.sort(function(a,b){return String(b.updated_at||b.created_at).localeCompare(String(a.updated_at||a.created_at))});renderCanceledOrders(items)}catch(e){$('canceledOrderRows').innerHTML='
'+esc(e.message)+' '}}
function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId).innerHTML='
'+esc(emptyText||'暂无策略交易')+' ';return}$(targetId).innerHTML=items.map(function(x){var st=x.status==='open'?'持仓中':'已平仓';var latest=x.latest_price||x.current_price||0;var pnlUsdt=x.status==='closed'?x.realized_pnl_usdt:x.unrealized_pnl_usdt;return '
'+
''+symbolCell(x)+' '+
''+st+' '+
@@ -244,21 +249,21 @@ function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId)
''+esc(strategyName(x))+'
'+esc(x.source_status||'--')+' · '+esc(x.strategy_version||'')+'
'+
'删除 '+
' '}).join('')}
-function renderCompletedOrders(items){if(!items.length){$('completedOrderRows').innerHTML='
暂无已结束策略挂单 ';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 '
'+
+function renderCanceledOrders(items){if(!items.length){$('canceledOrderRows').innerHTML=' 暂无取消或过期订单 ';return}$('canceledOrderRows').innerHTML=items.map(function(x){var latest=x.latest_price||x.current_price_at_create||0,ended=x.canceled_at||x.updated_at||x.filled_at;return '
'+
''+symbolCell(x)+' '+
''+esc(orderStatus(x))+' '+
''+sideBadge(x.side)+' '+
'$'+fmt(x.target_price,6)+'
'+
'$'+fmt(latest,6)+'
'+(x.latest_price_updated_at?time(x.latest_price_updated_at):'--')+'
'+
- ''+(dist>0?'+':'')+fmt(dist,2)+'% '+
+ ''+esc(cancelReasonLabel(x.cancel_reason||x.status))+'
'+
'TP $'+fmt(x.tp1,6)+' SL $'+fmt(x.stop_loss,6)+'
'+
''+time(x.created_at)+' '+
''+time(ended)+' '+
- ''+esc(strategyName(x))+'
'+esc(cancelReasonLabel(x.cancel_reason||x.status))+'
'+esc(x.source_status||'--')+' · '+esc(x.source_action||'')+'
'+
+ ''+esc(strategyName(x))+'
'+esc(x.source_status||'--')+' · '+esc(x.source_action||'')+'
'+
'删除 '+
' '}).join('')}
function orderStatus(x){return {filled:'已成交',expired:'已过期',canceled:'已取消',rejected:'已拒绝'}[x.status]||x.status||'--'}
-function cancelReasonLabel(r){return {global_risk_rejected:'全局风控拒绝:市场/账户风险过高,未转持仓',stop_loss_leverage_risk_exceeded:'止损杠杆风险超限:按当前止损和杠杆亏损过大',portfolio_drawdown_pause:'账户回撤保护:暂停新增仓位',weak_entries_pause:'近期弱入场过多:暂停新增仓位',recommendation_invalid:'原推荐已失效:机会过期/归档后撤单',too_far_from_entry:'价格远离计划价:继续等待意义不大',expired:'挂单超时:超过有效期未成交',upgraded_to_buy_now:'信号升级为入场窗口:旧挂单已撤销',canceled:'已取消',filled:'已成交',rejected:'已拒绝'}[r]||r||'--'}
+function cancelReasonLabel(r){return {global_risk_rejected:'全局风控拒绝:市场/账户风险过高,未转持仓',risk_paused_at_touch:'触价时风控暂停:目标价已到但账户/市场风险不允许开仓',stop_loss_leverage_risk_exceeded:'止损杠杆风险超限:按当前止损和杠杆亏损过大',portfolio_drawdown_pause:'账户回撤保护:暂停新增仓位',weak_entries_pause:'近期弱入场过多:暂停新增仓位',recommendation_invalid:'原推荐已失效:机会过期/归档后撤单',too_far_from_entry:'价格远离计划价:继续等待意义不大',expired:'挂单超时:超过有效期未成交',upgraded_to_buy_now:'信号升级为入场窗口:旧挂单已撤销',canceled:'已取消',filled:'已成交',rejected:'已拒绝'}[r]||r||'--'}
function renderOpenPager(){var page=Math.floor(openOffset/LIMIT)+1,totalPages=Math.max(1,Math.ceil(openTotal/LIMIT));$('openPageInfo').textContent='第 '+page+' / '+totalPages+' 页 · 共 '+openTotal+' 条';$('openPager').innerHTML='
上一页 第 '+page+' / '+totalPages+' 页 =openTotal)?'disabled':'')+' onclick="loadOpenTrades('+(openOffset+LIMIT)+')">下一页 '}
async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='
加载中...
';try{var sym=$('eventSymbol').value||'';var typ=$('eventType').value||'';var d=await api('/api/paper-trading/events?limit='+EVENT_LIMIT+'&offset='+eventOffset+'&symbol='+encodeURIComponent(sym)+'&event_type='+encodeURIComponent(typ)+tradeQuery());eventTotal=d.total||0;renderEvents(d.items||[]);renderEventPager()}catch(e){$('eventRows').innerHTML='
'+esc(e.message)+'
'}}
function eventLabel(t){return {open:'开仓',close:'平仓',trailing_activate:'移动止盈激活',trailing_move:'移动止盈上移'}[t]||t||'动作'}
diff --git a/tests/test_base_shell_ownership.py b/tests/test_base_shell_ownership.py
index f8248d5..86538b5 100644
--- a/tests/test_base_shell_ownership.py
+++ b/tests/test_base_shell_ownership.py
@@ -30,3 +30,38 @@ def test_base_template_owns_sidebar_and_user_shell():
continue
for pattern, label in forbidden_patterns:
assert not re.search(pattern, text), f"{path.name} should inherit shell behavior from base.html, found {label}"
+
+
+def test_base_template_owns_shared_app_tab_style():
+ base = (STATIC_DIR / "base.html").read_text(encoding="utf-8")
+ assert "Shared App Tabs" in base
+ assert ".main-content :is(.tabs, .tabbar, .admin-tabs, .user-tabs)" in base
+ assert ":is(.tab-btn, .tab, .admin-tab-btn)" in base
+
+ app_pages_with_tabs = [
+ "app.html",
+ "paper_trading.html",
+ "live_trading.html",
+ "logs.html",
+ "onchain.html",
+ "admin.html",
+ "iteration.html",
+ ]
+ for name in app_pages_with_tabs:
+ text = (STATIC_DIR / name).read_text(encoding="utf-8")
+ assert "extends \"base.html\"" in text or "extends 'base.html'" in text
+
+
+def test_opportunity_overview_direction_filter_is_client_side_only():
+ text = (STATIC_DIR / "app.html").read_text(encoding="utf-8")
+ assert "机会状态" in text
+ assert "交易方向" in text
+ assert "全部机会" in text
+ assert "全部方向" in text
+ assert "liveCount" not in text
+ assert "histCount" not in text
+ assert "stat-chip" not in text
+ assert "tab-count" in text
+ assert "数字展示当前加载机会的分布" in text
+ assert "side='+encodeURIComponent(currentSideFilter)" not in text
+ assert 'side="+encodeURIComponent(currentSideFilter)' not in text
diff --git a/tests/test_multi_strategy_infra.py b/tests/test_multi_strategy_infra.py
index 924989b..e1c63c9 100644
--- a/tests/test_multi_strategy_infra.py
+++ b/tests/test_multi_strategy_infra.py
@@ -1,4 +1,5 @@
from app.core.factor_roles import RISK, TRIGGER, factor_role, factor_roles_for_codes
+from app.core.signal_direction import sanitize_factor_breakdown_for_side, sanitize_signals_for_side
from app.core.strategy_contract import StrategySignal, default_main_composite_signal
from app.core.strategy_registry import (
BOX_RETEST_1H_STRATEGY,
@@ -8,6 +9,8 @@ from app.core.strategy_registry import (
INTRADAY_MOMENTUM_15M_STRATEGY,
MAIN_COMPOSITE_STRATEGY,
VOLUME_IGNITION_1H_STRATEGY,
+ is_strategy_allowed_for_side,
+ strategy_direction,
strategy_label,
)
from app.db.recommendation_commands import create_recommendation
@@ -50,6 +53,11 @@ def test_default_main_composite_strategy_signal_is_stable():
assert strategy_label(COMPRESSION_BREAKOUT_4H_STRATEGY) == "4H压缩蓄力突破"
assert strategy_label(INTRADAY_MOMENTUM_15M_STRATEGY) == "15m日内动量延续"
assert strategy_label(BREAKDOWN_RETEST_SHORT_1H_STRATEGY) == "1H破位反抽做空"
+ assert strategy_direction(VOLUME_IGNITION_1H_STRATEGY) == "long"
+ assert strategy_direction(BREAKDOWN_RETEST_SHORT_1H_STRATEGY) == "short"
+ assert is_strategy_allowed_for_side(MAIN_COMPOSITE_STRATEGY, "short") is True
+ assert is_strategy_allowed_for_side(VOLUME_IGNITION_1H_STRATEGY, "short") is False
+ assert is_strategy_allowed_for_side(BREAKDOWN_RETEST_SHORT_1H_STRATEGY, "short") is True
def test_volume_ignition_strategy_builds_independent_signal():
@@ -71,6 +79,63 @@ def test_volume_ignition_strategy_builds_independent_signal():
assert payload["factor_roles"]["vp_fly_1h_current"] == "trigger"
+def test_long_strategy_cannot_emit_short_signal():
+ import pytest
+
+ with pytest.raises(ValueError):
+ StrategySignal(
+ strategy_code=VOLUME_IGNITION_1H_STRATEGY,
+ symbol="BAD/USDT",
+ direction="short",
+ factor_roles={"vp_fly_1h_current": "trigger"},
+ )
+
+
+def test_short_direction_filters_bullish_supporting_evidence():
+ signals = [
+ "大户偏多(73%)",
+ "BTC回调中独立走强",
+ "板块联动: Layer1 龙头TON/USDT",
+ "1H箱体突破回踩",
+ "1H破位反抽做空",
+ "破位质量高",
+ ]
+ clean, removed = sanitize_signals_for_side(signals, "short")
+
+ assert "1H破位反抽做空" in clean
+ assert "破位质量高" in clean
+ assert all("大户偏多" not in item for item in clean)
+ assert all("BTC回调中独立走强" not in item for item in clean)
+ assert any("板块联动" in item for item in removed)
+
+
+def test_short_factor_breakdown_removes_long_only_positive_factors():
+ summary = {
+ "items": [
+ {"factor_code": "top_trader_long", "factor_group": "positioning", "score_delta": 1},
+ {"factor_code": "sector_rotation", "factor_group": "narrative", "score_delta": 2},
+ {"factor_code": "box_breakout_pullback_1h", "factor_group": "structure", "score_delta": 6},
+ {"factor_code": "breakdown_retest_1h_short", "factor_group": "structure", "score_delta": 7},
+ {"factor_code": "funding_positive_risk", "factor_group": "risk", "score_delta": -3},
+ ]
+ }
+
+ clean, removed = sanitize_factor_breakdown_for_side(summary, "short")
+ codes = [item["factor_code"] for item in clean["items"]]
+
+ assert "breakdown_retest_1h_short" in codes
+ assert "funding_positive_risk" in codes
+ assert "top_trader_long" not in codes
+ assert "sector_rotation" not in codes
+ assert "box_breakout_pullback_1h" not in codes
+ assert clean["total_delta"] == 4
+ assert {item["factor_code"] for item in removed} == {
+ "top_trader_long",
+ "sector_rotation",
+ "box_breakout_pullback_1h",
+ }
+
+
def test_compression_breakout_strategy_requires_structure_and_breakout_context():
signal = build_compression_breakout_4h_signal(
symbol="QUIET/USDT",
diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py
index 9fa0808..8ae7bbb 100644
--- a/tests/test_paper_trading.py
+++ b/tests/test_paper_trading.py
@@ -776,7 +776,7 @@ def test_pending_paper_order_reconciles_from_latest_price_cache(monkeypatch):
assert order["fill_price"] == pytest.approx(95)
-def test_touched_wait_pullback_order_stays_pending_when_global_risk_pauses(monkeypatch):
+def test_touched_wait_pullback_order_cancels_when_global_risk_pauses(monkeypatch):
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")
monkeypatch.setattr(
@@ -816,10 +816,12 @@ def test_touched_wait_pullback_order_stays_pending_when_global_risk_pauses(monke
paused = sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00")
assert created["reason"] == "paper_order_created"
- assert paused["reason"] == "paper_order_risk_paused"
+ assert paused["reason"] == "paper_order_risk_paused_at_touch"
assert list_paper_trades()["total"] == 0
- assert list_paper_orders(status="pending")["items"][0]["symbol"] == "RISKPAUSE/USDT"
- assert list_paper_orders(status="canceled")["total"] == 0
+ assert list_paper_orders(status="pending")["total"] == 0
+ canceled = list_paper_orders(status="canceled")["items"][0]
+ assert canceled["symbol"] == "RISKPAUSE/USDT"
+ assert canceled["cancel_reason"] == "risk_paused_at_touch"
def test_wait_pullback_order_cancels_when_recommendation_invalid(monkeypatch):
@@ -1375,6 +1377,67 @@ def test_delete_paper_order_removes_order_only(monkeypatch):
assert list_paper_orders()["total"] == 0
+def test_pending_order_distance_never_negative_for_touched_long_or_short(monkeypatch):
+ monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
+ altcoin_db.init_db()
+ long_rec_id = altcoin_db.create_recommendation(
+ symbol="LONGTOUCH/USDT",
+ rec_state="蓄力",
+ rec_score=22,
+ entry_price=95,
+ stop_loss=90,
+ tp1=105,
+ signals=["等待回踩"],
+ entry_plan={"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
+ )
+ short_rec_id = altcoin_db.create_recommendation(
+ symbol="SHORTTOUCH/USDT",
+ rec_state="蓄力",
+ rec_score=22,
+ entry_price=105,
+ stop_loss=110,
+ tp1=95,
+ signals=["等待反抽"],
+ entry_plan={"entry_action": "等反抽", "side": "short", "entry_price": 105, "stop_loss": 110, "tp1": 95, "risk_reward_ok": True, "rr1": 2.0},
+ )
+ conn = altcoin_db.get_conn()
+ try:
+ conn.execute(
+ """
+ INSERT INTO latest_price_cache (symbol, price, updated_at, source)
+ VALUES (%s, %s, %s, %s), (%s, %s, %s, %s)
+ """,
+ (
+ "LONGTOUCH/USDT", 94, "2026-05-16T10:00:00", "unit",
+ "SHORTTOUCH/USDT", 106, "2026-05-16T10:00:00", "unit",
+ ),
+ )
+ conn.execute(
+ """
+ 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, created_at, updated_at, expires_at
+ ) VALUES
+ (%s, %s, %s, 'limit', 'pending', 'wait_pullback', '等回踩', %s, %s, %s, %s, %s, %s, %s, %s, %s),
+ (%s, %s, %s, 'limit', 'pending', 'wait_pullback', '等反抽', %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ """,
+ (
+ long_rec_id, "LONGTOUCH/USDT", "long", 95, 100, 5000, 90, 105, 110, "2026-05-16T09:00:00", "2026-05-16T09:00:00", "2026-05-17T09:00:00",
+ short_rec_id, "SHORTTOUCH/USDT", "short", 105, 100, 5000, 110, 95, 90, "2026-05-16T09:00:00", "2026-05-16T09:00:00", "2026-05-17T09:00:00",
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ rows = list_paper_orders(status="pending", limit=10)["items"]
+ by_symbol = {item["symbol"]: item for item in rows}
+
+ assert by_symbol["LONGTOUCH/USDT"]["distance_to_target_pct"] == pytest.approx(0)
+ assert by_symbol["SHORTTOUCH/USDT"]["distance_to_target_pct"] == pytest.approx(0)
+
+
def test_reset_paper_trading_data_all_clears_ledger(monkeypatch, buy_now_rec):
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")