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+'
' + - '
全部方向
' + - '
'; + '
' + + '
机会状态
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '
交易方向
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
状态筛选和方向筛选是两个维度;数字展示当前加载机会的分布,点击只改变下方列表,不代表另一类机会消失。
'; } 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+' 页'} 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")