This commit is contained in:
aaron 2026-06-02 06:49:48 +08:00
parent c6780d118b
commit 0443072679
13 changed files with 806 additions and 115 deletions

View File

@ -54,6 +54,10 @@ def build_parser():
live_smoke.add_argument("--notional-usdt", type=float, default=10.0, help="测试名义金额,默认 10U") 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") 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 return parser
@ -122,6 +126,12 @@ def main():
notional_usdt=args.notional_usdt, notional_usdt=args.notional_usdt,
leverage=args.leverage, 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}") parser.error(f"unknown command: {args.command}")

View File

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

View File

@ -8,7 +8,7 @@ from typing import Any
from app.config.config_loader import get_meta from app.config.config_loader import get_meta
from app.core.factor_roles import factor_roles_for_codes, validate_factor_roles 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: def _safe_dict(value: Any) -> dict:
@ -43,6 +43,9 @@ class StrategySignal:
def __post_init__(self): def __post_init__(self):
self.strategy_code = normalize_strategy_code(self.strategy_code) 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) self.factor_roles = validate_factor_roles(self.factor_roles)
def to_json_dict(self) -> dict[str, Any]: def to_json_dict(self) -> dict[str, Any]:

View File

@ -19,6 +19,7 @@ class StrategyDefinition:
strategy_code: str strategy_code: str
strategy_name: str strategy_name: str
description: str = "" description: str = ""
direction: str = "long"
mode: str = "paper_only" mode: str = "paper_only"
status: str = "active" status: str = "active"
entry_gate_config: dict = field(default_factory=dict) entry_gate_config: dict = field(default_factory=dict)
@ -30,6 +31,7 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
strategy_code=MAIN_COMPOSITE_STRATEGY, strategy_code=MAIN_COMPOSITE_STRATEGY,
strategy_name="综合确认策略", strategy_name="综合确认策略",
description="迁移期兼容综合策略,承载现有综合筛选与确认逻辑;它与其他策略平等运行。", description="迁移期兼容综合策略,承载现有综合筛选与确认逻辑;它与其他策略平等运行。",
direction="both",
mode="paper_enabled", mode="paper_enabled",
), ),
BOX_RETEST_1H_STRATEGY: StrategyDefinition( BOX_RETEST_1H_STRATEGY: StrategyDefinition(
@ -144,6 +146,7 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
strategy_code=BREAKDOWN_RETEST_SHORT_1H_STRATEGY, strategy_code=BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
strategy_name="1H破位反抽做空", strategy_name="1H破位反抽做空",
description="箱体或关键均线破位后反抽失败的空头策略;只用于独立空头样本,不与多头突破策略共享入场门槛。", description="箱体或关键均线破位后反抽失败的空头策略;只用于独立空头样本,不与多头突破策略共享入场门槛。",
direction="short",
mode="paper_only", mode="paper_only",
entry_gate_config={ entry_gate_config={
"direction": "short", "direction": "short",
@ -187,6 +190,17 @@ def strategy_label(strategy_code: str | None) -> str:
return strategy_definition(strategy_code).strategy_name 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: def strategy_entry_gate_config(strategy_code: str | None) -> dict:
return dict(strategy_definition(strategy_code).entry_gate_config or {}) return dict(strategy_definition(strategy_code).entry_gate_config or {})

View File

@ -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)) 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) global_ok, global_detail = _global_risk_entry_check(conn, rec, base_notional, cfg)
if not global_ok: 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 # 这张挂单必须结束,不能继续 pending 等待下一次风控放行,否则会在
# 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 result = _cancel_paper_order(conn, order, "risk_paused_at_touch", event_time)
# don't later fill at a stale, distorted target price. result.update({
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"),
"target_price": order.get("target_price"), "target_price": order.get("target_price"),
"current_price": current_price, "current_price": current_price,
"risk_detail": global_detail, "risk_detail": global_detail,
} })
return result
adjusted_notional = _market_risk_adjusted_notional(base_notional, global_detail, cfg) 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) pause_ok, pause_reason, pause_detail = _portfolio_entry_pause_check(conn, adjusted_notional, event_time, cfg)
if not pause_ok: 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)) item.update(_strategy_lineage_from_trade_or_order(item))
target = _safe_float(item.get("target_price")) target = _safe_float(item.get("target_price"))
latest = _safe_float(item.get("latest_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) items.append(item)
return { return {
"items": items, "items": items,

View File

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

View File

@ -46,6 +46,7 @@ from app.core.strategy_registry import (
BOX_RETEST_4H_STRATEGY, BOX_RETEST_4H_STRATEGY,
BREAKDOWN_RETEST_SHORT_1H_STRATEGY, BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
MAIN_COMPOSITE_STRATEGY, MAIN_COMPOSITE_STRATEGY,
is_strategy_allowed_for_side,
) )
from app.core.trade_direction import direction_label, normalize_trade_side from app.core.trade_direction import direction_label, normalize_trade_side
from app.core.opportunity_level import ( 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.opportunity_funnel import build_screening_detail
from app.core.factor_scoring import FactorScorer 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.core.market_regime import classify_market_regime
from app.db.onchain_db import get_onchain_factor_context from app.db.onchain_db import get_onchain_factor_context
from app.db.strategy_signal_queries import insert_strategy_signal 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: 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.""" """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_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 {} 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 {} 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 {} market_regime = result.get("market_regime") or (result.get("market_context") or {}).get("market_regime") or {}
signal_candidates = [] signal_candidates = []
signal_candidates.extend([ if trade_side == "short":
build_volume_ignition_1h_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}), if not short_1h.get("detected"):
build_compression_breakout_4h_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}), return {}
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"):
signal_candidates.append( signal_candidates.append(
build_breakdown_retest_short_1h_signal( build_breakdown_retest_short_1h_signal(
symbol=symbol, symbol=symbol,
@ -138,9 +116,40 @@ def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan:
decision_log=result.get("decision_log") or {}, 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 = [] saved_payloads = []
for signal in [item for item in signal_candidates if item]: 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: if not saved_payloads:
return {} return {}
def _rank(payload: dict) -> tuple: def _rank(payload: dict) -> tuple:
@ -2098,6 +2107,20 @@ def confirm_burst(symbol, cand):
value=market_risk_gate_reason, value=market_risk_gate_reason,
) )
factor_score_breakdown = factor_scorer.summary() 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) 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) 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) 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["onchain_context"] = onchain_context
market_context["market_regime"] = market_regime market_context["market_regime"] = market_regime
market_context["market_snapshot"] = regime_context.get("market_snapshot") or {} 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"] = { market_context["score_components"] = {
"total_score": round(float(score), 3), "total_score": round(float(score), 3),
"opportunity_score": opportunity_score, "opportunity_score": opportunity_score,
@ -2136,6 +2165,7 @@ def confirm_burst(symbol, cand):
risk_flags=[ risk_flags=[
f"market_regime:{market_regime.get('regime', 'unknown')}", f"market_regime:{market_regime.get('regime', 'unknown')}",
f"market_risk:{market_regime.get('risk_level', 'medium')}", 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={ evidence={
"opportunity_score": opportunity_score, "opportunity_score": opportunity_score,

View File

@ -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: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} } @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 ===== */ /* ===== LIVE FILTERS ===== */
.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-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-main { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .stats-group { display: grid; grid-template-columns: 72px minmax(0, max-content); align-items: center; gap: 10px; min-width: 0; }
.stat-chip { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--slate); font-weight: 700; } .stats-label { color: var(--stone); font-size: 11px; font-weight: 900; letter-spacing: .02em; white-space: nowrap; }
.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); } .stats-strip .tabs { margin: 0; }
.stat-chip.filterable::after { content: '筛选'; font-size: 10px; color: var(--muted); font-weight: 800; margin-left: 2px; } .stats-strip .tab-btn { gap: 7px; }
.stat-chip.filterable:hover { background: rgba(66,98,255,.05); border-color: rgba(66,98,255,.28); color: var(--blue); transform: translateY(-1px); } .tab-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; background: var(--slate); }
.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); } .tab-dot.all { background: var(--slate); }
.stat-chip.filterable.active::after { color: rgba(255,255,255,.72); content: '已筛选'; } .tab-dot.buy { background: var(--green); }
.stat-chip.filterable.active .val { color: var(--on-primary); } .tab-dot.obs { background: var(--blue); }
.stat-chip .dot { width: 8px; height: 8px; border-radius: 50%; } .tab-dot.weak { background: var(--muted); }
.stat-chip .val { font-weight: 800; color: var(--ink); font-size: 16px; } .tab-dot.short { background: var(--red); }
.dot.all { background: var(--slate); } .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; }
.dot.buy { background: var(--green); } .dot.wait { background: var(--yellow-deep); } .dot.obs { background: var(--blue); } .dot.weak { background: var(--muted); } .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 GRID ===== */
.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } .cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
@ -344,8 +345,8 @@
<!-- compatibility markers: 实时机会 / 实时推荐 / 历史机会 / 历史推荐 / drawPin / data-entry-price / v.count / 止损 / 止盈 --> <!-- compatibility markers: 实时机会 / 实时推荐 / 历史机会 / 历史推荐 / drawPin / data-entry-price / v.count / 止损 / 止盈 -->
<div class="controls-row"> <div class="controls-row">
<div class="tabs"> <div class="tabs">
<button class="tab-btn active" data-tab="live" onclick="switchTab('live')">实时机会<span class="count" id="liveCount"></span></button> <button class="tab-btn active" data-tab="live" onclick="switchTab('live')">实时机会</button>
<button class="tab-btn" data-tab="history" onclick="switchTab('history')">机会归档<span class="count" id="histCount"></span></button> <button class="tab-btn" data-tab="history" onclick="switchTab('history')">机会归档</button>
</div> </div>
</div> </div>
@ -688,7 +689,7 @@ async function loadContent(reset) {
liveLoading = true; liveLoading = true;
try { try {
var offset = (reset === false) ? liveOffset : 0; 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 resp = await fetch(url);
var page = await resp.json(); var page = await resp.json();
var items = Array.isArray(page.items) ? page.items : (Array.isArray(page) ? page : []); var items = Array.isArray(page.items) ? page.items : (Array.isArray(page) ? page : []);
@ -728,7 +729,6 @@ async function loadContent(reset) {
renderLiveStats(cachedLiveData); renderLiveStats(cachedLiveData);
$('liveCards').innerHTML = fallbackItems.map(function(r){ return renderLiveFallbackCard(r); }).join('') + (weakCount && !currentFilter ? '<div class="weak-summary"><span>另有 '+weakCount+' 个弱观察候选已收起。</span><button onclick="setFilter(\'weak_observe\')">查看弱观察</button></div>' : '') + (liveHasMore ? '<div class="load-more-row"><button class="load-more-btn" onclick="loadMoreLive()">加载更多</button></div>' : '<div class="page-hint">已加载全部实时记录</div>'); $('liveCards').innerHTML = fallbackItems.map(function(r){ return renderLiveFallbackCard(r); }).join('') + (weakCount && !currentFilter ? '<div class="weak-summary"><span>另有 '+weakCount+' 个弱观察候选已收起。</span><button onclick="setFilter(\'weak_observe\')">查看弱观察</button></div>' : '') + (liveHasMore ? '<div class="load-more-row"><button class="load-more-btn" onclick="loadMoreLive()">加载更多</button></div>' : '<div class="page-hint">已加载全部实时记录</div>');
} }
$('liveCount').textContent = '';
} catch(e) { } catch(e) {
console.error('loadContent failed', e); console.error('loadContent failed', e);
var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); }); var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); });
@ -783,7 +783,8 @@ function setFilter(status) {
function setSideFilter(side) { function setSideFilter(side) {
currentSideFilter = side || ''; currentSideFilter = side || '';
loadContent(true); applyFilterAndRender();
refreshVisibleKlines();
} }
function renderLiveStats(data) { function renderLiveStats(data) {
@ -793,28 +794,47 @@ function renderLiveStats(data) {
} catch (e) { } catch (e) {
console.error('renderLiveStats failed', e); console.error('renderLiveStats failed', e);
} }
var total = visible.length; var statusBase = visible.filter(function(r){ return !currentSideFilter || recSide(r) === currentSideFilter; });
var buy = visible.filter(function(r){ return r.execution_status === 'buy_now' || r.display_bucket === 'realtime'; }).length; var total = statusBase.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 buy = statusBase.filter(function(r){ return r.execution_status === 'buy_now' || r.display_bucket === 'realtime'; }).length;
var observeWeak = visible.filter(function(r){ return r.observe_tier === 'weak'; }).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 longCount = visible.filter(function(r){ return recSide(r) === 'long'; }).length; var observeWeak = statusBase.filter(function(r){ return r.observe_tier === 'weak'; }).length;
var shortCount = visible.filter(function(r){ return recSide(r) === 'short'; }).length; var directionBase = visible.filter(function(r){
var allCls = 'stat-chip' + (!currentFilter ? ' filterable active' : ' filterable'); if (!currentFilter) return !isWeakObserveRec(r);
var bCls = 'stat-chip' + (currentFilter === 'buy_now' ? ' filterable active' : ' filterable'); if (currentFilter === 'buy_now') return (r.execution_status === 'buy_now' || r.display_bucket === 'realtime') && !isWeakObserveRec(r);
var oCls = 'stat-chip observe-chip' + (currentFilter === 'observe' ? ' filterable active' : ' filterable'); if (currentFilter === 'observe') return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak';
var wCls = 'stat-chip weak-chip' + (currentFilter === 'weak_observe' ? ' filterable active' : ' filterable'); if (currentFilter === 'weak_observe') return r.observe_tier === 'weak';
var lCls = 'stat-chip' + (currentSideFilter === 'long' ? ' filterable active' : ' filterable'); return true;
var sCls = 'stat-chip' + (currentSideFilter === 'short' ? ' filterable active' : ' filterable'); });
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 = $('liveStats').innerHTML =
'<div class="stats-main">' + '<div class="stats-group">' +
'<div class="'+allCls+'" onclick="setFilter(\'\')"><span class="dot all"></span><span>全部候选</span><span class="val">'+total+'</span></div>' + '<div class="stats-label">机会状态</div>' +
'<div class="'+bCls+'" onclick="setFilter(\'buy_now\')"><span class="dot buy"></span><span>入场窗口</span><span class="val">'+buy+'</span></div>' + '<div class="tabs" role="tablist" aria-label="机会状态筛选">' +
'<div class="'+oCls+'" onclick="setFilter(\'observe\')"><span class="dot obs"></span><span>重点观察</span><span class="val">'+observeStrong+'</span></div>' + '<button class="'+allCls+'" type="button" onclick="setFilter(\'\')"><span class="tab-dot all"></span><span>全部机会</span><span class="tab-count">'+total+'</span></button>' +
'<div class="'+wCls+'" onclick="setFilter(\'weak_observe\')"><span class="dot weak"></span><span>弱观察</span><span class="val">'+observeWeak+'</span></div>' + '<button class="'+bCls+'" type="button" onclick="setFilter(\'buy_now\')"><span class="tab-dot buy"></span><span>入场窗口</span><span class="tab-count">'+buy+'</span></button>' +
'<div class="'+lCls+'" onclick="setSideFilter(\'long\')"><span class="dot buy"></span><span>做多</span><span class="val">'+longCount+'</span></div>' + '<button class="'+oCls+'" type="button" onclick="setFilter(\'observe\')"><span class="tab-dot obs"></span><span>重点观察</span><span class="tab-count">'+observeStrong+'</span></button>' +
'<div class="'+sCls+'" onclick="setSideFilter(\'short\')"><span class="dot weak"></span><span>做空</span><span class="val">'+shortCount+'</span></div>' + '<button class="'+wCls+'" type="button" onclick="setFilter(\'weak_observe\')"><span class="tab-dot weak"></span><span>弱观察</span><span class="tab-count">'+observeWeak+'</span></button>' +
'<div class="stat-chip filterable" onclick="setSideFilter(\'\')"><span class="dot all"></span><span>全部方向</span></div>' + '</div>' +
'</div>'; '</div>' +
'<div class="stats-group">' +
'<div class="stats-label">交易方向</div>' +
'<div class="tabs" role="tablist" aria-label="交易方向筛选">' +
'<button class="'+aSideCls+'" type="button" onclick="setSideFilter(\'\')"><span class="tab-dot all"></span><span>全部方向</span><span class="tab-count">'+directionTotal+'</span></button>' +
'<button class="'+lCls+'" type="button" onclick="setSideFilter(\'long\')"><span class="tab-dot buy"></span><span>做多</span><span class="tab-count">'+longCount+'</span></button>' +
'<button class="'+sCls+'" type="button" onclick="setSideFilter(\'short\')"><span class="tab-dot short"></span><span>做空</span><span class="tab-count">'+shortCount+'</span></button>' +
'</div>' +
'</div>' +
'<div class="stats-note">状态筛选和方向筛选是两个维度;数字展示当前加载机会的分布,点击只改变下方列表,不代表另一类机会消失。</div>';
} }
function renderLiveCards(data, weakCount) { function renderLiveCards(data, weakCount) {
@ -1150,7 +1170,6 @@ async function loadHistoryRecommendations(reset) {
var completedCount = Number(summary.completed_count || 0); var completedCount = Number(summary.completed_count || 0);
var invalidCount = Number(summary.invalid_count || 0); var invalidCount = Number(summary.invalid_count || 0);
var notExecutedCount = Number(summary.not_executed_count || 0); var notExecutedCount = Number(summary.not_executed_count || 0);
$('histCount').textContent = totalCount ? ' ' + totalCount : '';
$('historyStats').innerHTML = $('historyStats').innerHTML =
'<div class="hstat"><div class="num" style="color:var(--blue)">'+totalCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--blue)"><use href="#svg-target"/></svg> 归档信号</div><div class="sub">机会/观察历史</div></div>'+ '<div class="hstat"><div class="num" style="color:var(--blue)">'+totalCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--blue)"><use href="#svg-target"/></svg> 归档信号</div><div class="sub">机会/观察历史</div></div>'+
'<div class="hstat"><div class="num" style="color:var(--green)">'+executedCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--green)"><use href="#svg-trendup"/></svg> 进入执行</div><div class="sub">收益见策略交易</div></div>'+ '<div class="hstat"><div class="num" style="color:var(--green)">'+executedCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--green)"><use href="#svg-trendup"/></svg> 进入执行</div><div class="sub">收益见策略交易</div></div>'+

View File

@ -139,6 +139,90 @@ a { color: inherit; text-decoration: none; }
</style> </style>
{% block extra_head_css %}{% endblock %} {% block extra_head_css %}{% endblock %}
{% block extra_style %}{% endblock %} {% block extra_style %}{% endblock %}
<style>
/* ===== Shared App Tabs =====
All in-app tab groups should read as the same control: quiet segmented
navigation, clear selected state, and stable mobile behavior. */
.main-content :is(.tabs, .tabbar, .admin-tabs, .user-tabs) {
display: flex;
align-items: center;
gap: 4px;
width: max-content;
max-width: 100%;
min-height: 46px;
padding: 4px;
border: 1px solid var(--hairline-soft);
border-radius: var(--radius-md);
background: var(--canvas);
box-shadow: 0 1px 2px rgba(5,0,56,.035);
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.main-content :is(.tabs, .tabbar, .admin-tabs, .user-tabs)::-webkit-scrollbar { display: none; }
.main-content :is(.tabs, .tabbar, .admin-tabs, .user-tabs) :is(.tab-btn, .tab, .admin-tab-btn) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: max-content;
height: 36px;
min-height: 36px;
padding: 0 14px;
border: 1px solid transparent;
border-radius: calc(var(--radius-md) - 2px);
background: transparent;
box-shadow: none;
color: var(--steel);
font-size: 13px;
font-weight: 850;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
cursor: pointer;
transition: background .15s ease, color .15s ease, border-color .15s ease, box-shadow .15s ease;
}
.main-content :is(.tabs, .tabbar, .admin-tabs, .user-tabs) :is(.tab-btn, .tab, .admin-tab-btn):hover {
color: var(--ink);
background: var(--surface);
border-color: var(--hairline-soft);
}
.main-content :is(.tabs, .tabbar, .admin-tabs, .user-tabs) :is(.tab-btn, .tab, .admin-tab-btn).active,
.main-content :is(.tabs, .tabbar, .admin-tabs, .user-tabs) :is(.tab-btn, .tab, .admin-tab-btn)[aria-selected="true"] {
color: var(--on-primary);
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 8px 18px rgba(5,0,56,.11);
}
.main-content :is(.tabs, .tabbar, .admin-tabs, .user-tabs) .count {
display: inline-flex;
align-items: center;
min-width: 16px;
height: 18px;
padding: 0 5px;
border-radius: var(--radius-full);
background: rgba(255,255,255,.18);
color: inherit;
font-size: 11px;
font-weight: 900;
opacity: .86;
}
.main-content :is(.tab-panel, .tab-page) { display: none; }
.main-content :is(.tab-panel, .tab-page).active { display: block; }
@media (max-width: 700px) {
.main-content :is(.tabs, .tabbar, .admin-tabs, .user-tabs) {
width: 100%;
min-height: 48px;
}
.main-content :is(.tabs, .tabbar, .admin-tabs, .user-tabs) :is(.tab-btn, .tab, .admin-tab-btn) {
flex: 1 0 auto;
height: 38px;
min-height: 38px;
padding: 0 12px;
}
}
</style>
</head> </head>
<body> <body>

View File

@ -36,7 +36,7 @@
<option value="open_trades">仅持仓中</option> <option value="open_trades">仅持仓中</option>
<option value="closed_trades">仅已平仓</option> <option value="closed_trades">仅已平仓</option>
<option value="orders">仅挂单</option> <option value="orders">仅挂单</option>
<option value="completed">已完成交易和挂</option> <option value="completed">已完结持仓和取消订</option>
<option value="events">仅操作日志</option> <option value="events">仅操作日志</option>
</select> </select>
<button class="btn danger" type="button" onclick="resetLedger()">重置所选数据</button> <button class="btn danger" type="button" onclick="resetLedger()">重置所选数据</button>
@ -51,7 +51,7 @@
<div class="strategy-board-head"> <div class="strategy-board-head">
<div> <div>
<div class="strategy-board-title">运行策略看板</div> <div class="strategy-board-title">运行策略看板</div>
<div class="strategy-board-copy">按策略独立看信号、机会、交易、胜率和收益;点击卡片可筛选下方持仓、挂单、已完成和日志。</div> <div class="strategy-board-copy">按策略独立看信号、机会、交易、胜率和收益;点击卡片可筛选下方持仓、挂单、完结持仓、取消订单和日志。</div>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn" type="button" onclick="clearStrategyAndSide()">查看全部</button> <button class="btn" type="button" onclick="clearStrategyAndSide()">查看全部</button>
@ -73,7 +73,8 @@
<div class="tabs" role="tablist" aria-label="策略交易视图切换"> <div class="tabs" role="tablist" aria-label="策略交易视图切换">
<button class="tab-btn active" id="tab-open" type="button" onclick="setTradeTab('open')" role="tab" aria-selected="true">持仓中</button> <button class="tab-btn active" id="tab-open" type="button" onclick="setTradeTab('open')" role="tab" aria-selected="true">持仓中</button>
<button class="tab-btn" id="tab-orders" type="button" onclick="setTradeTab('orders')" role="tab" aria-selected="false">挂单中</button> <button class="tab-btn" id="tab-orders" type="button" onclick="setTradeTab('orders')" role="tab" aria-selected="false">挂单中</button>
<button class="tab-btn" id="tab-completed" type="button" onclick="setTradeTab('completed')" role="tab" aria-selected="false">已完成</button> <button class="tab-btn" id="tab-closed" type="button" onclick="setTradeTab('closed')" role="tab" aria-selected="false">已完结持仓</button>
<button class="tab-btn" id="tab-canceled" type="button" onclick="setTradeTab('canceled')" role="tab" aria-selected="false">取消订单</button>
<button class="tab-btn" id="tab-events" type="button" onclick="setTradeTab('events')" role="tab" aria-selected="false">操作日志</button> <button class="tab-btn" id="tab-events" type="button" onclick="setTradeTab('events')" role="tab" aria-selected="false">操作日志</button>
</div> </div>
<div class="tab-panel active" id="panel-open" role="tabpanel" aria-labelledby="tab-open"> <div class="tab-panel active" id="panel-open" role="tabpanel" aria-labelledby="tab-open">
@ -99,19 +100,24 @@
</div> </div>
</section> </section>
</div> </div>
<div class="tab-panel" id="panel-completed" role="tabpanel" aria-labelledby="tab-completed"> <div class="tab-panel" id="panel-closed" role="tabpanel" aria-labelledby="tab-closed">
<section class="panel"> <section class="panel">
<div class="panel-head"><div><div class="panel-title">已完</div><div class="panel-note" id="completedInfo">已平仓交易与已结束挂单</div></div></div> <div class="panel-head"><div><div class="panel-title">已完结持仓</div><div class="panel-note" id="closedInfo">已经开仓并完成平仓的策略交易</div></div></div>
<div class="table-wrap"> <div class="table-wrap">
<table class="table"> <table class="table">
<thead><tr><th>币种</th><th>状态</th><th>持仓时间</th><th>方向</th><th>仓位</th><th>开仓</th><th>止盈 / 止损 / 移动止盈</th><th>最新价</th><th>平仓价</th><th>平仓时间</th><th>价格收益</th><th>账户收益</th><th>退出原因</th><th>来源</th><th>操作</th></tr></thead> <thead><tr><th>币种</th><th>状态</th><th>持仓时间</th><th>方向</th><th>仓位</th><th>开仓</th><th>止盈 / 止损 / 移动止盈</th><th>最新价</th><th>平仓价</th><th>平仓时间</th><th>价格收益</th><th>账户收益</th><th>退出原因</th><th>来源</th><th>操作</th></tr></thead>
<tbody id="completedTradeRows"><tr><td colspan="15" class="loading">加载中...</td></tr></tbody> <tbody id="closedTradeRows"><tr><td colspan="15" class="loading">加载中...</td></tr></tbody>
</table> </table>
</div> </div>
</section>
</div>
<div class="tab-panel" id="panel-canceled" role="tabpanel" aria-labelledby="tab-canceled">
<section class="panel">
<div class="panel-head"><div><div class="panel-title">取消订单</div><div class="panel-note">未转成持仓的挂单:风控取消、过期、拒绝等</div></div></div>
<div class="table-wrap"> <div class="table-wrap">
<table class="table"> <table class="table">
<thead><tr><th>币种</th><th>状态</th><th>方向</th><th>目标价</th><th>最新价</th><th>距离目标</th><th>止盈 / 止损</th><th>创建时间</th><th>结束时间</th><th>来源</th><th>操作</th></tr></thead> <thead><tr><th>币种</th><th>状态</th><th>方向</th><th>目标价</th><th>最新价</th><th>取消原因</th><th>止盈 / 止损</th><th>创建时间</th><th>结束时间</th><th>来源</th><th>操作</th></tr></thead>
<tbody id="completedOrderRows"><tr><td colspan="11" class="loading">加载中...</td></tr></tbody> <tbody id="canceledOrderRows"><tr><td colspan="11" class="loading">加载中...</td></tr></tbody>
</table> </table>
</div> </div>
</section> </section>
@ -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')}) 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 sideText(v){return String(v||'long').toLowerCase()==='short'?'空':'多'}
function sideBadge(v){var s=String(v||'long').toLowerCase();return '<span class="badge '+(s==='short'?'side-short':'side-long')+'">'+sideText(s)+'</span>'} function sideBadge(v){var s=String(v||'long').toLowerCase();return '<span class="badge '+(s==='short'?'side-short':'side-long')+'">'+sideText(s)+'</span>'}
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 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 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} 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 strategyQuery(){var code=selectedStrategy();return code?'&strategy_code='+encodeURIComponent(code):''}
function sideQuery(){var side=selectedSide();return side?'&side='+encodeURIComponent(side):''} function sideQuery(){var side=selectedSide();return side?'&side='+encodeURIComponent(side):''}
function tradeQuery(){return strategyQuery()+sideQuery()} 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 onStrategyFilterChange(){openOffset=0;eventOffset=0;loadAll()}
function onSideFilterChange(){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()} 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='<tr><td
function protectionCell(x){var trail=Number(x.trailing_stop||0);var trailHtml=trail>0?'<span class="trail-line">移动止盈 $'+fmt(trail,6)+'</span>':'<span class="trail-line off">移动止盈未启动</span>';return '<div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span>'+trailHtml+'</div>'} function protectionCell(x){var trail=Number(x.trailing_stop||0);var trailHtml=trail>0?'<span class="trail-line">移动止盈 $'+fmt(trail,6)+'</span>':'<span class="trail-line off">移动止盈未启动</span>';return '<div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span>'+trailHtml+'</div>'}
function strategyName(x){return (x&&x.strategy_name)||({main_composite_v1:'综合确认策略',box_retest_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)||'--'))} 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='<tr><td colspan="15" class="loading">加载中...</td></tr>';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='<tr><td colspan="15" class="empty">'+esc(e.message)+'</td></tr>'}} async function loadOpenTrades(nextOffset){openOffset=Math.max(0,nextOffset||0);$('openRows').innerHTML='<tr><td colspan="15" class="loading">加载中...</td></tr>';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='<tr><td colspan="15" class="empty">'+esc(e.message)+'</td></tr>'}}
async function loadCompleted(){await Promise.all([loadCompletedTrades(),loadCompletedOrders()])} async function loadClosedTrades(){$('closedTradeRows').innerHTML='<tr><td colspan="15" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed'+tradeQuery());renderTradeRows('closedTradeRows',d.items||[],'暂无已完结持仓')}catch(e){$('closedTradeRows').innerHTML='<tr><td colspan="15" class="empty">'+esc(e.message)+'</td></tr>'}}
async function loadCompletedTrades(){$('completedTradeRows').innerHTML='<tr><td colspan="15" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed'+tradeQuery());renderTradeRows('completedTradeRows',d.items||[],'暂无已平仓策略交易')}catch(e){$('completedTradeRows').innerHTML='<tr><td colspan="15" class="empty">'+esc(e.message)+'</td></tr>'}} async function loadCanceledOrders(){$('canceledOrderRows').innerHTML='<tr><td colspan="11" class="loading">加载中...</td></tr>';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='<tr><td colspan="11" class="empty">'+esc(e.message)+'</td></tr>'}}
async function loadCompletedOrders(){$('completedOrderRows').innerHTML='<tr><td colspan="11" class="loading">加载中...</td></tr>';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='<tr><td colspan="11" class="empty">'+esc(e.message)+'</td></tr>'}}
function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId).innerHTML='<tr><td colspan="15" class="empty">'+esc(emptyText||'暂无策略交易')+'</td></tr>';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 '<tr>'+ function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId).innerHTML='<tr><td colspan="15" class="empty">'+esc(emptyText||'暂无策略交易')+'</td></tr>';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 '<tr>'+
'<td>'+symbolCell(x)+'</td>'+ '<td>'+symbolCell(x)+'</td>'+
'<td><span class="badge '+esc(x.status)+'">'+st+'</span></td>'+ '<td><span class="badge '+esc(x.status)+'">'+st+'</span></td>'+
@ -244,21 +249,21 @@ function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId)
'<td><div>'+esc(strategyName(x))+'</div><div class="muted">'+esc(x.source_status||'--')+' · '+esc(x.strategy_version||'')+'</div></td>'+ '<td><div>'+esc(strategyName(x))+'</div><div class="muted">'+esc(x.source_status||'--')+' · '+esc(x.strategy_version||'')+'</div></td>'+
'<td><button class="row-action" type="button" onclick="deleteTrade('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,'&#39;')+'\',\''+esc(String(x.status||'')).replace(/'/g,'&#39;')+'\')">删除</button></td>'+ '<td><button class="row-action" type="button" onclick="deleteTrade('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,'&#39;')+'\',\''+esc(String(x.status||'')).replace(/'/g,'&#39;')+'\')">删除</button></td>'+
'</tr>'}).join('')} '</tr>'}).join('')}
function renderCompletedOrders(items){if(!items.length){$('completedOrderRows').innerHTML='<tr><td colspan="11" class="empty">暂无已结束策略挂</td></tr>';return}$('completedOrderRows').innerHTML=items.map(function(x){var latest=x.latest_price||x.current_price_at_create||0,dist=Number(x.distance_to_target_pct||0),ended=x.filled_at||x.canceled_at||x.updated_at;return '<tr>'+ function renderCanceledOrders(items){if(!items.length){$('canceledOrderRows').innerHTML='<tr><td colspan="11" class="empty">暂无取消或过期订</td></tr>';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 '<tr>'+
'<td>'+symbolCell(x)+'</td>'+ '<td>'+symbolCell(x)+'</td>'+
'<td><span class="badge '+(x.status==='filled'?'closed':'')+'">'+esc(orderStatus(x))+'</span></td>'+ '<td><span class="badge '+(x.status==='filled'?'closed':'')+'">'+esc(orderStatus(x))+'</span></td>'+
'<td>'+sideBadge(x.side)+'</td>'+ '<td>'+sideBadge(x.side)+'</td>'+
'<td><div class="mono">$'+fmt(x.target_price,6)+'</div></td>'+ '<td><div class="mono">$'+fmt(x.target_price,6)+'</div></td>'+
'<td><div class="mono">$'+fmt(latest,6)+'</div><div class="muted">'+(x.latest_price_updated_at?time(x.latest_price_updated_at):'--')+'</div></td>'+ '<td><div class="mono">$'+fmt(latest,6)+'</div><div class="muted">'+(x.latest_price_updated_at?time(x.latest_price_updated_at):'--')+'</div></td>'+
'<td><span class="mono '+(dist>0?'neg':'pos')+'">'+(dist>0?'+':'')+fmt(dist,2)+'%</span></td>'+ '<td><div>'+esc(cancelReasonLabel(x.cancel_reason||x.status))+'</div></td>'+
'<td><div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span></div></td>'+ '<td><div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span></div></td>'+
'<td>'+time(x.created_at)+'</td>'+ '<td>'+time(x.created_at)+'</td>'+
'<td>'+time(ended)+'</td>'+ '<td>'+time(ended)+'</td>'+
'<td><div>'+esc(strategyName(x))+'</div><div class="muted">'+esc(cancelReasonLabel(x.cancel_reason||x.status))+'</div><div class="muted">'+esc(x.source_status||'--')+' · '+esc(x.source_action||'')+'</div></td>'+ '<td><div>'+esc(strategyName(x))+'</div><div class="muted">'+esc(x.source_status||'--')+' · '+esc(x.source_action||'')+'</div></td>'+
'<td><button class="row-action" type="button" onclick="deleteOrder('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,'&#39;')+'\')">删除</button></td>'+ '<td><button class="row-action" type="button" onclick="deleteOrder('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,'&#39;')+'\')">删除</button></td>'+
'</tr>'}).join('')} '</tr>'}).join('')}
function orderStatus(x){return {filled:'已成交',expired:'已过期',canceled:'已取消',rejected:'已拒绝'}[x.status]||x.status||'--'} 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='<button '+(openOffset===0?'disabled':'')+' onclick="loadOpenTrades('+(openOffset-LIMIT)+')">上一页</button><span>第 '+page+' / '+totalPages+' 页</span><button '+((openOffset+LIMIT>=openTotal)?'disabled':'')+' onclick="loadOpenTrades('+(openOffset+LIMIT)+')">下一页</button>'} function renderOpenPager(){var page=Math.floor(openOffset/LIMIT)+1,totalPages=Math.max(1,Math.ceil(openTotal/LIMIT));$('openPageInfo').textContent='第 '+page+' / '+totalPages+' 页 · 共 '+openTotal+' 条';$('openPager').innerHTML='<button '+(openOffset===0?'disabled':'')+' onclick="loadOpenTrades('+(openOffset-LIMIT)+')">上一页</button><span>第 '+page+' / '+totalPages+' 页</span><button '+((openOffset+LIMIT>=openTotal)?'disabled':'')+' onclick="loadOpenTrades('+(openOffset+LIMIT)+')">下一页</button>'}
async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='<div class="loading">加载中...</div>';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='<div class="empty">'+esc(e.message)+'</div>'}} async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='<div class="loading">加载中...</div>';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='<div class="empty">'+esc(e.message)+'</div>'}}
function eventLabel(t){return {open:'开仓',close:'平仓',trailing_activate:'移动止盈激活',trailing_move:'移动止盈上移'}[t]||t||'动作'} function eventLabel(t){return {open:'开仓',close:'平仓',trailing_activate:'移动止盈激活',trailing_move:'移动止盈上移'}[t]||t||'动作'}

View File

@ -30,3 +30,38 @@ def test_base_template_owns_sidebar_and_user_shell():
continue continue
for pattern, label in forbidden_patterns: for pattern, label in forbidden_patterns:
assert not re.search(pattern, text), f"{path.name} should inherit shell behavior from base.html, found {label}" 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

View File

@ -1,4 +1,5 @@
from app.core.factor_roles import RISK, TRIGGER, factor_role, factor_roles_for_codes 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_contract import StrategySignal, default_main_composite_signal
from app.core.strategy_registry import ( from app.core.strategy_registry import (
BOX_RETEST_1H_STRATEGY, BOX_RETEST_1H_STRATEGY,
@ -8,6 +9,8 @@ from app.core.strategy_registry import (
INTRADAY_MOMENTUM_15M_STRATEGY, INTRADAY_MOMENTUM_15M_STRATEGY,
MAIN_COMPOSITE_STRATEGY, MAIN_COMPOSITE_STRATEGY,
VOLUME_IGNITION_1H_STRATEGY, VOLUME_IGNITION_1H_STRATEGY,
is_strategy_allowed_for_side,
strategy_direction,
strategy_label, strategy_label,
) )
from app.db.recommendation_commands import create_recommendation 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(COMPRESSION_BREAKOUT_4H_STRATEGY) == "4H压缩蓄力突破"
assert strategy_label(INTRADAY_MOMENTUM_15M_STRATEGY) == "15m日内动量延续" assert strategy_label(INTRADAY_MOMENTUM_15M_STRATEGY) == "15m日内动量延续"
assert strategy_label(BREAKDOWN_RETEST_SHORT_1H_STRATEGY) == "1H破位反抽做空" 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(): 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" 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(): def test_compression_breakout_strategy_requires_structure_and_breakout_context():
signal = build_compression_breakout_4h_signal( signal = build_compression_breakout_4h_signal(
symbol="QUIET/USDT", symbol="QUIET/USDT",

View File

@ -776,7 +776,7 @@ def test_pending_paper_order_reconciles_from_latest_price_cache(monkeypatch):
assert order["fill_price"] == pytest.approx(95) 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_TRADING_ENABLED", "1")
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1") monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")
monkeypatch.setattr( 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") paused = sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00")
assert created["reason"] == "paper_order_created" 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_trades()["total"] == 0
assert list_paper_orders(status="pending")["items"][0]["symbol"] == "RISKPAUSE/USDT" assert list_paper_orders(status="pending")["total"] == 0
assert list_paper_orders(status="canceled")["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): 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 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): def test_reset_paper_trading_data_all_clears_ledger(monkeypatch, buy_now_rec):
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00") sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")