12
This commit is contained in:
parent
c6780d118b
commit
0443072679
10
app/cli.py
10
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("--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}")
|
||||||
|
|
||||||
|
|||||||
179
app/core/signal_direction.py
Normal file
179
app/core/signal_direction.py
Normal 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
|
||||||
@ -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]:
|
||||||
|
|||||||
@ -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 {})
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
199
app/db/strategy_direction_repair.py
Normal file
199
app/db/strategy_direction_repair.py
Normal 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}
|
||||||
@ -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,
|
||||||
|
|||||||
101
static/app.html
101
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: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>'+
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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,''')+'\',\''+esc(String(x.status||'')).replace(/'/g,''')+'\')">删除</button></td>'+
|
'<td><button class="row-action" type="button" onclick="deleteTrade('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,''')+'\',\''+esc(String(x.status||'')).replace(/'/g,''')+'\')">删除</button></td>'+
|
||||||
'</tr>'}).join('')}
|
'</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,''')+'\')">删除</button></td>'+
|
'<td><button class="row-action" type="button" onclick="deleteOrder('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,''')+'\')">删除</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||'动作'}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user